import { camelCaseToKebabCase } from "#app/utils/common"; import i18next from "i18next"; import LanguageDetector from "i18next-browser-languagedetector"; import HttpBackend from "i18next-http-backend"; import processor, { KoreanPostpositionProcessor } from "i18next-korean-postposition-processor"; import pkg from "../../package.json"; //#region Interfaces/Types interface LoadingFontFaceProperty { face: FontFace; extraOptions?: { [key: string]: any }; only?: Array; } //#region Constants let isInitialized = false; const unicodeRanges = { fullwidth: "U+FF00-FFEF", hangul: "U+1100-11FF,U+3130-318F,U+A960-A97F,U+AC00-D7AF,U+D7B0-D7FF", kana: "U+3040-30FF", CJKCommon: "U+2E80-2EFF,U+3000-303F,U+31C0-31EF,U+3200-32FF,U+3400-4DBF,U+F900-FAFF,U+FE30-FE4F", CJKIdeograph: "U+4E00-9FFF", specialCharacters: "U+266A,U+2605,U+2665,U+2663", //♪.★,♥,♣ }; const rangesByLanguage = { korean: [unicodeRanges.CJKCommon, unicodeRanges.hangul].join(","), chinese: [unicodeRanges.CJKCommon, unicodeRanges.fullwidth, unicodeRanges.CJKIdeograph].join(","), japanese: [unicodeRanges.CJKCommon, unicodeRanges.fullwidth, unicodeRanges.kana, unicodeRanges.CJKIdeograph].join( ",", ), }; const fonts: Array = [ // unicode (special character from PokePT) { face: new FontFace("emerald", "url(./fonts/PokePT_Wansung.woff2)", { unicodeRange: unicodeRanges.specialCharacters, }), }, { face: new FontFace("pkmnems", "url(./fonts/PokePT_Wansung.woff2)", { unicodeRange: unicodeRanges.specialCharacters, }), extraOptions: { sizeAdjust: "133%" }, }, // unicode (korean) { face: new FontFace("emerald", "url(./fonts/PokePT_Wansung.woff2)", { unicodeRange: rangesByLanguage.korean, }), }, { face: new FontFace("pkmnems", "url(./fonts/PokePT_Wansung.woff2)", { unicodeRange: rangesByLanguage.korean, }), extraOptions: { sizeAdjust: "133%" }, }, // unicode (chinese) { face: new FontFace("emerald", "url(./fonts/unifont-15.1.05.subset.woff2)", { unicodeRange: rangesByLanguage.chinese, }), extraOptions: { sizeAdjust: "70%", format: "woff2" }, only: ["en", "es", "fr", "it", "de", "zh", "pt", "ko", "ca", "da", "tr", "ro", "ru"], }, { face: new FontFace("pkmnems", "url(./fonts/unifont-15.1.05.subset.woff2)", { unicodeRange: rangesByLanguage.chinese, }), extraOptions: { format: "woff2" }, only: ["en", "es", "fr", "it", "de", "zh", "pt", "ko", "ca", "da", "tr", "ro", "ru"], }, // japanese { face: new FontFace("emerald", "url(./fonts/Galmuri11.subset.woff2)", { unicodeRange: rangesByLanguage.japanese, }), extraOptions: { sizeAdjust: "66%" }, only: ["ja"], }, { face: new FontFace("pkmnems", "url(./fonts/Galmuri9.subset.woff2)", { unicodeRange: rangesByLanguage.japanese, }), only: ["ja"], }, ]; /** maps namespaces that deviate from the file-name */ const namespaceMap = { titles: "trainer-titles", moveTriggers: "move-trigger", abilityTriggers: "ability-trigger", battlePokemonForm: "pokemon-form-battle", miscDialogue: "dialogue-misc", battleSpecDialogue: "dialogue-final-boss", doubleBattleDialogue: "dialogue-double-battle", splashMessages: "splash-texts", mysteryEncounterMessages: "mystery-encounter-texts", biome: "biomes", }; //#region Functions async function initFonts(language: string | undefined) { const results = await Promise.allSettled( fonts .filter(font => !font.only || font.only.some(exclude => language?.indexOf(exclude) === 0)) .map(font => Object.assign(font.face, font.extraOptions ?? {}).load()), ); for (const result of results) { if (result.status === "fulfilled") { document.fonts?.add(result.value); } else { console.error(result.reason); } } } /** * I18n money formatter with. (useful for BBCode coloring of text)\ * *If you don't want the BBCode tag applied, just use 'number' formatter* * @example Input: `{{myMoneyValue, money}}` * Output: `@[MONEY]{₽100,000,000}` * @param amount the money amount * @returns a money formatted string */ function i18nMoneyFormatter(amount: any): string { if (Number.isNaN(Number(amount))) { console.warn(`i18nMoneyFormatter: value "${amount}" is not a number!`); } return `@[MONEY]{${i18next.t("common:money", { amount })}}`; } //#region Exports /** * Initialize i18n with fonts */ export async function initI18n(): Promise { // Prevent reinitialization if (isInitialized) { return; } isInitialized = true; /** * i18next is a localization library for maintaining and using translation resources. * * Q: How do I add a new language? * A: To add a new language, create a new folder in the locales directory with the language code. * Each language folder should contain a file for each namespace (ex. menu.ts) with the translations. * Don't forget to declare new language in `supportedLngs` i18next initializer * * Q: How do I add a new namespace? * A: To add a new namespace, create a new file in each language folder with the translations. * Then update the config file for that language in its locale directory * and the CustomTypeOptions interface in the @types/i18next.d.ts file. * * Q: How do I make a language selectable in the settings? * A: In src/system/settings.ts, add a new case to the Setting.Language switch statement. */ i18next.use(HttpBackend); i18next.use(LanguageDetector); i18next.use(processor); i18next.use(new KoreanPostpositionProcessor()); await i18next.init({ fallbackLng: { "es-MX": ["es-ES", "en"], default: ["en"], }, supportedLngs: [ "en", "es-ES", "es-MX", "fr", "it", "de", "zh-CN", "zh-TW", "pt-BR", "ko", "ja", "ca", "da", "tr", "ro", "ru", ], backend: { loadPath(lng: string, [ns]: string[]) { let fileName: string; if (namespaceMap[ns]) { fileName = namespaceMap[ns]; } else if (ns.startsWith("mysteryEncounters/")) { fileName = camelCaseToKebabCase(ns + "Dialogue"); } else { fileName = camelCaseToKebabCase(ns); } return `./locales/${lng}/${fileName}.json?v=${pkg.version}`; }, }, defaultNS: "menu", ns: [ "ability", "abilityTriggers", "arenaFlyout", "arenaTag", "battle", "battleScene", "battleInfo", "battleMessageUiHandler", "battlePokemonForm", "battlerTags", "berry", "bgmName", "biome", "challenges", "commandUiHandler", "common", "achv", "dialogue", "battleSpecDialogue", "miscDialogue", "doubleBattleDialogue", "egg", "fightUiHandler", "filterBar", "filterText", "gameMode", "gameStatsUiHandler", "growth", "menu", "menuUiHandler", "modifier", "modifierType", "move", "nature", "pokeball", "pokedexUiHandler", "pokemon", "pokemonCategory", "pokemonEvolutions", "pokemonForm", "pokemonInfo", "pokemonInfoContainer", "pokemonSummary", "saveSlotSelectUiHandler", "settings", "splashMessages", "starterSelectUiHandler", "statusEffect", "terrain", "titles", "trainerClasses", "trainersCommon", "trainerNames", "tutorial", "voucher", "weather", "partyUiHandler", "modifierSelectUiHandler", "moveTriggers", "runHistory", "mysteryEncounters/mysteriousChallengers", "mysteryEncounters/mysteriousChest", "mysteryEncounters/darkDeal", "mysteryEncounters/fightOrFlight", "mysteryEncounters/slumberingSnorlax", "mysteryEncounters/trainingSession", "mysteryEncounters/departmentStoreSale", "mysteryEncounters/shadyVitaminDealer", "mysteryEncounters/fieldTrip", "mysteryEncounters/safariZone", "mysteryEncounters/lostAtSea", "mysteryEncounters/fieryFallout", "mysteryEncounters/theStrongStuff", "mysteryEncounters/thePokemonSalesman", "mysteryEncounters/anOfferYouCantRefuse", "mysteryEncounters/delibirdy", "mysteryEncounters/absoluteAvarice", "mysteryEncounters/aTrainersTest", "mysteryEncounters/trashToTreasure", "mysteryEncounters/berriesAbound", "mysteryEncounters/clowningAround", "mysteryEncounters/partTimer", "mysteryEncounters/dancingLessons", "mysteryEncounters/weirdDream", "mysteryEncounters/theWinstrateChallenge", "mysteryEncounters/teleportingHijinks", "mysteryEncounters/bugTypeSuperfan", "mysteryEncounters/funAndGames", "mysteryEncounters/uncommonBreed", "mysteryEncounters/globalTradeSystem", "mysteryEncounters/theExpertPokemonBreeder", "mysteryEncounters/creepingFog", "mysteryEncounterMessages", ], detection: { lookupLocalStorage: "prLang", }, debug: Number(import.meta.env.VITE_I18N_DEBUG) === 1, interpolation: { escapeValue: false, }, postProcess: ["korean-postposition"], }); if (i18next.services.formatter) { i18next.services.formatter.add("money", i18nMoneyFormatter); } await initFonts(localStorage.getItem("prLang") ?? undefined); } export function getIsInitialized(): boolean { return isInitialized; } export default i18next; //#endregion