mirror of
https://github.com/KytyPS5/KytyPS5.git
synced 2026-10-19 18:36:53 +02:00
Localize trophy notifications with embedded JSON catalogs
This commit is contained in:
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"trophy.toast.earned": "تم كسب الكأس!"
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"trophy.toast.earned": "Trofej získána!"
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"trophy.toast.earned": "Trofæ optjent!"
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"trophy.toast.earned": "Trophäe erhalten!"
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"trophy.toast.earned": "Κερδίσατε ένα τρόπαιο!"
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"trophy.toast.earned": "Trophy earned!"
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"trophy.toast.earned": "Trophy earned!"
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"trophy.toast.earned": "¡Ganaste un trofeo!"
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"trophy.toast.earned": "¡Ganaste un trofeo!"
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"trophy.toast.earned": "Pokaali ansaittu!"
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"trophy.toast.earned": "Trophée obtenu !"
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"trophy.toast.earned": "Trophée obtenu !"
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"trophy.toast.earned": "Trófea megszerezve!"
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"trophy.toast.earned": "Trofi diperoleh!"
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"trophy.toast.earned": "Trofeo ottenuto!"
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"trophy.toast.earned": "トロフィーを獲得しました!"
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"trophy.toast.earned": "트로피를 획득했습니다!"
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"trophy.toast.earned": "Trofee behaald!"
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"trophy.toast.earned": "Trofé opptjent!"
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"trophy.toast.earned": "Zdobyto trofeum!"
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"trophy.toast.earned": "Troféu conquistado!"
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"trophy.toast.earned": "Troféu conquistado!"
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"trophy.toast.earned": "Trofeu câștigat!"
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"trophy.toast.earned": "Трофей получен!"
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"trophy.toast.earned": "Trofé intjänad!"
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"trophy.toast.earned": "ได้รับถ้วยรางวัลแล้ว!"
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"trophy.toast.earned": "Kupa kazanıldı!"
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"trophy.toast.earned": "Đã nhận cúp!"
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"trophy.toast.earned": "已获得奖杯!"
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"trophy.toast.earned": "已獲得獎盃!"
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
# Notification localization
|
||||||
|
|
||||||
|
Trophy notification subtitles use the configured console language. Trophy names still come
|
||||||
|
from the game's metadata. The launcher trophy dialogs are unchanged.
|
||||||
|
|
||||||
|
Edit `assets/localization/<locale>.json`, using flat UTF-8 string values:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"trophy.toast.earned": "Trophy earned!"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
CMake converts the catalogs into C++ byte arrays in the build directory. The emulator parses
|
||||||
|
the embedded catalogs once and falls back to `en-US.json` for missing translations. No language
|
||||||
|
files or translation service are needed at runtime. Rebuild after editing a catalog.
|
||||||
|
|
||||||
|
For local testing, rebuild `kyty_emulator`, choose a game's Console Language in the launcher,
|
||||||
|
and unlock a trophy with notifications enabled. Installed system fonts provide non-Latin
|
||||||
|
glyphs; the selected language needs an appropriate font on the host.
|
||||||
@@ -12,9 +12,23 @@ endif()
|
|||||||
|
|
||||||
add_library(common STATIC ${common_src} ${common_headers})
|
add_library(common STATIC ${common_src} ${common_headers})
|
||||||
|
|
||||||
|
set(localization_dir "${PROJECT_SOURCE_DIR}/assets/localization")
|
||||||
|
file(GLOB localization_catalogs CONFIGURE_DEPENDS "${localization_dir}/*.json")
|
||||||
|
set(localization_data "${CMAKE_CURRENT_BINARY_DIR}/localizationData.h")
|
||||||
|
add_custom_command(
|
||||||
|
OUTPUT "${localization_data}"
|
||||||
|
COMMAND ${CMAKE_COMMAND} "-DINPUTS=${localization_catalogs}" "-DOUTPUT=${localization_data}"
|
||||||
|
-P "${KYTY_SOURCE_DIR}/embed_localization.cmake"
|
||||||
|
DEPENDS ${localization_catalogs} "${KYTY_SOURCE_DIR}/embed_localization.cmake"
|
||||||
|
COMMENT "Embed notification localization catalogs"
|
||||||
|
VERBATIM
|
||||||
|
)
|
||||||
|
target_sources(common PRIVATE "${localization_data}")
|
||||||
|
|
||||||
target_include_directories(common
|
target_include_directories(common
|
||||||
PUBLIC ${KYTY_SOURCE_DIR}
|
PUBLIC ${KYTY_SOURCE_DIR}
|
||||||
PRIVATE
|
PRIVATE
|
||||||
|
${CMAKE_CURRENT_BINARY_DIR}
|
||||||
${KYTY_THIRD_PARTY_DIR}/cpuinfo/include
|
${KYTY_THIRD_PARTY_DIR}/cpuinfo/include
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
#include "common/localization.h"
|
||||||
|
|
||||||
|
#include <array>
|
||||||
|
#include <cstddef>
|
||||||
|
#include <nlohmann/json.hpp>
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
#include "localizationData.h"
|
||||||
|
|
||||||
|
namespace Common::Localization {
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
constexpr std::array<std::string_view, 30> Locales = {
|
||||||
|
"ja-JP", "en-US", "fr-FR", "es-ES", "de-DE", "it-IT", "nl-NL", "pt-PT",
|
||||||
|
"ru-RU", "ko-KR", "zh-Hant", "zh-Hans", "fi-FI", "sv-SE", "da-DK", "no-NO",
|
||||||
|
"pl-PL", "pt-BR", "en-GB", "tr-TR", "es-419", "ar-AE", "fr-CA", "cs-CZ",
|
||||||
|
"hu-HU", "el-GR", "ro-RO", "th-TH", "vi-VN", "id-ID"};
|
||||||
|
|
||||||
|
size_t LanguageIndex(int console_language) {
|
||||||
|
return console_language >= 0 && console_language < static_cast<int>(Locales.size())
|
||||||
|
? static_cast<size_t>(console_language)
|
||||||
|
: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
std::string_view LocaleName(int console_language) {
|
||||||
|
return Locales[LanguageIndex(console_language)];
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string_view Get(int console_language, std::string_view key) {
|
||||||
|
static const auto catalogs = [] {
|
||||||
|
std::array<nlohmann::json, Locales.size()> result;
|
||||||
|
for (size_t i = 0; i < Locales.size(); ++i) {
|
||||||
|
const auto bytes = EmbeddedCatalog(Locales[i]);
|
||||||
|
result[i] = nlohmann::json::parse(bytes.begin(), bytes.end(), nullptr, false);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}();
|
||||||
|
for (const auto index: std::array {LanguageIndex(console_language), size_t {1}}) {
|
||||||
|
const auto& catalog = catalogs[index];
|
||||||
|
const auto found = catalog.find(key);
|
||||||
|
if (found != catalog.end() && found->is_string()) {
|
||||||
|
const auto& text = found->get_ref<const std::string&>();
|
||||||
|
if (!text.empty()) {
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace Common::Localization
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <string_view>
|
||||||
|
|
||||||
|
namespace Common::Localization {
|
||||||
|
|
||||||
|
// Console language indices follow the system configuration; invalid indices use English.
|
||||||
|
[[nodiscard]] std::string_view LocaleName(int console_language);
|
||||||
|
|
||||||
|
// Translations have static lifetime. Missing translations use English, then the supplied key.
|
||||||
|
[[nodiscard]] std::string_view Get(int console_language, std::string_view key);
|
||||||
|
|
||||||
|
} // namespace Common::Localization
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
#include "common/trophies.h"
|
#include "common/trophies.h"
|
||||||
|
|
||||||
#include "common/file.h"
|
#include "common/file.h"
|
||||||
|
#include "common/localization.h"
|
||||||
|
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
#include <array>
|
|
||||||
#include <charconv>
|
#include <charconv>
|
||||||
#include <fmt/format.h>
|
#include <fmt/format.h>
|
||||||
#include <nlohmann/json.hpp>
|
#include <nlohmann/json.hpp>
|
||||||
@@ -105,13 +105,7 @@ Package ParsePackage(std::span<const std::byte> data, int console_language) {
|
|||||||
!conf["defaultLanguage"].is_string()) {
|
!conf["defaultLanguage"].is_string()) {
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
static constexpr std::array<std::string_view, 30> locales = {
|
const auto locale = Localization::LocaleName(console_language);
|
||||||
"ja-JP", "en-US", "fr-FR", "es-ES", "de-DE", "it-IT", "nl-NL", "pt-PT",
|
|
||||||
"ru-RU", "ko-KR", "zh-Hant", "zh-Hans", "fi-FI", "sv-SE", "da-DK", "no-NO",
|
|
||||||
"pl-PL", "pt-BR", "en-GB", "tr-TR", "es-419", "ar-AE", "fr-CA", "cs-CZ",
|
|
||||||
"hu-HU", "el-GR", "ro-RO", "th-TH", "vi-VN", "id-ID"};
|
|
||||||
const auto locale =
|
|
||||||
locales[console_language >= 0 && console_language < locales.size() ? console_language : 1];
|
|
||||||
auto meta = ReadJson(files, fmt::format("tropmeta_{}.json", locale));
|
auto meta = ReadJson(files, fmt::format("tropmeta_{}.json", locale));
|
||||||
if (meta.is_null()) {
|
if (meta.is_null()) {
|
||||||
meta = ReadJson(files, fmt::format("tropmeta_{}.json",
|
meta = ReadJson(files, fmt::format("tropmeta_{}.json",
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
if(NOT DEFINED INPUTS OR NOT DEFINED OUTPUT)
|
||||||
|
message(FATAL_ERROR "embed_localization.cmake requires INPUTS and OUTPUT")
|
||||||
|
endif()
|
||||||
|
|
||||||
|
set(contents "// Generated from assets/localization; do not edit.\n#pragma once\n#include <span>\n#include <string_view>\n\nnamespace {\n")
|
||||||
|
set(lookup "std::span<const unsigned char> EmbeddedCatalog(std::string_view locale) {\n")
|
||||||
|
set(continuation "[89ab][0-9a-f]")
|
||||||
|
string(CONCAT utf8_character
|
||||||
|
"[0-7][0-9a-f]|(c[2-9a-f]|d[0-9a-f])${continuation}"
|
||||||
|
"|e0[ab][0-9a-f]${continuation}|e[1-9abcef]${continuation}${continuation}"
|
||||||
|
"|ed[89][0-9a-f]${continuation}|f0[9ab][0-9a-f]${continuation}${continuation}"
|
||||||
|
"|f[123]${continuation}${continuation}${continuation}|f48[0-9a-f]${continuation}${continuation}")
|
||||||
|
foreach(catalog IN LISTS INPUTS)
|
||||||
|
file(READ "${catalog}" bytes HEX)
|
||||||
|
# Validate UTF-8, excluding overlong sequences, surrogates, and values above U+10FFFF.
|
||||||
|
if(NOT bytes MATCHES "^(${utf8_character})*$")
|
||||||
|
message(FATAL_ERROR "Localization must be UTF-8: ${catalog}")
|
||||||
|
endif()
|
||||||
|
file(READ "${catalog}" json)
|
||||||
|
string(JSON type ERROR_VARIABLE error TYPE "${json}")
|
||||||
|
if(error OR NOT type STREQUAL "OBJECT")
|
||||||
|
message(FATAL_ERROR "Invalid localization object in ${catalog}: ${error}")
|
||||||
|
endif()
|
||||||
|
string(JSON count LENGTH "${json}")
|
||||||
|
if(count GREATER 0)
|
||||||
|
math(EXPR last "${count} - 1")
|
||||||
|
foreach(index RANGE ${last})
|
||||||
|
string(JSON key MEMBER "${json}" ${index})
|
||||||
|
string(JSON type TYPE "${json}" "${key}")
|
||||||
|
if(NOT type STREQUAL "STRING")
|
||||||
|
message(FATAL_ERROR "Localization value '${key}' must be a string: ${catalog}")
|
||||||
|
endif()
|
||||||
|
string(JSON text GET "${json}" "${key}")
|
||||||
|
if(key STREQUAL "" OR text STREQUAL "")
|
||||||
|
message(FATAL_ERROR "Localization keys and values must not be empty: ${catalog}")
|
||||||
|
endif()
|
||||||
|
endforeach()
|
||||||
|
endif()
|
||||||
|
get_filename_component(locale "${catalog}" NAME_WE)
|
||||||
|
if(locale STREQUAL "en-US")
|
||||||
|
string(JSON earned ERROR_VARIABLE error GET "${json}" "trophy.toast.earned")
|
||||||
|
if(error OR earned STREQUAL "")
|
||||||
|
message(FATAL_ERROR "English localization requires trophy.toast.earned: ${catalog}")
|
||||||
|
endif()
|
||||||
|
set(english_found TRUE)
|
||||||
|
endif()
|
||||||
|
string(MAKE_C_IDENTIFIER "${locale}" symbol)
|
||||||
|
string(REGEX REPLACE "(..)" "0x\\1," bytes "${bytes}")
|
||||||
|
string(APPEND contents "constexpr unsigned char catalog_${symbol}[] = {${bytes}};\n")
|
||||||
|
string(APPEND lookup "\tif (locale == \"${locale}\") return catalog_${symbol};\n")
|
||||||
|
endforeach()
|
||||||
|
if(NOT english_found)
|
||||||
|
message(FATAL_ERROR "Localization requires an en-US.json fallback")
|
||||||
|
endif()
|
||||||
|
string(APPEND contents "\n${lookup}\treturn {};\n}\n} // namespace\n")
|
||||||
|
|
||||||
|
get_filename_component(output_dir "${OUTPUT}" DIRECTORY)
|
||||||
|
file(MAKE_DIRECTORY "${output_dir}")
|
||||||
|
file(WRITE "${OUTPUT}.tmp" "${contents}")
|
||||||
|
execute_process(COMMAND "${CMAKE_COMMAND}" -E copy_if_different "${OUTPUT}.tmp" "${OUTPUT}"
|
||||||
|
COMMAND_ERROR_IS_FATAL ANY)
|
||||||
|
file(REMOVE "${OUTPUT}.tmp")
|
||||||
@@ -5,6 +5,7 @@
|
|||||||
#include "common/assert.h"
|
#include "common/assert.h"
|
||||||
#include "common/emulatorConfig.h"
|
#include "common/emulatorConfig.h"
|
||||||
#include "common/file.h"
|
#include "common/file.h"
|
||||||
|
#include "common/localization.h"
|
||||||
#include "common/logging/log.h"
|
#include "common/logging/log.h"
|
||||||
#include "common/stringUtils.h"
|
#include "common/stringUtils.h"
|
||||||
#include "gpu_blit_shaders/gpu_imgui_hdr_spv.h"
|
#include "gpu_blit_shaders/gpu_imgui_hdr_spv.h"
|
||||||
@@ -30,6 +31,7 @@
|
|||||||
#include <cstring>
|
#include <cstring>
|
||||||
#include <deque>
|
#include <deque>
|
||||||
#include <fmt/format.h>
|
#include <fmt/format.h>
|
||||||
|
#include <initializer_list>
|
||||||
#include <limits>
|
#include <limits>
|
||||||
#include <memory>
|
#include <memory>
|
||||||
#include <mutex>
|
#include <mutex>
|
||||||
@@ -280,6 +282,61 @@ std::vector<std::string_view> WrapTrophyTitle(std::string_view text, float font_
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ImGui's built-in font covers Latin only. Merge installed fonts for the selected console language.
|
||||||
|
void AddSystemFontFallbacks(ImGuiIO& io) {
|
||||||
|
io.Fonts->AddFontDefault();
|
||||||
|
const auto add = [&](std::initializer_list<const char*> paths) {
|
||||||
|
for (const char* path: paths) {
|
||||||
|
std::error_code error;
|
||||||
|
if (std::filesystem::is_regular_file(path, error)) {
|
||||||
|
ImFontConfig config;
|
||||||
|
config.MergeMode = true;
|
||||||
|
config.Flags = ImFontFlags_NoLoadError;
|
||||||
|
if (io.Fonts->AddFontFromFileTTF(path, 0.0f, &config) != nullptr) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
bool found =
|
||||||
|
add({"C:/Windows/Fonts/segoeui.ttf", "/System/Library/Fonts/Supplemental/Arial Unicode.ttf",
|
||||||
|
"/Library/Fonts/Arial Unicode.ttf", "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
|
||||||
|
"/usr/share/fonts/TTF/DejaVuSans.ttf", "/usr/share/fonts/noto/NotoSans-Regular.ttf"});
|
||||||
|
const auto locale = Common::Localization::LocaleName(Config::GetConsoleLanguage());
|
||||||
|
const char* noto_cjk[] = {"/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc",
|
||||||
|
"/usr/share/fonts/noto-cjk/NotoSansCJK-Regular.ttc",
|
||||||
|
"/usr/share/fonts/google-noto-cjk/NotoSansCJK-Regular.ttc"};
|
||||||
|
if (locale == "ja-JP") {
|
||||||
|
found = add({"C:/Windows/Fonts/meiryo.ttc", "C:/Windows/Fonts/YuGothM.ttc",
|
||||||
|
"/System/Library/Fonts/ヒラギノ角ゴシック W3.ttc", noto_cjk[0], noto_cjk[1],
|
||||||
|
noto_cjk[2]});
|
||||||
|
} else if (locale == "ko-KR") {
|
||||||
|
found = add({"C:/Windows/Fonts/malgun.ttf", "/System/Library/Fonts/AppleSDGothicNeo.ttc",
|
||||||
|
noto_cjk[0], noto_cjk[1], noto_cjk[2]});
|
||||||
|
} else if (locale == "zh-Hans" || locale == "zh-Hant") {
|
||||||
|
found =
|
||||||
|
add({locale == "zh-Hans" ? "C:/Windows/Fonts/msyh.ttc" : "C:/Windows/Fonts/msjh.ttc",
|
||||||
|
"/System/Library/Fonts/PingFang.ttc", noto_cjk[0], noto_cjk[1], noto_cjk[2]});
|
||||||
|
} else if (locale == "th-TH") {
|
||||||
|
found = add({"C:/Windows/Fonts/leelawui.ttf", "C:/Windows/Fonts/tahoma.ttf",
|
||||||
|
"/System/Library/Fonts/Supplemental/Ayuthaya.ttf",
|
||||||
|
"/usr/share/fonts/truetype/tlwg/Garuda.ttf",
|
||||||
|
"/usr/share/fonts/truetype/noto/NotoSansThai-Regular.ttf",
|
||||||
|
"/usr/share/fonts/noto/NotoSansThai-Regular.ttf"});
|
||||||
|
} else if (locale == "ar-AE") {
|
||||||
|
found = add({"C:/Windows/Fonts/tahoma.ttf", "/System/Library/Fonts/Supplemental/Arial.ttf",
|
||||||
|
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
|
||||||
|
"/usr/share/fonts/TTF/DejaVuSans.ttf",
|
||||||
|
"/usr/share/fonts/truetype/noto/NotoSansArabic-Regular.ttf",
|
||||||
|
"/usr/share/fonts/noto/NotoSansArabic-Regular.ttf"});
|
||||||
|
}
|
||||||
|
if (!found) {
|
||||||
|
LOGF("[Trophy] no installed fallback font for %.*s\n", static_cast<int>(locale.size()),
|
||||||
|
locale.data());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void ClearInputEvents() {
|
void ClearInputEvents() {
|
||||||
std::scoped_lock lock(g_input_mutex);
|
std::scoped_lock lock(g_input_mutex);
|
||||||
g_input_events.clear();
|
g_input_events.clear();
|
||||||
@@ -441,6 +498,63 @@ std::u16string Utf8ToUtf16(std::string_view text) {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The Arabic notification is a single RTL run. Use presentation forms because ImGui does not
|
||||||
|
// shape Arabic. This intentionally applies only to the catalog subtitle, never game-provided names.
|
||||||
|
std::string PrepareTrophySubtitle(int language) {
|
||||||
|
const auto text = Common::Localization::Get(language, "trophy.toast.earned");
|
||||||
|
if (Common::Localization::LocaleName(language) != "ar-AE") {
|
||||||
|
return std::string(text);
|
||||||
|
}
|
||||||
|
struct ArabicForm {
|
||||||
|
char16_t letter;
|
||||||
|
char16_t isolated;
|
||||||
|
bool joins_next;
|
||||||
|
};
|
||||||
|
static constexpr ArabicForm FORMS[] = {
|
||||||
|
{0x0621, 0xfe80, false}, {0x0622, 0xfe81, false}, {0x0623, 0xfe83, false},
|
||||||
|
{0x0624, 0xfe85, false}, {0x0625, 0xfe87, false}, {0x0626, 0xfe89, true},
|
||||||
|
{0x0627, 0xfe8d, false}, {0x0628, 0xfe8f, true}, {0x0629, 0xfe93, false},
|
||||||
|
{0x062a, 0xfe95, true}, {0x062b, 0xfe99, true}, {0x062c, 0xfe9d, true},
|
||||||
|
{0x062d, 0xfea1, true}, {0x062e, 0xfea5, true}, {0x062f, 0xfea9, false},
|
||||||
|
{0x0630, 0xfeab, false}, {0x0631, 0xfead, false}, {0x0632, 0xfeaf, false},
|
||||||
|
{0x0633, 0xfeb1, true}, {0x0634, 0xfeb5, true}, {0x0635, 0xfeb9, true},
|
||||||
|
{0x0636, 0xfebd, true}, {0x0637, 0xfec1, true}, {0x0638, 0xfec5, true},
|
||||||
|
{0x0639, 0xfec9, true}, {0x063a, 0xfecd, true}, {0x0641, 0xfed1, true},
|
||||||
|
{0x0642, 0xfed5, true}, {0x0643, 0xfed9, true}, {0x0644, 0xfedd, true},
|
||||||
|
{0x0645, 0xfee1, true}, {0x0646, 0xfee5, true}, {0x0647, 0xfee9, true},
|
||||||
|
{0x0648, 0xfeed, false}, {0x0649, 0xfeef, false}, {0x064a, 0xfef1, true},
|
||||||
|
};
|
||||||
|
const auto form = [](char16_t c) -> const ArabicForm* {
|
||||||
|
for (const auto& value: FORMS) {
|
||||||
|
if (value.letter == c) {
|
||||||
|
return &value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nullptr;
|
||||||
|
};
|
||||||
|
const auto logical = Utf8ToUtf16(text);
|
||||||
|
if (std::none_of(logical.begin(), logical.end(),
|
||||||
|
[](char16_t c) { return c >= 0x0600 && c <= 0x06ff; })) {
|
||||||
|
return std::string(text);
|
||||||
|
}
|
||||||
|
auto visual = logical;
|
||||||
|
for (size_t i = 0; i < logical.size(); ++i) {
|
||||||
|
const auto* current = form(logical[i]);
|
||||||
|
if (current == nullptr) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const auto* previous = i > 0 ? form(logical[i - 1]) : nullptr;
|
||||||
|
const auto* next = i + 1 < logical.size() ? form(logical[i + 1]) : nullptr;
|
||||||
|
const bool joins_previous =
|
||||||
|
previous != nullptr && previous->joins_next && current->letter != 0x0621;
|
||||||
|
const bool joins_next = current->joins_next && next != nullptr && next->letter != 0x0621;
|
||||||
|
visual[i] = static_cast<char16_t>(current->isolated + (joins_next ? 2 : 0) +
|
||||||
|
(joins_previous ? 1 : 0));
|
||||||
|
}
|
||||||
|
std::reverse(visual.begin(), visual.end());
|
||||||
|
return Common::Utf16ToUtf8(visual);
|
||||||
|
}
|
||||||
|
|
||||||
std::string VisibleText(const Ime::HostSnapshot& snapshot, size_t max_units) {
|
std::string VisibleText(const Ime::HostSnapshot& snapshot, size_t max_units) {
|
||||||
const size_t cursor = std::min<size_t>(snapshot.cursor, snapshot.text.size());
|
const size_t cursor = std::min<size_t>(snapshot.cursor, snapshot.text.size());
|
||||||
const size_t payload_units = max_units > 4 ? max_units - 4 : 1;
|
const size_t payload_units = max_units > 4 ? max_units - 4 : 1;
|
||||||
@@ -794,6 +908,7 @@ struct SystemOverlay::Impl {
|
|||||||
io.ConfigNavCursorVisibleAlways = true;
|
io.ConfigNavCursorVisibleAlways = true;
|
||||||
io.BackendFlags |= ImGuiBackendFlags_HasGamepad;
|
io.BackendFlags |= ImGuiBackendFlags_HasGamepad;
|
||||||
io.BackendPlatformName = "Kyty system overlay input";
|
io.BackendPlatformName = "Kyty system overlay input";
|
||||||
|
AddSystemFontFallbacks(io);
|
||||||
ImGui::StyleColorsDark();
|
ImGui::StyleColorsDark();
|
||||||
auto& style = ImGui::GetStyle();
|
auto& style = ImGui::GetStyle();
|
||||||
style.WindowRounding = 10.0f;
|
style.WindowRounding = 10.0f;
|
||||||
@@ -1578,8 +1693,9 @@ struct SystemOverlay::Impl {
|
|||||||
trophy_notification = std::move(g_trophy_notifications.front());
|
trophy_notification = std::move(g_trophy_notifications.front());
|
||||||
g_trophy_notifications.pop_front();
|
g_trophy_notifications.pop_front();
|
||||||
}
|
}
|
||||||
trophy_notification_born = now;
|
trophy_notification_subtitle = PrepareTrophySubtitle(Config::GetConsoleLanguage());
|
||||||
trophy_notification_image = LoadTrophyIcon(trophy_notification->icon_png);
|
trophy_notification_born = now;
|
||||||
|
trophy_notification_image = LoadTrophyIcon(trophy_notification->icon_png);
|
||||||
PlayTrophySound();
|
PlayTrophySound();
|
||||||
}
|
}
|
||||||
const float age = std::chrono::duration<float>(now - trophy_notification_born).count();
|
const float age = std::chrono::duration<float>(now - trophy_notification_born).count();
|
||||||
@@ -1663,8 +1779,17 @@ struct SystemOverlay::Impl {
|
|||||||
line.data(), line.data() + line.size());
|
line.data(), line.data() + line.size());
|
||||||
line_y += line_height;
|
line_y += line_height;
|
||||||
}
|
}
|
||||||
draw->AddText(nullptr, 20.0f * screen_scale, {text_left, line_y + 8.0f * screen_scale},
|
const float subtitle_size = 20.0f * screen_scale;
|
||||||
fade(176, 180, 192, 255), "Trophy earned!");
|
const float subtitle_width =
|
||||||
|
ImGui::GetFont()
|
||||||
|
->CalcTextSizeA(subtitle_size, std::numeric_limits<float>::max(), 0.0f,
|
||||||
|
trophy_notification_subtitle.c_str())
|
||||||
|
.x;
|
||||||
|
const float available_width = std::max(1.0f, width - text_offset - text_padding);
|
||||||
|
const float fitted_size = std::max(
|
||||||
|
1.0f, subtitle_size * std::min(1.0f, available_width / std::max(1.0f, subtitle_width)));
|
||||||
|
draw->AddText(nullptr, fitted_size, {text_left, line_y + 8.0f * screen_scale},
|
||||||
|
fade(176, 180, 192, 255), trophy_notification_subtitle.c_str());
|
||||||
draw->PopClipRect();
|
draw->PopClipRect();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1782,6 +1907,7 @@ struct SystemOverlay::Impl {
|
|||||||
static constexpr std::size_t MAX_TOUCH_PARTICLES = 64;
|
static constexpr std::size_t MAX_TOUCH_PARTICLES = 64;
|
||||||
static constexpr std::size_t MAX_TOUCH_TRAIL_SEGMENTS = 128;
|
static constexpr std::size_t MAX_TOUCH_TRAIL_SEGMENTS = 128;
|
||||||
std::optional<TrophyNotification> trophy_notification;
|
std::optional<TrophyNotification> trophy_notification;
|
||||||
|
std::string trophy_notification_subtitle;
|
||||||
Clock::time_point trophy_notification_born;
|
Clock::time_point trophy_notification_born;
|
||||||
std::vector<std::unique_ptr<ImTextureData>> retired_trophy_images;
|
std::vector<std::unique_ptr<ImTextureData>> retired_trophy_images;
|
||||||
std::array<std::unique_ptr<DdsImage>, 2> splash_dds;
|
std::array<std::unique_ptr<DdsImage>, 2> splash_dds;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
#include "common/file.h"
|
#include "common/file.h"
|
||||||
|
#include "common/localization.h"
|
||||||
#include "common/trophies.h"
|
#include "common/trophies.h"
|
||||||
|
|
||||||
#include <chrono>
|
#include <chrono>
|
||||||
@@ -8,6 +9,7 @@
|
|||||||
#include <filesystem>
|
#include <filesystem>
|
||||||
#include <map>
|
#include <map>
|
||||||
#include <string>
|
#include <string>
|
||||||
|
#include <string_view>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
|
|
||||||
namespace {
|
namespace {
|
||||||
@@ -20,6 +22,65 @@ void Check(bool value, const char *text) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void TestLocalization() {
|
||||||
|
struct Translation {
|
||||||
|
std::string_view locale;
|
||||||
|
std::string_view earned;
|
||||||
|
};
|
||||||
|
static constexpr Translation translations[] = {
|
||||||
|
{"ja-JP", "トロフィーを獲得しました!"},
|
||||||
|
{"en-US", "Trophy earned!"},
|
||||||
|
{"fr-FR", "Trophée obtenu !"},
|
||||||
|
{"es-ES", "¡Ganaste un trofeo!"},
|
||||||
|
{"de-DE", "Trophäe erhalten!"},
|
||||||
|
{"it-IT", "Trofeo ottenuto!"},
|
||||||
|
{"nl-NL", "Trofee behaald!"},
|
||||||
|
{"pt-PT", "Troféu conquistado!"},
|
||||||
|
{"ru-RU", "Трофей получен!"},
|
||||||
|
{"ko-KR", "트로피를 획득했습니다!"},
|
||||||
|
{"zh-Hant", "已獲得獎盃!"},
|
||||||
|
{"zh-Hans", "已获得奖杯!"},
|
||||||
|
{"fi-FI", "Pokaali ansaittu!"},
|
||||||
|
{"sv-SE", "Trofé intjänad!"},
|
||||||
|
{"da-DK", "Trofæ optjent!"},
|
||||||
|
{"no-NO", "Trofé opptjent!"},
|
||||||
|
{"pl-PL", "Zdobyto trofeum!"},
|
||||||
|
{"pt-BR", "Troféu conquistado!"},
|
||||||
|
{"en-GB", "Trophy earned!"},
|
||||||
|
{"tr-TR", "Kupa kazanıldı!"},
|
||||||
|
{"es-419", "¡Ganaste un trofeo!"},
|
||||||
|
{"ar-AE", "تم كسب الكأس!"},
|
||||||
|
{"fr-CA", "Trophée obtenu !"},
|
||||||
|
{"cs-CZ", "Trofej získána!"},
|
||||||
|
{"hu-HU", "Trófea megszerezve!"},
|
||||||
|
{"el-GR", "Κερδίσατε ένα τρόπαιο!"},
|
||||||
|
{"ro-RO", "Trofeu câștigat!"},
|
||||||
|
{"th-TH", "ได้รับถ้วยรางวัลแล้ว!"},
|
||||||
|
{"vi-VN", "Đã nhận cúp!"},
|
||||||
|
{"id-ID", "Trofi diperoleh!"},
|
||||||
|
};
|
||||||
|
constexpr std::string_view key = "trophy.toast.earned";
|
||||||
|
const auto japanese = Common::Localization::Get(0, key);
|
||||||
|
int language = 0;
|
||||||
|
for (const auto &translation : translations) {
|
||||||
|
Check(Common::Localization::LocaleName(language) == translation.locale,
|
||||||
|
"console language selects its locale");
|
||||||
|
Check(Common::Localization::Get(language, key) == translation.earned,
|
||||||
|
"embedded notification preserves translated UTF-8 bytes");
|
||||||
|
++language;
|
||||||
|
}
|
||||||
|
Check(japanese == translations[0].earned,
|
||||||
|
"translation view survives other locale lookups");
|
||||||
|
for (const int invalid : {-1, 30, 1000}) {
|
||||||
|
Check(Common::Localization::LocaleName(invalid) == "en-US" &&
|
||||||
|
Common::Localization::Get(invalid, key) == "Trophy earned!",
|
||||||
|
"invalid console language falls back to English");
|
||||||
|
}
|
||||||
|
Check(Common::Localization::Get(0, "missing.translation") ==
|
||||||
|
"missing.translation",
|
||||||
|
"unknown translation returns its key");
|
||||||
|
}
|
||||||
|
|
||||||
class TempDirectory {
|
class TempDirectory {
|
||||||
public:
|
public:
|
||||||
TempDirectory() {
|
TempDirectory() {
|
||||||
@@ -252,6 +313,7 @@ void TestUnlockPersistence(const std::filesystem::path &directory) {
|
|||||||
} // namespace
|
} // namespace
|
||||||
|
|
||||||
int main() {
|
int main() {
|
||||||
|
TestLocalization();
|
||||||
TempDirectory directory;
|
TempDirectory directory;
|
||||||
TestEventExtraction(directory.Path());
|
TestEventExtraction(directory.Path());
|
||||||
TestProgress(directory.Path());
|
TestProgress(directory.Path());
|
||||||
|
|||||||
Reference in New Issue
Block a user