diff --git a/src/ui/gen2/OptionsMenu.lua b/src/ui/gen2/OptionsMenu.lua index 3ff6f334..5d8d62c8 100644 --- a/src/ui/gen2/OptionsMenu.lua +++ b/src/ui/gen2/OptionsMenu.lua @@ -22,6 +22,7 @@ local Chrome = require("src.ui.gen2.Chrome") local Logger = require("src.core.Logger") local Runtime = require("src.mods.Runtime") local Save = require("src.core.gen2.Save") +local Strings = require("src.core.Strings") local OptionsMenu = {} OptionsMenu.__index = OptionsMenu @@ -42,43 +43,60 @@ end -- Each row: the label, the option key it edits, and the cycle of values with -- the exact strings the cart prints (trailing spaces included -- they are what -- blank the longer previous value, e.g. "MID " over "SLOW"). +-- Labels and cart-original display strings are wrapped in Strings.source so +-- the catalog generator harvests them even though this table is built once +-- at require time, before any mod's Strings.load has a catalog to answer +-- from (src/core/Strings.lua's own note on this). The lookup itself happens +-- live, in drawPanel, through plain Strings(...) calls. local ROWS = { { - label = "TEXT SPEED", key = "textSpeed", + label = Strings.source("TEXT SPEED"), key = "textSpeed", values = { "FAST", "MID", "SLOW" }, - display = { FAST = "FAST", MID = "MID ", SLOW = "SLOW" }, - }, - { - label = "BATTLE SCENE", key = "battleScene", - values = { true, false }, - display = { [true] = "ON ", [false] = "OFF" }, - }, - { - label = "BATTLE STYLE", key = "battleStyle", - values = { "SHIFT", "SET" }, - display = { SHIFT = "SHIFT", SET = "SET " }, - }, - { - label = "SOUND", key = "sound", - values = { "MONO", "STEREO" }, - display = { MONO = "MONO ", STEREO = "STEREO" }, - }, - { - label = "PRINT", key = "print", - values = { "LIGHTEST", "LIGHTER", "NORMAL", "DARKER", "DARKEST" }, display = { - LIGHTEST = "LIGHTEST", LIGHTER = "LIGHTER ", NORMAL = "NORMAL ", - DARKER = "DARKER ", DARKEST = "DARKEST ", + FAST = Strings.source("FAST"), MID = Strings.source("MID "), + SLOW = Strings.source("SLOW"), }, }, { - label = "MENU ACCOUNT", key = "menuAccount", + label = Strings.source("BATTLE SCENE"), key = "battleScene", + values = { true, false }, + display = { + [true] = Strings.source("ON "), [false] = Strings.source("OFF"), + }, + }, + { + label = Strings.source("BATTLE STYLE"), key = "battleStyle", + values = { "SHIFT", "SET" }, + display = { + SHIFT = Strings.source("SHIFT"), SET = Strings.source("SET "), + }, + }, + { + label = Strings.source("SOUND"), key = "sound", + values = { "MONO", "STEREO" }, + display = { + MONO = Strings.source("MONO "), STEREO = Strings.source("STEREO"), + }, + }, + { + label = Strings.source("PRINT"), key = "print", + values = { "LIGHTEST", "LIGHTER", "NORMAL", "DARKER", "DARKEST" }, + display = { + LIGHTEST = Strings.source("LIGHTEST"), LIGHTER = Strings.source("LIGHTER "), + NORMAL = Strings.source("NORMAL "), DARKER = Strings.source("DARKER "), + DARKEST = Strings.source("DARKEST "), + }, + }, + { + label = Strings.source("MENU ACCOUNT"), key = "menuAccount", values = { false, true }, - display = { [false] = "OFF", [true] = "ON " }, + display = { + [false] = Strings.source("OFF"), [true] = Strings.source("ON "), + }, }, -- FRAME is the textbox border style, 1-8, and prints its number after the -- word TYPE rather than in the shared value column. - { label = "FRAME", key = "frame", frame = true }, + { label = Strings.source("FRAME"), key = "frame", frame = true }, -- Everything from here down is the port's, not the cart's. They are the -- same settings the Gen 1 OPTION screen carries and they drive the same -- shared modules, so a player who learns them in Red knows them here. The @@ -86,17 +104,17 @@ local ROWS = { -- -- The two volume rows clamp at the ends rather than wrapping, the way -- pokered's text-speed cursor does, so holding left reaches OFF and stays. - { id = "controls", label = "CONTROLS", port = true, + { id = "controls", label = Strings.source("CONTROLS"), port = true, activate = function(game) require("src.ui.Screens").push(game, "BindingsMenu") end }, - { label = "MUSIC VOL", key = "musicVol", port = true, + { label = Strings.source("MUSIC VOL"), key = "musicVol", port = true, cycle = function(options, delta) options.musicVol = stepVolume(options.musicVol, delta) require("src.core.Music").setVolumeLevel(options.musicVol) end, text = function(options) return volLabel(options.musicVol) end }, - { label = "SFX VOL", key = "sfxVol", port = true, + { label = Strings.source("SFX VOL"), key = "sfxVol", port = true, cycle = function(options, delta) options.sfxVol = stepVolume(options.sfxVol, delta) require("src.core.Sound").setVolumeLevel(options.sfxVol) @@ -104,7 +122,7 @@ local ROWS = { text = function(options) return volLabel(options.sfxVol) end }, -- Each filter step keeps 40% of the previous step's treble, so 2X and 3X -- are the 1X low-pass applied twice and three times over. - { label = "MUSIC FILTER", key = "musicFilter", port = true, + { label = Strings.source("MUSIC FILTER"), key = "musicFilter", port = true, cycle = function(options, delta) options.musicFilter = ((options.musicFilter or 0) + delta) % #FILTERS require("src.core.Music").setFilterLevel(options.musicFilter) @@ -112,7 +130,7 @@ local ROWS = { text = function(options) return FILTERS[(options.musicFilter or 0) + 1] end }, - { label = "GAME SPEED", key = "speed", port = true, + { label = Strings.source("GAME SPEED"), key = "speed", port = true, cycle = function(options, delta) local GameSpeed = require("src.core.GameSpeed") options.speed = GameSpeed.cycle(options.speed, delta) @@ -120,7 +138,7 @@ local ROWS = { text = function(options) return require("src.core.GameSpeed").levelLabel(options.speed) end }, - { label = "ZOOM", key = "zoom", port = true, + { label = Strings.source("ZOOM"), key = "zoom", port = true, cycle = function(options, delta, game) local Zoom = require("src.render.Zoom") local scale = Zoom.windowFitScale() @@ -136,7 +154,7 @@ local ROWS = { -- a boundary; WATER / TREES force one outdoor block; BLACK is a flat void. -- #1418. Same key the Gen 1 OPTION screen uses, different ladder (FADE -- is Gold's default because that is already what the maps call for). - { label = "VOID FILL", key = "voidFill", port = true, + { label = Strings.source("VOID FILL"), key = "voidFill", port = true, cycle = function(options, delta) local BorderFill = require("src.world.gen2.BorderFill") BorderFill.setVoidFill(options.voidFill or "fade") @@ -145,7 +163,7 @@ local ROWS = { text = function(options) return require("src.world.gen2.BorderFill").voidFillLabel(options.voidFill) end }, - { label = "TILT", key = "tilt", port = true, + { label = Strings.source("TILT"), key = "tilt", port = true, cycle = function(options, delta) local Tilt = require("src.render.Tilt") -- Four levels (OFF, 15, 35, 50); left steps back through them. @@ -160,7 +178,7 @@ local ROWS = { -- CGB game whose colour comes from its own palettes, so there are no packs -- to swap -- what there is instead is the choice to turn that colour OFF, -- down to the grey Game Boy or the green one. GBC is the default. - { label = "COLOR", key = "color", port = true, + { label = Strings.source("COLOR"), key = "color", port = true, cycle = function(options, delta) local GbcPalette = require("src.render.GbcPalette") GbcPalette.setMode(options.color or "gbc") @@ -169,7 +187,7 @@ local ROWS = { text = function(options) return require("src.render.GbcPalette").modeLabel(options.color or "gbc") end }, - { label = "GBC FX", key = "gbcfx", port = true, + { label = Strings.source("GBC FX"), key = "gbcfx", port = true, cycle = function(options, delta) local GBCFX = require("src.render.GBCFX") if not GBCFX.isSupported() then @@ -183,7 +201,7 @@ local ROWS = { text = function(options) return require("src.render.GBCFX").levelLabel(options.gbcfx or 0) end }, - { label = "VIDEO MODE", key = "videoMode", port = true, + { label = Strings.source("VIDEO MODE"), key = "videoMode", port = true, cycle = function(options, delta) local VideoMode = require("src.core.VideoMode") options.videoMode = VideoMode.cycle(options.videoMode, delta) @@ -194,20 +212,20 @@ local ROWS = { return VideoMode.normalize(options.videoMode) == "borderless" and "FULL" or "WINDOWED" end }, - { label = "SCREEN POS", key = "screenPos", port = true, + { label = Strings.source("SCREEN POS"), key = "screenPos", port = true, cycle = function(options, delta) local ScreenPosition = require("src.core.ScreenPosition") options.screenPos = ScreenPosition.cycle(options.screenPos, delta) ScreenPosition.setMode(options.screenPos) end, text = function(options) - return require("src.core.ScreenPosition").label(options.screenPos) + return Strings(require("src.core.ScreenPosition").label(options.screenPos)) end }, - { id = "touchControls", label = "TOUCH PAD", port = true, + { id = "touchControls", label = Strings.source("TOUCH PAD"), port = true, text = function(options) local tc = options.touchControls local on = not (type(tc) == "table" and tc.enabled == false) - return on and "ON" or "OFF" + return on and Strings("ON") or Strings("OFF") end, cycle = function(options, _delta, game) local tc = type(options.touchControls) == "table" and options.touchControls or {} @@ -216,13 +234,13 @@ local ROWS = { require("src.core.TouchControls"):applyOptions(options) if game and game.persistOptions then game:persistOptions() end end }, - { id = "touchLayout", label = "TOUCH LAYOUT", port = true, + { id = "touchLayout", label = Strings.source("TOUCH LAYOUT"), port = true, activate = function(game) game.stack:push(require("src.ui.TouchControlsEditor").new(game)) end }, - { id = "haptics", label = "VIBRATION", port = true, + { id = "haptics", label = Strings.source("VIBRATION"), port = true, text = function(options) - return require("src.core.TouchControls").hapticLabel(options.haptics) + return Strings(require("src.core.TouchControls").hapticLabel(options.haptics)) end, cycle = function(options, delta, game) local TC = require("src.core.TouchControls") @@ -231,7 +249,7 @@ local ROWS = { TC.buzz(options.haptics) if game and game.persistOptions then game:persistOptions() end end }, - { label = "MAX FPS", key = "fpsCap", port = true, + { label = Strings.source("MAX FPS"), key = "fpsCap", port = true, cycle = function(options, delta) local FrameCap = require("src.core.FrameCap") options.fpsCap = FrameCap.cycle(options.fpsCap, delta) @@ -240,7 +258,7 @@ local ROWS = { text = function(options) return require("src.core.FrameCap").label(options.fpsCap) end }, - { label = "CANCEL", cancel = true }, + { label = Strings.source("CANCEL"), cancel = true }, } -- The cart's screen is one full-height textbox with every row on it. This one @@ -424,9 +442,9 @@ function OptionsMenu:drawPanel() local row = self.rows[i] if row then local labelY = 2 + (slot - 1) * 2 - Chrome.print(row.label, 2, labelY) + Chrome.print(Strings(row.label), 2, labelY) if row.frame then - Chrome.print(":TYPE", 10, labelY + 1) + Chrome.print(Strings(":TYPE"), 10, labelY + 1) Chrome.print(tostring(self.options.frame or 1), 16, labelY + 1) elseif row.text then Chrome.print(":", 10, labelY + 1) @@ -434,7 +452,7 @@ function OptionsMenu:drawPanel() elseif row.values then Chrome.print(":", 10, labelY + 1) local value = self.options[row.key] - local text = row.display and row.display[value] or tostring(value) + local text = row.display and Strings(row.display[value]) or tostring(value) Chrome.print(text, 11, labelY + 1) elseif type(row.value) == "function" then -- the Gen 1 row's value reader (src/ui/OptionRows.lua:4), so a mod row diff --git a/tests/engine/gen2_options_menu_translation_test.lua b/tests/engine/gen2_options_menu_translation_test.lua new file mode 100644 index 00000000..2ce171c3 --- /dev/null +++ b/tests/engine/gen2_options_menu_translation_test.lua @@ -0,0 +1,121 @@ +-- Gold's OPTION screen (src/ui/gen2/OptionsMenu.lua) used to draw every row +-- label -- and the cart-original value strings (FAST/MID/SLOW, ON/OFF, +-- SHIFT/SET, ...) -- as bare literals baked into the module-level ROWS +-- table, invisible to a translation mod's `strings` registry (reported +-- against a real Gold build, gen1recomp#1642). This drives +-- OptionsMenu:drawPanel() with a mod-loaded Strings catalog and checks the +-- translated text reaches Font.draw, for both a cart-original row (label + +-- display value) and a port-added row (label only -- its value already +-- comes pre-translated from the shared module it calls, same as the Gen 1 +-- OPTION screen's equivalent rows), plus a vanilla no-mod case proving the +-- fallback is unchanged. +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") + +love = require("tests.love_stub") + +require("src.core.Logger").warn = function() end + +-- Chrome.print (the only draw call this screen makes) goes straight to +-- Font.draw, so recording that call is enough to see exactly what text +-- reached the screen -- same technique as +-- tests/engine/status_abbreviation_translation_test.lua. Stubbed before +-- OptionsMenu (and the Chrome module it requires) ever loads, so Chrome's +-- own `local Font = require(...)` captures this stub instead of the real +-- module. +local drawn +package.loaded["src.render.Font"] = { + draw = function(text, x, y) + drawn[#drawn + 1] = { text = text, x = x, y = y } + end, + drawCode = function() end, + drawBox = function() end, +} + +local OptionsMenu = require("src.ui.gen2.OptionsMenu") +local Strings = require("src.core.Strings") + +local function drawnAt(x, y) + for _, d in ipairs(drawn) do + if d.x == x and d.y == y then return d.text end + end + return nil +end + +-- Chrome.print multiplies tile coordinates by 8 (src/ui/gen2/Chrome.lua); +-- drawPanel puts labels at tile x=2 and values at tile x=11. +local LABEL_X = 2 * 8 +local VALUE_X = 11 * 8 + +local function rowIndex(rows, id) + for i, row in ipairs(rows) do + if row.id == id then return i end + end +end + +-- ---------------------------------------------- vanilla: no mod catalog +do + local menu = OptionsMenu.new({}) + drawn = {} + menu:drawPanel() + T.eq(drawnAt(LABEL_X, 2 * 8), "TEXT SPEED", + "row 1's label draws in English with no mod loaded") + -- Save.DEFAULT_OPTIONS.textSpeed is "MID", the cart's own default. + T.eq(drawnAt(VALUE_X, 3 * 8), "MID ", + "and its cart-original display value too") +end + +-- ------------------------------------------------- a translation mod's turn +do + Strings.load({ + strings = { + ["TEXT SPEED"] = "VITESSE TEXTE", + ["MID "] = "MOY ", + ["CONTROLS"] = "COMMANDES", + ["CANCEL"] = "ANNULER", + }, + }) + + local menu = OptionsMenu.new({}) + drawn = {} + menu:drawPanel() + T.eq(drawnAt(LABEL_X, 2 * 8), "VITESSE TEXTE", + "a mod catalog reaches a cart-original row's label") + T.eq(drawnAt(VALUE_X, 3 * 8), "MOY ", + "and its cart-original display value") + + -- CONTROLS is the first port-added row; scroll to it so it lands in the + -- VISIBLE_ROWS=7 window drawPanel actually draws. + local index = rowIndex(menu.rows, "controls") + T.check(index ~= nil, "CONTROLS is one of the rows") + menu.index = index + menu:ensureVisible() + drawn = {} + menu:drawPanel() + local slot = index - menu.scroll + T.eq(drawnAt(LABEL_X, (2 + (slot - 1) * 2) * 8), "COMMANDES", + "and a port-added row's label is translated too") + + -- CANCEL is the last row, built into ROWS like any other -- there is no + -- separate hook to fall through if this one row is missed. + local cancelMenu = OptionsMenu.new({}) + local cancelIndex = #cancelMenu.rows + T.check(cancelMenu.rows[cancelIndex].cancel, "the last row is CANCEL") + cancelMenu.index = cancelIndex + cancelMenu:ensureVisible() + drawn = {} + cancelMenu:drawPanel() + local cancelSlot = cancelIndex - cancelMenu.scroll + T.eq(drawnAt(LABEL_X, (2 + (cancelSlot - 1) * 2) * 8), "ANNULER", + "CANCEL, the way out of the menu, is translated too") + + -- Module state is process-global (see tests/gen2_clock_test.lua's own + -- note); this suite gets its own process from tests/tier_runner.lua, but + -- leaving the catalog loaded past this point would still mistranslate + -- every check below it in this file. + Strings.load({}) + T.check(not Strings.active(), "the catalog is unloaded for the checks after this one") +end + +T.finish("gen2_options_menu_translation_test")