diff --git a/src/core/game3/easy_chat_text.lua b/src/core/game3/easy_chat_text.lua new file mode 100644 index 00000000..75b98ccd --- /dev/null +++ b/src/core/game3/easy_chat_text.lua @@ -0,0 +1,73 @@ +-- Translation seam for the Easy Chat vocabulary. +-- +-- src/core/game3/easy_chat_data.lua is written by the extractor +-- (src/import/gba/easy_chat_extract.lua) from the ROM's own word tables, so it +-- stays exactly what the cart holds: English words, keyed by the ids the save +-- file stores. Everything that puts one of those words on screen goes through +-- here instead, the same "data stays raw, the display translates" split the +-- rest of game3 uses. +-- +-- A word carries its group's context, because the same English word means +-- different things in different groups and the official translations do not +-- agree on one wording: SHINE is a MOVE in one group and a FEELING in another, +-- and words like ATTACK, BAG or CANCEL collide with menu labels this engine +-- already translates elsewhere. Strings() falls back to the plain key when a +-- catalog has no context-specific entry, so a translation that does not care +-- about the distinction still lands with one entry. +local EasyChatData = require("src.core.game3.easy_chat_data") +local Strings = require("src.core.Strings") + +local EasyChatText = {} + +--- The catalog context for a group id ("easyChat.FEELINGS"). +function EasyChatText.context(groupId) + local group = EasyChatData.GROUPS and EasyChatData.GROUPS[groupId] + return "easyChat." .. tostring(group and group.name or groupId) +end + +--- A group's name as the picker lists it down its left side. +function EasyChatText.groupName(group) + if type(group) == "number" then + group = EasyChatData.GROUPS and EasyChatData.GROUPS[group] + end + local name = group and group.name + if type(name) ~= "string" or name == "" then return "" end + return Strings(name, "easyChat.group") +end + +--- One word, by the id the save file stores. +function EasyChatText.word(wordId) + local raw = EasyChatData.getWord(wordId) + if type(raw) ~= "string" or raw == "" then return raw or "" end + local groupId = EasyChatData.decodeWord(wordId) + return Strings(raw, EasyChatText.context(groupId)) +end + +--- A word the picker draws from its own group list, where the group is known +--- without decoding the id. +function EasyChatText.wordInGroup(entry, group) + local raw = entry and entry.text + if type(raw) ~= "string" or raw == "" then return raw or "" end + if type(group) == "table" then group = group.id end + return Strings(raw, EasyChatText.context(group)) +end + +--- The profile as a message box shows it: the same rows EasyChatData builds, +--- with every word translated. +function EasyChatText.phrase(words, columns, rows) + words = words or {} + columns, rows = columns or 2, rows or 2 + local out, index = {}, 1 + for _ = 1, rows do + local line = {} + for _ = 1, columns do + local word = EasyChatText.word(words[index]) + if word ~= "" then line[#line + 1] = word end + index = index + 1 + end + out[#out + 1] = table.concat(line, " ") + end + return table.concat(out, "\n") +end + +return EasyChatText diff --git a/src/core/game3/scripting/natives.lua b/src/core/game3/scripting/natives.lua index 65937352..6794e9a1 100644 --- a/src/core/game3/scripting/natives.lua +++ b/src/core/game3/scripting/natives.lua @@ -602,8 +602,11 @@ Natives.ALLOW = { local Runtime = package.loaded["src.core.game3.runtime"] local session = Runtime and Runtime.getSession and Runtime.getSession() local EasyChatData = require("src.core.game3.easy_chat_data") + local EasyChatText = require("src.core.game3.easy_chat_text") local words = (session and session.easyChatProfile) or EasyChatData.DEFAULT_PROFILE - local text = EasyChatData.formatPhrase(words, 2, 2) + -- The saved profile is a list of word ids; the words themselves are drawn + -- here, so they go through the catalog like the picker's own list. + local text = EasyChatText.phrase(words, 2, 2) if adapters and adapters.openMessage then adapters.openMessage(text) end diff --git a/src/ui/game3/easy_chat.lua b/src/ui/game3/easy_chat.lua index 64aff3ae..8091ef89 100644 --- a/src/ui/game3/easy_chat.lua +++ b/src/ui/game3/easy_chat.lua @@ -6,6 +6,7 @@ local FrlgFont = require("src.ui.game3.frlg_font") local Stack = require("src.ui.game3.stack") local Audio = require("src.core.game3.audio") local EasyChatData = require("src.core.game3.easy_chat_data") +local EasyChatText = require("src.core.game3.easy_chat_text") local Chrome = require("src.ui.game3.chrome") local Strings = require("src.core.Strings") @@ -22,6 +23,22 @@ local SLOTS_LAYOUT = { { x = 126, y = 52, w = 84, h = 16 }, } +-- The cart's own words fit the boxes they are drawn in -- the widest is +-- 72 px -- a translated one need not, so every word is clipped to its box. +-- In the picker that box is the red selection rectangle, which starts 12 px +-- left of the pen and is 92 px wide. In the phrase frame it is the slot's +-- own frame (SLOTS_LAYOUT), which also keeps a left word clear of the right +-- slot's cursor. Group names get the room up to the scroll arrows (centred +-- at +112, 8 px wide) instead: an official translation already needs it -- +-- the French cart's VIE QUOTIDIEN. is 83 px. +local CELL_WIDTH = 80 +local GROUP_CELL_WIDTH = 88 + +local function slotWidth(index, penX) + local frame = SLOTS_LAYOUT[index] + return frame.x + frame.w - penX +end + -- Strings.source, not Strings: this table is built at require time, before a -- catalog is loaded, so the draw below looks each label up at use time. local FOOTER_BTNS = { @@ -502,9 +519,9 @@ function EasyChat.draw() -- 7 Red underscores matching pret CHAR_EXTRA_SYMBOL + CHAR_UNDERSCORE (7 glyphs in FONT_NORMAL_COPY_1) FrlgFont.draw("_______", s.x, s.y, { colors = FrlgFont.COLOR.RED }) else - local wText = EasyChatData.getWord(wid) + local wText = EasyChatText.word(wid) if wText and wText ~= "" then - FrlgFont.draw(wText, s.x, s.y, { colors = FrlgFont.COLOR.NORMAL }) + FrlgFont.draw(wText, s.x, s.y, { maxWidth = slotWidth(i, s.x), colors = FrlgFont.COLOR.NORMAL }) else FrlgFont.draw("_______", s.x, s.y, { colors = FrlgFont.COLOR.RED }) end @@ -595,9 +612,9 @@ function EasyChat.draw() love.graphics.setColor(224 / 255, 32 / 255, 32 / 255, 1) love.graphics.rectangle("line", xPos - 12, yPos - 1, 92, 14, 2, 2) drawTriangleCursor(xPos - 10, yPos + 3, st.animTimer) - FrlgFont.draw(grp.name, xPos, yPos, { colors = FrlgFont.COLOR.NORMAL }) + FrlgFont.draw(EasyChatText.groupName(grp), xPos, yPos, { maxWidth = GROUP_CELL_WIDTH, colors = FrlgFont.COLOR.NORMAL }) else - FrlgFont.draw(grp.name, xPos, yPos, { colors = FrlgFont.COLOR.NORMAL }) + FrlgFont.draw(EasyChatText.groupName(grp), xPos, yPos, { maxWidth = GROUP_CELL_WIDTH, colors = FrlgFont.COLOR.NORMAL }) end end end @@ -616,7 +633,7 @@ function EasyChat.draw() local pageOffset = st.wordPage * pageSize -- Top group title - local grpHeader = string.format("%s", curGroup and curGroup.name or "") + local grpHeader = EasyChatText.groupName(curGroup) FrlgFont.draw(grpHeader, wX + 8, wY + 4, { colors = FrlgFont.COLOR.NORMAL }) -- Page indicator @@ -646,9 +663,9 @@ function EasyChat.draw() love.graphics.setColor(224 / 255, 32 / 255, 32 / 255, 1) love.graphics.rectangle("line", xPos - 12, yPos - 1, 92, 14, 2, 2) drawTriangleCursor(xPos - 10, yPos + 3, st.animTimer) - FrlgFont.draw(wEntry.text, xPos, yPos, { colors = FrlgFont.COLOR.NORMAL }) + FrlgFont.draw(EasyChatText.wordInGroup(wEntry, curGroup), xPos, yPos, { maxWidth = CELL_WIDTH, colors = FrlgFont.COLOR.NORMAL }) else - FrlgFont.draw(wEntry.text, xPos, yPos, { colors = FrlgFont.COLOR.NORMAL }) + FrlgFont.draw(EasyChatText.wordInGroup(wEntry, curGroup), xPos, yPos, { maxWidth = CELL_WIDTH, colors = FrlgFont.COLOR.NORMAL }) end end end diff --git a/tests/engine/game3_easy_chat_text_test.lua b/tests/engine/game3_easy_chat_text_test.lua new file mode 100644 index 00000000..bce65949 --- /dev/null +++ b/tests/engine/game3_easy_chat_text_test.lua @@ -0,0 +1,61 @@ +#!/usr/bin/env luajit +-- The Easy Chat vocabulary is engine data extracted from the ROM, so it stays +-- English in easy_chat_data.lua and is translated where it is drawn +-- (src/core/game3/easy_chat_text.lua). Each word carries its group's context, +-- because the same English word means different things in different groups -- +-- and collides with menu labels this engine already translates elsewhere. + +package.path = "./?.lua;./?/init.lua;" .. package.path +love = require("tests.love_stub") + +local T = require("tests.harness") +local check = T.check + +local Strings = require("src.core.Strings") +local EasyChatData = require("src.core.game3.easy_chat_data") +local EasyChatText = require("src.core.game3.easy_chat_text") + +local function groupNamed(name) + for id, group in pairs(EasyChatData.GROUPS) do + if group.name == name then return id, group end + end +end + +local moveId, moveGroup = groupNamed("MOVE 1") +local feelId, feelGroup = groupNamed("FEELINGS") +check(moveGroup and feelGroup, "the extracted data still has the MOVE 1 and FEELINGS groups") + +local moveWord = moveGroup.words[1] +local feelWord = feelGroup.words[1] + +-- ------------------------------------------------------ no catalog loaded +Strings.load({}) +check(EasyChatText.word(moveWord.id) == moveWord.text, "a word is its English self with no catalog") +check(EasyChatText.groupName(feelGroup) == "FEELINGS", "so is a group name") +check(EasyChatData.getWord(moveWord.id) == moveWord.text, + "and the extracted data itself is never rewritten") + +-- ------------------------------------------------------- a mod's catalog +Strings.load({ strings = { + ["easyChat.group|FEELINGS"] = "SENTIMENTS", + ["easyChat.MOVE 1|" .. moveWord.text] = "ATTAQUE-FR", + [feelWord.text] = "SANS-CONTEXTE", +} }) + +check(EasyChatText.groupName(feelGroup) == "SENTIMENTS", "a group name translates under its own context") +check(EasyChatText.word(moveWord.id) == "ATTAQUE-FR", "a word translates under its group's context") +check(EasyChatText.wordInGroup(moveWord, moveGroup) == "ATTAQUE-FR", + "the picker's own list resolves the same key without decoding the id") +check(EasyChatText.word(feelWord.id) == "SANS-CONTEXTE", + "a catalog that does not care about the group still lands through the plain key") + +local phrase = EasyChatText.phrase({ moveWord.id, feelWord.id }, 2, 1) +check(phrase == "ATTAQUE-FR SANS-CONTEXTE", "a saved profile prints its words translated") +check(EasyChatText.phrase({}, 2, 2) == "\n", "an empty profile still yields its rows") + +-- A word id that is not in the table keeps the data module's own placeholder. +check(EasyChatText.word(EasyChatData.EC_WORD_UNDEFINED) == "", "an unset slot stays empty") + +Strings.load({}) + +T.finish("game3_easy_chat_text_test") diff --git a/tools/modkit.py b/tools/modkit.py index 6f5effab..bda492f5 100644 --- a/tools/modkit.py +++ b/tools/modkit.py @@ -1457,6 +1457,24 @@ for id, def in pairs(statuses or {}) do emit("status_hud", id, def.hudLabel) end end +-- The Easy Chat vocabulary is engine data, not a Strings() call site, so the +-- literal harvester below cannot see it: the picker looks each word up at +-- draw time under its group's context (src/core/game3/easy_chat_text.lua). +-- Emit those keys here so a catalog carries them like any other engine text. +local okEasyChat, EasyChatData = pcall(require, "src.core.game3.easy_chat_data") +if okEasyChat and type(EasyChatData) == "table" and type(EasyChatData.GROUPS) == "table" then + for _, group in pairs(EasyChatData.GROUPS) do + if type(group) == "table" and type(group.name) == "string" and group.name ~= "" then + emit("easy_chat", "easyChat.group|" .. group.name, group.name) + for _, word in ipairs(group.words or {}) do + if type(word) == "table" and type(word.text) == "string" and word.text ~= "" then + emit("easy_chat", "easyChat." .. group.name .. "|" .. word.text, word.text) + end + end + end + end +end + -- dex entries carry their own prose (species flavour text) for id, def in pairs(D.pokemon or {}) do if type(def.dexEntry) == "table" then @@ -1878,9 +1896,14 @@ def cmd_translation(args, repo): catalogs = [ ("dialogue", "Script text", grouped.get("dialogue", []), False, "Keyed by the original text label. The English is in the comment."), - ("strings", "Engine text", [(lit, where) for lit, where in engine], True, + ("strings", "Engine text", + [(lit, where) for lit, where in engine] + sorted(grouped.get("easy_chat", [])), True, "Keyed by the English source, which is also what draws if you leave\n" - "an entry empty. Keep any %s / %d directives."), + "an entry empty. Keep any %s / %d directives.\n" + "The easyChat.* keys are the Easy Chat vocabulary: a group's name, or\n" + "one of its words. They carry the group as a context because the same\n" + "word means different things in different groups; an entry keyed by\n" + "the bare word still applies where no context-specific one exists."), ("species_names", "Species names", grouped.get("species", []), False, ""), ("move_names", "Move names", grouped.get("move", []), False, ""), ("item_names", "Item names", grouped.get("item", []), False, ""),