diff --git a/data/scripts/flavor/pewter_nidoran_house.lua b/data/scripts/flavor/pewter_nidoran_house.lua index 8612b697..084d5f97 100644 --- a/data/scripts/flavor/pewter_nidoran_house.lua +++ b/data/scripts/flavor/pewter_nidoran_house.lua @@ -1,13 +1,21 @@ -- pokered/scripts/PewterNidoranHouse.asm -- PewterNidoranHouseNidoranText: text_far _PewterNidoranHouseNidoranText, then -- text_asm plays the NIDORAN_M cry (PlayCry/WaitForSoundToFinish) before ending. --- No sound-effect command exists in Commands.lua, so we port the dialogue line --- only; the cry SFX is cosmetic and has no gameplay effect. +-- play_cry only stashes ctx.pendingCry for the very next show_text row, so it +-- has to sit immediately before that row (src/script/Commands.lua); the cry +-- then sounds once the box has typed out, matching the asm's print-then-cry +-- order. Its waitForButton form keeps DisplayTextID's trailing +-- WaitForTextScrollButtonPress -- PewterNidoranHouse_Script is +-- `jp EnableAutoTextBoxDrawing`, which zeroes +-- wDoNotWaitForButtonPressAfterDisplayingText -- so the box still holds until +-- A/B. The cry row was missing, leaving the NIDORAN silent (#247). return { PEWTER_NIDORAN_HOUSE = { talk = { TEXT_PEWTERNIDORANHOUSE_NIDORAN = { { "face_player" }, + -- text_asm: ld a, NIDORAN_M / call PlayCry + { "play_cry", "NIDORAN_M", true }, { "show_text", "_PewterNidoranHouseNidoranText" }, }, }, diff --git a/data/scripts/flavor/viridian_city.lua b/data/scripts/flavor/viridian_city.lua index e9c9b25e..b9e476da 100644 --- a/data/scripts/flavor/viridian_city.lua +++ b/data/scripts/flavor/viridian_city.lua @@ -59,13 +59,17 @@ M.VIRIDIAN_CITY = { -- ViridianCityYoungster2CaterpieAndWeedleDescriptionText are -- defined without a leading underscore in pokered/text/ViridianCity.asm -- and aren't present in data/generated/text.lua, so we fall back to - -- the literal strings from pokered. + -- the literal strings from pokered. Those fallbacks have to carry the + -- extractor's markers, not plain newlines: line -> \n, cont -> \v, + -- para -> \f. Spelling cont/para as \n and \n\n put all six lines on + -- one page with nothing to wait on, so the whole speech scrolled past + -- without a button press (#250). TEXT_VIRIDIANCITY_YOUNGSTER2 = function(game, ow, npc, done) local t = text(game) ask(game, t._ViridianCityYoungster2YouWantToKnowAboutText - or "You want to know\nabout the 2 kinds\nof caterpillar\nPOKéMON?", function(yes) + or "You want to know\nabout the 2 kinds\vof caterpillar\vPOKéMON?", function(yes) if yes then - push(game, "CATERPIE has no\npoison, but\nWEEDLE does.\n\nWatch out for its\nPOISON STING!", done) + push(game, "CATERPIE has no\npoison, but\vWEEDLE does.\fWatch out for its\nPOISON STING!", done) else push(game, "Oh, OK then!", done) end diff --git a/data/scripts/flavor/viridian_nickname_house.lua b/data/scripts/flavor/viridian_nickname_house.lua index cee7b89f..84741ee5 100644 --- a/data/scripts/flavor/viridian_nickname_house.lua +++ b/data/scripts/flavor/viridian_nickname_house.lua @@ -3,12 +3,16 @@ return { VIRIDIAN_NICKNAME_HOUSE = { talk = { - -- ViridianNicknameHouseSpearowText: text_asm that shows the text - -- then plays a SPEAROW cry (PlayCry/WaitForSoundToFinish). The - -- cry playback isn't in the talk-script command vocabulary, so - -- only the flavor line is ported here. + -- ViridianNicknameHouseSpearowText: text_asm that PrintTexts the + -- line, then plays the SPEAROW cry (PlayCry/WaitForSoundToFinish) + -- before TextScriptEnd. play_cry only arms the show_text row that + -- follows it, so the cry sounds once the box has typed out; its + -- waitForButton form keeps DisplayTextID's trailing + -- WaitForTextScrollButtonPress, so the box then holds until A/B like + -- any other NPC line rather than popping itself (#251). TEXT_VIRIDIANNICKNAMEHOUSE_SPEAROW = { - { "show_text", "_ViridianNicknameHouseSpearowText" }, + { "play_cry", "SPEAROW", true }, -- 1 PlayCry + { "show_text", "_ViridianNicknameHouseSpearowText" }, -- 2 PrintText }, }, }, diff --git a/data/scripts/story.lua b/data/scripts/story.lua index 3ed7cf3e..5aeb849e 100644 --- a/data/scripts/story.lua +++ b/data/scripts/story.lua @@ -16,6 +16,14 @@ M.VIRIDIAN_MART = { -- StartSimulatingJoypadStates walks the player to the counter -- (.PlayerMovement: PAD_LEFT 1, PAD_UP 2, door (3,7) -> counter (2,5)) -- and ViridianMartOaksParcelScript hands the parcel over. The player + -- never presses A. + -- + -- The RLE list is consumed back to front: DecodeRLEList fills forward + -- from wSimulatedJoypadStatesEnd, the script seeds wSimulatedJoypadStates- + -- Index with the count, and home/overworld.asm decrements that index + -- before each read. So PAD_LEFT 1, PAD_UP 2 actually plays up, up, left, + -- and the walk ends facing the clerk. Replaying the list front to back + -- put the player at the counter facing up instead (#235). The player -- never presses A. This matters beyond convenience: the parcel gates -- Oak's Pokedex and the old man clearing Route 2, so vanilla guarantees -- it on entry rather than letting you walk out without it. @@ -28,8 +36,8 @@ M.VIRIDIAN_MART = { if not f.EVENT_GOT_STARTER then return end ow:queueScript({ { "show_text", "_ViridianMartClerkYouCameFromPalletTownText" }, - { "move_player", "left", 1 }, { "move_player", "up", 2 }, + { "move_player", "left", 1 }, -- the quest text's last page is "{PLAYER} got\nOAK's PARCEL!" { "give_item", "OAKS_PARCEL", 1, "_ViridianMartClerkParcelQuestText" }, { "set_flag", "EVENT_GOT_OAKS_PARCEL" }, diff --git a/docs/new-features.md b/docs/new-features.md index 3f9c1bf2..fd8964d2 100644 --- a/docs/new-features.md +++ b/docs/new-features.md @@ -193,4 +193,38 @@ appears while no controller is being used: the first gamepad button or stick push hides it, the next screen touch brings it back, and unplugging the last controller restores it immediately. Layout re-derives from the window size on rotation. Desktop testing: `POKEPORT_TOUCH=1 love .` forces -the overlay on and lets the mouse act as a finger (`=0` forces it off). \ No newline at end of file +the overlay on and lets the mouse act as a finger (`=0` forces it off). +## Translation support + +Every string the player can read is now reachable from a mod, so a +translation is an ordinary content mod rather than a fork. + +Two things had to change. Text layout stopped counting bytes: the dialogue +box measures a line in glyphs (charmap sequences), so a 3-byte character +costs one column, a cut never lands inside a character, and a page with a +non-default `advance` re-measures instead of overflowing. That also fixed +25 vanilla English lines that were wrapping early because `é` in POKéMON +and POKéDEX costs two bytes ("I study POKéMON as" is 19 bytes and 18 +glyphs, and the box was breaking it). + +Second, the text the engine writes itself - battle messages, item results, +menu labels, the link-play screens - moved behind `src/core/Strings.lua` +and the new `strings` registry. Extracted script text was already +overridable through `text`; this covers the other half. Entries are keyed +by the English source, so a translation that has not reached a string yet +keeps rendering in English and a half-finished translation stays playable. + +Authors generate the whole thing: + +```sh +python3 tools/modkit.py translation francais --language "Francais" +``` + +That scaffolds a mod with every translatable string as an empty catalog, +plus a glyph-page and charmap stub, a naming-grid stub, and a +`francais-worksheet/` directory holding the English to translate from +(deliberately outside the mod: extracted text is ROM content and must not +be packed). `--refresh` re-harvests after an engine update, keeping +existing translations and parking orphaned keys rather than dropping them. + +See the wiki's Translations guide. diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index 0744f65b..a1af87ab 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -26,6 +26,7 @@ local Status = require("src.battle.Status") local TrainerAI = require("src.battle.TrainerAI") local TurnOrder = require("src.battle.TurnOrder") local TypeChart = require("src.battle.TypeChart") +local Strings = require("src.core.Strings") local BattleState = {} BattleState.__index = BattleState @@ -208,13 +209,17 @@ local TRAINER_DVS = { attack = 9, defense = 8, speed = 8, special = 8, hp = 8 } -- charge-turn texts by move id; the move record's chargeText field wins -- (ChargeEffect's per-move text pointers) +-- Strings.source, not Strings: this table is built at require time, before +-- Strings.load has a catalog, so translating here would freeze the English. +-- The marker is a no-op that puts these lines in the catalog anyway; the +-- lookup happens where chargeText is formatted below. local CHARGE_TEXT = { - FLY = "%s\nflew up high!", - DIG = "%s\ndug a hole!", - RAZOR_WIND = "%s\nmade a whirlwind!", - SOLARBEAM = "%s\ntook in sunlight!", - SKULL_BASH = "%s\nlowered its head!", - SKY_ATTACK = "%s\nis glowing!", + FLY = Strings.source("%s\nflew up high!"), + DIG = Strings.source("%s\ndug a hole!"), + RAZOR_WIND = Strings.source("%s\nmade a whirlwind!"), + SOLARBEAM = Strings.source("%s\ntook in sunlight!"), + SKULL_BASH = Strings.source("%s\nlowered its head!"), + SKY_ATTACK = Strings.source("%s\nis glowing!"), } -- pokered's / text macros (home/text.asm @@ -416,9 +421,9 @@ function BattleState.newWild(game, species, level, opts) self.enemy = makeBattler(game.data, Pokemon.new(game.data, species, level), false) markSeen(game, species) if opts and opts.hooked then - self.introText = ("The hooked\n%s\nattacked!"):format(self.enemy.name) + self.introText = Strings("The hooked\n%s\nattacked!", self.enemy.name) else - self.introText = ("Wild %s\nappeared!"):format(self.enemy.name) + self.introText = Strings("Wild %s\nappeared!", self.enemy.name) end return self end @@ -539,7 +544,7 @@ function BattleState.newTrainer(game, oppClass, partyIndex) -- wEnemyMonSpecies2 before the intro's SET_PAL_BATTLE -- (engine/battle/core.asm:6682, engine/gfx/palettes.asm SetPal_Battle) self.trainerPic = getImage(self.trainer.pic, namedPalette(game.data, "MEWMON")) - self.introText = ("%s wants\nto fight!"):format(self.trainer.name) + self.introText = Strings("%s wants\nto fight!", self.trainer.name) return self end @@ -553,7 +558,7 @@ function BattleState:makeGhost() -- (engine/battle/core.asm InitWildBattle .isGhost) self.enemy.sprite = getImage("assets/generated/battle/front/ghost.png", monPalette(self.data, self.enemy.mon.species)) - self.introText = "The GHOST\nappeared!" + self.introText = Strings("The GHOST\nappeared!") end -- The old man's catch tutorial (BATTLE_TYPE_OLD_MAN, @@ -947,10 +952,10 @@ function BattleState:sendOutText(name) if e and e.hp > 0 and math.floor(e.stats.hp / 4) > 0 then pct = math.floor(e.hp * 25 / math.floor(e.stats.hp / 4)) end - if pct >= 70 then return ("Go! %s!"):format(name) end - if pct >= 40 then return ("Do it! %s!"):format(name) end - if pct >= 10 then return ("Get'm! %s!"):format(name) end - return ("The enemy's weak!\nGet'm! %s!"):format(name) + if pct >= 70 then return Strings("Go! %s!", name) end + if pct >= 40 then return Strings("Do it! %s!", name) end + if pct >= 10 then return Strings("Get'm! %s!", name) end + return Strings("The enemy's weak!\nGet'm! %s!", name) end -- audio/play_battle_music.asm: gym leaders (wGymLeaderNo) get the @@ -1020,37 +1025,54 @@ function BattleState:enter() -- MonsterPalettes[0] = PAL_MEWMON (wBattleMonSpecies is still 0 when -- the intro's SET_PAL_BATTLE runs -- SetPal_Battle, -- engine/gfx/palettes.asm:28) - self.playerBackPic = getImage(self.demo - and "assets/generated/battle/oldmanb.png" - or "assets/generated/battle/redb.png", - namedPalette(self.data, "MEWMON")) + -- field.playerPics picks the pic (the catch tutorial's old man fights in + -- the player's place), then the player.sprite hook gets the last word so + -- a mod can vary it per save. It loads through getImage like every other + -- battle pic, so a replacement keeps the SGB recolor, the transition + -- fade, the ground-padding measurement and battle_sprite_scales. + local backPath, backTrueColor = + require("src.pokemon.Sprites").playerPath(self.data, "back", + { kind = "battle", demo = self.demo, battle = self }) + self.playerBackPic = getImage(backPath, + namedPalette(self.data, "MEWMON"), backTrueColor) self.showPlayerBack = self.playerBackPic ~= nil + -- the enemy's cry as it appears (data/pokemon/cries.asm); PlayCry sits at + -- a different point in each battle kind, so queue it per branch + local function queueEnemyCry() + self:act(function() + require("src.core.Sound").playCry(self.data, self.enemy.mon.species) + end) + end + -- PrintBeginningBattleText (engine/battle/common_text.asm:10-19): a wild + -- battle calls PlayCry BEFORE PrintText WildMonAppearedText, so the cry + -- sounds with the "Wild X appeared!" box instead of waiting on the A + -- press that clears its `prompt` (#303). The Silph-Scope-less tower + -- ghost gets no cry at all (common_text.asm:43-48). + if self.kind ~= "trainer" and self.kind ~= "link" and not self.ghost then + queueEnemyCry() + end self:say(self.introText) if self.kind == "trainer" then - self:say(("%s sent\nout %s!"):format(self.trainer.name, self.enemy.name)) + self:say(Strings("%s sent\nout %s!", self.trainer.name, self.enemy.name)) self:act(function() -- EnemySendOutFirstMon (core.asm:1421-1434): after the text the -- pic grows out of the ball (AnimateSendingOutMon), then the cry self.showEnemyTrainer = false self:startGrowIn(self.enemy) end) + queueEnemyCry() elseif self.kind == "link" then -- Colosseum has no foe trainer pic, but the enemy mon still grows -- out of the ball after "X sent out Y!" (not the wild "already there" -- intro that LinkBattle previously inherited from newWild). self.enemySendingOut = true - self:say(("%s sent\nout %s!"):format(self.opponentName or "FOE", + self:say(Strings("%s sent\nout %s!", self.opponentName or "FOE", self.enemy.name)) self:act(function() self.enemySendingOut = false self:startGrowIn(self.enemy) end) - end - if not self.ghost then - -- the enemy's cry plays as it appears (data/pokemon/cries.asm) - self:act(function() - require("src.core.Sound").playCry(self.data, self.enemy.mon.species) - end) + queueEnemyCry() end if not self.safari and not self.demo then self:say(self:sendOutText(self.player.name)) @@ -1218,7 +1240,7 @@ function BattleState:update(dt) if self.phase == "menu" and self.safari then if self.safari.balls <= 0 then - self:say("PA: You're out of\nSAFARI BALLs!\nGame over!") + self:say(Strings("PA: You're out of\nSAFARI BALLs!\nGame over!")) self.phase = "messages" self.result = "run" self.afterQueue = "finish" @@ -1273,7 +1295,7 @@ function BattleState:update(dt) if input:wasPressed("a") then local choice = ({ "fight", "pkmn", "item", "run" })[self.menuIndex] if choice == "fight" and self.ghost then - self:say(("%s is too\nscared to move!"):format(self.player.name)) + self:say(Strings("%s is too\nscared to move!", self.player.name)) self.phase = "messages" self.afterQueue = "menu" self:act(function() @@ -1290,7 +1312,7 @@ function BattleState:update(dt) end if not self:playerHasPP() then -- _NoMovesLeftText, then Struggle engages - self:say(("%s has no\nmoves left!"):format(self.player.name)) + self:say(Strings("%s has no\nmoves left!", self.player.name)) self:resolveTurn({ id = "STRUGGLE", pp = 1, struggle = true }) return end @@ -1332,11 +1354,11 @@ function BattleState:update(dt) end local mv = moves[self.moveIndex] if self.player.disabledSlot == self.moveIndex then - self:say("The move is\ndisabled!") + self:say(Strings("The move is\ndisabled!")) self.phase = "messages" self.afterQueue = "menu" elseif mv.pp <= 0 then - self:say("No PP left for\nthis move!") + self:say(Strings("No PP left for\nthis move!")) self.phase = "messages" self.afterQueue = "menu" else @@ -1380,7 +1402,7 @@ function BattleState:resolveMimic(user, target, move, moveInst) table.insert(self.queue, self.nextInsert, { wait = 50 }) if target.invulnerable or not self:accuracyRoll(move, user, target) then - self:sayNext("But, it failed!") + self:sayNext(Strings("But, it failed!")) return end local slots = {} @@ -1390,7 +1412,7 @@ function BattleState:resolveMimic(user, target, move, moveInst) if #slots == 0 then -- .getRandomMove rerolls empty slots forever; a moveless target -- can't happen in practice, so just fail instead of hanging - self:sayNext("But, it failed!") + self:sayNext(Strings("But, it failed!")) return end if user.isPlayer and self.kind ~= "link" then @@ -1451,7 +1473,7 @@ function BattleState:applyMimic(user, target, moveInst, slot) entry.mimic = true self:animNext("MIMIC", user.isPlayer) -- _MimicLearnedMoveText: " / learned / MOVE!" - self:sayNext(("%s\nlearned\n%s!"):format(displayName(user), + self:sayNext(Strings("%s\nlearned\n%s!", displayName(user), self.data.moves[src.id].name)) end @@ -1491,7 +1513,7 @@ function BattleState:openOldManBag() self:ui(function() local list list = ListMenu.new(game, "ITEMS", { - { value = "POKE_BALL", label = "POKé BALL", right = "x50" }, + { value = "POKE_BALL", label = Strings("POKé BALL"), right = "x50" }, }, { script = function(l) l.scriptTimer = (l.scriptTimer or 0) + 1 @@ -1521,7 +1543,7 @@ function BattleState:oldManThrow() self.phase = "messages" self.afterQueue = "finish" self.result = "run" -- nothing is kept; wBattleResult only ends the demo - self:say("OLD MAN used\nPOKé BALL!") + self:say(Strings("OLD MAN used\nPOKé BALL!")) self:act(function() require("src.core.Sound").play(self.data, "Ball_Toss") -- ItemUseBall's beat before the toss chain (like throwBall) @@ -1531,7 +1553,7 @@ function BattleState:oldManThrow() self:actNext(function() require("src.core.Sound").play(self.data, "Caught_Mon") end) - self:sayNext(("All right!\n%s was\ncaught!"):format(self.enemy.name)) + self:sayNext(Strings("All right!\n%s was\ncaught!", self.enemy.name)) end) end @@ -2283,7 +2305,7 @@ function BattleState:executeAction(user, target, action) -- ghost battles: the ghost never attacks; its whole turn is the -- GetOutText (ExecuteEnemyMove -> PrintGhostText, core.asm:5462-5463) if self.ghost and not user.isPlayer then - self:sayNext(self.data.text._GetOutText or "GHOST: Get out...\nGet out...") + self:sayNext(self.data.text._GetOutText or Strings("GHOST: Get out...\nGet out...")) return end @@ -2320,8 +2342,8 @@ function BattleState:executeAction(user, target, action) self.aiUses = self:aiUsesFor() markSeen(self.game, self.enemy.mon.species) -- _AIBattleWithdrawText: "X with-/drew Y!" - self:sayNext(("%s with-\ndrew %s!"):format(self.trainer.name, oldName)) - self:sayNext(("%s sent\nout %s!"):format(self.trainer.name, self.enemy.name)) + self:sayNext(Strings("%s with-\ndrew %s!", self.trainer.name, oldName)) + self:sayNext(Strings("%s sent\nout %s!", self.trainer.name, self.enemy.name)) return end @@ -2334,7 +2356,7 @@ function BattleState:executeAction(user, target, action) -- 3392): sleep/freeze/held/flinch keep the mon recharging next turn if self:preRechargeChecks(user, target) then return end user.mustRecharge = nil - self:sayNext(("%s\nmust recharge!"):format(displayName(user))) + self:sayNext(Strings("%s\nmust recharge!", displayName(user))) return end if action.special == "bound" then @@ -2379,8 +2401,8 @@ function BattleState:statusOnomatopoeia(user, kind) anim = isPlayer and "CONF_PLAYER_ANIM" or "CONF_ANIM" end local text = kind == "sleep" - and (displayName(user) .. "\nis fast asleep!") - or (displayName(user) .. "\nis confused!") + and Strings("%s\nis fast asleep!", displayName(user)) + or Strings("%s\nis confused!", displayName(user)) if kind == "sleep" and isPlayer then self:animNext(anim, isPlayer) self:sayNext(text) @@ -2419,18 +2441,18 @@ function BattleState:preRechargeChecks(user, target) user.sleepTurns = (user.sleepTurns or 1) - 1 if user.sleepTurns <= 0 then mon.status = nil - self:sayNext(displayName(user) .. "\nwoke up!") + self:sayNext(Strings("%s\nwoke up!", displayName(user))) else self:statusOnomatopoeia(user, "sleep") end return true end if mon.status == "FRZ" then - self:sayNext(displayName(user) .. "\nis frozen solid!") + self:sayNext(Strings("%s\nis frozen solid!", displayName(user))) return true end if target.trappingTurns then - self:sayNext(displayName(user) .. "\ncan't move!") + self:sayNext(Strings("%s\ncan't move!", displayName(user))) return true end if user.flinched then @@ -2438,7 +2460,7 @@ function BattleState:preRechargeChecks(user, target) -- player recharges, so the flinch eats the recharge turn and the -- flag survives (the Hyper Beam flinch glitch) user.flinched = false - self:sayNext(displayName(user) .. "\nflinched!") + self:sayNext(Strings("%s\nflinched!", displayName(user))) return true end return false @@ -2459,7 +2481,7 @@ function BattleState:statusInterrupt(user, target) { id = "CONFUSED", power = 40, type = "NORMAL", accuracy = 100 }, { rng = self.rng, forceCrit = false, typeless = true, screens = target }) - self:sayNext("It hurt itself in\nits confusion!") + self:sayNext(Strings("It hurt itself in\nits confusion!")) self:clearVolatiles(user, true) self:applyDamage(user, dmg) if user.mon.hp <= 0 then self:onFaint(user) end @@ -2547,7 +2569,7 @@ function BattleState:performMove(user, target, moveInst, isCalled) self.moveAnimRow = nil if not (user.thrashTurns and moveInst == user.thrashMove and user.thrashAnnounced) then - self:sayNext(("%s\nused %s!"):format(displayName(user), move.name)) + self:sayNext(Strings("%s\nused %s!", displayName(user), move.name)) -- the move's animation plays right after the announcement; the -- damage path attaches the target's hit blink to this row so the -- blink follows the animation (pokered's order). Mimic is the @@ -2607,8 +2629,10 @@ function BattleState:performMove(user, target, moveInst, isCalled) self:animNext(chargeAnim, user.isPlayer) end local chargeText = move.chargeText or CHARGE_TEXT[move.id] - or "%s\nis charging up!" - self:sayNext(chargeText:format(displayName(user))) + or Strings.source("%s\nis charging up!") + -- the template is a source string (a move record may supply its own), + -- so translate it here rather than where it was declared + self:sayNext(Strings(chargeText, displayName(user))) return end @@ -2630,7 +2654,7 @@ function BattleState:performMove(user, target, moveInst, isCalled) -- SleepEffect/PoisonEffect/... call PlayCurrentMoveAnimation only -- after the effect lands; a miss skips it self:cancelMoveAnim() - self:sayNext(("%s's\nattack missed!"):format(displayName(user))) + self:sayNext(Strings("%s's\nattack missed!", displayName(user))) return end local msgs = record.run(ctx) @@ -2648,7 +2672,7 @@ function BattleState:performMove(user, target, moveInst, isCalled) if move.power == 0 and not (record and record.kind == "full") then MoveEffects.warnUnknown(move.effect) self:cancelMoveAnim() - self:sayNext("But, it failed!") + self:sayNext(Strings("But, it failed!")) return end @@ -2657,7 +2681,7 @@ function BattleState:performMove(user, target, moveInst, isCalled) end function BattleState:continueTrapping(user, target) - self:sayNext(("%s's\nattack continues!"):format(displayName(user))) + self:sayNext(Strings("%s's\nattack continues!", displayName(user))) -- .MultiturnMoveCheck (core.asm:3554-3566) prints AttackContinuesText -- then jumps to GetPlayerAnimationType, so the trapping move's full -- animation replays each locked turn (same damage, animation shown). @@ -2680,15 +2704,15 @@ end function BattleState:continueBide(user, target) user.bideTurns = user.bideTurns - 1 if user.bideTurns > 0 then - self:sayNext(("%s\nis storing energy!"):format(displayName(user))) + self:sayNext(Strings("%s\nis storing energy!", displayName(user))) return end - self:sayNext(("%s\nunleashed energy!"):format(displayName(user))) + self:sayNext(Strings("%s\nunleashed energy!", displayName(user))) local dmg = (user.bideDamage or 0) * 2 user.bideTurns, user.bideDamage = nil, nil if dmg <= 0 then self:cancelMoveAnim() - self:sayNext("But, it failed!") + self:sayNext(Strings("But, it failed!")) return end self:applyDamage(target, dmg) @@ -2707,9 +2731,9 @@ function BattleState:applyDamage(target, dmg) target.substituteHP = target.substituteHP - dmg if target.substituteHP <= 0 then target.substituteHP = nil - self:sayNext(("%s's\nSUBSTITUTE broke!"):format(displayName(target))) + self:sayNext(Strings("%s's\nSUBSTITUTE broke!", displayName(target))) else - self:sayNext(("The SUBSTITUTE\ntook damage for\n%s!"):format(displayName(target))) + self:sayNext(Strings("The SUBSTITUTE\ntook damage for\n%s!", displayName(target))) end return dmg end @@ -2721,7 +2745,7 @@ function BattleState:applyDamage(target, dmg) end if target.rageMove and dealt > 0 then target.stages.attack = math.min(6, (target.stages.attack or 0) + 1) - self:sayNext(("%s's\nRAGE is building!"):format(displayName(target))) + self:sayNext(Strings("%s's\nRAGE is building!", displayName(target))) end return dealt end @@ -2760,7 +2784,7 @@ function BattleState:onFaint(battler) self:actNext(function() self:playVictoryMusic() end) end -- _EnemyMonFaintedText "Enemy X fainted!" / _PlayerMonFaintedText - self:sayNext(("%s\nfainted!"):format(displayName(battler))) + self:sayNext(Strings("%s\nfainted!", displayName(battler))) if battler.isPlayer then self:act(function() self:playerMonFainted() end) else @@ -2809,19 +2833,19 @@ function BattleState:enemyMonFainted() -- _BoostedText / _WithExpAllText end in the CONT code (\v, "...\011" -- in data/generated/text.lua): the box waits for A/B + ▼ then scrolls -- the amount line in, so it stays on-screen instead of at y=144 (#216). - local tail = "%d EXP. Points!" + local text = Strings.source("%s gained\n%d EXP. Points!") if announce == "expAll" then - tail = "with EXP.ALL,\v" .. tail + text = Strings.source("%s gained\nwith EXP.ALL,\v%d EXP. Points!") elseif mon.traded then - tail = "a boosted\v" .. tail + text = Strings.source("%s gained\na boosted\v%d EXP. Points!") end - self:sayNext(("%s gained\n" .. tail):format(name, gained)) + self:sayNext(Strings(text, name, gained)) end -- per level: GrewLevelText -> the stats window (PrintStatsBox) -> -- the move-learn checks (experience.asm:245-256) local game = self.game for _, lv in ipairs(levels) do - self:sayNext(("%s grew\nto level %d!"):format(name, lv)) + self:sayNext(Strings("%s grew\nto level %d!", name, lv)) self:uiNext(function() require("src.core.Sound").play(game.data, "Level_Up") return StatBox.new(game, mon) @@ -2894,10 +2918,10 @@ function BattleState:enemyMonFainted() if style ~= "set" and partyCount > 1 and self.player.mon.hp > 0 then -- _TrainerAboutToUseText: "X is" / "about to use" then cont nick, -- then para "Will PLAYER" / "change POKéMON?" with YES/NO. - self:say(("%s is\nabout to use"):format(self.trainer.name)) - self:say(("%s!"):format(nextName)) + self:say(Strings("%s is\nabout to use", self.trainer.name)) + self:say(Strings("%s!", nextName)) self:sayChoice( - ("Will %s\nchange POKéMON?"):format(self.game.save.player.name), + Strings("Will %s\nchange POKéMON?", self.game.save.player.name), function(yes) if not yes then return end local game = self.game @@ -2930,7 +2954,7 @@ function BattleState:enemyMonFainted() -- (AnimateSendingOutMon) with the cry; no POOF -- that animation -- belongs to the player-side SendOutMon (core.asm:1757-1762) self.enemySendingOut = true - self:sayNext(("%s sent\nout %s!"):format(self.trainer.name, self.enemy.name)) + self:sayNext(Strings("%s sent\nout %s!", self.trainer.name, self.enemy.name)) self:actNext(function() self.enemySendingOut = false self:startGrowIn(self.enemy) @@ -2974,9 +2998,9 @@ function BattleState:enemyMonFainted() -- prize money self:actNext(function() self:playVictoryMusic() end) -- _TrainerDefeatedText: " defeated\nTRAINER!" - self:sayNext(("%s defeated\n%s!"):format(self.game.save.player.name, + self:sayNext(Strings("%s defeated\n%s!", self.game.save.player.name, self.trainer.name)) - self:sayNext(("%s got ¥%d\nfor winning!"):format(self.game.save.player.name, prize)) + self:sayNext(Strings("%s got ¥%d\nfor winning!", self.game.save.player.name, prize)) end self.result = "win" self.afterQueue = "finish" @@ -2992,7 +3016,7 @@ function BattleState:learnMove(mon, moveId) if #mon.moves < 4 then table.insert(mon.moves, { id = moveId, pp = mdef.pp }) Runtime.emit("pokemon.move_learned", { mon = mon, moveId = moveId }) - self:sayNext(("%s learned\n%s!"):format(mon.nickname or self.data.pokemon[mon.species].name, + self:sayNext(Strings("%s learned\n%s!", mon.nickname or self.data.pokemon[mon.species].name, mdef.name)) return end @@ -3037,14 +3061,14 @@ function BattleState:playerMonFainted() if self.oppClass == "OPP_RIVAL1" then local TextBox = require("src.render.TextBox") local raw = (self.data.text and self.data.text._Rival1WinText) - or "{RIVAL}: Yeah! Am\nI great or what?" + or Strings("{RIVAL}: Yeah! Am\nI great or what?") self:sayNext(TextBox.substitute(self.game, raw)) end -- Oak's Lab starter rival: Rival1WinText only (no blackout lines). -- Any other wipe, including Route 22 RIVAL1, still blacks out. if not BattleState.isOaksLabStarterRival(self) then - self:sayNext(("%s is out of\nuseable POKéMON!"):format(self.game.save.player.name)) - self:sayNext(("%s blacked\nout!"):format(self.game.save.player.name)) + self:sayNext(Strings("%s is out of\nuseable POKéMON!", self.game.save.player.name)) + self:sayNext(Strings("%s blacked\nout!", self.game.save.player.name)) end self.result = "lose" self.afterQueue = "finish" @@ -3057,7 +3081,7 @@ function BattleState:playerMonFainted() -- battles go straight to the party menu (the menu-phase guard). if self.kind ~= "wild" then return end local game = self.game - self:say(self.data.text._UseNextMonText or "Use next POKéMON?") + self:say(self.data.text._UseNextMonText or Strings("Use next POKéMON?")) self:ui(function() local ChoiceBox = require("src.ui.ChoiceBox") return ChoiceBox.new(game, function(yes) @@ -3065,11 +3089,11 @@ function BattleState:playerMonFainted() local pSpd = (game.save.party[1].stats or { speed = 0 }).speed or 0 if self:runRoll(pSpd, TurnOrder.effectiveSpeed(self.enemy)) then require("src.core.Sound").play(self.data, "Run") - self:say("Got away safely!") + self:say(Strings("Got away safely!")) self.result = "run" self.afterQueue = "finish" else - self:say("Can't escape!") + self:say(Strings("Can't escape!")) end end) end) @@ -3089,7 +3113,7 @@ function BattleState:openReplacementMenu() forceSwitch = true, onSwitch = function(mon) if mon.hp <= 0 then - self:say("There's no will\nto fight!") + self:say(Strings("There's no will\nto fight!")) return -- the menu-phase guard reopens the menu end self:restoreMimicked(self.player) @@ -3133,7 +3157,7 @@ function BattleState:safariAction(choice) if choice == "run" then require("src.core.Sound").play(self.data, "Run") - self:say("Got away safely!") + self:say(Strings("Got away safely!")) self.result = "run" self.afterQueue = "finish" return @@ -3141,7 +3165,7 @@ function BattleState:safariAction(choice) if choice == "ball" then st.balls = st.balls - 1 - self:say(("%s used\nSAFARI BALL!"):format(playerName)) + self:say(Strings("%s used\nSAFARI BALL!", playerName)) self:act(function() require("src.core.Sound").play(self.data, "Ball_Toss") self.lastBall = "SAFARI_BALL" @@ -3158,7 +3182,7 @@ function BattleState:safariAction(choice) self:actNext(function() require("src.core.Sound").play(self.data, "Caught_Mon") end) - self:sayNext(("All right!\n%s was\ncaught!"):format(self.enemy.name)) + self:sayNext(Strings("All right!\n%s was\ncaught!", self.enemy.name)) -- same ItemUseBall .captured flow as a regular ball self:act(function() self:storeCaughtMon() end) else @@ -3170,12 +3194,12 @@ function BattleState:safariAction(choice) end if choice == "bait" then - self:say(("%s threw some\nBAIT."):format(playerName)) + self:say(Strings("%s threw some\nBAIT.", playerName)) self.safariCatchRate = math.floor(self.safariCatchRate / 2) self.baitFactor = math.min(255, self.baitFactor + self.rng(1, 5)) self.escapeFactor = 0 else -- rock - self:say(("%s threw a\nROCK."):format(playerName)) + self:say(Strings("%s threw a\nROCK.", playerName)) self.safariCatchRate = math.min(255, self.safariCatchRate * 2) self.escapeFactor = math.min(255, self.escapeFactor + self.rng(1, 5)) self.baitFactor = 0 @@ -3191,13 +3215,13 @@ end function BattleState:safariEnemyTurn() if self.baitFactor > 0 then self.baitFactor = self.baitFactor - 1 - self:sayNext(("Wild %s\nis eating!"):format(self.enemy.name)) + self:sayNext(Strings("Wild %s\nis eating!", self.enemy.name)) elseif self.escapeFactor > 0 then self.escapeFactor = self.escapeFactor - 1 if self.escapeFactor == 0 then self.safariCatchRate = self.enemy.def.catchRate end - self:sayNext(("Wild %s\nis angry!"):format(self.enemy.name)) + self:sayNext(Strings("Wild %s\nis angry!", self.enemy.name)) end self:act(function() local speed = self.enemy.curStats.speed % 256 @@ -3213,7 +3237,7 @@ function BattleState:safariEnemyTurn() fled = self.rng(0, 255) < b end if fled then - self:sayNext(("Wild %s\nran!"):format(self.enemy.name)) + self:sayNext(Strings("Wild %s\nran!", self.enemy.name)) self:actNext(function() require("src.core.Sound").play(self.data, "Run") startPicKind(self:picFxFor(self.enemy), "slideOff") @@ -3266,7 +3290,12 @@ function BattleState:tryRun() self.phase = "messages" self.afterQueue = "menu" if self.kind == "trainer" then - self:say("No! There's no\nrunning from a\ntrainer battle!") + -- _NoRunningText is three lines in a two-line box, so the third arrives + -- on a \v scroll (ContText: ▼ then a button press) rather than a \n. + -- Spelling it with three \n dropped "trainer battle!" and handed the + -- menu straight back (#239). + self:say(self.data.text._NoRunningText + or Strings("No! There's no\nrunning from a\vtrainer battle!")) return end -- modified in-battle speeds (stat stages + paralysis), like the @@ -3275,11 +3304,11 @@ function BattleState:tryRun() TurnOrder.effectiveSpeed(self.enemy)) if escaped then require("src.core.Sound").play(self.data, "Run") - self:say("Got away safely!") + self:say(Strings("Got away safely!")) self.result = "run" self.afterQueue = "finish" else - self:say("Can't escape!") + self:say(Strings("Can't escape!")) self:act(function() self:executeAction(self.enemy, self.player, self:enemyAction()) end) @@ -3312,13 +3341,13 @@ end function BattleState:ballMissMessage(shakes) local t = self.data.text if shakes == 0 then - return t._ItemUseBallText01 or "You missed the\nPOKéMON!" + return t._ItemUseBallText01 or Strings("You missed the\nPOKéMON!") elseif shakes == 1 then - return t._ItemUseBallText02 or "Darn! The POKéMON\nbroke free!" + return t._ItemUseBallText02 or Strings("Darn! The POKéMON\nbroke free!") elseif shakes == 2 then - return (t._ItemUseBallText03 or "Aww! It appeared\nto be caught!"):gsub("%s+$", "") + return (t._ItemUseBallText03 or Strings("Aww! It appeared\nto be caught!")):gsub("%s+$", "") end - return t._ItemUseBallText04 or "Shoot! It was so\nclose too!" + return t._ItemUseBallText04 or Strings("Shoot! It was so\nclose too!") end -- AskName (engine/menus/naming_screen.asm): ClearSprites, wild field blank, @@ -3329,7 +3358,7 @@ function BattleState:askNicknameUI(mon, displayName) self.lockedBall = nil self.blankForAskName = true local TextBox = require("src.render.TextBox") - local text = ("Do you want to\ngive a nickname\nto %s?"):format(displayName) + local text = Strings("Do you want to\ngive a nickname\nto %s?", displayName) local label = game.data.text and game.data.text._DoYouWantToNicknameText if label then -- extractor CONT is \t; TextBox scrolls on \n/\v @@ -3340,7 +3369,7 @@ function BattleState:askNicknameUI(mon, displayName) self.blankForAskName = false if not yes then return end pcall(Screens.push, game, "NamingScreen", { - title = "NICKNAME?", maxLen = 10, + title = Strings("NICKNAME?"), maxLen = 10, onDone = function(name) if name and #name > 0 then mon.nickname = name end end, @@ -3368,7 +3397,7 @@ function BattleState:storeCaughtMon() stampOT(game.save, self.enemy.mon) if isNew then -- _ItemUseBallText06 + ShowPokedexData - self:sayNext(("New POKéDEX data\nwill be added for\n%s!"):format(self.enemy.name)) + self:sayNext(Strings("New POKéDEX data\nwill be added for\n%s!", self.enemy.name)) self:uiNext(function() return self:buildScreen("DexEntryMenu", species) end) @@ -3389,10 +3418,10 @@ function BattleState:storeCaughtMon() askCaughtNickname() -- _ItemUseBallText07/08 keyed on EVENT_MET_BILL local pc = (game.save.flags and game.save.flags.EVENT_MET_BILL) - and "BILL's PC" or "someone's PC" - self:sayNext(("%s was\ntransferred to\n%s!"):format(self.enemy.name, pc)) + and "BILL's PC" or Strings("someone's PC") + self:sayNext(Strings("%s was\ntransferred to\n%s!", self.enemy.name, pc)) else - self:sayNext("But every BOX\nis full!") + self:sayNext(Strings("But every BOX\nis full!")) end end Runtime.emit("pokemon.caught", { @@ -3446,13 +3475,13 @@ end -- called by BagMenu when a ball is thrown function BattleState:throwBall(ball) - self:say(("%s used\n%s!"):format(self.game.save.player.name, + self:say(Strings("%s used\n%s!", self.game.save.player.name, self.data.items[ball].name)) self:act(function() require("src.core.Sound").play(self.data, "Ball_Toss") if self.kind ~= "wild" then - self:sayNext("The TRAINER\nblocked the BALL!") - self:sayNext("Don't be a thief!") + self:sayNext(Strings("The TRAINER\nblocked the BALL!")) + self:sayNext(Strings("Don't be a thief!")) return end if self.ghost then @@ -3462,8 +3491,8 @@ function BattleState:throwBall(ball) -- ($10 anim data, no wobbles), and the turn is spent like any -- failed throw self:animNext(self:tossAnimFor(ball), true, nil, ball) - self:sayNext("It dodged the\nthrown BALL!") - self:sayNext("This POKéMON\ncan't be caught!") + self:sayNext(Strings("It dodged the\nthrown BALL!")) + self:sayNext(Strings("This POKéMON\ncan't be caught!")) self:act(function() self:executeAction(self.enemy, self.player, self:enemyAction()) end) @@ -3486,7 +3515,7 @@ function BattleState:throwBall(ball) self:actNext(function() require("src.core.Sound").play(self.data, "Caught_Mon") end) - self:sayNext(("All right!\n%s was\ncaught!"):format(self.enemy.name)) + self:sayNext(Strings("All right!\n%s was\ncaught!", self.enemy.name)) self:act(function() self:storeCaughtMon() end) else self:sayNext(self:ballMissMessage(shakes)) @@ -3506,9 +3535,9 @@ function BattleState:openParty() battle = self, onSwitch = function(mon) if mon == self.player.mon then - self:say(("%s is\nalready out!"):format(self.player.name)) + self:say(Strings("%s is\nalready out!", self.player.name)) elseif mon.hp <= 0 then - self:say("There's no will\nto fight!") + self:say(Strings("There's no will\nto fight!")) else self:resolveSwitch(mon) end @@ -3534,7 +3563,7 @@ end function BattleState:finish() if self.payDay and self.result == "win" then self.game.save.money = self.game.save.money + self.payDay - self:say(("%s picked up\n¥%d!"):format(self.game.save.player.name, self.payDay)) + self:say(Strings("%s picked up\n¥%d!", self.game.save.player.name, self.payDay)) self.payDay = nil self.afterQueue = "finish" self.phase = "messages" @@ -3581,9 +3610,11 @@ local HudTiles = require("src.render.HudTiles") local hudTile = HudTiles.tile local drawHPBar = HudTiles.drawHPBar --- CenterMonName: 1-2 letter names print two tiles right, 3-4 one tile +-- CenterMonName: 1-2 letter names print two tiles right, 3-4 one tile. +-- Counted in glyphs, not bytes: a nickname carrying "é" or "♂" is one +-- charmap sequence per glyph, and byte length would push it a tile left. local function nameX(tx, name) - local n = #name + local n = #Font.split(name) return tx * 8 + (n <= 2 and 16 or n <= 4 and 8 or 0) end @@ -4343,9 +4374,9 @@ function BattleState:drawTextArea() -- -- next to FIGHT (9,14) for the first 80 frames, then ITEM (9,16) Font.drawBox(8, 12, 12, 6) love.graphics.setColor(0, 0, 0, 1) - Font.draw("FIGHT", 80, 112) + Font.draw(Strings("FIGHT"), 80, 112) Font.drawCode(0xE1, 128, 112); Font.drawCode(0xE2, 136, 112) - Font.draw("ITEM", 80, 128); Font.draw("RUN", 128, 128) + Font.draw(Strings("ITEM"), 80, 128); Font.draw(Strings("RUN"), 128, 128) Font.drawCode(0xED, 72, (self.demoTimer or 0) <= 80 and 112 or 128) elseif self.phase == "menu" then local col = (self.menuIndex - 1) % 2 @@ -4354,16 +4385,16 @@ function BattleState:drawTextArea() -- SAFARI_BATTLE_MENU_TEMPLATE: full-width box, "BALLx BAIT / -- THROW ROCK RUN" from (2,14) Font.drawBox(0, 12, 20, 6) - Font.draw("BALLx", 16, 112); Font.draw("BAIT", 112, 112) - Font.draw("THROW ROCK", 16, 128); Font.draw("RUN", 112, 128) + Font.draw(Strings("BALLx"), 16, 112); Font.draw(Strings("BAIT"), 112, 112) + Font.draw(Strings("THROW ROCK"), 16, 128); Font.draw(Strings("RUN"), 112, 128) Font.drawCode(0xED, (col == 0 and 8 or 104), 112 + row * 16) else -- BATTLE_MENU_TEMPLATE: box (8,12)-(19,17), "FIGHT / -- ITEM RUN" from (10,14); cursor columns 9 / 15 Font.drawBox(8, 12, 12, 6) - Font.draw("FIGHT", 80, 112) + Font.draw(Strings("FIGHT"), 80, 112) Font.drawCode(0xE1, 128, 112); Font.drawCode(0xE2, 136, 112) - Font.draw("ITEM", 80, 128); Font.draw("RUN", 128, 128) + Font.draw(Strings("ITEM"), 80, 128); Font.draw(Strings("RUN"), 128, 128) Font.drawCode(0xED, (col == 0 and 72 or 120), 112 + row * 16) end elseif self.phase == "moveSelect" then @@ -4388,10 +4419,10 @@ function BattleState:drawTextArea() local sel = self.player.curMoves[self.moveIndex] if sel then if self.player.disabledSlot == self.moveIndex then - Font.draw("disabled!", 8, 80) + Font.draw(Strings("disabled!"), 8, 80) else local def = self.data.moves[sel.id] - Font.draw("TYPE/", 8, 72) + Font.draw(Strings("TYPE/"), 8, 72) -- the type record's display name (a mod type shows its name, and -- PSYCHIC_TYPE prints PSYCHIC like the original) Font.draw(def.type and TypeChart.displayName(def.type) or "", 16, 80) diff --git a/src/battle/EffectRegistry.lua b/src/battle/EffectRegistry.lua index 09fe128b..a259ca5a 100644 --- a/src/battle/EffectRegistry.lua +++ b/src/battle/EffectRegistry.lua @@ -8,6 +8,7 @@ local MoveEffects = require("src.battle.MoveEffects") local Runtime = require("src.mods.Runtime") local StatusRegistry = require("src.battle.StatusRegistry") +local Strings = require("src.core.Strings") local EffectRegistry = {} @@ -91,7 +92,7 @@ function EffectRegistry.runDamaging(battle, ctx, record) if target.invulnerable and not neverMiss then -- Explosion/Selfdestruct still animate on a miss (HandleIfPlayerMoveMissed) if not (record and record.explode) then battle:cancelMoveAnim() end - battle:sayNext(("%s's\nattack missed!"):format(displayName(user))) + battle:sayNext(Strings("%s's\nattack missed!", displayName(user))) return end @@ -113,7 +114,7 @@ function EffectRegistry.runDamaging(battle, ctx, record) if not battle:accuracyRoll(move, user, target) then -- Explosion/Selfdestruct still animate on a miss (HandleIfPlayerMoveMissed) if not (record and record.explode) then battle:cancelMoveAnim() end - battle:sayNext(("%s's\nattack missed!"):format(displayName(user))) + battle:sayNext(Strings("%s's\nattack missed!", displayName(user))) -- Jump Kick crash, Explode self-destruct if record and record.onMiss then record.onMiss(ctx, "accuracy") end user.trappingTurns = nil @@ -140,7 +141,7 @@ function EffectRegistry.runDamaging(battle, ctx, record) end if not counterable or (battle.lastDamage or 0) == 0 then battle:cancelMoveAnim() - battle:sayNext(("%s's\nattack missed!"):format(displayName(user))) + battle:sayNext(Strings("%s's\nattack missed!", displayName(user))) return end dmg = math.min(65535, battle.lastDamage * 2) @@ -163,14 +164,14 @@ function EffectRegistry.runDamaging(battle, ctx, record) if info.typeMult == 0 then -- type immunity zeros damage and sets wMoveMissed in Gen 1, so no anim if not (record and record.explode) then battle:cancelMoveAnim() end - battle:sayNext(("It doesn't affect\n%s!"):format(displayName(target))) + battle:sayNext(Strings("It doesn't affect\n%s!", displayName(target))) if record and record.onMiss then record.onMiss(ctx, "immune") end return end if info.missed then -- 0.25x floored the damage to zero: the original registers a miss if not (record and record.explode) then battle:cancelMoveAnim() end - battle:sayNext(("%s's\nattack missed!"):format(displayName(user))) + battle:sayNext(Strings("%s's\nattack missed!", displayName(user))) if record and record.onMiss then record.onMiss(ctx, "floored") end return end @@ -214,12 +215,12 @@ function EffectRegistry.runDamaging(battle, ctx, record) -- multi-hit loop (core.asm .moveDidNotMiss before the jump back -- to GetPlayerAnimationType), so crit/effectiveness reprint on -- every strike -- damage was only rolled once - if info.crit then battle:sayNext("Critical hit!") end - if info.ohko then battle:sayNext("One-hit KO!") end + if info.crit then battle:sayNext(Strings("Critical hit!")) end + if info.ohko then battle:sayNext(Strings("One-hit KO!")) end if info.typeMult > 10 then - battle:sayNext("It's super\neffective!") + battle:sayNext(Strings("It's super\neffective!")) elseif info.typeMult < 10 then - battle:sayNext("It's not very\neffective...") + battle:sayNext(Strings("It's not very\neffective...")) end if Runtime.wants("battle.damage_dealt") then Runtime.emit("battle.damage_dealt", { @@ -237,9 +238,9 @@ function EffectRegistry.runDamaging(battle, ctx, record) if hits > 1 then -- player: _MultiHitText; enemy: _HitXTimesText (always plural) if user.isPlayer then - battle:sayNext(("Hit the enemy\n%d times!"):format(hits)) + battle:sayNext(Strings("Hit the enemy\n%d times!", hits)) else - battle:sayNext(("Hit %d times!"):format(hits)) + battle:sayNext(Strings("Hit %d times!", hits)) end end @@ -251,7 +252,7 @@ function EffectRegistry.runDamaging(battle, ctx, record) elseif moveInst.struggle then -- struggle recoils even when its effect id resolves to no record local recoil = math.max(1, math.floor(dmg / 2)) - battle:sayNext(("%s's\nhit with recoil!"):format(displayName(user))) + battle:sayNext(Strings("%s's\nhit with recoil!", displayName(user))) battle:applyDamage(user, recoil) end diff --git a/src/battle/MoveEffects.lua b/src/battle/MoveEffects.lua index 31b7c598..085ef431 100644 --- a/src/battle/MoveEffects.lua +++ b/src/battle/MoveEffects.lua @@ -15,6 +15,7 @@ local Logger = require("src.core.Logger") local StatusRegistry = require("src.battle.StatusRegistry") local TurnOrder = require("src.battle.TurnOrder") local TypeChart = require("src.battle.TypeChart") +local Strings = require("src.core.Strings") local MoveEffects = {} @@ -36,14 +37,14 @@ local STAT_LABEL = { local function changeStage(battle, who, stat, delta, fromEnemy) if fromEnemy and (who.substituteHP or who.mist) then if who.mist then - return { displayName(who) .. " is\nprotected by MIST!" } + return { Strings("%s is\nprotected by MIST!", displayName(who)) } end - return { "But, it failed!" } + return { Strings("But, it failed!") } end local cur = who.stages[stat] or 0 local new = math.max(-6, math.min(6, cur + delta)) if new == cur then - return { ("Nothing happened!") } + return { Strings("Nothing happened!") } end who.stages[stat] = new -- effects.asm:505-506: after any stat-stage change, modified stats are @@ -53,13 +54,13 @@ local function changeStage(battle, who, stat, delta, fromEnemy) -- _MonsStatsRoseText/_MonsStatsFellText: "X's / STAT rose!"; the -- two-stage variants scroll "greatly" onto a third line if delta >= 2 then - return { ("%s's\n%s\ngreatly rose!"):format(displayName(who), STAT_LABEL[stat]) } + return { Strings("%s's\n%s\ngreatly rose!", displayName(who), STAT_LABEL[stat]) } elseif delta == 1 then - return { ("%s's\n%s rose!"):format(displayName(who), STAT_LABEL[stat]) } + return { Strings("%s's\n%s rose!", displayName(who), STAT_LABEL[stat]) } elseif delta == -1 then - return { ("%s's\n%s fell!"):format(displayName(who), STAT_LABEL[stat]) } + return { Strings("%s's\n%s fell!", displayName(who), STAT_LABEL[stat]) } end - return { ("%s's\n%s\ngreatly fell!"):format(displayName(who), STAT_LABEL[stat]) } + return { Strings("%s's\n%s\ngreatly fell!", displayName(who), STAT_LABEL[stat]) } end MoveEffects.changeStage = changeStage @@ -88,10 +89,10 @@ end local function statusMove(status) return function(battle, user, target, move) if target.mon.status then - return { "But, it failed!" } + return { Strings("But, it failed!") } end if status == "PSN" and target.substituteHP then - return { "But, it failed!" } + return { Strings("But, it failed!") } end local msgs = inflictStatus(battle, target, status, { toxic = move and move.id == "TOXIC", @@ -99,7 +100,7 @@ local function statusMove(status) source = move and move.id, }) if #msgs == 0 then - return { "But, it failed!" } + return { Strings("But, it failed!") } end return msgs end @@ -111,7 +112,7 @@ local function statusSide(status, chance) -- target (regardless of the burn roll) if move and move.type == "FIRE" and target.mon.status == "FRZ" then target.mon.status = nil - return { ("Fire defrosted\n%s!"):format(displayName(target)) } + return { Strings("Fire defrosted\n%s!", displayName(target)) } end if battle.rng(0, 255) >= chance then return {} end return inflictStatus(battle, target, status, { @@ -144,10 +145,10 @@ end local function confuse(battle, target, pierceSub) if target.confusedTurns or (target.substituteHP and not pierceSub) then - return { "But, it failed!" } + return { Strings("But, it failed!") } end target.confusedTurns = battle.rng(2, 5) - return { ("%s\nbecame confused!"):format(displayName(target)) } + return { Strings("%s\nbecame confused!", displayName(target)) } end -- --------------------------------------------------------------------- @@ -181,53 +182,53 @@ MoveEffects.primary = { LEECH_SEED_EFFECT = function(battle, user, target) -- leech_seed.asm has no substitute check: seeding lands through one if target.leechSeeded then - return { "But, it failed!" } + return { Strings("But, it failed!") } end for _, t in ipairs(target.curTypes) do - if t == "GRASS" then return { "But, it failed!" } end + if t == "GRASS" then return { Strings("But, it failed!") } end end target.leechSeeded = true - return { ("%s\nwas seeded!"):format(displayName(target)) } + return { Strings("%s\nwas seeded!", displayName(target)) } end, HEAL_EFFECT = function(battle, user, target, move) local mon = user.mon if move.id == "REST" then - if mon.hp == mon.stats.hp then return { "But, it failed!" } end + if mon.hp == mon.stats.hp then return { Strings("But, it failed!") } end mon.hp = mon.stats.hp mon.status = "SLP" user.sleepTurns = 2 user.toxicCounter = nil - return { ("%s\nstarted sleeping!"):format(displayName(user)) } + return { Strings("%s\nstarted sleeping!", displayName(user)) } end - if mon.hp == mon.stats.hp then return { "But, it failed!" } end + if mon.hp == mon.stats.hp then return { Strings("But, it failed!") } end mon.hp = math.min(mon.stats.hp, mon.hp + math.floor(mon.stats.hp / 2)) - return { ("%s\nregained health!"):format(displayName(user)) } + return { Strings("%s\nregained health!", displayName(user)) } end, LIGHT_SCREEN_EFFECT = function(battle, user) - if user.lightScreen then return { "But, it failed!" } end + if user.lightScreen then return { Strings("But, it failed!") } end user.lightScreen = true - return { ("%s's\nprotected against\nspecial attacks!"):format(displayName(user)) } + return { Strings("%s's\nprotected against\nspecial attacks!", displayName(user)) } end, REFLECT_EFFECT = function(battle, user) - if user.reflect then return { "But, it failed!" } end + if user.reflect then return { Strings("But, it failed!") } end user.reflect = true - return { ("%s\ngained armor!"):format(displayName(user)) } + return { Strings("%s\ngained armor!", displayName(user)) } end, MIST_EFFECT = function(battle, user) - if user.mist then return { "But, it failed!" } end + if user.mist then return { Strings("But, it failed!") } end user.mist = true -- _ShroudedInMistText (lowercase "mist") - return { ("%s's\nshrouded in mist!"):format(displayName(user)) } + return { Strings("%s's\nshrouded in mist!", displayName(user)) } end, FOCUS_ENERGY_EFFECT = function(battle, user) - if user.focusEnergy then return { "But, it failed!" } end + if user.focusEnergy then return { Strings("But, it failed!") } end user.focusEnergy = true - return { ("%s's\ngetting pumped!"):format(displayName(user)) } + return { Strings("%s's\ngetting pumped!", displayName(user)) } end, HAZE_EFFECT = function(battle, user, target) @@ -254,33 +255,33 @@ MoveEffects.primary = { target.skipMove = true end target.mon.status = nil - return { "All STATUS changes\nare eliminated!" } + return { Strings("All STATUS changes\nare eliminated!") } end, SUBSTITUTE_EFFECT = function(battle, user) - if user.substituteHP then return { ("%s\nhas a SUBSTITUTE!"):format(displayName(user)) } end + if user.substituteHP then return { Strings("%s\nhas a SUBSTITUTE!", displayName(user)) } end local cost = math.floor(user.mon.stats.hp / 4) -- substitute.asm only fails on subtraction underflow (current HP -- strictly below maxHP/4); at equality the substitute is built and -- the user is left standing on exactly 0 HP (it faints only when -- the engine next checks HP, not here) if user.mon.hp < cost then - return { "Too weak to make\na SUBSTITUTE!" } + return { Strings("Too weak to make\na SUBSTITUTE!") } end user.mon.hp = user.mon.hp - cost user.substituteHP = cost + 1 -- _SubstituteText - return { "It created a\nSUBSTITUTE!" } + return { Strings("It created a\nSUBSTITUTE!") } end, CONVERSION_EFFECT = function(battle, user, target) -- conversion.asm fails against a mid-Fly/Dig target (INVULNERABLE) if target.invulnerable then - return { "But, it failed!" } + return { Strings("But, it failed!") } end user.curTypes = { target.curTypes[1], target.curTypes[2] } -- _ConvertedTypeText - return { ("Converted type to\n%s's!"):format(displayName(target)) } + return { Strings("Converted type to\n%s's!", displayName(target)) } end, -- MIMIC_EFFECT lives in BattleState:resolveMimic: MimicEffect @@ -311,27 +312,27 @@ MoveEffects.primary = { table.insert(user.curMoves, { id = mv.id, pp = 5, mimic = true }) end -- _TransformedText: the copied name prints bare (wNameBuffer) - return { ("%s\ntransformed into\n%s!"):format(displayName(user), target.name) } + return { Strings("%s\ntransformed into\n%s!", displayName(user), target.name) } end, DISABLE_EFFECT = function(battle, user, target) - if target.disabledSlot then return { "But, it failed!" } end + if target.disabledSlot then return { Strings("But, it failed!") } end local usable = {} for i, mv in ipairs(target.curMoves) do if mv.pp > 0 then table.insert(usable, i) end end - if #usable == 0 then return { "But, it failed!" } end + if #usable == 0 then return { Strings("But, it failed!") } end local slot = usable[battle.rng(1, #usable)] target.disabledSlot = slot target.disabledTurns = battle.rng(1, 8) local id = target.curMoves[slot].id -- _MoveWasDisabledText: "X's / MOVE was / disabled!" - return { ("%s's\n%s was\ndisabled!"):format(displayName(target), + return { Strings("%s's\n%s was\ndisabled!", displayName(target), battle.data.moves[id].name) } end, SPLASH_EFFECT = function() - return { "No effect!" } + return { Strings("No effect!") } end, } @@ -428,7 +429,9 @@ local function drainHalf(text) local mon = ctx.user.mon mon.hp = math.min(mon.stats.hp, mon.hp + heal) ctx.drain() - ctx.say(text:format(displayName(ctx.target))) + -- `text` arrives as a source string (Strings.source at the call + -- site keeps it in the catalog); look it up here, at use time + ctx.say(Strings(text, displayName(ctx.target))) end end @@ -436,7 +439,7 @@ end -- flags the miss before the special-damage override) local function immuneMsg(ctx) if TypeChart.effectiveness(ctx.move.type, ctx.target.curTypes) == 0 then - return ("It doesn't affect\n%s!"):format(displayName(ctx.target)) + return Strings("It doesn't affect\n%s!", displayName(ctx.target)) end return nil end @@ -498,12 +501,12 @@ MoveEffects.full = { -- removed): overkill and substitute hits recoil at full strength local recoil = math.max(1, math.floor(ctx.rawDamage / (ctx.moveInst.struggle and 2 or 4))) - ctx.say(("%s's\nhit with recoil!"):format(displayName(ctx.user))) + ctx.say(Strings("%s's\nhit with recoil!", displayName(ctx.user))) ctx.battle:applyDamage(ctx.user, recoil) end, }, DRAIN_HP_EFFECT = { - afterDamage = drainHalf("Sucked health from\n%s!"), + afterDamage = drainHalf(Strings.source("Sucked health from\n%s!")), }, DREAM_EATER_EFFECT = { -- only works on sleeping targets (checked before damage) @@ -511,7 +514,7 @@ MoveEffects.full = { if ctx.target.mon.status ~= "SLP" then return false, "But, it failed!" end return true end, - afterDamage = drainHalf("%s's\ndream was eaten!"), + afterDamage = drainHalf(Strings.source("%s's\ndream was eaten!")), }, -- charge moves: first turn just charges; Fly AND Dig go @@ -557,7 +560,7 @@ MoveEffects.full = { user.thrashTurns, user.thrashMove, user.thrashAnnounced = nil, nil, nil if not user.confusedTurns then user.confusedTurns = ctx.rng(2, 5) - ctx.say(("%s\nbecame confused!"):format(displayName(user))) + ctx.say(Strings("%s\nbecame confused!", displayName(user))) end end end @@ -566,7 +569,7 @@ MoveEffects.full = { JUMP_KICK_EFFECT = { onMiss = function(ctx, reason) if reason ~= "accuracy" then return end - ctx.say(("%s\nkept going and\ncrashed!"):format(displayName(ctx.user))) + ctx.say(Strings("%s\nkept going and\ncrashed!", displayName(ctx.user))) ctx.damage(ctx.user, 1) end, }, @@ -595,7 +598,7 @@ MoveEffects.full = { afterDamage = function(ctx) local battle = ctx.battle battle.payDay = (battle.payDay or 0) + 2 * ctx.user.mon.level - ctx.say("Coins scattered\neverywhere!") + ctx.say(Strings("Coins scattered\neverywhere!")) end, }, SWIFT_EFFECT = { neverMiss = true }, @@ -610,7 +613,7 @@ MoveEffects.full = { local user = ctx.user user.bideTurns = ctx.rng(2, 3) user.bideDamage = 0 - ctx.say(("%s\nis storing energy!"):format(displayName(user))) + ctx.say(Strings("%s\nis storing energy!", displayName(user))) end, }, SWITCH_AND_TELEPORT_EFFECT = { @@ -631,27 +634,27 @@ MoveEffects.full = { end if ok then if move.id == "ROAR" then - ctx.say(("%s\nran away scared!"):format(displayName(target))) + ctx.say(Strings("%s\nran away scared!", displayName(target))) elseif move.id == "WHIRLWIND" then - ctx.say(("%s\nwas blown away!"):format(displayName(target))) + ctx.say(Strings("%s\nwas blown away!", displayName(target))) else - ctx.say(("%s\nran from battle!"):format(displayName(user))) + ctx.say(Strings("%s\nran from battle!", displayName(user))) end battle.result = "run" battle.afterQueue = "finish" elseif move.id == "TELEPORT" then battle:cancelMoveAnim() - ctx.say("But, it failed!") + ctx.say(Strings("But, it failed!")) else battle:cancelMoveAnim() - ctx.say(("It didn't affect\n%s!"):format(displayName(target))) + ctx.say(Strings("It didn't affect\n%s!", displayName(target))) end elseif move.id == "TELEPORT" then battle:cancelMoveAnim() - ctx.say("But, it failed!") + ctx.say(Strings("But, it failed!")) else battle:cancelMoveAnim() - ctx.say(("%s\nis unaffected!"):format(displayName(target))) + ctx.say(Strings("%s\nis unaffected!", displayName(target))) end end, }, @@ -669,7 +672,7 @@ MoveEffects.full = { callsMove = function(ctx) local last = ctx.target.lastMove if not last then - ctx.say("The MIRROR MOVE\nfailed!") + ctx.say(Strings("The MIRROR MOVE\nfailed!")) return nil end return last diff --git a/src/battle/Status.lua b/src/battle/Status.lua index a0c5e3f4..8fad8716 100644 --- a/src/battle/Status.lua +++ b/src/battle/Status.lua @@ -5,6 +5,8 @@ -- and residual sweep. Callers without a battle (pure-module tests) fall -- back to the vanilla records, which is bit-identical behavior. +local Strings = require("src.core.Strings") + local Status = {} -- pokered's / text macros print "Enemy " before the enemy @@ -28,8 +30,11 @@ local function hasType(battler, wanted) end -- shared PSN/BRN residual: 1/16 max HP, multiplied (and advanced) by the --- Toxic counter (HandlePoisonBurnLeechSeed) -local function damageOverTime(what) +-- Toxic counter (HandlePoisonBurnLeechSeed). The caller passes the whole +-- sentence rather than the noun: "hurt by poison" and "hurt by the burn" +-- decline differently once translated, so a shared fragment cannot be the +-- translatable unit. +local function damageOverTime(template) return function(battler) local mon = battler.mon local base = math.max(1, math.floor(mon.stats.hp / 16)) @@ -39,10 +44,15 @@ local function damageOverTime(what) battler.toxicCounter = battler.toxicCounter + 1 end mon.hp = math.max(0, mon.hp - dmg) - return { ("%s's\nhurt by %s!"):format(name(battler), what) } + return { Strings(template, name(battler)) } end end +-- Labels stay plain literals on purpose: this table is built at require +-- time, before Strings.load has a catalog, so a Strings() here would +-- freeze the English. They are already translatable through the +-- statuses registry (mod.content.statuses:patch(id, { label = ... })). +-- -- The five persistent conditions as records: the beforeMove gauntlet, the -- residual sweep, the inflict text/immunities (StatusRegistry.inflict), -- the catch/wobble bonuses (Catching.attempt), the HUD label, and the @@ -57,13 +67,13 @@ Status.RECORDS = { battler.sleepTurns = (battler.sleepTurns or 1) - 1 if battler.sleepTurns <= 0 then battler.mon.status = nil - return false, { name(battler) .. "\nwoke up!" } -- wakes but loses the turn + return false, { Strings("%s\nwoke up!", name(battler)) } -- wakes, loses the turn end - return false, { name(battler) .. "\nis fast asleep!" } + return false, { Strings("%s\nis fast asleep!", name(battler)) } end, onInflict = function(battle, target, opts, display) target.sleepTurns = battle.rng(1, 7) - return { ("%s\nfell asleep!"):format(display) } + return { Strings("%s\nfell asleep!", display) } end, }, FRZ = { @@ -71,35 +81,35 @@ Status.RECORDS = { catchBonus = 25, shakeBonus = 10, beforeMovePriority = 30, beforeMove = function(battler) - return false, { name(battler) .. "\nis frozen solid!" } + return false, { Strings("%s\nis frozen solid!", name(battler)) } end, canInflict = function(target) return not hasType(target, "ICE") end, onInflict = function(_, _, _, display) - return { ("%s\nwas frozen solid!"):format(display) } + return { Strings("%s\nwas frozen solid!", display) } end, }, PSN = { id = "PSN", label = "PSN", hudLabel = "PSN", catchBonus = 12, shakeBonus = 5, - residual = damageOverTime("poison"), + residual = damageOverTime(Strings.source("%s's\nhurt by poison!")), canInflict = function(target) return not hasType(target, "POISON") end, onInflict = function(_, target, opts, display) if opts.toxic then target.toxicCounter = 1 -- _BadlyPoisonedText - return { ("%s's\nbadly poisoned!"):format(display) } + return { Strings("%s's\nbadly poisoned!", display) } end - return { ("%s\nwas poisoned!"):format(display) } + return { Strings("%s\nwas poisoned!", display) } end, }, BRN = { id = "BRN", label = "BRN", hudLabel = "BRN", catchBonus = 12, shakeBonus = 5, statPenalty = { stat = "attack", div = 2 }, - residual = damageOverTime("the burn"), + residual = damageOverTime(Strings.source("%s's\nhurt by the burn!")), canInflict = function(target) return not hasType(target, "FIRE") end, onInflict = function(_, _, _, display) - return { ("%s\nwas burned!"):format(display) } + return { Strings("%s\nwas burned!", display) } end, }, PAR = { @@ -110,7 +120,7 @@ Status.RECORDS = { beforeMove = function(battler, rng) -- cp 25 percent / jr nc: fully paralyzed on rand < 63 (63/256) if rng(0, 255) < 63 then - return false, { name(battler) .. "'s\nfully paralyzed!" } + return false, { Strings("%s's\nfully paralyzed!", name(battler)) } end return true, {} end, @@ -120,7 +130,7 @@ Status.RECORDS = { end, onInflict = function(_, _, _, display) -- _ParalyzedMayNotAttackText (primary and secondary paralysis) - return { ("%s's\nparalyzed! It may\nnot attack!"):format(display) } + return { Strings("%s's\nparalyzed! It may\nnot attack!", display) } end, }, } @@ -156,7 +166,7 @@ function Status.beforeMove(battler, rng, battle) end if battler.flinched then battler.flinched = false - return false, { name(battler) .. "\nflinched!" } + return false, { Strings("%s\nflinched!", name(battler)) } end local record = Status.recordFor(battleStatuses(battle), mon.status) local handler = record and record.beforeMove @@ -174,23 +184,23 @@ function Status.beforeMove(battler, rng, battle) end if battler.boundTurns and battler.boundTurns > 0 then battler.boundTurns = battler.boundTurns - 1 - msgs[#msgs + 1] = name(battler) .. "\ncan't move!" + msgs[#msgs + 1] = Strings("%s\ncan't move!", name(battler)) return false, msgs end if battler.disabledTurns then battler.disabledTurns = battler.disabledTurns - 1 if battler.disabledTurns <= 0 then battler.disabledTurns, battler.disabledSlot = nil, nil - table.insert(msgs, name(battler) .. "'s\ndisabled no more!") + table.insert(msgs, Strings("%s's\ndisabled no more!", name(battler))) end end if battler.confusedTurns then battler.confusedTurns = battler.confusedTurns - 1 if battler.confusedTurns <= 0 then battler.confusedTurns = nil - table.insert(msgs, name(battler) .. "\nsnapped out of\nconfusion!") + table.insert(msgs, Strings("%s\nsnapped out of\nconfusion!", name(battler))) else - table.insert(msgs, name(battler) .. "\nis confused!") + table.insert(msgs, Strings("%s\nis confused!", name(battler))) -- cp 50 percent + 1 / jr c: hurt itself on rand >= 128 (128/256) if rng(0, 255) < 128 then return false, msgs, true -- hurt itself @@ -231,7 +241,7 @@ function Status.residual(battler, opponent, battle) dmg = math.min(dmg, mon.hp) mon.hp = mon.hp - dmg opponent.mon.hp = math.min(opponent.mon.stats.hp, opponent.mon.hp + dmg) - table.insert(msgs, ("LEECH SEED saps\n%s!"):format(name(battler))) + table.insert(msgs, Strings("LEECH SEED saps\n%s!", name(battler))) end return msgs end diff --git a/src/battle/StatusRegistry.lua b/src/battle/StatusRegistry.lua index 2c9f8892..55de40c3 100644 --- a/src/battle/StatusRegistry.lua +++ b/src/battle/StatusRegistry.lua @@ -5,6 +5,7 @@ local Runtime = require("src.mods.Runtime") local Status = require("src.battle.Status") +local Strings = require("src.core.Strings") local StatusRegistry = {} @@ -45,7 +46,7 @@ function StatusRegistry.inflict(battle, target, status, opts) if record and record.onInflict then msgs = record.onInflict(battle, target, opts, display) else - msgs = { ("%s\nwas afflicted\nby %s!"):format(display, + msgs = { Strings("%s\nwas afflicted\nby %s!", display, record and record.label or tostring(status)) } end Runtime.emit("battle.status_inflicted", { diff --git a/src/battle/TrainerAI.lua b/src/battle/TrainerAI.lua index 48f1efb9..09308ed5 100644 --- a/src/battle/TrainerAI.lua +++ b/src/battle/TrainerAI.lua @@ -19,6 +19,7 @@ -- enemy PP — Gen 1 AI never reads wEnemyMonPP). local TypeChart = require("src.battle.TypeChart") +local Strings = require("src.core.Strings") local TrainerAI = {} @@ -99,7 +100,7 @@ function TrainerAI.useItem(battle, item) local enemy = battle.enemy local trainerName = battle.trainer.name local itemName = battle.data.items[item] and battle.data.items[item].name or item - local msgs = { ("%s\nused %s!"):format(trainerName, itemName) } + local msgs = { Strings("%s\nused %s!", trainerName, itemName) } if item == "FULL_HEAL" then enemy.mon.status = nil enemy.toxicCounter = nil @@ -112,10 +113,10 @@ function TrainerAI.useItem(battle, item) elseif X_STAT[item] then local stat = X_STAT[item] enemy.stages[stat] = math.min(6, (enemy.stages[stat] or 0) + 1) - table.insert(msgs, ("%s's\n%s rose!"):format(enemy.name, stat:upper())) + table.insert(msgs, Strings("%s's\n%s rose!", enemy.name, stat:upper())) elseif item == "GUARD_SPEC" then enemy.mist = true - table.insert(msgs, ("%s's\nprotected against\nstat changes!"):format(enemy.name)) + table.insert(msgs, Strings("%s's\nprotected against\nstat changes!", enemy.name)) end return msgs end diff --git a/src/core/DiscordPresence.lua b/src/core/DiscordPresence.lua index 344152cb..56679edb 100644 --- a/src/core/DiscordPresence.lua +++ b/src/core/DiscordPresence.lua @@ -9,6 +9,7 @@ local Json = require("src.link.Json") local Logger = require("src.core.Logger") local Runtime = require("src.mods.Runtime") +local Strings = require("src.core.Strings") local DiscordPresence = { APP_ID = "1529183141267374262", @@ -436,7 +437,7 @@ end local function buildActivity() local details, activityState if state.activity == "battle" then - details = state.battleLabel or "In battle" + details = state.battleLabel or Strings("In battle") activityState = state.location and (state.location) or "Kanto" elseif state.activity == "exploring" and state.location then details = state.location @@ -550,20 +551,20 @@ local function subscribe(game) track("battle.started", function(ev) local battle = ev and ev.battle - local label = "In battle" + local label = Strings("In battle") if ev and ev.kind == "wild" then local name = speciesName(game, ev.species) if name then label = "Battling wild " .. name if ev.level then label = label .. " Lv" .. tostring(ev.level) end else - label = "Wild battle" + label = Strings("Wild battle") end elseif ev and ev.kind == "trainer" then local tname = battle and battle.trainer and battle.trainer.name - label = tname and ("Battling " .. tname) or "Trainer battle" + label = tname and ("Battling " .. tname) or Strings("Trainer battle") elseif ev and ev.kind == "link" then - label = "Link battle" + label = Strings("Link battle") end local mapId = state.mapId or (game.save and game.save.player and game.save.player.map) @@ -594,7 +595,7 @@ local function subscribe(game) state.mapId = nil setPresence({ activity = "menu", - location = id == "IntroMovie" and "Watching the intro" or "Title screen", + location = id == "IntroMovie" and "Watching the intro" or Strings("Title screen"), clearBattle = true, }) end diff --git a/src/core/Game.lua b/src/core/Game.lua index 195915a5..cb55c1c3 100644 --- a/src/core/Game.lua +++ b/src/core/Game.lua @@ -53,6 +53,9 @@ function Game:load() require("src.render.Font").load(Data) -- menu cursor/border/geometry constants; field.theme restyles them require("src.ui.Theme").load(Data) + -- the engine's own text, after the merge so a translation mod's catalog + -- is already in Data.strings; empty on a mod-free boot and skipped + require("src.core.Strings").load(Data) self.stack = StateStack StateStack:init() diff --git a/src/core/SaveData.lua b/src/core/SaveData.lua index d9f6182b..3d540cc7 100644 --- a/src/core/SaveData.lua +++ b/src/core/SaveData.lua @@ -1155,9 +1155,16 @@ function SaveData.defaultHeal(boot) end -- Post-credits home (issue #103). pokered left the player in HALL_OF_FAME --- after jp Init; this port places CONTINUE at the NewGameWarp bedroom and --- retargets LAST_MAP exits (Red's house mats) at the heal-point town so --- leaving the house does not dump the player back at Indigo Plateau. +-- after jp Init; this port relocates CONTINUE instead, and retargets +-- LAST_MAP exits (Red's house mats) at the heal-point town so leaving the +-- house does not dump the player back at Indigo Plateau. +-- +-- The landing spot is the blackout point, not the NewGameWarp bedroom: +-- HallOfFameResetEventsAndSaveScript sets wLastBlackoutMap := PALLET_TOWN, +-- so resuming puts the player outside the front door. Spawning them back +-- in the upstairs bedroom was a port-only detour (#253). Non-vanilla +-- spawns are unaffected -- defaultHeal returns boot's own start map for +-- those, which is what the bedroom branch resolved to anyway. -- Marks postGameHomeOk so a later intentional HoF save is not relocated. function SaveData.applyPostGameHome(save, boot) boot = type(boot) == "table" and boot or {} @@ -1165,9 +1172,9 @@ function SaveData.applyPostGameHome(save, boot) save.lastHeal = { map = heal.map, x = heal.x, y = heal.y } save.lastOutdoor = { id = heal.map, x = heal.x, y = heal.y } save.player = save.player or {} - save.player.map = boot.startMap or "REDS_HOUSE_2F" - save.player.x = boot.startX or 3 - save.player.y = boot.startY or 6 + save.player.map = heal.map + save.player.x = heal.x + save.player.y = heal.y save.player.facing = boot.startFacing or "down" save.postGameHomeOk = true return heal diff --git a/src/core/Strings.lua b/src/core/Strings.lua new file mode 100644 index 00000000..6c1b01bf --- /dev/null +++ b/src/core/Strings.lua @@ -0,0 +1,134 @@ +-- The engine's own player-facing text, made overridable. +-- +-- Extracted dialogue already had a home: `Data.text`, keyed by pokered's +-- labels, which a mod reaches through `mod.content.text`. The strings the +-- engine *authors* had none. Battle messages, item results, menu labels +-- and the link-play screens were literals in Lua, so a translator could +-- reach two thirds of the game and no more (#186, #245). +-- +-- Those literals stay where they are, wrapped in `S(...)`, and the English +-- source doubles as the catalog key: +-- +-- self:say(S("But, it failed!")) +-- self:say(S("Wild %s\nappeared!", self.enemy.name)) +-- +-- Keying on the source rather than on an invented id is deliberate. It +-- keeps the English readable at the point it is used (the alternative +-- scatters a thousand `battle.it_failed` ids that have to be looked up to +-- review a diff), it needs no id registry to stay in sync, and an entry a +-- translation has not covered yet falls through to English instead of +-- rendering a raw id at the player. The cost is that editing an English +-- string orphans its translations; `tools/modkit.py translation --refresh` +-- reports those as changed keys rather than silently dropping them. +-- +-- Same-source-different-meaning is the one case source keys cannot hold on +-- their own ("OFF" as a filter setting vs "OFF" as a toggle, which some +-- languages render differently). Those sites pass a context: +-- +-- S("OFF", "options.musicFilter") -- key: "options.musicFilter|OFF" +-- +-- A mod supplies the catalog through the `strings` registry: +-- +-- mod.content.strings:override("But, it failed!", "Echec !") +-- +-- With no mod loaded the catalog is empty and `S` is an identity function +-- guarded by one boolean, so a vanilla boot draws byte-identical text. + +local Strings = {} + +local catalog = nil -- Data.strings once a mod has put something in it +local missing = {} -- format-arity complaints, reported once each + +-- Called from Game after the mod merge, and again on dev-mode hot reload. +-- Holding the table (not a copy) means a mod that registers late still +-- takes effect without a second load. +function Strings.load(data) + local t = data and data.strings + catalog = nil + if type(t) ~= "table" then return end + for _ in pairs(t) do + catalog = t + return + end +end + +function Strings.active() + return catalog ~= nil +end + +-- The lookup itself. `context` is optional and only disambiguates sources +-- that collide; the plain key is tried after it, so a translation that does +-- not care about the distinction can supply one entry for both. +function Strings.lookup(source, context) + if not catalog then return source end + if context then + local hit = catalog[context .. "|" .. source] + if type(hit) == "string" then return hit end + end + local hit = catalog[source] + if type(hit) == "string" then return hit end + return source +end + +-- Count `%`-directives so a translation that drops or adds one is caught +-- here rather than as a mid-battle `string.format` error. +local function specifiers(s) + local n = 0 + for spec in s:gmatch("%%(.)") do + if spec ~= "%" then n = n + 1 end + end + return n +end + +-- S(source) -> translated source +-- S(source, ...) -> translated source, string.format'ed +-- S(source, context) -> context-disambiguated lookup, no formatting +-- +-- The two-argument forms are told apart by whether the source carries any +-- format directives: a source with no `%s` cannot be formatting, so a lone +-- string second argument is a context. +function Strings.get(source, ...) + local argc = select("#", ...) + if argc == 0 then return Strings.lookup(source) end + + local wants = specifiers(source) + if wants == 0 and argc == 1 and type((...)) == "string" then + return Strings.lookup(source, (...)) + end + + local text = Strings.lookup(source) + -- A translation with the wrong arity would raise inside string.format, + -- which in a battle means a crash the player cannot escape. Fall back to + -- the English source, which is known to match, and say so once. + if specifiers(text) ~= wants then + if not missing[source] then + missing[source] = true + require("src.core.Logger").warn( + "strings: translation of %q has %d format directives, source has %d" + .. " -- using the source", source, specifiers(text), wants) + end + text = source + end + local ok, out = pcall(string.format, text, ...) + if not ok then return source end + return out +end + +-- A marker, not a lookup: returns its argument untouched. +-- +-- Some templates are declared in a module-level table and formatted much +-- later (BattleState's charge-move lines, for one). Translating at the +-- declaration would freeze the English, because those tables are built at +-- require time and Strings.load has no catalog yet; the use site therefore +-- calls Strings(template, ...) and looks the source up then. That works at +-- runtime but leaves the literal invisible to the catalog generator, which +-- only sees what is spelled out at a call site. Wrapping the declaration in +-- Strings.source puts it back in the harvest while changing nothing at all +-- about when the lookup happens. +function Strings.source(text) + return text +end + +setmetatable(Strings, { __call = function(_, ...) return Strings.get(...) end }) + +return Strings diff --git a/src/import/RomImporter.lua b/src/import/RomImporter.lua index de9f14c2..4eb18f9f 100644 --- a/src/import/RomImporter.lua +++ b/src/import/RomImporter.lua @@ -1,4 +1,5 @@ local GameVersion = require("src.core.GameVersion") +local Strings = require("src.core.Strings") local RomImporter = {} RomImporter.__index = RomImporter @@ -356,7 +357,7 @@ end -- Returns the chosen absolute path or nil. Android uses love.system.pickFile -- ("mod") instead -- see RomImporter:chooseMod. local function chooseZip() - local prompt = "Choose a mod .zip" + local prompt = Strings("Choose a mod .zip") local platform = love.system.getOS() if platform == "OS X" then return commandOutput( @@ -387,7 +388,7 @@ end -- dialogs). Returns the chosen absolute path or nil. Android uses -- love.system.pickFile("sav") instead -- see RomImporter:chooseSaveImport. local function chooseSav() - local prompt = "Choose a .sav save file" + local prompt = Strings("Choose a .sav save file") local platform = love.system.getOS() if platform == "OS X" then return commandOutput( @@ -1426,7 +1427,7 @@ function RomImporter:draw() self.updateButton = { x = rect.x, y = rect.y, width = rect.width, height = rect.height, action = "download" } message(upLatest and ("Update v" .. upLatest .. " available") - or "An update is available", rect.width) + or Strings("An update is available"), rect.width) elseif upStatus == "needs_full" then local rect = actionButton("Open releases") self.updateButton = { x = rect.x, y = rect.y, width = rect.width, @@ -1718,7 +1719,7 @@ function RomImporter:_playButton(x, y, w, h, gameName, ready, locked) col(PAL.disabledInk, 0.3) love.graphics.rectangle("line", x, y, w, h, r, r) col(PAL.disabledInk) - local label = locked and "Coming soon" or "Import a ROM to play" + local label = locked and "Coming soon" or Strings("Import a ROM to play") printfB(label, x, y + (h - self.playFont:getHeight()) / 2, w, "center") self.playButtonRect = nil end @@ -1730,13 +1731,13 @@ function RomImporter:_drawTabBar(x, y, w, h, chip) local s, pulse = self._s, self.pulse local tabs = { { id = "red", letter = "R", top = PAL.chipRedTop, bot = PAL.chipRedBot, - under = PAL.red, label = "RED", ink = PAL.white }, + under = PAL.red, label = Strings("RED"), ink = PAL.white }, { id = "blue", letter = "B", top = PAL.chipBlueTop, bot = PAL.chipBlueBot, - under = PAL.blue, label = "BLUE", ink = PAL.white }, + under = PAL.blue, label = Strings("BLUE"), ink = PAL.white }, { id = "yellow", letter = "Y", top = PAL.chipGoldTop, bot = PAL.chipGoldBot, - under = PAL.gold, label = "YELLOW", ink = PAL.chipInkGold }, + under = PAL.gold, label = Strings("YELLOW"), ink = PAL.chipInkGold }, { id = "mods", mods = true, top = PAL.chipModTop, bot = PAL.chipModBot, - under = PAL.modDot, label = "MODS" }, + under = PAL.modDot, label = Strings("MODS") }, } local gap = 10 * s local r = 12 * s @@ -1795,7 +1796,7 @@ function RomImporter:_drawTabBar(x, y, w, h, chip) local ready = 0 for _, v in ipairs(GameVersion.ORDER) do if self.ready[v] then ready = ready + 1 end end love.graphics.setFont(self.readyFont) - local label = ("%d of 3 ready"):format(ready) + local label = Strings("%d of 3 ready", ready) local lw = self.readyFont:getWidth(label) if x + w - lw > cursorX + 8 * s then col(PAL.labelGray) @@ -1851,7 +1852,7 @@ function RomImporter:_drawGamePanel(version, x, y, w, h) -- ROM card contents by state (rehomes the existing import flow) local dropHint = self.android and "Copy the .gb via USB." - or "Or drop the .gb file here." + or Strings("Or drop the .gb file here.") local accent = locked and PAL.gold or (version == "red" and PAL.red or PAL.blue) local romState, romDetail, romBtnLabel, romBtnEnabled, romProgress if locked then @@ -1866,12 +1867,12 @@ function RomImporter:_drawGamePanel(version, x, y, w, h) romDetail = self.detail or "" romProgress = self.progress or 0 elseif ready then - romState = self.romName[version] or "ROM imported" + romState = self.romName[version] or Strings("ROM imported") romDetail = "Verified." romBtnLabel, romBtnEnabled = "Re-import ROM", true elseif erroring then romState = "Import failed" - romDetail = self.detail or "That ROM could not be imported." + romDetail = self.detail or Strings("That ROM could not be imported.") romBtnLabel, romBtnEnabled = "Import ROM", true elseif notice then romState = "No ROM imported" @@ -2000,7 +2001,7 @@ function RomImporter:_drawGamePanel(version, x, y, w, h) if sfNotice and sfNotice.dir then iy = iy + 4 * s love.graphics.setFont(self.hintFont) - local label = "Open folder" + local label = Strings("Open folder") local lw = self.hintFont:getWidth(label) local frect = { x = ix, y = iy, width = lw, height = self.hintFont:getHeight(), dir = sfNotice.dir } @@ -2228,14 +2229,13 @@ function RomImporter:_drawSaveSlotPanel(version, x, y, w, h) love.graphics.setFont(self.slotNameFont) col(PAL.white) - local name = slot.name or "NEW GAME" + local name = slot.name or Strings("NEW GAME") printB(ellipsize(self.slotNameFont, name, rw - 24 * s - math.max(pillW, rightReserve)), rx + 12 * s, ry + rowPadV) local metaTxt if slot.exists and slot.meta then - metaTxt = ("%d badges - %s - %d caught"):format( - slot.meta.badges or 0, slot.meta.timeText or "0:00", + metaTxt = Strings("%d badges - %s - %d caught", slot.meta.badges or 0, slot.meta.timeText or "0:00", slot.meta.dexCount or 0) else metaTxt = "empty slot" @@ -2339,7 +2339,7 @@ function RomImporter:_drawModsPanel(x, y, w, h) for _, m in ipairs(mods) do if m.enabled then enabledCount = enabledCount + 1 end end love.graphics.setFont(self.hintFont) col(PAL.warning) - love.graphics.print(("%d of %d enabled"):format(enabledCount, #mods), + love.graphics.print(Strings("%d of %d enabled", enabledCount, #mods), x + nameW + 14 * s, y + (headerH - self.hintFont:getHeight()) / 2) local btnLabel = "Import mod .zip" @@ -2360,7 +2360,7 @@ function RomImporter:_drawModsPanel(x, y, w, h) else col(PAL.warning) love.graphics.printf(self.android and "Or copy a mod .zip via USB." - or "Or drop a mod .zip onto the window.", x, top, w, "left") + or Strings("Or drop a mod .zip onto the window."), x, top, w, "left") end top = top + self.hintFont:getHeight() + 12 * s @@ -2376,7 +2376,7 @@ function RomImporter:_drawModsPanel(x, y, w, h) col(PAL.warning) local emptyHint = self.android and "No mods installed - tap Import mod .zip to add one." - or "No mods installed - drop a mod .zip here to add one." + or Strings("No mods installed - drop a mod .zip here to add one.") love.graphics.printf(emptyHint, x + 16 * s, top + boxH / 2 - self.hintFont:getHeight() / 2, w - 32 * s, "center") self.modRects = {} diff --git a/src/inventory/ItemEffects.lua b/src/inventory/ItemEffects.lua index 7cbed126..193b6ece 100644 --- a/src/inventory/ItemEffects.lua +++ b/src/inventory/ItemEffects.lua @@ -12,6 +12,7 @@ local Pokemon = require("src.pokemon.Pokemon") local Flags = require("src.script.Flags") +local Strings = require("src.core.Strings") local ItemEffects = {} @@ -119,8 +120,8 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow) if battle and (VITAMINS[itemId] or STONES[itemId] or itemId == "PP_UP" or itemId == "RARE_CANDY" or itemId == "COIN_CASE" or (itemDef and itemDef.machine)) then - return "failed", { "OAK: " .. save.player.name - .. "!\nThis isn't the\ntime to use that!" } + return "failed", { Strings("OAK: %s!\nThis isn't the\ntime to use that!", + save.player.name) } end if BALLS[itemId] then @@ -138,11 +139,12 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow) local mapId, npc = adjacentSleepingSnorlax(save, ow) if npc then return "flute_wake", { data.text._PlayedFluteHadEffectText - or "{PLAYER} played the\nPOKé FLUTE." }, { mapId = mapId, npc = npc } + or Strings("{PLAYER} played the\nPOKé FLUTE.") }, + { mapId = mapId, npc = npc } end -- otherwise: play the tune, nothing happens (ItemUsePokeFlute's -- PlayedFluteNoEffectText branch) - return "flute_field", { "Played the POKé\nFLUTE.\fNow, that's a\ncatchy tune!" } + return "flute_field", { Strings("Played the POKé\nFLUTE.\fNow, that's a\ncatchy tune!") } end local woke = false local function wake(mon) @@ -157,24 +159,24 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow) -- WakeUpEntireParty runs on the enemy's bench too for _, mon in ipairs(battle.enemyParty or {}) do wake(mon) end if not woke then - return "failed", { "Played the POKé\nFLUTE.\fNow, that's a\ncatchy tune!" } + return "failed", { Strings("Played the POKé\nFLUTE.\fNow, that's a\ncatchy tune!") } end - return "flute", { ("%s played the\nPOKé FLUTE."):format(save.player.name), - "All sleeping\nPOKéMON woke up!" } + return "flute", { Strings("%s played the\nPOKé FLUTE.", save.player.name), + Strings("All sleeping\nPOKéMON woke up!") } end -- battle-only items if X_ITEMS[itemId] or itemId == "DIRE_HIT" or itemId == "GUARD_SPEC" or itemId == "POKE_DOLL" then if not battle then - return "failed", { "OAK: " .. save.player.name .. "!\nThis isn't the\ntime to use that!" } + return "failed", { Strings("OAK: %s!\nThis isn't the\ntime to use that!", save.player.name) } end local b = battle.player if itemId == "X_ACCURACY" then -- ItemUseXAccuracy sets USING_X_ACCURACY: moves never miss -- (not an accuracy stage) b.xAccuracy = true - return "consumed", { ("%s's\nhits will never\nmiss!"):format(b.name) } + return "consumed", { Strings("%s's\nhits will never\nmiss!", b.name) } end if X_ITEMS[itemId] then local stat = X_ITEMS[itemId] @@ -183,28 +185,28 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow) -- effect, so at +6 it is still consumed and StatModifierUpEffect -- just prints "Nothing happened!" if cur >= 6 then - return "consumed", { "Nothing happened!" } + return "consumed", { Strings("Nothing happened!") } end b.stages[stat] = cur + 1 - return "consumed", { ("%s's\n%s rose!"):format(b.name, stat:upper()) } + return "consumed", { Strings("%s's\n%s rose!", b.name, stat:upper()) } end -- ItemUseDireHit/ItemUseGuardSpec always set the bit and consume -- the item, even when it is already active if itemId == "DIRE_HIT" then b.focusEnergy = true - return "consumed", { ("%s's\ngetting pumped!"):format(b.name) } + return "consumed", { Strings("%s's\ngetting pumped!", b.name) } end if itemId == "GUARD_SPEC" then b.mist = true - return "consumed", { ("%s's\nprotected against\nstat changes!"):format(b.name) } + return "consumed", { Strings("%s's\nprotected against\nstat changes!", b.name) } end if itemId == "POKE_DOLL" then if battle.kind ~= "wild" then -- ItemUsePokeDoll jumps to ItemUseNotTime in trainer battles - return "failed", { "OAK: " .. save.player.name - .. "!\nThis isn't the\ntime to use that!" } + return "failed", { Strings( + "OAK: %s!\nThis isn't the\ntime to use that!", save.player.name) } end - return "consumed_escape", { "The wild POKéMON\nran away!" } + return "consumed_escape", { Strings("The wild POKéMON\nran away!") } end end @@ -213,7 +215,7 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow) -- restore every move with no menu. if itemId == "ETHER" or itemId == "MAX_ETHER" or itemId == "ELIXER" or itemId == "MAX_ELIXER" then - if not target then return "failed", { "It won't have\nany effect." } end + if not target then return "failed", { Strings("It won't have\nany effect.") } end local restored = false local full = itemId == "MAX_ETHER" or itemId == "MAX_ELIXER" local allMoves = itemId == "ELIXER" or itemId == "MAX_ELIXER" @@ -235,9 +237,9 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow) restored = mv and restore(mv) or false end if not restored then - return "failed", { "It won't have\nany effect." } + return "failed", { Strings("It won't have\nany effect.") } end - return "consumed", { ("%s's PP\nwas restored!"):format(monName(data, target)) } + return "consumed", { Strings("%s's PP\nwas restored!", monName(data, target)) } end local heal = HEAL_AMOUNT[itemId] @@ -250,17 +252,17 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow) target.status = nil cureActiveToxic(battle, target) require("src.core.Sound").play(data, "Heal_Ailment") - return "consumed", { ("%s's\nstatus returned\nto normal!"):format(monName(data, target)) } + return "consumed", { Strings("%s's\nstatus returned\nto normal!", monName(data, target)) } end if not target or target.hp <= 0 or target.hp >= target.stats.hp then - return "failed", { "It won't have\nany effect." } + return "failed", { Strings("It won't have\nany effect.") } end if itemId == "MAX_POTION" or itemId == "FULL_RESTORE" then target.hp = target.stats.hp else target.hp = math.min(target.stats.hp, target.hp + heal) end - local msgs = { ("%s's HP\nwas restored!"):format(monName(data, target)) } + local msgs = { Strings("%s's HP\nwas restored!", monName(data, target)) } if itemId == "FULL_RESTORE" then target.status = nil cureActiveToxic(battle, target) @@ -272,27 +274,27 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow) local cures = STATUS_HEAL[itemId] if cures then if not target or not target.status or not cures[target.status] then - return "failed", { "It won't have\nany effect." } + return "failed", { Strings("It won't have\nany effect.") } end target.status = nil cureActiveToxic(battle, target) require("src.core.Sound").play(data, "Heal_Ailment") - return "consumed", { ("%s's\nstatus returned\nto normal!"):format(monName(data, target)) } + return "consumed", { Strings("%s's\nstatus returned\nto normal!", monName(data, target)) } end if itemId == "REVIVE" or itemId == "MAX_REVIVE" then if not target or target.hp > 0 then - return "failed", { "It won't have\nany effect." } + return "failed", { Strings("It won't have\nany effect.") } end target.status = nil target.hp = itemId == "REVIVE" and math.floor(target.stats.hp / 2) or target.stats.hp require("src.core.Sound").play(data, "Heal_HP") - return "consumed", { ("%s\nis revitalized!"):format(monName(data, target)) } + return "consumed", { Strings("%s\nis revitalized!", monName(data, target)) } end if itemId == "RARE_CANDY" then if not target or target.level >= 100 then - return "failed", { "It won't have\nany effect." } + return "failed", { Strings("It won't have\nany effect.") } end local Growth = require("src.pokemon.Growth") local Stats = require("src.pokemon.Stats") @@ -302,56 +304,56 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow) local old = target.stats target.stats = Stats.calc(speciesDef, target.level, target.dvs, target.statExp) target.hp = math.min(target.stats.hp, target.hp + (target.stats.hp - old.hp)) - return "consumed", { ("%s grew\nto level %d!"):format(monName(data, target), target.level) }, + return "consumed", { Strings("%s grew\nto level %d!", monName(data, target), target.level) }, { leveledTo = target.level } end if STONES[itemId] then - if not target then return "failed", { "It won't have\nany effect." } end + if not target then return "failed", { Strings("It won't have\nany effect.") } end local speciesDef = data.pokemon[target.species] for _, evo in ipairs(speciesDef.evolutions) do if evo.method == "ITEM" and evo.item == itemId then return "consumed", nil, { evolveTo = evo.species } end end - return "failed", { "It won't have\nany effect." } + return "failed", { Strings("It won't have\nany effect.") } end -- vitamins: +2560 stat exp, refused at 25600+ (ItemUseVitamin, -- engine/items/item_effects.asm) local vitaminStat = VITAMINS[itemId] if vitaminStat then - if not target then return "failed", { "It won't have\nany effect." } end + if not target then return "failed", { Strings("It won't have\nany effect.") } end target.statExp = target.statExp or {} local cur = target.statExp[vitaminStat] or 0 if cur >= 25600 then - return "failed", { "It won't have\nany effect." } + return "failed", { Strings("It won't have\nany effect.") } end target.statExp[vitaminStat] = math.min(65535, cur + 2560) local Stats = require("src.pokemon.Stats") target.stats = Stats.calc(data.pokemon[target.species], target.level, target.dvs, target.statExp) target.hp = math.min(target.hp, target.stats.hp) - return "consumed", { ("%s's %s\nrose!"):format(monName(data, target), + return "consumed", { Strings("%s's %s\nrose!", monName(data, target), vitaminStat == "hp" and "HP" or vitaminStat:upper()) } end -- PP UP boosts the move the player picked (ItemUsePPUp's move menu) if itemId == "PP_UP" then - if not target then return "failed", { "It won't have\nany effect." } end + if not target then return "failed", { Strings("It won't have\nany effect.") } end local mv = target.moves[moveIndex or 1] local mdef = mv and data.moves[mv.id] if mdef and (mv.ppUps or 0) < 3 then mv.ppUps = (mv.ppUps or 0) + 1 -- each PP UP adds maxPP/5 uses on top of the base maximum mv.pp = mv.pp + math.floor(mdef.pp / 5) - return "consumed", { ("%s's PP\nincreased!"):format(mdef.name) } + return "consumed", { Strings("%s's PP\nincreased!", mdef.name) } end - return "failed", { "It won't have\nany effect." } + return "failed", { Strings("It won't have\nany effect.") } end if itemDef and itemDef.machine then - if not target then return "failed", { "It won't have\nany effect." } end + if not target then return "failed", { Strings("It won't have\nany effect.") } end local speciesDef = data.pokemon[target.species] local ok = false for _, m in ipairs(speciesDef.tmhm) do @@ -362,11 +364,11 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow) -- plays SFX_DENIED before MonCannotLearnMachineMoveText (the generic -- ItemUseNotTime/NoCyclingAllowedHere paths are silent) require("src.core.Sound").play(data, "Denied") - return "failed", { ("%s can't\nlearn that move!"):format(monName(data, target)) } + return "failed", { Strings("%s can't\nlearn that move!", monName(data, target)) } end for _, mv in ipairs(target.moves) do if mv.id == itemDef.machine.move then - return "failed", { "It knows that\nmove already!" } + return "failed", { Strings("It knows that\nmove already!") } end end -- HMs are never consumed; TMs are single-use @@ -375,14 +377,14 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow) if itemId == "OLD_ROD" or itemId == "GOOD_ROD" or itemId == "SUPER_ROD" then if battle then - return "failed", { "OAK: " .. save.player.name .. "!\nThis isn't the\ntime to use that!" } + return "failed", { Strings("OAK: %s!\nThis isn't the\ntime to use that!", save.player.name) } end return "fish", itemId end if itemId == "BICYCLE" then if battle then - return "failed", { "OAK: " .. save.player.name .. "!\nThis isn't the\ntime to use that!" } + return "failed", { Strings("OAK: %s!\nThis isn't the\ntime to use that!", save.player.name) } end return "bicycle" end @@ -392,26 +394,26 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow) end if itemId == "TOWN_MAP" then if battle then - return "failed", { "OAK: " .. save.player.name .. "!\nThis isn't the\ntime to use that!" } + return "failed", { Strings("OAK: %s!\nThis isn't the\ntime to use that!", save.player.name) } end return "townmap" end if itemId == "ITEMFINDER" then if battle then - return "failed", { "OAK: " .. save.player.name .. "!\nThis isn't the\ntime to use that!" } + return "failed", { Strings("OAK: %s!\nThis isn't the\ntime to use that!", save.player.name) } end return "itemfinder" end if itemId == "COIN_CASE" then - return "failed", { ("Coin count:\n%d"):format(save.coins or 0) } + return "failed", { Strings("Coin count:\n%d", save.coins or 0) } end if itemId == "REPEL" or itemId == "SUPER_REPEL" or itemId == "MAX_REPEL" then local steps = itemId == "REPEL" and 100 or itemId == "SUPER_REPEL" and 200 or 250 save.repelSteps = steps - return "consumed", { ("%s used\n%s!"):format(save.player.name, name) } + return "consumed", { Strings("%s used\n%s!", save.player.name, name) } end - return "failed", { "OAK: " .. save.player.name .. "!\nThis isn't the\ntime to use that!" } + return "failed", { Strings("OAK: %s!\nThis isn't the\ntime to use that!", save.player.name) } end return ItemEffects diff --git a/src/link/LinkBattle.lua b/src/link/LinkBattle.lua index c0e2c258..4567a6fb 100644 --- a/src/link/LinkBattle.lua +++ b/src/link/LinkBattle.lua @@ -21,6 +21,7 @@ local Logger = require("src.core.Logger") local Protocol = require("src.link.Protocol") local Runtime = require("src.mods.Runtime") local TurnOrder = require("src.battle.TurnOrder") +local Strings = require("src.core.Strings") local LinkBattle = {} @@ -177,18 +178,18 @@ function LinkBattle.new(game, net, opts) local theirName = opts.theirName or "FOE" if not Handshake.battleAllowed(opts.verdict) then - return nil, "Link battle needs\nthe same mods on\nboth games." + return nil, Strings("Link battle needs\nthe same mods on\nboth games.") end -- both parties pass through the same pack->unpack clamp on both -- machines, so the copies are identical everywhere local unpackOpts = { strict = opts.strict or false, forceLevel = opts.forceLevel } local myParty, myErr = unpackParty(game, opts.myParty, unpackOpts, function(p) - return ("Your %s can't\nbattle on the\nother game."):format(tostring(p.species)) + return Strings("Your %s can't\nbattle on the\nother game.", tostring(p.species)) end) if not myParty then return nil, myErr end local theirParty, theirErr = unpackParty(game, opts.theirParty, unpackOpts, function(p, why) - return ("Their %s isn't\nin this game.\n(%s)"):format(tostring(p.species), tostring(why)) + return Strings("Their %s isn't\nin this game.\n(%s)", tostring(p.species), tostring(why)) end) if not theirParty then return nil, theirErr end if #myParty == 0 or #theirParty == 0 then @@ -232,7 +233,7 @@ function LinkBattle.new(game, net, opts) self.enemyParty = theirParty self.playerParty = myParty -- intro ball row uses the clamped copies self.opponentName = theirName - self.introText = ("%s wants\nto battle!"):format(theirName) + self.introText = Strings("%s wants\nto battle!", theirName) self.remoteHashes = {} self.localHashes = {} self.remoteParts = {} @@ -292,7 +293,7 @@ function LinkBattle.new(game, net, opts) battle = s, side = s.sides[2], battler = s.enemy, previous = previous, }) s.enemySendingOut = true - s:sayNext(("%s sent\nout %s!"):format(theirName, s.enemy.name)) + s:sayNext(Strings("%s sent\nout %s!", theirName, s.enemy.name)) s:actNext(function() s.enemySendingOut = false s:startGrowIn(s.enemy) @@ -315,8 +316,9 @@ function LinkBattle.new(game, net, opts) tostring(turn), component, tostring(localH), tostring(remoteH)) Runtime.emit("link.desync", { turn = turn, component = component, localHash = localH, remoteHash = remoteH }) - endAsDraw(s, ("Link desync!\n%s differs.\fAre both games\nrunning the same\nmods?") - :format(component)) + endAsDraw(s, Strings( + "Link desync!\n%s differs.\fAre both games\nrunning the same\nmods?", + component)) end -- a verified turn stays recorded: consuming it here left a finished @@ -349,7 +351,7 @@ function LinkBattle.new(game, net, opts) local function resolveLockstep(s, myMsg, theirMsg) if myMsg.kind == "run" or theirMsg.kind == "run" then local who = myMsg.kind == "run" and game.save.player.name or theirName - endAsDraw(s, ("%s ran from\nthe battle!"):format(who)) + endAsDraw(s, Strings("%s ran from\nthe battle!", who)) return end s.phase = "messages" @@ -479,9 +481,9 @@ function LinkBattle.new(game, net, opts) party = myParty, onSwitch = function(mon) if mon == s.player.mon then - s:say(("%s is\nalready out!"):format(s.player.name)) + s:say(Strings("%s is\nalready out!", s.player.name)) elseif mon.hp <= 0 then - s:say("There's no will\nto fight!") + s:say(Strings("There's no will\nto fight!")) else s:resolveSwitch(mon) end @@ -491,7 +493,7 @@ function LinkBattle.new(game, net, opts) end self.openItems = function(s) - s:say("Items can't be\nused in a link\nbattle!") + s:say(Strings("Items can't be\nused in a link\nbattle!")) s.phase = "messages" s.afterQueue = "menu" end @@ -509,7 +511,7 @@ function LinkBattle.new(game, net, opts) return end end - s:sayNext(("%s is out of\nPOKéMON!\f%s wins!"):format(game.save.player.name, + s:sayNext(Strings("%s is out of\nPOKéMON!\f%s wins!", game.save.player.name, theirName)) s.result = "lose" s.afterQueue = "finish" @@ -522,7 +524,7 @@ function LinkBattle.new(game, net, opts) return end end - s:sayNext(("%s is out of\nPOKéMON!\f%s wins!"):format(theirName, + s:sayNext(Strings("%s is out of\nPOKéMON!\f%s wins!", theirName, game.save.player.name)) s.result = "win" s.afterQueue = "finish" @@ -543,13 +545,13 @@ function LinkBattle.new(game, net, opts) -- only a draw if our own simulation hasn't already decided -- (the winner's bye can arrive while we're still animating) if not s.result then - endAsDraw(s, ("%s left the\nbattle."):format(theirName)) + endAsDraw(s, Strings("%s left the\nbattle.", theirName)) end elseif msg.type == "forfeit" then -- the peer's own shot clock ran out; unlike a mutual RUN/desync -- draw, this has a definite winner (us) if not s.result then - endWithResult(s, "win", ("%s ran out of\ntime!"):format(theirName)) + endWithResult(s, "win", Strings("%s ran out of\ntime!", theirName)) end else -- a tournament control message (bracket_update, the next @@ -582,7 +584,7 @@ function LinkBattle.new(game, net, opts) if s.turnClock <= 0 then s.turnClockActive = false send({ type = "forfeit" }) - endWithResult(s, "lose", "Time's up! You\nforfeit the match.") + endWithResult(s, "lose", Strings("Time's up! You\nforfeit the match.")) end elseif opts.turnLimit then s.turnClockActive = false @@ -643,17 +645,16 @@ function LinkBattle.newSpectator(game, net, opts) local guestName = opts.guestName or "GUEST" if not Handshake.battleAllowed(opts.verdict) then - return nil, "Link battle needs\nthe same mods on\nboth games." + return nil, Strings("Link battle needs\nthe same mods on\nboth games.") end local unpackOpts = { strict = opts.strict or false, forceLevel = opts.forceLevel } local hostParty, hostErr = unpackParty(game, opts.hostParty, unpackOpts, function(p) - return ("%s's %s can't\nbattle on this\ngame."):format(hostName, tostring(p.species)) + return Strings("%s's %s can't\nbattle on this\ngame.", hostName, tostring(p.species)) end) if not hostParty then return nil, hostErr end local guestParty, guestErr = unpackParty(game, opts.guestParty, unpackOpts, function(p, why) - return ("%s's %s can't\nbattle on this\ngame.\n(%s)"):format( - guestName, tostring(p.species), tostring(why)) + return Strings("%s's %s can't\nbattle on this\ngame.\n(%s)", guestName, tostring(p.species), tostring(why)) end) if not guestParty then return nil, guestErr end if #hostParty == 0 or #guestParty == 0 then @@ -672,7 +673,7 @@ function LinkBattle.newSpectator(game, net, opts) self.enemyParty = guestParty self.playerParty = hostParty self.opponentName = guestName - self.introText = ("%s vs %s!"):format(hostName, guestName) + self.introText = Strings("%s vs %s!", hostName, guestName) local function orderMove(action) if action and action.id then return game.data.moves[action.id] end @@ -712,7 +713,7 @@ function LinkBattle.newSpectator(game, net, opts) battle = s, side = s.sides[2], battler = s.enemy, previous = previous, }) s.enemySendingOut = true - s:sayNext(("%s sent\nout %s!"):format(guestName, s.enemy.name)) + s:sayNext(Strings("%s sent\nout %s!", guestName, s.enemy.name)) s:actNext(function() s.enemySendingOut = false s:startGrowIn(s.enemy) @@ -789,7 +790,7 @@ function LinkBattle.newSpectator(game, net, opts) return end end - s:sayNext(("%s is out of\nPOKéMON!\f%s wins!"):format(hostName, guestName)) + s:sayNext(Strings("%s is out of\nPOKéMON!\f%s wins!", hostName, guestName)) s.result = "guestWin" s.afterQueue = "finish" end @@ -801,7 +802,7 @@ function LinkBattle.newSpectator(game, net, opts) return end end - s:sayNext(("%s is out of\nPOKéMON!\f%s wins!"):format(guestName, hostName)) + s:sayNext(Strings("%s is out of\nPOKéMON!\f%s wins!", guestName, hostName)) s.result = "hostWin" s.afterQueue = "finish" end diff --git a/src/link/LinkState.lua b/src/link/LinkState.lua index 4ea0fba1..a744dd09 100644 --- a/src/link/LinkState.lua +++ b/src/link/LinkState.lua @@ -11,6 +11,7 @@ local Protocol = require("src.link.Protocol") local Runtime = require("src.mods.Runtime") local Screens = require("src.ui.Screens") local TextBox = require("src.render.TextBox") +local Strings = require("src.core.Strings") local LinkState = {} LinkState.__index = LinkState @@ -90,7 +91,7 @@ function LinkState.newJoinOnline(game, code) self.stage = "onlineJoining" else self.stage = "menu" -- exitWith below needs a real stage to unwind from - self:exitWith("Link error:\n" .. (self.net.error or "?")) + self:exitWith(Strings("Link error:\n%s", self.net.error or "?")) end return self end @@ -174,7 +175,7 @@ function LinkState:update(dt) if self.net then self.net:update() if self.net.error and not PRE_CONNECT_STAGES[self.stage] then - self:exitWith("Link error:\n" .. self.net.error:sub(1, 60)) + self:exitWith(Strings("Link error:\n%s", self.net.error:sub(1, 60))) return end -- the peer vanished without a bye (only once the inbox is drained, @@ -183,7 +184,7 @@ function LinkState:update(dt) and not PRE_CONNECT_STAGES[self.stage] and self.stage ~= "addrEntry" and self.stage ~= "codeEntry" and self.stage ~= "notice" and self.stage ~= "battleRunning" then - self:exitWith("The link was\nbroken.") + self:exitWith(Strings("The link was\nbroken.")) return end end @@ -201,7 +202,7 @@ function LinkState:update(dt) self.index = 1 elseif self.index == 2 or self.index == 3 then if not Handshake.onlineAllowed(self.game) then - self:exitWith("Online play needs\nno mods enabled.\fDisable them in\nSTART > MODS.") + self:exitWith(Strings("Online play needs\nno mods enabled.\fDisable them in\nSTART > MODS.")) return end if self.index == 2 then @@ -227,7 +228,7 @@ function LinkState:update(dt) if self.net:host() then self.stage = "hosting" else - self:exitWith("Link error:\n" .. (self.net.error or "?")) + self:exitWith(Strings("Link error:\n%s", self.net.error or "?")) end else self.stage = "addrEntry" @@ -246,7 +247,7 @@ function LinkState:update(dt) if self.net:hostOnline() then self.stage = "onlineHosting" else - self:exitWith("Link error:\n" .. (self.net.error or "?")) + self:exitWith(Strings("Link error:\n%s", self.net.error or "?")) end else self.stage = "codeEntry" @@ -284,7 +285,7 @@ function LinkState:update(dt) if self.net:joinOnline(nil, code) then self.stage = "onlineJoining" else - self:exitWith("Link error:\n" .. (self.net.error or "?")) + self:exitWith(Strings("Link error:\n%s", self.net.error or "?")) end end @@ -323,7 +324,7 @@ function LinkState:update(dt) if self.net:join(table.concat(octets, ".")) then self.stage = "joining" else - self:exitWith("Link error:\n" .. (self.net.error or "?")) + self:exitWith(Strings("Link error:\n%s", self.net.error or "?")) end end @@ -433,7 +434,7 @@ function LinkState:update(dt) end if not battle then self.net:send({ type = "bye" }) - self:exitWith(why or "Link battle\ncan't start.", "error") + self:exitWith(why or Strings("Link battle\ncan't start."), "error") return end self.game.stack:push(battle) @@ -491,8 +492,8 @@ function LinkState:updateTrade(input) local t = self.trade if t.stage == "cancelled" then - self:exitWith(t.error and ("The trade stopped:\n%s."):format(t.error) - or "The trade was\ncancelled.") + self:exitWith(t.error and Strings("The trade stopped:\n%s.", t.error) + or Strings("The trade was\ncancelled.")) return end if t.stage == "done" then @@ -520,7 +521,7 @@ function LinkState:updateTrade(input) enemyOtId = received.otId, onDone = function() game.stack:push(TextBox.new(game, - ("Trade completed!\f%s received\n%s!"):format(game.save.player.name, name), + Strings("Trade completed!\f%s received\n%s!", game.save.player.name, name), function() if evoTo then -- via="TRADE": a trade evolution cannot be B-cancelled @@ -547,7 +548,7 @@ function LinkState:updateTrade(input) -- the trade) -- B is dead after that, matching the A branch's own -- self.confirmed == nil guard self.net:send({ type = "bye" }) - self:exitWith("The trade was\ncancelled.") + self:exitWith(Strings("The trade was\ncancelled.")) elseif t.stage == "picking" and input:wasPressed("a") then if t:canPick(self.index) then self.net:send(t:pick(self.index)) @@ -574,30 +575,30 @@ end function LinkState:draw() if self.stage == "menu" then drawTitle("BOIS CLUB LIVE") - Font.draw("LINK CABLE (LAN)", 32, 44) - Font.draw("ONLINE MATCH", 32, 60) - Font.draw("TOURNAMENT", 32, 76) + Font.draw(Strings("LINK CABLE (LAN)"), 32, 44) + Font.draw(Strings("ONLINE MATCH"), 32, 60) + Font.draw(Strings("TOURNAMENT"), 32, 76) Font.drawCode(CURSOR, 24, 44 + (self.index - 1) * 16) elseif self.stage == "lanMenu" then drawTitle("LINK CABLE (LAN)") - Font.draw("HOST A GAME", 32, 48) - Font.draw("JOIN A GAME", 32, 68) + Font.draw(Strings("HOST A GAME"), 32, 48) + Font.draw(Strings("JOIN A GAME"), 32, 68) Font.drawCode(CURSOR, 24, self.index == 1 and 48 or 68) - Font.draw("UDP port " .. Net.defaultPort(), 8, 128) + Font.draw(Strings("UDP port %s", Net.defaultPort()), 8, 128) elseif self.stage == "onlineMenu" then drawTitle("ONLINE MATCH") - Font.draw("HOST ONLINE", 32, 48) - Font.draw("JOIN ONLINE", 32, 68) + Font.draw(Strings("HOST ONLINE"), 32, 48) + Font.draw(Strings("JOIN ONLINE"), 32, 68) Font.drawCode(CURSOR, 24, self.index == 1 and 48 or 68) elseif self.stage == "onlineHosting" then drawTitle("HOSTING ONLINE") - Font.draw("Tell your friend", 16, 40) - Font.draw("the code:", 16, 52) + Font.draw(Strings("Tell your friend"), 16, 40) + Font.draw(Strings("the code:"), 16, 52) Font.draw(self.net.code or "??????", 32, 68) - Font.draw("Waiting for join...", 8, 96) + Font.draw(Strings("Waiting for join..."), 8, 96) elseif self.stage == "codeEntry" then drawTitle("ENTER CODE") @@ -609,18 +610,18 @@ function LinkState:draw() Font.drawCode(0xEE, x, 76) -- ▼ under the active slot end end - Font.draw("A: connect B: back", 8, 128) + Font.draw(Strings("A: connect B: back"), 8, 128) elseif self.stage == "onlineJoining" then drawTitle("CONNECTING...") - Font.draw("Calling...", 8, 56) + Font.draw(Strings("Calling..."), 8, 56) Font.draw(self.net.target or "", 8, 72) elseif self.stage == "hosting" then drawTitle("HOSTING") - Font.draw("Friend joins at:", 16, 48) + Font.draw(Strings("Friend joins at:"), 16, 48) Font.draw(self.net.address or "?", 16, 64) - Font.draw("Waiting for join...", 8, 96) + Font.draw(Strings("Waiting for join..."), 8, 96) elseif self.stage == "addrEntry" then drawTitle("ENTER HOST ADDRESS") @@ -635,34 +636,34 @@ function LinkState:draw() for octet = 1, 3 do Font.draw(".", 16 + octet * 32 - 8, 64) end - Font.draw("Port: " .. Net.defaultPort(), 16, 96) - Font.draw("A: connect B: back", 8, 128) + Font.draw(Strings("Port: %s", Net.defaultPort()), 16, 96) + Font.draw(Strings("A: connect B: back"), 8, 128) elseif self.stage == "joining" then drawTitle("JOINING...") - Font.draw("Calling...", 8, 56) + Font.draw(Strings("Calling..."), 8, 56) Font.draw(self.net.target or "", 8, 72) elseif self.stage == "modeSelect" then drawTitle("CONNECTED!") - Font.draw("TRADE", 32, 48) - Font.draw("BATTLE", 32, 68) + Font.draw(Strings("TRADE"), 32, 48) + Font.draw(Strings("BATTLE"), 32, 68) Font.drawCode(CURSOR, 24, self.index == 1 and 48 or 68) elseif self.stage == "battleOptions" then drawTitle("BATTLE OPTIONS") - Font.draw("LEVELS:", 16, 56) + Font.draw(Strings("LEVELS:"), 16, 56) Font.draw(forceLevelLabel(self.levelChoice or ANY), 88, 56) - Font.draw("A: continue B: back", 8, 128) + Font.draw(Strings("A: continue B: back"), 8, 128) elseif self.stage == "waitMode" or self.stage == "waitHello" then drawTitle("CONNECTED!") if self.stage == "waitHello" then - Font.draw("Checking the", 16, 56) - Font.draw("other game...", 16, 72) + Font.draw(Strings("Checking the"), 16, 56) + Font.draw(Strings("other game..."), 16, 72) else - Font.draw("Waiting for the", 16, 56) - Font.draw("host to choose...", 16, 72) + Font.draw(Strings("Waiting for the"), 16, 56) + Font.draw(Strings("host to choose..."), 16, 72) end elseif self.stage == "notice" then @@ -671,12 +672,12 @@ function LinkState:draw() if i > 8 then break end -- what fits above the prompt row Font.draw(line, 8, 24 + (i - 1) * 12) end - Font.draw(self.noticeExits and "A: back" or "A: trade anyway", 8, 128) + Font.draw(self.noticeExits and "A: back" or Strings("A: trade anyway"), 8, 128) elseif self.stage == "trade" then drawTitle("TRADE") local t = self.trade - Font.draw("YOURS", 8, 20) + Font.draw(Strings("YOURS"), 8, 20) for i, mon in ipairs(self.game.save.party) do local def = self.game.data.pokemon[mon.species] local label = (mon.nickname or def.name):sub(1, 8) @@ -684,7 +685,7 @@ function LinkState:draw() Font.draw(label, 16, 20 + i * 12) if i == self.index then Font.drawCode(CURSOR, 8, 20 + i * 12) end end - Font.draw("THEIRS", 84, 20) + Font.draw(Strings("THEIRS"), 84, 20) for i, mon in ipairs(t.theirParty or {}) do local def = self.game.data.pokemon[mon.species] Font.draw((mon.nickname or def.name):sub(1, 8), 92, 20 + i * 12) @@ -695,16 +696,16 @@ function LinkState:draw() elseif t.stage == "waitParty" then hint = "Exchanging data..." elseif t.stage == "picking" then hint = t:canPick(self.index) and "Pick one to trade" - or "X: not on theirs" + or Strings("X: not on theirs") elseif t.stage == "waitPick" then hint = "Waiting for them..." elseif t.stage == "confirming" then - hint = self.confirmed and "Waiting..." or "A: trade B: cancel" + hint = self.confirmed and "Waiting..." or Strings("A: trade B: cancel") end Font.draw(hint or "", 8, 132) elseif self.stage == "battleWait" or self.stage == "battleRunning" then drawTitle("LINK BATTLE") - Font.draw("Exchanging data...", 16, 64) + Font.draw(Strings("Exchanging data..."), 16, 64) end love.graphics.setColor(1, 1, 1, 1) end diff --git a/src/link/Net.lua b/src/link/Net.lua index ca72d5a9..950bee1e 100644 --- a/src/link/Net.lua +++ b/src/link/Net.lua @@ -26,6 +26,7 @@ local Json = require("src.link.Json") local Logger = require("src.core.Logger") +local Strings = require("src.core.Strings") local hasEnet, enet = pcall(require, "enet") if not hasEnet then enet = nil end @@ -173,7 +174,7 @@ function Net:connectTCP(addr) tcp:settimeout(5) local ok, err = tcp:connect(host, port) if not ok then - self.error = ("can't reach relay %s:%d\n(%s)"):format(host, port, tostring(err)) + self.error = Strings("can't reach relay %s:%d\n(%s)", host, port, tostring(err)) return false end tcp:settimeout(0) @@ -236,11 +237,14 @@ local function handleGenericRelayControl(self, msg) self.paired = true return true elseif msg.type == "join_error" then - self.error = ({ - not_found = "That code wasn't\nfound.", - full = "That game already\nhas two players.", - expired = "That code has\nexpired.", - })[msg.reason] or ("Couldn't join:\n%s"):format(tostring(msg.reason)) + self.error = Strings(({ + not_found = Strings.source("That code wasn't\nfound."), + full = Strings.source("That game already\nhas two players."), + expired = Strings.source("That code has\nexpired."), + })[msg.reason] or "") + if self.error == "" then + self.error = Strings("Couldn't join:\n%s", tostring(msg.reason)) + end self.closed = true return true elseif msg.type == "peer_gone" then @@ -328,7 +332,7 @@ function Net:update() -- an unreachable join target surfaces as a service error -- (ICMP unreachable on the connected UDP socket) if self.mode == "joining" and not self.paired then - self.error = ("no answer from\n%s"):format(self.target or "the host") + self.error = Strings("no answer from\n%s", self.target or "the host") else self.error = tostring(event) end @@ -358,14 +362,14 @@ function Net:update() self.closed = true if not self.paired then self.error = self.error or - ("no answer from\n%s"):format(self.target or "the host") + Strings("no answer from\n%s", self.target or "the host") end end end end if self.mode == "joining" and not self.paired and self.joinDeadline and now() > self.joinDeadline then - self.error = ("no answer from\n%s"):format(self.target or "the host") + self.error = Strings("no answer from\n%s", self.target or "the host") self.closed = true pcall(function() self.peer:disconnect_now() end) end diff --git a/src/link/Tournament.lua b/src/link/Tournament.lua index ab15e5af..47019e4b 100644 --- a/src/link/Tournament.lua +++ b/src/link/Tournament.lua @@ -18,6 +18,7 @@ local Protocol = require("src.link.Protocol") local Runtime = require("src.mods.Runtime") local Sound = require("src.core.Sound") local TextBox = require("src.render.TextBox") +local Strings = require("src.core.Strings") local Tournament = {} Tournament.__index = Tournament @@ -128,7 +129,7 @@ end function Tournament:startHosting() self.net = Net.new() if not self.net:connectTCP(Net.defaultRelayAddress()) then - self:exitWith("Link error:\n" .. (self.net.error or "?")) + self:exitWith(Strings("Link error:\n%s", self.net.error or "?")) return end local size, minL, maxL = partyStats(self.game.save.party) @@ -151,7 +152,7 @@ end function Tournament:startJoining(code) self.net = Net.new() if not self.net:connectTCP(Net.defaultRelayAddress()) then - self:exitWith("Link error:\n" .. (self.net.error or "?")) + self:exitWith(Strings("Link error:\n%s", self.net.error or "?")) return end local size, minL, maxL = partyStats(self.game.save.party) @@ -171,9 +172,9 @@ end -- ------------------------------------------------------------------- local JOIN_ERROR_TEXT = { - not_found = "That code wasn't\nfound.", - already_started = "That tournament\nhas already begun.", - expired = "That code has\nexpired.", + not_found = Strings.source("That code wasn't\nfound."), + already_started = Strings.source("That tournament\nhas already begun."), + expired = Strings.source("That code has\nexpired."), } function Tournament:handleMessage(msg) @@ -198,17 +199,17 @@ function Tournament:handleMessage(msg) end elseif msg.type == "tournament_host_error" then if msg.reason == "party_ineligible" then - self:exitWith(("Can't host:\nneed %d Pokemon\nLv %s-%s."):format( - msg.requiredPartySize, levelLabel(msg.minLevel), levelLabel(msg.maxLevel))) + self:exitWith(Strings("Can't host:\nneed %d Pokemon\nLv %s-%s.", msg.requiredPartySize, levelLabel(msg.minLevel), levelLabel(msg.maxLevel))) else - self:exitWith("Couldn't host\nthat tournament.") + self:exitWith(Strings("Couldn't host\nthat tournament.")) end elseif msg.type == "tournament_join_error" then if msg.reason == "party_ineligible" then - self:exitWith(("Your party needs\n%d Pokemon, Lv\n%s-%s."):format( - msg.requiredPartySize, levelLabel(msg.minLevel), levelLabel(msg.maxLevel))) + self:exitWith(Strings("Your party needs\n%d Pokemon, Lv\n%s-%s.", msg.requiredPartySize, levelLabel(msg.minLevel), levelLabel(msg.maxLevel))) else - self:exitWith(JOIN_ERROR_TEXT[msg.reason] or "Couldn't join\nthat tournament.") + self:exitWith(JOIN_ERROR_TEXT[msg.reason] + and Strings(JOIN_ERROR_TEXT[msg.reason]) + or Strings("Couldn't join\nthat tournament.")) end elseif msg.type == "tournament_roster" then self.roster = msg.players @@ -281,7 +282,7 @@ function Tournament:beginMatchBattle() -- shouldn't happen (both sides already passed the online-play mods -- gate), but a mismatched engine/build is still possible -- bail out -- of just this match rather than crash the tournament - self:exitWith("Link error:\nversion mismatch\nwith opponent.") + self:exitWith(Strings("Link error:\nversion mismatch\nwith opponent.")) return end self.linkSeed = self.isHost and love.math.random(1, 2 ^ 30) or nil @@ -351,11 +352,11 @@ function Tournament:update(dt) self.net:update() if self.net.error and self.stage ~= "menu" and self.stage ~= "hostSettings" and self.stage ~= "codeEntry" then - self:exitWith("Link error:\n" .. self.net.error:sub(1, 60)) + self:exitWith(Strings("Link error:\n%s", self.net.error:sub(1, 60))) return end if self.net.closed and self.stage ~= "done" then - self:exitWith("The tournament\nconnection was\nlost.") + self:exitWith(Strings("The tournament\nconnection was\nlost.")) return end end @@ -381,7 +382,7 @@ function Tournament:update(dt) and LinkBattle.newHost(self.game, self.net, self.pendingBattleOpts) or LinkBattle.newGuest(self.game, self.net, self.pendingBattleOpts) if not battle then - self:exitWith(why or "Link battle\ncan't start.") + self:exitWith(why or Strings("Link battle\ncan't start.")) return end -- anything after `party` in this same batch belongs to the @@ -418,7 +419,7 @@ function Tournament:update(dt) forceLevel = levelForWire(self.settings.forceLevel), }) if not battle then - self:exitWith(why or "Can't watch this\nmatch.") + self:exitWith(why or Strings("Can't watch this\nmatch.")) return end for j = #msgs, i + 1, -1 do @@ -541,8 +542,8 @@ local SETTINGS_LABELS = { "POKEMON", "MIN LV", "MAX LV", "TIMER", "LEVELS", "PLA function Tournament:draw() if self.stage == "menu" then drawTitle("TOURNAMENT") - Font.draw("HOST", 32, 48) - Font.draw("JOIN", 32, 68) + Font.draw(Strings("HOST"), 32, 48) + Font.draw(Strings("JOIN"), 32, 68) Font.drawCode(CURSOR, 24, self.index == 1 and 48 or 68) elseif self.stage == "hostSettings" then @@ -561,7 +562,7 @@ function Tournament:draw() Font.draw(values[i], 96, y) if i == self.settingsIndex then Font.drawCode(CURSOR, 8, y) end end - Font.draw("START: create", 8, 128) + Font.draw(Strings("START: create"), 8, 128) elseif self.stage == "codeEntry" then drawTitle("ENTER CODE") @@ -573,28 +574,27 @@ function Tournament:draw() Font.drawCode(0xEE, x, 76) end end - Font.draw("A: join B: back", 8, 128) + Font.draw(Strings("A: join B: back"), 8, 128) elseif self.stage == "registering" then drawTitle("CONNECTING...") - Font.draw("B: cancel", 8, 128) + Font.draw(Strings("B: cancel"), 8, 128) elseif self.stage == "bracket" or self.stage == "matchHello" or self.stage == "matchWaitParty" or self.stage == "spectateWait" then - drawTitle(("TOURNAMENT %s"):format(self.code or "??????")) + drawTitle(Strings("TOURNAMENT %s", self.code or "??????")) if self.bracket then local y = 20 for _, round in ipairs(self.bracket.rounds) do - Font.draw(("ROUND %d"):format(round.round), 8, y) + Font.draw(Strings("ROUND %d", round.round), 8, y) y = y + 10 for _, m in ipairs(round.matches) do local line if m.bye then - line = ("%s (bye)"):format(m.a or m.b or "?") + line = Strings("%s (bye)", m.a or m.b or "?") else local mark = m.state == "live" and "*" or (m.winner and "" or "") - line = ("%s%s vs %s%s"):format( - m.winner == m.a and ">" or " ", m.a or "?", + line = Strings("%s%s vs %s%s", m.winner == m.a and ">" or " ", m.a or "?", m.b or "?", m.winner == m.b and "<" or (mark == "*" and " *" or "")) end Font.draw(line, 12, y) @@ -604,11 +604,11 @@ function Tournament:draw() end else if self.participating == false then - Font.draw("(organizing --", 16, 32) - Font.draw("not playing)", 16, 42) + Font.draw(Strings("(organizing --"), 16, 32) + Font.draw(Strings("not playing)"), 16, 42) end - Font.draw("Waiting for", 16, 48) - Font.draw("players to join:", 16, 60) + Font.draw(Strings("Waiting for"), 16, 48) + Font.draw(Strings("players to join:"), 16, 60) local y = 60 for i, name in ipairs(self.roster) do y = 60 + i * 10 @@ -620,18 +620,18 @@ function Tournament:draw() end end if self.isCreator and #self.roster >= 2 and not self.bracket then - Font.draw("A: START B: cancel", 8, 132) + Font.draw(Strings("A: START B: cancel"), 8, 132) else - Font.draw("B: cancel", 8, 132) + Font.draw(Strings("B: cancel"), 8, 132) end elseif self.stage == "done" then drawTitle("TOURNAMENT OVER") if self.champion then - Font.draw(("%s is the"):format(self.champion), 16, 56) - Font.draw("champion!", 16, 68) + Font.draw(Strings("%s is the", self.champion), 16, 56) + Font.draw(Strings("champion!"), 16, 68) end - Font.draw("A: continue", 8, 128) + Font.draw(Strings("A: continue"), 8, 128) end love.graphics.setColor(1, 1, 1, 1) end diff --git a/src/mods/ManagerState.lua b/src/mods/ManagerState.lua index 6a438613..42671ecf 100644 --- a/src/mods/ManagerState.lua +++ b/src/mods/ManagerState.lua @@ -10,6 +10,7 @@ local Semver = require("src.mods.Semver") local Version = require("src.core.Version") local Theme = require("src.ui.Theme") local OptionRows = require("src.ui.OptionRows") +local Strings = require("src.core.Strings") local ManagerState = {} ManagerState.__index = ManagerState @@ -269,7 +270,7 @@ function ManagerState:modRows() end end if #rows == 0 then - rows[1] = { header = true, label = "NO MODS INSTALLED" } + rows[1] = { header = true, label = Strings("NO MODS INSTALLED") } end return rows end @@ -281,7 +282,7 @@ function ManagerState:profileRows() rows[#rows + 1] = { profile = p, label = p.name, glyph = opts.activeProfile == p.name and GLYPH.errored or " " } end - rows[#rows + 1] = { saveAs = true, label = "SAVE CURRENT AS.." } + rows[#rows + 1] = { saveAs = true, label = Strings("SAVE CURRENT AS..") } rows[#rows + 1] = { adhoc = true, label = opts.activeProfile and "[AD-HOC]" or "[AD-HOC] (LIVE)" } return rows @@ -316,30 +317,30 @@ function ManagerState:detailRows(m) rows[#rows + 1] = { label = m.enabled and "DISABLE" or "ENABLE", action = function() self:beginToggle(m) end } if self:schemaFor(m) then - rows[#rows + 1] = { label = "OPTIONS..", + rows[#rows + 1] = { label = Strings("OPTIONS.."), action = function() self:openOptions(m) end } end if m.permissions and #m.permissions > 0 then - rows[#rows + 1] = { label = "PERMISSIONS..", + rows[#rows + 1] = { label = Strings("PERMISSIONS.."), action = function() self:goTo("permissions") end } end if m.error then - rows[#rows + 1] = { label = "VIEW ERROR..", + rows[#rows + 1] = { label = Strings("VIEW ERROR.."), action = function() self:goTo("errors") end } end - rows[#rows + 1] = { label = "BACK", action = function() self:goBack() end } + rows[#rows + 1] = { label = Strings("BACK"), action = function() self:goBack() end } return rows end function ManagerState:applyRows() local rows = {} - rows[#rows + 1] = { label = "APPLY & RESTART", action = function() + rows[#rows + 1] = { label = Strings("APPLY & RESTART"), action = function() self:openConfirm({ "RESTART NOW?" }, function() self:restartGame() end) end } - rows[#rows + 1] = { label = "DISCARD CHANGES", action = function() + rows[#rows + 1] = { label = Strings("DISCARD CHANGES"), action = function() self:discardChanges() end } - rows[#rows + 1] = { label = "BACK", action = function() self:goBack() end } + rows[#rows + 1] = { label = Strings("BACK"), action = function() self:goBack() end } return rows end @@ -352,7 +353,7 @@ function ManagerState:permissionRows(m) label = info and info.text or name } end if #rows == 0 then - rows[1] = { inert = true, label = "DATA & API ONLY" } + rows[1] = { inert = true, label = Strings("DATA & API ONLY") } end return rows end @@ -564,7 +565,7 @@ function ManagerState:openCascade(r, m, want) else local dep = self.byId[r.alsoDisable[1]] lines[#lines + 1] = (dep and dep.name or r.alsoDisable[1]) .. " NEEDS THIS." - lines[#lines + 1] = #r.alsoDisable > 1 and "DISABLE ALL?" or "DISABLE BOTH?" + lines[#lines + 1] = #r.alsoDisable > 1 and "DISABLE ALL?" or Strings("DISABLE BOTH?") end self.overlay = { kind = "confirm", lines = lines, index = 1, onYes = function() self:commitToggle(r.apply) end } @@ -700,7 +701,7 @@ end function ManagerState:saveCurrentAs() local NamingScreen = require("src.ui.NamingScreen") self.game.stack:push(NamingScreen.new(self.game, { - title = "PROFILE NAME?", + title = Strings("PROFILE NAME?"), maxLen = 10, onDone = function(name) local opts = self:optionsTable() @@ -726,7 +727,7 @@ end function ManagerState:renameProfile(p) local NamingScreen = require("src.ui.NamingScreen") self.game.stack:push(NamingScreen.new(self.game, { - title = "RENAME?", + title = Strings("RENAME?"), maxLen = 10, default = p.name, onDone = function(name) @@ -886,7 +887,7 @@ function ManagerState:buildOptionRows(m, schema) end } end end - rows[#rows + 1] = { id = "__reset", label = "RESET DEFAULTS", + rows[#rows + 1] = { id = "__reset", label = Strings("RESET DEFAULTS"), value = function() return "" end, activate = function() for _, row in ipairs(schema) do @@ -1047,7 +1048,7 @@ function ManagerState:drawApply() y = y + 1 end if #staged == 0 then - Font.draw(Runtime.safeMode and "SAFE MODE" or "NO CHANGES", 16, y * 8) + Font.draw(Runtime.safeMode and "SAFE MODE" or Strings("NO CHANGES"), 16, y * 8) y = y + 1 end local rows = self:rowsForScreen() @@ -1076,12 +1077,12 @@ function ManagerState:drawOverlay() end if overlay.kind == "confirm" then local yesY = ty + #lines + 1 - Font.draw("YES", 5 * 8, yesY * 8) - Font.draw("NO", 5 * 8, (yesY + 1) * 8) + Font.draw(Strings("YES"), 5 * 8, yesY * 8) + Font.draw(Strings("NO"), 5 * 8, (yesY + 1) * 8) Font.drawCode(Theme.cursor, 4 * 8, (overlay.index == 1 and yesY or yesY + 1) * 8) else - Font.draw("A:OK", 5 * 8, (ty + #lines + 1) * 8) + Font.draw(Strings("A:OK"), 5 * 8, (ty + #lines + 1) * 8) end end @@ -1090,7 +1091,7 @@ function ManagerState:draw() OptionRows.draw(self.game, self.optionRows or {}, self.cursor, self.scroll or 0) love.graphics.setColor(0, 0, 0, 1) - Font.draw(self.notice or "B:DONE (NO RESTART)", 8, 136) + Font.draw(self.notice or Strings("B:DONE (NO RESTART)"), 8, 136) love.graphics.setColor(1, 1, 1, 1) if self.overlay then self:drawOverlay() end return @@ -1099,7 +1100,7 @@ function ManagerState:draw() love.graphics.rectangle("fill", 0, 0, 160, 144) love.graphics.setColor(1, 1, 1, 1) Font.drawBox(0, 0, 20, 18) - Font.draw(self.banner or "MOD MANAGER", 16, 8) + Font.draw(self.banner or Strings("MOD MANAGER"), 16, 8) if self.screen == "list" then self:drawList() elseif self.screen == "detail" then diff --git a/src/mods/Schemas.lua b/src/mods/Schemas.lua index f692c4e2..c73aa09b 100644 --- a/src/mods/Schemas.lua +++ b/src/mods/Schemas.lua @@ -584,6 +584,17 @@ R.text = { example = 'mod.content.text:override("_PalletTownText1", "HELLO!")', } +-- The engine's own authored text, the half of the game `text` does not +-- cover (src/core/Strings.lua). The id is the English source string, so a +-- translation supplies one entry per literal the engine draws and anything +-- it has not reached yet keeps rendering in English. Ids that collide in +-- English but not elsewhere carry a "context|source" prefix. +R.strings = { + semantics = "record", target = "strings", + value = f.str, + example = 'mod.content.strings:override("But, it failed!", "Echec !")', +} + -- the self-contained bytecode blob ChipAsm.song/sfx emit (13.1 shape 2); -- shared by every namespace that plays a chip program local chipProgram = f.rec{ @@ -918,8 +929,20 @@ R.battle_sprite_scales = { -- 1 = native pixels; the drawn size relative to the pic's own pixels scale = f.numRange(0.25, 4.0), }, - example = 'mod.content.battle_sprite_scales:register("abra_back", ' .. - '{ path = "assets/generated/battle/back/abrab.png", scale = 1.5 })', + example = [[ +mod.content.battle_sprite_scales:register("abra_back", { + path = "assets/generated/battle/back/abrab.png", + scale = 1.5, +})]], + notes = [[ +Scales one battle pic by its asset path, overriding the species-level +`battleScaleFront`/`battleScaleBack` (see [pokemon](#pokemon)) for that +image. The only way to scale a pic that is not species-keyed, like the +player's trainer back sprite. Resolution order at draw time: +image-level, then species-level, then the defaults (1x front, 2x back). +The pic stays grounded at any scale: player feet stay flush on the +text-box top, the enemy pic stays bottom-pinned in its slot, and the +scale composes with the send-out grow animation.]], } -- ------- progression @@ -1146,6 +1169,12 @@ R.field = { name = f.opt(f.str) })), nest = f.opt(f.any) }, flyOrder = f.list(f.str), + -- the player's own trainer art (FieldDefaults.PLAYER_PICS): the battle + -- back pic, the catch tutorial's old man, and the front pic the intro, + -- trainer card and Hall of Fame share. Every key is optional so a + -- conversion can replace one pic and inherit the rest. + playerPics = f.rec{ + back = f.opt(f.str), demoBack = f.opt(f.str), front = f.opt(f.str) }, -- the new-game and boot config a total conversion replaces boot = f.rec{ startMap = f.opt(f.str), startX = f.opt(f.int(0)), startY = f.opt(f.int(0)), diff --git a/src/pokemon/Evolution.lua b/src/pokemon/Evolution.lua index beb4a9af..a740ff50 100644 --- a/src/pokemon/Evolution.lua +++ b/src/pokemon/Evolution.lua @@ -14,6 +14,7 @@ local Runtime = require("src.mods.Runtime") local Screens = require("src.ui.Screens") local Stats = require("src.pokemon.Stats") local TextBox = require("src.render.TextBox") +local Strings = require("src.core.Strings") local Evolution = {} @@ -23,7 +24,7 @@ Evolution.METHODS = { return trigger.kind == "levelup" and mon.level >= (evo.level or 0) end, describe = function(evo) - return ("Level %d"):format(evo.level or 0) + return Strings("Level %d", evo.level or 0) end, }, ITEM = { @@ -139,7 +140,7 @@ function Evolution.learnEvolutionMoves(game, mon, onDone) table.insert(mon.moves, { id = moveId, pp = mdef.pp }) Runtime.emit("pokemon.move_learned", { mon = mon, moveId = moveId }) game.stack:push(TextBox.new(game, - ("%s learned\n%s!"):format(name, mdef.name), nextStep)) + Strings("%s learned\n%s!", name, mdef.name), nextStep)) else -- LearnMoveFromLevelUp with a full moveset: the forget UI Screens.push(game, "MoveLearnMenu", mon, moveId, nextStep) @@ -160,8 +161,8 @@ function Evolution.evolve(game, mon, newSpecies, onDone, via) Music.play(game.data, Music.special(game.data, "evolution")) local oldName = mon.nickname or game.data.pokemon[mon.species].name Evolution.apply(game, mon, newSpecies, via) - local msg = ("What?\n%s is\nevolving!\fCongratulations!\nYour %s\nevolved into\n%s!") - :format(oldName, oldName, game.data.pokemon[newSpecies].name) + local msg = Strings("What?\n%s is\nevolving!\fCongratulations!\nYour %s\nevolved into\n%s!", + oldName, oldName, game.data.pokemon[newSpecies].name) game.stack:push(TextBox.new(game, msg, function() Music.restoreMap(game.data) -- re-run the evolved species' level-up learn check before onDone diff --git a/src/pokemon/Sprites.lua b/src/pokemon/Sprites.lua index 4f3450c0..ee844271 100644 --- a/src/pokemon/Sprites.lua +++ b/src/pokemon/Sprites.lua @@ -3,8 +3,12 @@ -- patch pokemon.spriteFront / icons.bySpecies. These helpers are the -- sanctioned seam: every battle pic and party icon load goes through -- pokemon.sprite / pokemon.icon, which stay live for the whole process. +-- +-- playerPath is the same seam for the player's own trainer art, whose +-- vanilla paths are data (field.playerPics) rather than a species record. local Runtime = require("src.mods.Runtime") +local FieldDefaults = require("src.world.FieldDefaults") local Sprites = {} @@ -36,6 +40,36 @@ function Sprites.path(data, species, side, opts) return path, ctx.trueColor and true or false end +-- Resolve the player's own trainer pic path. +-- side: "back" (the battle intro pic) | "front" (intro / card / Hall of Fame) +-- opts.kind: "battle" | "intro" | "trainer_card" | "hof" +-- opts.demo: the catch tutorial, where the old man fights in the player's +-- place and stands in for the back pic +-- opts.battle: the live battle, for kind == "battle" +-- Returns path, trueColor. +function Sprites.playerPath(data, side, opts) + opts = opts or {} + side = side == "back" and "back" or "front" + -- one key per pic, so a conversion can replace the back and inherit the + -- rest; fieldValue folds data.field over FieldDefaults per key + local key = side == "front" and "front" + or (opts.demo and "demoBack" or "back") + local path = FieldDefaults.fieldValue(data, "playerPics", key) + local ctx = { + side = side, + kind = opts.kind or "battle", + demo = opts.demo and true or false, + battle = opts.battle, + trueColor = false, + data = data, + } + if path and Runtime.wantsHook("player.sprite") then + local hooked = Runtime.call("player.sprite", samePath, path, ctx) + if type(hooked) == "string" and hooked ~= "" then path = hooked end + end + return path, ctx.trueColor and true or false +end + -- Resolve a party-menu icon image path for `mon`. -- vanillaPath is the path PartyMenu already picked from icons.bySpecies / -- def.icon / icons.byDex; the hook may replace it. diff --git a/src/render/Font.lua b/src/render/Font.lua index e25e6ef2..33f1a97a 100644 --- a/src/render/Font.lua +++ b/src/render/Font.lua @@ -102,35 +102,84 @@ local function pageFor(code) return nil end +local SPACE = 0x7F + +-- Segment text into glyph spans: `{ from, to, code }` byte ranges, one per +-- drawn glyph, code nil when the charmap has nothing. A span is a whole +-- charmap sequence, so a multi-byte char ("é", "♂") and an ASCII ligature +-- ("", "'d") are each one glyph. +-- +-- Every caller that *measures* or *cuts* text walks these instead of bytes. +-- "POKéMON" is 8 bytes and 7 glyphs: measuring it as 8 wraps lines that fit +-- (25 vanilla lines did), and cutting at byte 8 splits the é into two +-- invalid bytes that both draw as spaces. That distinction is the whole +-- reason a non-English font can be shipped as a mod (#186, #245). +-- +-- Safe before Font.load: with no charmap it falls back to UTF-8 lead-byte +-- boundaries, which is all a headless paginate needs. +function Font.split(text) + local spans = {} + local i, n = 1, #text + while i <= n do + local span + local candidates = state and state.byFirstByte[text:byte(i)] + if candidates then + for _, entry in ipairs(candidates) do + local len = #entry.seq + if text:sub(i, i + len - 1) == entry.seq then + span = { from = i, to = i + len - 1, code = entry.code } + break + end + end + end + if not span then + -- Nothing matched. Still keep a UTF-8 sequence whole, so a cut never + -- lands mid-character even for a glyph we cannot draw. + local last = i + if text:byte(i) >= 0xC0 then + local k = i + 1 + while k <= n do + local b = text:byte(k) + if b < 0x80 or b > 0xBF then break end + last, k = k, k + 1 + end + end + span = { from = i, to = last } + end + spans[#spans + 1] = span + i = span.to + 1 + end + return spans +end + +-- How many leading spans fit in `budget` pixels. Advances come from each +-- glyph's own page, so a variable-width page measures correctly. +function Font.spansFitting(spans, budget) + local used, fit = 0, 0 + for _, span in ipairs(spans) do + used = used + Font.advanceOf(span.code or SPACE) + if used > budget then break end + fit = fit + 1 + end + return fit +end + -- Convert a text string into a list of glyph codes. Unknown characters -- render as space (and are reported once). local reported = {} function Font.encode(text) local codes = {} - local i = 1 - while i <= #text do - local candidates = state.byFirstByte[text:byte(i)] - local matched = false - if candidates then - for _, entry in ipairs(candidates) do - local n = #entry.seq - if text:sub(i, i + n - 1) == entry.seq then - codes[#codes + 1] = entry.code - i = i + n - matched = true - break - end - end - end - if not matched then - local ch = text:sub(i, i) - if not reported[ch] and ch:byte() >= 32 then + for _, span in ipairs(Font.split(text)) do + local code = span.code + if not code then + local ch = text:sub(span.from, span.to) + if not reported[ch] and text:byte(span.from) >= 32 then reported[ch] = true require("src.core.Logger").warn("font: no glyph for %q", ch) end - codes[#codes + 1] = 0x7F -- space - i = i + 1 + code = SPACE end + codes[#codes + 1] = code end return codes end diff --git a/src/render/TextBox.lua b/src/render/TextBox.lua index 08f80f84..8afac56a 100644 --- a/src/render/TextBox.lua +++ b/src/render/TextBox.lua @@ -92,13 +92,28 @@ end -- pokered ContText waits for A/B + ▼ before scrolling that line in. function TextBox.paginate(text, maxCols) maxCols = maxCols or (Theme.textBox and Theme.textBox.maxCols) or MAX_COLS + -- maxCols is a column count, so the budget is that many vanilla 8px + -- cells. Measuring in pixels rather than columns is what lets a mod's + -- variable-advance page wrap correctly (#186). + local budget = maxCols * 8 local pages = {} local contBefore = {} + -- Soft-wrap on glyph boundaries, never byte boundaries: a line is over + -- budget by what it *draws*, and the cut falls between glyphs so a + -- multi-byte char is never torn in half. local function pushLine(lines, conts, line, wait) - while #line > maxCols do - local cut = maxCols - for i = maxCols, 1, -1 do - if line:sub(i, i) == " " then cut = i break end + while true do + local spans = Font.split(line) + local fit = Font.spansFitting(spans, budget) + if fit >= #spans then break end + -- a glyph wider than the whole box still has to advance by one + fit = math.max(fit, 1) + local cut = spans[fit].to + for i = fit, 1, -1 do + if line:sub(spans[i].from, spans[i].to) == " " then + cut = spans[i].to + break + end end table.insert(lines, line:sub(1, cut)) table.insert(conts, wait) @@ -167,6 +182,18 @@ function TextBox:update(dt) if self.autoSrc and self.autoSrc.isPlaying and self.autoSrc:isPlaying() then return -- the cry is still sounding (WaitForSoundToFinish) end + -- auto.wait: the pet-NPC cries (PewterNidoranHouseNidoranText, + -- ViridianNicknameHouseSpearowText) have nothing queued behind the + -- cry, so DisplayTextID's trailing WaitForTextScrollButtonPress still + -- runs once it is over -- their maps enable auto text box drawing, + -- which zeroes wDoNotWaitForButtonPressAfterDisplayingText + -- (home/window.asm AutoTextBoxDrawingCommon). Drop the auto gate and + -- hand the box to the plain A/B path, which also starts the blinking + -- arrow, instead of popping it (#247, #251). + if self.auto.wait then + self.auto = nil + return + end self.autoTimer = self.autoTimer + 1 local delay = self.auto.delay or 3 -- auto.onOverlap: fired once when the delay elapses but before the diff --git a/src/script/Commands.lua b/src/script/Commands.lua index caf9469e..25022d57 100644 --- a/src/script/Commands.lua +++ b/src/script/Commands.lua @@ -10,6 +10,7 @@ local Flags = require("src.script.Flags") local Logger = require("src.core.Logger") local Screens = require("src.ui.Screens") local TextBox = require("src.render.TextBox") +local Strings = require("src.core.Strings") local Commands = {} @@ -74,6 +75,9 @@ end -- of its own; the box only closes once WaitForSoundToFinish's poll -- loop sees the cry channel go quiet, so it's a no-button-wait auto text -- gated on the cry, not a button-wait one -- see TextBox's opts.auto. +-- play_cry's waitForButton form keeps that cry gate but hands the box back +-- to the A/B path once the cry is over -- see TextBox's opts.auto.wait +-- (#247, #251). function Commands.show_text(ctx, textId, subs) local text = ctx.game.data.text[textId] if not text and ctx.overworld then @@ -100,10 +104,12 @@ function Commands.show_text(ctx, textId, subs) local opts if ctx.pendingCry then local species = ctx.pendingCry - ctx.pendingCry = nil + local waitForButton = ctx.pendingCryWait + ctx.pendingCry, ctx.pendingCryWait = nil, nil + -- delay 0: WaitForSoundToFinish has no trailing Delay3 of its own opts = { auto = { sound = function() return require("src.core.Sound").playCry(ctx.game.data, species) - end, delay = 0 } } -- WaitForSoundToFinish has no trailing Delay3 of its own + end, delay = 0, wait = waitForButton } } end -- text_opts armed the next box: auto = true is the plain no-button-wait -- form, overlap folds under auto, everything else passes through @@ -189,7 +195,7 @@ function Commands.give_item(ctx, itemId, count, gotText) -- skips the received text entirely when AddItemToInventory refuses) if not require("src.inventory.Bag").add(ctx.save, itemId, count or 1) then Commands.show_text(ctx, ctx.game.data.text - and ctx.game.data.text._BagFullText or "You can't carry\nany more items!") + and ctx.game.data.text._BagFullText or Strings("You can't carry\nany more items!")) return math.huge end local def = ctx.game.data.items[itemId] @@ -202,7 +208,7 @@ function Commands.give_item(ctx, itemId, count, gotText) (def and def.keyItem) and "Get_Key_Item" or "Get_Item1") if gotText ~= false then Commands.show_text(ctx, gotText - or "{PLAYER} got\n" .. ctx.game.stringBuffer .. "!") + or Strings("{PLAYER} got\n%s!", ctx.game.stringBuffer)) end end @@ -469,18 +475,26 @@ function Commands.play_once(ctx, songId) runner:yield() end --- play_cry : PlayCry (home/audio.asm). The text_asm bodies that --- use it run text_far (a no-button-wait "...@" string) -> PlayCry -> --- WaitForSoundToFinish, all within the same text ID -- the cry only starts --- once the box has finished typing, and the box then auto-closes (no A --- press) the instant the cry finishes, rather than firing immediately +-- play_cry [waitForButton]: PlayCry (home/audio.asm). The +-- text_asm bodies that use it run text_far (a no-button-wait "...@" string) +-- -> PlayCry -> WaitForSoundToFinish, all within the same text ID -- the cry +-- only starts once the box has finished typing, and the box then auto-closes +-- (no A press) the instant the cry finishes, rather than firing immediately -- alongside the typewriter effect. Script rows run strictly in order, so -- this stashes the species on ctx for the show_text row that always -- immediately follows it (Power Plant Zapdos, Seafoam Articuno, Victory -- Road Moltres, Cerulean Cave Mewtwo battle text) to play once its box is -- done typing (see show_text's opts.auto). Headless-safe no-op there. -function Commands.play_cry(ctx, species) +-- +-- waitForButton is the pet-NPC form (scripts/PewterNidoranHouse.asm and +-- scripts/ViridianNicknameHouse.asm: the text prints, then PlayCry, +-- WaitForSoundToFinish, TextScriptEnd). Nothing is queued behind the cry +-- there, so DisplayTextID's trailing WaitForTextScrollButtonPress still +-- runs and the box holds until A/B like any other NPC line instead of +-- popping itself into a static battle (#247, #251). +function Commands.play_cry(ctx, species, waitForButton) ctx.pendingCry = species + ctx.pendingCryWait = waitForButton or nil end -- check_battle_result [r2 ...]: lastCheck = the last scripted @@ -517,7 +531,7 @@ local function askNickname(ctx, mon) if ctx.game.data.text and ctx.game.data.text._DoYouWantToNicknameText then Commands.show_text(ctx, "_DoYouWantToNicknameText", { RAM = name }) else - Commands.show_text(ctx, ("Do you want to\ngive a nickname\nto %s?"):format(name)) + Commands.show_text(ctx, Strings("Do you want to\ngive a nickname\nto %s?", name)) end local ChoiceBox = require("src.ui.ChoiceBox") ctx.game.stack:push(ChoiceBox.new(ctx.game, function(yes) @@ -527,7 +541,7 @@ local function askNickname(ctx, mon) return end Screens.push(ctx.game, "NamingScreen", { - title = "NICKNAME?", maxLen = 10, + title = Strings("NICKNAME?"), maxLen = 10, onDone = function(nick) if nick and #nick > 0 then mon.nickname = nick end ctx.lastCheck = success @@ -598,8 +612,9 @@ function Commands.give_pokemon(ctx, species, level) local TextBox = require("src.render.TextBox") local raw = ctx.game.data.text and ctx.game.data.text._SentToBoxText ctx.game.stack:push(TextBox.new(ctx.game, raw or ( - "There's no more\nroom for POKéMON!\v" .. name - .. " was\vsent to POKéMON\vBOX " .. boxNum .. " on PC!"), + Strings( + "There's no more\nroom for POKéMON!\v%s was\vsent to POKéMON\vBOX %s on PC!", + name, boxNum)), function() end)) end end diff --git a/src/script/MapScripts.lua b/src/script/MapScripts.lua index 8e88bbea..b5fdc2db 100644 --- a/src/script/MapScripts.lua +++ b/src/script/MapScripts.lua @@ -15,6 +15,7 @@ local Data = require("src.core.Data") local Logger = require("src.core.Logger") local Runtime = require("src.mods.Runtime") +local Strings = require("src.core.Strings") local MapScripts = {} @@ -268,7 +269,7 @@ function MapScripts.validateContribution(contribution, lookup) end end if type(contribution) ~= "table" then - return { "contribution is not a table" } + return { Strings("contribution is not a table") } end for textConst, script in pairs(contribution.talk or {}) do if type(script) == "table" then collect("talk." .. textConst, script) end diff --git a/src/script/ScriptRunner.lua b/src/script/ScriptRunner.lua index 43b8a470..bac4c31d 100644 --- a/src/script/ScriptRunner.lua +++ b/src/script/ScriptRunner.lua @@ -13,6 +13,7 @@ local Commands = require("src.script.Commands") local Logger = require("src.core.Logger") local Runtime = require("src.mods.Runtime") +local Strings = require("src.core.Strings") local unpack = table.unpack or unpack -- LuaJIT (LÖVE) compatibility @@ -195,7 +196,7 @@ function ScriptRunner:resume(...) if not ok then local source = self.ctx and self.ctx.source local where = source - and (" [%s %s.%s]"):format(tostring(source.modId or "engine"), + and Strings(" [%s %s.%s]", tostring(source.modId or "engine"), tostring(source.mapId or "?"), tostring(source.hook or "?")) or "" Logger.error("script error%s: %s", where, tostring(err)) diff --git a/src/ui/BagMenu.lua b/src/ui/BagMenu.lua index 1205dde6..78339750 100644 --- a/src/ui/BagMenu.lua +++ b/src/ui/BagMenu.lua @@ -9,6 +9,7 @@ local TextBox = require("src.render.TextBox") local BagMenu = {} local Bag = require("src.inventory.Bag") +local Strings = require("src.core.Strings") -- acquisition order like wBagItems (Bag.order), not alphabetical local function buildItems(game) @@ -108,13 +109,13 @@ local function useOn(game, battle, id, target, list, moveIndex) if game.save.onBike then game.save.onBike = false Music.playMap(game.data, ow and ow.map.id, false) - showMessages(game, { save_name(game) .. " got off\nthe BICYCLE." }) + showMessages(game, { Strings("%s got off\nthe BICYCLE.", save_name(game)) }) elseif bikeAllowed() then game.save.onBike = true Music.playMap(game.data, ow.map.id, true) - showMessages(game, { save_name(game) .. " got on\nthe BICYCLE!" }) + showMessages(game, { Strings("%s got on\nthe BICYCLE!", save_name(game)) }) else - showMessages(game, { "No cycling\nallowed here." }) + showMessages(game, { Strings("No cycling\nallowed here.") }) end return end @@ -130,13 +131,14 @@ local function useOn(game, battle, id, target, list, moveIndex) return end end - showMessages(game, { "No good! It's not\neven near water." }) + showMessages(game, { Strings("No good! It's not\neven near water.") }) return end if result == "ball" then if not battle then - showMessages(game, { "OAK: " .. game.save.player.name .. "!\nThis isn't the\ntime to use that!" }) + showMessages(game, { Strings("OAK: %s!\nThis isn't the\ntime to use that!", + game.save.player.name) }) return end consume(game, id) @@ -151,7 +153,7 @@ local function useOn(game, battle, id, target, list, moveIndex) local function teach() if #target.moves < 4 then table.insert(target.moves, { id = moveId, pp = mdef.pp }) - showMessages(game, { ("%s learned\n%s!"):format(target.nickname or + showMessages(game, { Strings("%s learned\n%s!", target.nickname or game.data.pokemon[target.species].name, mdef.name) }) if result == "learn" then consume(game, id) end else @@ -172,7 +174,7 @@ local function useOn(game, battle, id, target, list, moveIndex) require("src.ui.Screens").push(game, "TownMap") end) if not ok then - showMessages(game, { "The TOWN MAP is\nunreadable here." }) + showMessages(game, { Strings("The TOWN MAP is\nunreadable here.") }) end return end @@ -184,10 +186,10 @@ local function useOn(game, battle, id, target, list, moveIndex) local t = game.data.text if ow and ow:hasHiddenItemLeft() then showMessages(game, { t._ItemfinderFoundItemText - or "Yes! ITEMFINDER\nindicates there's\nan item nearby." }) + or Strings("Yes! ITEMFINDER\nindicates there's\nan item nearby.") }) else showMessages(game, { t._ItemfinderFoundNothingText - or "Nope! ITEMFINDER\nisn't responding." }) + or Strings("Nope! ITEMFINDER\nisn't responding.") }) end return end @@ -219,8 +221,9 @@ local function useOn(game, battle, id, target, list, moveIndex) -- departure helper -- the same path Dig/Teleport take from the party menu ow:beginTeleportOut() else - showMessages(game, { "OAK: " .. game.save.player.name - .. "!\nThis isn't the\ntime to use that!" }) + showMessages(game, { Strings( + "OAK: %s!\nThis isn't the\ntime to use that!", + game.save.player.name) }) end return end @@ -264,7 +267,7 @@ local function useOn(game, battle, id, target, list, moveIndex) if #target.moves < 4 then table.insert(target.moves, { id = moveId, pp = mdef.pp }) local name = target.nickname or def.name - showMessages(game, { ("%s learned\n%s!"):format(name, mdef.name) }, + showMessages(game, { Strings("%s learned\n%s!", name, mdef.name) }, nextStep) else require("src.ui.Screens").push(game, "MoveLearnMenu", @@ -354,8 +357,8 @@ local function useItem(game, battle, id, list) local moveDef = game.data.moves[def.machine.move] local moveName = moveDef and moveDef.name or def.machine.move local booted = def.machine.kind == "HM" - and "Booted up an HM!" or "Booted up a TM!" - showMessages(game, { booted, ("It contained\n%s!"):format(moveName) }, + and "Booted up an HM!" or Strings("Booted up a TM!") + showMessages(game, { booted, Strings("It contained\n%s!", moveName) }, function() pickTargetAndUse(game, battle, id, list) end) return end @@ -402,17 +405,23 @@ function BagMenu.new(game, opts) useItem(game, battle, id, list) return end - -- USE / TOSS submenu (the original's item options) + -- USE / TOSS submenu (the original's item options). + -- data/text_boxes.asm USE_TOSS_MENU_TEMPLATE: box (13,10)-(19,14), + -- text at (15,11); start_sub_menus.asm then sets wTopMenuItemY/X to + -- 11/14 for the cursor. Menu's own geometry reproduces all of that + -- from the box alone, so this needs opts rather than a change to the + -- shared Menu. The old 12/10/8/6 box was a column too wide and a row + -- too tall, which left the labels stranded near its top edge (#284). local Menu = require("src.ui.Menu") game.stack:push(Menu.new(game, { - { label = "USE", onSelect = function() + { label = Strings("USE"), onSelect = function() useItem(game, battle, id, list) end }, - { label = "TOSS", onSelect = function() + { label = Strings("TOSS"), onSelect = function() -- KeyItemFlags + HMs decide tossability (not price: -- MOON STONE is price 0 but tossable) if not def or def.keyItem or id:find("^HM_") then - showMessages(game, { "That's too impor-\ntant to toss!" }) + showMessages(game, { Strings("That's too impor-\ntant to toss!") }) return end local QuantityBox = require("src.ui.QuantityBox") @@ -426,12 +435,12 @@ function BagMenu.new(game, opts) Bag.remove(game.save, id, qty) list.items = buildItems(game) list.index = math.min(list.index, math.max(1, #list.items)) - showMessages(game, { ("Threw away\n%s."):format(def and def.name or id) }) + showMessages(game, { Strings("Threw away\n%s.", def and def.name or id) }) end)) end, })) end }, - }, { tx = 12, ty = 10, tw = 8, th = 6 })) + }, { tx = 13, ty = 10, tw = 7, th = 5 })) end, }) return list diff --git a/src/ui/BindingsMenu.lua b/src/ui/BindingsMenu.lua index 2c5ef036..92b9ff20 100644 --- a/src/ui/BindingsMenu.lua +++ b/src/ui/BindingsMenu.lua @@ -7,6 +7,7 @@ local Font = require("src.render.Font") local ListMenu = require("src.ui.ListMenu") local Input = require("src.core.Input") +local Strings = require("src.core.Strings") local BindingsMenu = setmetatable({}, { __index = ListMenu }) BindingsMenu.__index = BindingsMenu @@ -55,7 +56,9 @@ function BindingsMenu.new(game) and game.save.options.bindings local items = {} for i, def in ipairs(BUTTONS) do - items[i] = { label = def.label, + -- translated here, not in ROWS: that table is built at require + -- time, before Strings.load has a catalog to look in + items[i] = { label = Strings(def.label), right = boundRight(overlay, def), button = def } end local self = setmetatable(ListMenu.new(game, "CONTROLS", items, {}), @@ -111,7 +114,7 @@ function BindingsMenu:draw() if self.capture then Font.drawBox(1, 6, 18, 4) love.graphics.setColor(0, 0, 0, 1) - Font.draw("PRESS A BUTTON", 24, 60) + Font.draw(Strings("PRESS A BUTTON"), 24, 60) love.graphics.setColor(1, 1, 1, 1) end end diff --git a/src/ui/BoxMenu.lua b/src/ui/BoxMenu.lua index adb6e62c..ee8a1300 100644 --- a/src/ui/BoxMenu.lua +++ b/src/ui/BoxMenu.lua @@ -8,12 +8,13 @@ local ListMenu = require("src.ui.ListMenu") local Menu = require("src.ui.Menu") local Party = require("src.pokemon.Party") local TextBox = require("src.render.TextBox") +local Strings = require("src.core.Strings") local BoxMenu = {} local function monLabel(game, mon) local def = game.data.pokemon[mon.species] - return ("%s :L%d"):format(mon.nickname or def.name, mon.level) + return Strings("%s :L%d", mon.nickname or def.name, mon.level) end local function monName(game, mon) @@ -28,13 +29,13 @@ local function monSubmenu(game, action, mon, onAction) game.stack:push(Menu.new(game, { { label = action, onSelect = onAction }, { - label = "STATS", + label = Strings("STATS"), keepOpen = true, onSelect = function() require("src.ui.Screens").push(game, "SummaryMenu", mon) end, }, - { label = "CANCEL" }, + { label = Strings("CANCEL") }, }, { tx = 9, ty = 10, tw = 11, th = 8, noSound = true })) end @@ -50,12 +51,12 @@ local function withdraw(game) local t = game.data.text if #box == 0 then game.stack:push(TextBox.new(game, t._NoMonText - or "What? There are\nno POKéMON here!")) + or Strings("What? There are\nno POKéMON here!"))) return end if #game.save.party >= Party.MAX then game.stack:push(TextBox.new(game, t._CantTakeMonText - or "You can't take\nany more POKéMON.\fDeposit POKéMON\nfirst.")) + or Strings("You can't take\nany more POKéMON.\fDeposit POKéMON\nfirst."))) return end local items = {} @@ -63,7 +64,7 @@ local function withdraw(game) table.insert(items, { label = monLabel(game, mon), value = i }) end game.stack:push(ListMenu.new(game, - ("BOX %d (WITHDRAW)"):format(game.save.currentBox), items, { + Strings("BOX %d (WITHDRAW)", game.save.currentBox), items, { onChoose = function(item, list) local mon = box[item.value] if not mon then return end @@ -78,7 +79,7 @@ local function withdraw(game) game.stringBuffer = name require("src.core.Sound").playCry(game.data, mon.species) afterTransfer(game, list, t._MonIsTakenOutText - or (name .. " is\ntaken out.\vGot " .. name .. ".")) + or Strings("%s is\ntaken out.\vGot %s.", name, name)) end) end, })) @@ -88,13 +89,13 @@ local function deposit(game) local t = game.data.text if #game.save.party <= 1 then game.stack:push(TextBox.new(game, t._CantDepositLastMonText - or "You can't deposit\nthe last POKéMON!")) + or Strings("You can't deposit\nthe last POKéMON!"))) return end local box = Boxes.active(game.save) if #box >= Boxes.CAPACITY then game.stack:push(TextBox.new(game, t._BoxFullText - or "Oops! This Box is\nfull of POKéMON.")) + or Strings("Oops! This Box is\nfull of POKéMON."))) return end local items = {} @@ -107,12 +108,12 @@ local function deposit(game) if not mon then return end monSubmenu(game, "DEPOSIT", mon, function() if #game.save.party <= 1 then - list.footer = "You need at least\none POKéMON!" + list.footer = Strings("You need at least\none POKéMON!") return end local active = Boxes.active(game.save) if #active >= Boxes.CAPACITY then - list.footer = ("BOX %d is full!"):format(game.save.currentBox) + list.footer = Strings("BOX %d is full!", game.save.currentBox) return end table.remove(game.save.party, item.value) @@ -122,7 +123,7 @@ local function deposit(game) game.boxNumString = tostring(game.save.currentBox) require("src.core.Sound").playCry(game.data, mon.species) afterTransfer(game, list, t._MonWasStoredText - or (name .. " was\nstored in Box " .. game.boxNumString .. ".")) + or Strings("%s was\nstored in Box %s.", name, game.boxNumString)) end) end, })) @@ -135,7 +136,7 @@ local function release(game) local t = game.data.text if #box == 0 then game.stack:push(TextBox.new(game, t._NoMonText - or "What? There are\nno POKéMON here!")) + or Strings("What? There are\nno POKéMON here!"))) return end local items = {} @@ -143,20 +144,20 @@ local function release(game) table.insert(items, { label = monLabel(game, mon), value = i }) end game.stack:push(ListMenu.new(game, - ("BOX %d (RELEASE)"):format(game.save.currentBox), items, { + Strings("BOX %d (RELEASE)", game.save.currentBox), items, { onChoose = function(_, list) local mon = box[list.index] if not mon then return end local name = monName(game, mon) local ChoiceBox = require("src.ui.ChoiceBox") game.stack:push(TextBox.new(game, - "Once released,\n" .. name .. " is\ngone forever. OK?", function() + Strings("Once released,\n%s is\ngone forever. OK?", name), function() game.stack:push(ChoiceBox.new(game, function(yes) if not yes then return end table.remove(box, list.index) require("src.core.Sound").playCry(game.data, mon.species) game.stack:push(TextBox.new(game, - ("%s was\nreleased outside.\fBye %s!"):format(name, name))) + Strings("%s was\nreleased outside.\fBye %s!", name, name))) list:removeCurrent() end, { defaultNo = true, noSound = true })) end)) @@ -170,7 +171,7 @@ local function changeBox(game) for i = 1, Boxes.COUNT do local mark = i == game.save.currentBox and "*" or " " table.insert(items, { - label = ("%sBOX %2d"):format(mark, i), + label = Strings("%sBOX %2d", mark, i), right = ("%d/%d"):format(#boxes[i], Boxes.CAPACITY), value = i, }) @@ -181,7 +182,7 @@ local function changeBox(game) -- BOX, data will be saved. OK?"); declining aborts the change local ChoiceBox = require("src.ui.ChoiceBox") game.stack:push(TextBox.new(game, - "When you change a\nPOKéMON BOX, data\nwill be saved. OK?", function() + Strings("When you change a\nPOKéMON BOX, data\nwill be saved. OK?"), function() game.stack:push(ChoiceBox.new(game, function(yes) if not yes then return end game.save.currentBox = item.value @@ -197,10 +198,10 @@ end local function drawChrome(game) Font.drawBox(0, 12, 20, 6) love.graphics.setColor(0, 0, 0, 1) - Font.draw("What?", 8, 112) + Font.draw(Strings("What?"), 8, 112) Font.drawBox(9, 14, 11, 4) love.graphics.setColor(0, 0, 0, 1) - Font.draw("BOX No.", 80, 128) + Font.draw(Strings("BOX No."), 80, 128) local n = game.save.currentBox or 1 if n >= 10 then Font.draw(tostring(n), 136, 128) @@ -217,15 +218,15 @@ function BoxMenu.new(game) -- full interior (cursor col + label). keepOpen so WITHDRAW/DEPOSIT/ -- RELEASE/CHANGE BOX leave this menu underneath (jp BillsPCMenu). local menu = Menu.new(game, { - { label = "WITHDRAW ", keepOpen = true, + { label = Strings("WITHDRAW "), keepOpen = true, onSelect = function() withdraw(game) end }, - { label = "DEPOSIT ", keepOpen = true, + { label = Strings("DEPOSIT "), keepOpen = true, onSelect = function() deposit(game) end }, - { label = "RELEASE ", keepOpen = true, + { label = Strings("RELEASE "), keepOpen = true, onSelect = function() release(game) end }, - { label = "CHANGE BOX", keepOpen = true, + { label = Strings("CHANGE BOX"), keepOpen = true, onSelect = function() changeBox(game) end }, - { label = "SEE YA!" }, + { label = Strings("SEE YA!") }, -- Bill's PC runs silent end to end (BIT_NO_MENU_BUTTON_SOUND, -- engine/menus/pokemon_pc.asm) }, { tx = 0, ty = 0, tw = 14, th = 12, noSound = true }) diff --git a/src/ui/ChoiceBox.lua b/src/ui/ChoiceBox.lua index b30fda61..013bbafc 100644 --- a/src/ui/ChoiceBox.lua +++ b/src/ui/ChoiceBox.lua @@ -2,6 +2,7 @@ local Font = require("src.render.Font") local Theme = require("src.ui.Theme") +local Strings = require("src.core.Strings") local ChoiceBox = {} ChoiceBox.__index = ChoiceBox @@ -46,8 +47,8 @@ function ChoiceBox:draw() local tx, ty, tw, th = self.tx, self.ty, self.tw, self.th Font.drawBox(tx, ty, tw, th) love.graphics.setColor(0, 0, 0, 1) - Font.draw("YES", (tx + 2) * 8, (ty + 1) * 8) - Font.draw("NO", (tx + 2) * 8, (ty + 3) * 8) + Font.draw(Strings("YES"), (tx + 2) * 8, (ty + 1) * 8) + Font.draw(Strings("NO"), (tx + 2) * 8, (ty + 3) * 8) Font.drawCode(Theme.cursor, (tx + 1) * 8, (ty + (self.index == 1 and 1 or 3)) * 8) love.graphics.setColor(1, 1, 1, 1) diff --git a/src/ui/Credits.lua b/src/ui/Credits.lua index 37dbeb82..dfb02eea 100644 --- a/src/ui/Credits.lua +++ b/src/ui/Credits.lua @@ -31,6 +31,7 @@ local Font = require("src.render.Font") local Music = require("src.core.Music") +local Strings = require("src.core.Strings") local Credits = {} Credits.__index = Credits @@ -261,14 +262,14 @@ function Credits:drawCopyright(xoff) love.graphics.draw(self.gfImg, xoff + 80, 88) else love.graphics.setColor(0, 0, 0, 1) - Font.draw("GAME FREAK", xoff + 80, 88) + Font.draw(Strings("GAME FREAK"), xoff + 80, 88) love.graphics.setColor(1, 1, 1, 1) end else love.graphics.setColor(0, 0, 0, 1) - Font.draw("Nintendo", xoff + 80, 56) - Font.draw("Creatures inc.", xoff + 80, 72) - Font.draw("GAME FREAK inc.", xoff + 16, 88) + Font.draw(Strings("Nintendo"), xoff + 80, 56) + Font.draw(Strings("Creatures inc."), xoff + 80, 72) + Font.draw(Strings("GAME FREAK inc."), xoff + 16, 88) love.graphics.setColor(1, 1, 1, 1) end end @@ -298,7 +299,7 @@ function Credits:drawTheEnd() end else love.graphics.setColor(0, 0, 0, 1) - Font.draw((te and te.display) or "T H E E N D", 32, 64) + Font.draw((te and te.display) or Strings("T H E E N D"), 32, 64) love.graphics.setColor(1, 1, 1, 1) end end diff --git a/src/ui/DexEntryMenu.lua b/src/ui/DexEntryMenu.lua index f25224fc..221ce017 100644 --- a/src/ui/DexEntryMenu.lua +++ b/src/ui/DexEntryMenu.lua @@ -8,6 +8,7 @@ -- without permanently marking the mon owned. local Font = require("src.render.Font") +local Strings = require("src.core.Strings") local DexEntryMenu = {} DexEntryMenu.__index = DexEntryMenu @@ -76,8 +77,8 @@ function DexEntryMenu:draw() -- feet/inches use the dex screen's ′/″ glyphs ("HT ?′??″" in -- pokedex.asm; the tiles come from gfx/pokedex/pokedex.png via -- engine/gfx/load_pokedex_tiles.asm) - Font.draw(("HT %d′%02d″"):format(e.heightFt, e.heightIn or 0), 72, 44) - Font.draw(("WT %.1flb"):format((e.weight or 0) / 10), 72, 54) + Font.draw(Strings("HT %d′%02d″", e.heightFt, e.heightIn or 0), 72, 44) + Font.draw(Strings("WT %.1flb", (e.weight or 0) / 10), 72, 54) end local text = owned and e.text and self.game.data.text[e.text] or nil local y = 72 @@ -88,7 +89,7 @@ function DexEntryMenu:draw() y = y + 10 end else - Font.draw("Data unknown.", 8, y) + Font.draw(Strings("Data unknown."), 8, y) end love.graphics.setColor(1, 1, 1, 1) end diff --git a/src/ui/EvolutionState.lua b/src/ui/EvolutionState.lua index 815102dd..70a3a9ba 100644 --- a/src/ui/EvolutionState.lua +++ b/src/ui/EvolutionState.lua @@ -1,14 +1,17 @@ -- The evolution movie (engine/movie/evolution.asm): the mon's pic -- flashes back and forth with the evolved form, speeding up, then the -- new form appears with its cry and the congratulations text. --- pokered engine/pokemon/evos_moves.asm (EvolveMon) polls hJoyHeld during --- the flash: holding B aborts the evolution -- the mon keeps its species --- and _StoppedEvolvingText ("Huh? MON stopped evolving!") prints. The --- lone exception is trade evolutions (wLinkState == LINK_STATE_TRADING), --- which skip that poll and cannot be cancelled (#213). +-- pokered engine/movie/evolution.asm (Evolution_CheckForCancel) polls the +-- joypad during the flash: holding B aborts the evolution -- the mon keeps +-- its species and _StoppedEvolvingText ("Huh? MON stopped evolving!") +-- prints. Two kinds are exempt: trade evolutions, which evos_moves.asm +-- routes past the poll entirely (wLinkState == LINK_STATE_TRADING, #213), +-- and stone evolutions, where the B press is read but thrown away because +-- ItemUseEvoStone left wForceEvolution set (#290). local Font = require("src.render.Font") local Music = require("src.core.Music") +local Strings = require("src.core.Strings") local EvolutionState = {} EvolutionState.__index = EvolutionState @@ -43,9 +46,12 @@ function EvolutionState.new(game, mon, newSpecies, onDone, via) self.newSpecies = newSpecies self.onDone = onDone self.via = via - -- evos_moves.asm: only trade evolutions (LINK_STATE_TRADING) skip the - -- B-cancel poll; level-up, stone and rare-candy evos are all cancelable. - self.cancelable = (via ~= "TRADE") + -- evolution.asm Evolution_CheckForCancel: a B press is discarded when + -- wForceEvolution is set, and ItemUseEvoStone sets it before calling + -- TryEvolvingMon, so a stone evolution (via == "ITEM") cannot be + -- cancelled either. Only level-up and rare-candy evolutions run with + -- wForceEvolution clear and so honour B (#290, #213). + self.cancelable = (via ~= "TRADE" and via ~= "ITEM") self.oldName = mon.nickname or game.data.pokemon[mon.species].name self.oldSprite = frontSprite(game, mon.species, mon) self.newSprite = frontSprite(game, newSpecies, mon) @@ -69,7 +75,7 @@ function EvolutionState:update(dt) local TextBox = require("src.render.TextBox") -- mirrors data/generated/text.lua _StoppedEvolvingText game.stack:push(TextBox.new(game, - ("Huh? %s\nstopped evolving!"):format(self.oldName), + Strings("Huh? %s\nstopped evolving!", self.oldName), function() Music.restoreMap(game.data) game.stack:pop() -- the evolution screen itself @@ -85,8 +91,8 @@ function EvolutionState:update(dt) local TextBox = require("src.render.TextBox") local newName = game.data.pokemon[self.newSpecies].name game.stack:push(TextBox.new(game, - ("Congratulations!\nYour %s\nevolved into\n%s!") - :format(self.oldName, newName), + Strings("Congratulations!\nYour %s\nevolved into\n%s!", + self.oldName, newName), function() Music.restoreMap(game.data) game.stack:pop() -- the evolution screen itself @@ -121,9 +127,9 @@ function EvolutionState:draw() love.graphics.setColor(0, 0, 0, 1) if not self.done then - Font.draw("What?", 8, 104) + Font.draw(Strings("What?"), 8, 104) Font.draw(self.oldName .. " is", 8, 114) - Font.draw("evolving!", 8, 124) + Font.draw(Strings("evolving!"), 8, 124) end love.graphics.setColor(1, 1, 1, 1) end diff --git a/src/ui/HallOfFame.lua b/src/ui/HallOfFame.lua index c0e1f0db..f45c968c 100644 --- a/src/ui/HallOfFame.lua +++ b/src/ui/HallOfFame.lua @@ -7,10 +7,12 @@ -- name/time/money boxes plus the dex rating. Plays Music_HallOfFame -- when the audio data has it. Calls onDone() after popping itself. +local Assets = require("src.render.Assets") local Font = require("src.render.Font") local Music = require("src.core.Music") local Sound = require("src.core.Sound") local TypeChart = require("src.battle.TypeChart") +local Strings = require("src.core.Strings") local HallOfFame = {} HallOfFame.__index = HallOfFame @@ -47,9 +49,11 @@ local FADE_FRAMES = 20 -- HoFPrintTextAndDelay after each dex line local DEX_HOLD = 120 +-- through Assets.resolve so an enabled mod's overrides/ shadows these the +-- same way it shadows every other generated asset local function tryImage(path) if not path then return nil end - local ok, img = pcall(love.graphics.newImage, path) + local ok, img = pcall(love.graphics.newImage, Assets.resolve(path)) return ok and img or nil end @@ -79,7 +83,8 @@ function HallOfFame.new(game, onDone) self.timer = 0 self.phase = "mons" self.sprites = {} -- species -> image or false - self.playerPic = tryImage("assets/generated/trainer_card/red.png") + self.playerPic = tryImage(require("src.pokemon.Sprites").playerPath( + game.data, "front", { kind = "hof" })) self.scrollX = PIC_X self.showHofBanner = false self.fade = 0 @@ -206,13 +211,13 @@ function HallOfFame:drawMonInfo(mon) love.graphics.setColor(0, 0, 0, 1) local name = mon.nickname or (def and def.name) or mon.species Font.draw(name, 1 * 8, 4 * 8) - Font.draw("LEVEL/", 2 * 8, 6 * 8) - Font.draw("TYPE1/", 2 * 8, 7 * 8) + Font.draw(Strings("LEVEL/"), 2 * 8, 6 * 8) + Font.draw(Strings("TYPE1/"), 2 * 8, 7 * 8) local t1 = def and def.types and def.types[1] local t2 = def and def.types and def.types[2] local dual = t2 and t2 ~= t1 if dual then - Font.draw("TYPE2/", 2 * 8, 8 * 8) + Font.draw(Strings("TYPE2/"), 2 * 8, 8 * 8) end -- PrintLevelCommon at (8,7): bare level digits (no tile here) Font.draw(tostring(mon.level), 8 * 8, 7 * 8) @@ -229,7 +234,7 @@ end function HallOfFame:drawHofBanner() Font.drawBox(2, 13, 16, 5) love.graphics.setColor(0, 0, 0, 1) - Font.draw("HALL OF FAME", 4 * 8, 15 * 8) + Font.draw(Strings("HALL OF FAME"), 4 * 8, 15 * 8) end function HallOfFame:drawPic(img) @@ -249,11 +254,11 @@ function HallOfFame:drawPlayerStats() -- play time / money box: TextBoxBorder (0,4) b=6,c=10 → drawBox(0,4,12,8) Font.drawBox(0, 4, 12, 8) love.graphics.setColor(0, 0, 0, 1) - Font.draw("PLAY TIME", 1 * 8, 6 * 8) + Font.draw(Strings("PLAY TIME"), 1 * 8, 6 * 8) local t = math.floor(save.playTime or 0) Font.draw(("%3d:%02d"):format(math.floor(t / 3600), math.floor(t / 60) % 60), 5 * 8, 7 * 8) - Font.draw("MONEY", 1 * 8, 9 * 8) + Font.draw(Strings("MONEY"), 1 * 8, 9 * 8) -- PrintBCDNumber with MONEY_SIGN; port uses ¥ like TrainerCard Font.draw(("¥%d"):format(save.money or 0), 4 * 8, 10 * 8) end @@ -266,16 +271,16 @@ function HallOfFame:drawDexBox(kind) if kind == "seen" then local seen, owned = self:dexSeenOwned() local seenOwned = text._DexSeenOwnedText - or "POKéDEX Seen:{NUM:wDexRatingNumMonsSeen, 1, 3}\n Owned:{NUM:wDexRatingNumMonsOwned, 1, 3}" + or Strings("POKéDEX Seen:{NUM:wDexRatingNumMonsSeen, 1, 3}\n Owned:{NUM:wDexRatingNumMonsOwned, 1, 3}") seenOwned = seenOwned :gsub("{NUM:wDexRatingNumMonsSeen[^}]*}", tostring(seen)) :gsub("{NUM:wDexRatingNumMonsOwned[^}]*}", tostring(owned)) drawTextBlock(seenOwned, 1 * 8, 14 * 8, 17 * 8) else local _, owned = self:dexSeenOwned() - local ratingHeader = (text._DexRatingText or "POKéDEX Rating{COLON}"):gsub("{COLON}", ":") + local ratingHeader = (text._DexRatingText or Strings("POKéDEX Rating{COLON}")):gsub("{COLON}", ":") Font.draw(ratingHeader, 1 * 8, 14 * 8) - local rating = text[dexRatingKey(owned)] or "Keep it up!" + local rating = text[dexRatingKey(owned)] or Strings("Keep it up!") drawTextBlock(rating, 1 * 8, 15 * 8, 17 * 8) end end diff --git a/src/ui/IntroMovie.lua b/src/ui/IntroMovie.lua index d77d29dc..2046bcc3 100644 --- a/src/ui/IntroMovie.lua +++ b/src/ui/IntroMovie.lua @@ -27,6 +27,7 @@ local Font = require("src.render.Font") local Music = require("src.core.Music") local Sound = require("src.core.Sound") +local Strings = require("src.core.Strings") local IntroMovie = {} IntroMovie.__index = IntroMovie @@ -287,7 +288,7 @@ function IntroMovie:drawSplash() end -- custom studio name (replaces the GAME FREAK splash text) love.graphics.setColor(0, 0, 0, dim and 0.35 or 1) - local card = self.studio.card or "bois club games" + local card = self.studio.card or Strings("bois club games") Font.draw(card, (160 - #card * 8) / 2, TEXT_Y) love.graphics.setColor(1, 1, 1, 1) end @@ -348,7 +349,7 @@ function IntroMovie:drawFight() if not gengar and not nido then love.graphics.setColor(0, 0, 0, 1) - Font.draw("GENGAR VS NIDORINO", (160 - 18 * 8) / 2, 64) + Font.draw(Strings("GENGAR VS NIDORINO"), (160 - 18 * 8) / 2, 64) love.graphics.setColor(1, 1, 1, 1) end drawBars() @@ -366,7 +367,7 @@ function IntroMovie:draw() -- custom boot card (replaces the Nintendo / GAME FREAK copyright -- card; no (c) glyph in the charmap, keep it ASCII-safe) love.graphics.setColor(0, 0, 0, 1) - local credit = self.studio.credit or "bois club" + local credit = self.studio.credit or Strings("bois club") Font.draw("2026", (160 - 4 * 8) / 2, 48) Font.draw(credit, (160 - #credit * 8) / 2, 64) Font.draw("bryanthaboi", (160 - 11 * 8) / 2, 80) diff --git a/src/ui/ListMenu.lua b/src/ui/ListMenu.lua index 1d42080c..b6cd8d09 100644 --- a/src/ui/ListMenu.lua +++ b/src/ui/ListMenu.lua @@ -5,6 +5,7 @@ local Font = require("src.render.Font") local Runtime = require("src.mods.Runtime") local Theme = require("src.ui.Theme") +local Strings = require("src.core.Strings") local ListMenu = {} ListMenu.__index = ListMenu @@ -192,7 +193,7 @@ function ListMenu:draw() love.graphics.setColor(0, 0, 0, 1) Font.draw(self.title, 8, 4) if #self.items == 0 then - Font.draw("Nothing here.", 16, 64) + Font.draw(Strings("Nothing here."), 16, 64) end for row = 1, self.rows do local i = self.scroll + row @@ -201,7 +202,10 @@ function ListMenu:draw() local y = 8 + row * 16 Font.draw(item.label, 16, y) if item.ball then -- the Pokédex owned-ball marker tile - local bx = 16 + (#item.label + 1) * 8 + 3 + -- one blank glyph after the name, measured in glyph advances rather + -- than bytes: NIDORAN♂/♀ carry a multi-byte charmap entry, so + -- `#item.label` overcounted by 2 and pushed their ball 16px right (#285) + local bx = 16 + Font.width(item.label) + 8 + 3 local by = y + 3 love.graphics.circle("fill", bx, by, 3.5) love.graphics.setColor(1, 1, 1, 1) diff --git a/src/ui/MoveLearnMenu.lua b/src/ui/MoveLearnMenu.lua index f107b454..222f7f1c 100644 --- a/src/ui/MoveLearnMenu.lua +++ b/src/ui/MoveLearnMenu.lua @@ -4,6 +4,7 @@ -- can't be forgotten; B / CANCEL gives up on the new move. local Font = require("src.render.Font") +local Strings = require("src.core.Strings") local MoveLearnMenu = {} MoveLearnMenu.__index = MoveLearnMenu @@ -43,9 +44,9 @@ function MoveLearnMenu:enter() local name = self:monName() self.selecting = false game.stack:push(TextBox.new(game, - ("%s is\ntrying to learn\v%s!\fBut, %s\ncan't learn more\vthan 4 moves!\f") - :format(name, mdef.name, name) .. - ("Delete an older\nmove to make room\vfor %s?"):format(mdef.name), + Strings("%s is\ntrying to learn\v%s!\fBut, %s\ncan't learn more\vthan 4 moves!\f", + name, mdef.name, name) .. + Strings("Delete an older\nmove to make room\vfor %s?", mdef.name), nil, { choice = function(yes) if yes then @@ -76,7 +77,7 @@ function MoveLearnMenu:update(dt) -- HMCantDeleteText, then back to the forget list local TextBox = require("src.render.TextBox") self.game.stack:push(TextBox.new(self.game, - "HM techniques\ncan't be deleted!")) + Strings("HM techniques\ncan't be deleted!"))) return end local mdef = self.game.data.moves[self.newMoveId] @@ -96,7 +97,7 @@ function MoveLearnMenu:confirmAbandon() local mdef = game.data.moves[self.newMoveId] self.selecting = false game.stack:push(TextBox.new(game, - ("Abandon learning\n%s?"):format(mdef.name), nil, { + Strings("Abandon learning\n%s?", mdef.name), nil, { choice = function(yes) if yes then self:finish(false) else self:enter() end end, @@ -113,11 +114,11 @@ function MoveLearnMenu:finish(learned) local msg if learned then -- OneTwoAndText/PoofText/ForgotAndText - msg = ("1, 2 and... Poof!\f%s forgot\n%s!\fAnd...\f%s learned\n%s!") - :format(name, self.forgot, name, mdef.name) + msg = Strings("1, 2 and... Poof!\f%s forgot\n%s!\fAnd...\f%s learned\n%s!", + name, self.forgot, name, mdef.name) else -- DidNotLearnText - msg = ("%s\ndid not learn\v%s!"):format(name, mdef.name) + msg = Strings("%s\ndid not learn\v%s!", name, mdef.name) end game.stack:push(TextBox.new(game, msg, function() if self.onDone then self.onDone(learned) end @@ -133,12 +134,12 @@ function MoveLearnMenu:draw() for i, mv in ipairs(self.mon.moves) do Font.draw(self.game.data.moves[mv.id].name, 48, (5 + i) * 8) end - Font.draw("CANCEL", 48, (6 + #self.mon.moves) * 8) + Font.draw(Strings("CANCEL"), 48, (6 + #self.mon.moves) * 8) Font.drawCode(CURSOR, 40, (5 + self.index) * 8) -- WhichMoveToForgetText in the bottom dialogue box Font.drawBox(0, 12, 20, 6) - Font.draw("Which move should", 8, 14 * 8) - Font.draw("be forgotten?", 8, 16 * 8) + Font.draw(Strings("Which move should"), 8, 14 * 8) + Font.draw(Strings("be forgotten?"), 8, 16 * 8) love.graphics.setColor(1, 1, 1, 1) end diff --git a/src/ui/NamingScreen.lua b/src/ui/NamingScreen.lua index 0bd72916..366d484a 100644 --- a/src/ui/NamingScreen.lua +++ b/src/ui/NamingScreen.lua @@ -13,6 +13,7 @@ local Font = require("src.render.Font") local Runtime = require("src.mods.Runtime") local Sound = require("src.core.Sound") local Theme = require("src.ui.Theme") +local Strings = require("src.core.Strings") local NamingScreen = {} NamingScreen.__index = NamingScreen @@ -64,7 +65,7 @@ function NamingScreen.new(game, opts) opts = opts or {} local self = setmetatable({}, NamingScreen) self.game = game - self.title = opts.title or "YOUR NAME?" + self.title = opts.title or Strings("YOUR NAME?") self.presets = opts.presets self.maxLen = opts.maxLen or 7 self.default = opts.default @@ -78,7 +79,7 @@ end function NamingScreen:enter() if self.presets and #self.presets > 0 then local Menu = require("src.ui.Menu") - local items = { { label = "NEW NAME" } } + local items = { { label = Strings("NEW NAME") } } for _, preset in ipairs(self.presets) do table.insert(items, { label = preset, diff --git a/src/ui/OakSpeech.lua b/src/ui/OakSpeech.lua index 47409f6f..bf4d2a4b 100644 --- a/src/ui/OakSpeech.lua +++ b/src/ui/OakSpeech.lua @@ -7,12 +7,14 @@ -- mod can insertBefore("name_player", ...) without counting indices. -- Calls onDone() after popping itself. +local Assets = require("src.render.Assets") local Sound = require("src.core.Sound") local Music = require("src.core.Music") local Logger = require("src.core.Logger") local Runtime = require("src.mods.Runtime") local TextBox = require("src.render.TextBox") local Font = require("src.render.Font") +local Strings = require("src.core.Strings") local OakSpeech = {} OakSpeech.__index = OakSpeech @@ -51,9 +53,11 @@ local function textOr(game, key) return (t and t[key]) or FALLBACKS[key] end +-- through Assets.resolve so an enabled mod's overrides/ shadows these the +-- same way it shadows every other generated asset local function tryImage(path) if not path then return nil end - local ok, img = pcall(love.graphics.newImage, path) + local ok, img = pcall(love.graphics.newImage, Assets.resolve(path)) return ok and img or nil end @@ -101,7 +105,9 @@ function OakSpeech.resolvePic(game, desc, speech) if speech and speech.playerPic and not desc.path then return speech.playerPic, false end - return tryImage(desc.path or "assets/generated/trainer_card/red.png"), false + if desc.path then return tryImage(desc.path), false end + return tryImage(require("src.pokemon.Sprites").playerPath( + game.data, "front", { kind = "intro" })), false elseif t == "image" then return tryImage(desc.path), desc.flip and true or false elseif t == "sprite" then @@ -140,7 +146,7 @@ function OakSpeech.defaultSteps(speech) id = "name_player", kind = "name", who = "player", - title = "YOUR NAME?", + title = Strings("YOUR NAME?"), presetsWho = "player", presetsFallback = { "RED", "ASH", "JACK" }, }, @@ -154,7 +160,7 @@ function OakSpeech.defaultSteps(speech) id = "name_rival", kind = "name", who = "rival", - title = "HIS NAME?", + title = Strings("HIS NAME?"), presetsWho = "rival", presetsFallback = { "BLUE", "GARY", "JOHN" }, }, @@ -220,7 +226,8 @@ function OakSpeech.new(game, onDone) self.nameLen = constants.playerNameLength or 7 -- RedPicFront (gfx/player/red.png, shared with the trainer card) and -- the ShrinkPic1/ShrinkPic2 frames (gfx/player/shrink{1,2}.png) - self.playerPic = tryImage("assets/generated/trainer_card/red.png") + self.playerPic = tryImage(require("src.pokemon.Sprites").playerPath( + game.data, "front", { kind = "intro" })) self.shrinkPic1 = tryImage(oakGfx.shrink1 or "assets/generated/intro/shrink1.png") self.shrinkPic2 = tryImage(oakGfx.shrink2 @@ -337,7 +344,7 @@ function OakSpeech:runStep(step) self.picFlip = true self:revealPic("wipe", function() Sound.playCry(self.game.data, self.demoSpecies) - self:say("_OakSpeechText2A", function() self:advance() end) + self:say(Strings("_OakSpeechText2A"), function() self:advance() end) end) elseif kind == "name" then local who = step.who or "player" @@ -345,7 +352,7 @@ function OakSpeech:runStep(step) or namePresets(self.game, step.presetsWho or who, step.presetsFallback or { "RED" }) require("src.ui.Screens").push(self.game, "NamingScreen", { - title = step.title or (who == "rival" and "HIS NAME?" or "YOUR NAME?"), + title = step.title or (who == "rival" and "HIS NAME?" or Strings("YOUR NAME?")), presets = presets, maxLen = step.maxLen or self.nameLen, onDone = function(name) diff --git a/src/ui/OptionsMenu.lua b/src/ui/OptionsMenu.lua index fe7f8463..4a614750 100644 --- a/src/ui/OptionsMenu.lua +++ b/src/ui/OptionsMenu.lua @@ -22,6 +22,7 @@ local Logger = require("src.core.Logger") local Runtime = require("src.mods.Runtime") local OptionRows = require("src.ui.OptionRows") local Renderer = require("src.render.Renderer") +local Strings = require("src.core.Strings") local OptionsMenu = {} OptionsMenu.__index = OptionsMenu @@ -116,14 +117,14 @@ local function sameRows(_, rows) return rows end -- ladder's, so the save.options mutations are unchanged local function buildRows(game) local rows = { - { id = "textSpeed", label = "TEXT SPEED", + { id = "textSpeed", label = Strings("TEXT SPEED"), value = function(g) return SPEEDS[speedIndex(g)][2] end, step = function(g) local i = speedIndex(g) % #SPEEDS + 1 g.save.options.textSpeed = SPEEDS[i][1] return true end }, - { id = "animations", label = "BATTLE ANIMATION", + { id = "animations", label = Strings("BATTLE ANIMATION"), value = function(g) return g.save.options.animations == false and "OFF" or "ON" end, @@ -132,7 +133,7 @@ local function buildRows(game) o.animations = o.animations == false and true or false return true end }, - { id = "battleStyle", label = "BATTLE STYLE", + { id = "battleStyle", label = Strings("BATTLE STYLE"), value = function(g) return g.save.options.battleStyle == "set" and "SET" or "SHIFT" end, @@ -141,7 +142,7 @@ local function buildRows(game) o.battleStyle = o.battleStyle == "set" and "shift" or "set" return true end }, - { id = "ruleset", label = "RULESET", + { id = "ruleset", label = Strings("RULESET"), value = function(g) return rulesetName(g) end, step = function(g, dir) local ids = rulesetIds(g) @@ -150,7 +151,7 @@ local function buildRows(game) g.save.options.ruleset = ids[wrapIndex(i - 1 + dir, #ids) + 1] return true end }, - { id = "musicVol", label = "MUSIC VOL", + { id = "musicVol", label = Strings("MUSIC VOL"), value = function(g) return volLabel(g.save.options.musicVol) end, step = function(g, dir) local o = g.save.options @@ -158,7 +159,7 @@ local function buildRows(game) require("src.core.Music").setVolumeLevel(o.musicVol) return true end }, - { id = "sfxVol", label = "SFX VOL", + { id = "sfxVol", label = Strings("SFX VOL"), value = function(g) return volLabel(g.save.options.sfxVol) end, step = function(g, dir) local o = g.save.options @@ -166,7 +167,7 @@ local function buildRows(game) require("src.core.Sound").setVolumeLevel(o.sfxVol) return true end }, - { id = "musicFilter", label = "MUSIC FILTER", + { id = "musicFilter", label = Strings("MUSIC FILTER"), value = function(g) return FILTERS[(g.save.options.musicFilter or 0) + 1] end, @@ -176,7 +177,7 @@ local function buildRows(game) require("src.core.Music").setFilterLevel(o.musicFilter) return true end }, - { id = "colors", label = "COLORS", + { id = "colors", label = Strings("COLORS"), value = function(g) return PaletteFX.modeLabel(g.save.options.colors or "gbc") end, @@ -188,7 +189,7 @@ local function buildRows(game) PaletteFX.setMode(o.colors) return true end }, - { id = "tilt", label = "TILT", + { id = "tilt", label = Strings("TILT"), value = function(g) return Tilt.levelLabel(g.save.options.tilt or 0) end, step = function(g, dir) local o = g.save.options @@ -205,7 +206,7 @@ local function buildRows(game) end return true end }, - { id = "gbcfx", label = "GBC FX", + { id = "gbcfx", label = Strings("GBC FX"), value = function(g) return GBCFX.levelLabel(g.save.options.gbcfx or 0) end, @@ -215,7 +216,7 @@ local function buildRows(game) GBCFX.setLevel(o.gbcfx) return true end }, - { id = "zoom", label = "ZOOM", + { id = "zoom", label = Strings("ZOOM"), value = function(g) return Zoom.offsetLabel(g.save.options.zoom or 0) end, @@ -230,7 +231,7 @@ local function buildRows(game) Zoom.offset = off return true end }, - { id = "voidFill", label = "VOID FILL", + { id = "voidFill", label = Strings("VOID FILL"), value = function(g) return TileRenderer.voidFillLabel(g.save.options.voidFill) end, @@ -246,7 +247,7 @@ local function buildRows(game) TileRenderer.setVoidFill(o.voidFill) return true end }, - { id = "videoMode", label = "VIDEO MODE", + { id = "videoMode", label = Strings("VIDEO MODE"), value = function(g) return VideoMode.modeLabel(g.save.options.videoMode) end, @@ -259,7 +260,7 @@ local function buildRows(game) -- hard render cap (issue #88): bounds the present rate so a -- driver-forced vsync-off run cannot spin at thousands of FPS. Logic -- is fixed-step off dt, so this touches presentation only. - { id = "fpsCap", label = "MAX FPS", + { id = "fpsCap", label = Strings("MAX FPS"), value = function(g) return FrameCap.label(g.save.options.fpsCap) end, @@ -271,7 +272,7 @@ local function buildRows(game) end }, -- fast-forward the logic clock only; music and sfx keep their tempo -- (src/core/GameSpeed.lua), so this is safe to leave on - { id = "speed", label = "GAME SPEED", + { id = "speed", label = Strings("GAME SPEED"), value = function(g) return GameSpeed.levelLabel(g.save.options.speed) end, @@ -282,17 +283,17 @@ local function buildRows(game) end }, -- the manager's discoverable home (18-mod-manager-ux); inert until -- opened, so the row costs a vanilla install nothing - { id = "mods", label = "MODS", + { id = "mods", label = Strings("MODS"), value = function(g) local status = g.modStatus or {} - return ("%d INSTALLED"):format(#(status.available or {})) + return Strings("%d INSTALLED", #(status.available or {})) end, activate = function(g) require("src.ui.Screens").push(g, "ManagerState") end }, -- rebinding UI (gap C2, 12-ui-extensibility 4.4); captured inputs -- live in options.bindings, so the row costs a vanilla install nothing - { id = "controls", label = "CONTROLS", + { id = "controls", label = Strings("CONTROLS"), activate = function(g) require("src.ui.Screens").push(g, "BindingsMenu") end }, diff --git a/src/ui/PartyMenu.lua b/src/ui/PartyMenu.lua index e3d5f67b..9bc478fb 100644 --- a/src/ui/PartyMenu.lua +++ b/src/ui/PartyMenu.lua @@ -17,6 +17,7 @@ local Screens = require("src.ui.Screens") local Theme = require("src.ui.Theme") local FieldDefaults = require("src.world.FieldDefaults") local Map = require("src.world.Map") +local Strings = require("src.core.Strings") local PartyMenu = {} PartyMenu.__index = PartyMenu @@ -204,7 +205,7 @@ function PartyMenu:update(dt) self.game.save.flashLit = true self.game.stack:push(TextBox.new(self.game, self.game.data.text._FlashLightsAreaText - or "A blinding FLASH\nlights the area!", function() + or Strings("A blinding FLASH\nlights the area!"), function() self.game.stack:push(Transition.whiteFlash(self.game)) end)) return @@ -247,7 +248,7 @@ function PartyMenu:update(dt) current = "_CurrentTooFastText", no_place = "_SurfingNoPlaceToGetOffText" })[reason] or "_NoSurfingHereText" - local txt = (self.game.data.text[key] or "No SURFing here!") + local txt = (self.game.data.text[key] or Strings("No SURFing here!")) :gsub("{RAM:wNameBuffer}", mon.nickname or def.name) if reason == "no_place" then -- .cannotStopSurfing prints _SurfingNoPlaceToGetOffText but @@ -278,7 +279,7 @@ function PartyMenu:update(dt) local def = self.game.data.pokemon[mon.species] local key = (reason == "no_badge") and "_NewBadgeRequiredText" or "_NothingToCutText" - local txt = (self.game.data.text[key] or "Nothing to CUT!") + local txt = (self.game.data.text[key] or Strings("Nothing to CUT!")) :gsub("{RAM:wNameBuffer}", mon.nickname or def.name) self.game.stack:push(TextBox.new(self.game, txt)) return -- .loop: submenu stays open behind the message @@ -300,9 +301,9 @@ function PartyMenu:update(dt) self.game.stack:pop() -- close the party menu (jp .goBackToMap) ow.strengthActive = true local t1 = (self.game.data.text._UsedStrengthText - or "{RAM:wNameBuffer} used\nSTRENGTH."):gsub("{RAM:wNameBuffer}", name) + or Strings("{RAM:wNameBuffer} used\nSTRENGTH.")):gsub("{RAM:wNameBuffer}", name) local t2 = (self.game.data.text._CanMoveBouldersText - or "{RAM:wNameBuffer} can\nmove boulders."):gsub("{RAM:wNameBuffer}", name) + or Strings("{RAM:wNameBuffer} can\nmove boulders.")):gsub("{RAM:wNameBuffer}", name) self.game.stack:push(TextBox.new(self.game, t1, function() self.game.stack:push(TextBox.new(self.game, t2, function() self.game.stack:push(Transition.whiteFlash(self.game)) @@ -348,7 +349,7 @@ function PartyMenu:update(dt) or user.hp <= heal then self.softboiledFrom = nil local TextBox = require("src.render.TextBox") - self.game.stack:push(TextBox.new(self.game, "It won't have\nany effect.")) + self.game.stack:push(TextBox.new(self.game, Strings("It won't have\nany effect."))) else user.hp = user.hp - heal mon.hp = math.min(mon.stats.hp, mon.hp + heal) @@ -357,7 +358,7 @@ function PartyMenu:update(dt) local def = self.game.data.pokemon[mon.species] local TextBox = require("src.render.TextBox") self.game.stack:push(TextBox.new(self.game, - ("%s's HP\nwas restored!"):format(mon.nickname or def.name))) + Strings("%s's HP\nwas restored!", mon.nickname or def.name))) end elseif self.swapFrom then if self.swapFrom ~= self.index then @@ -375,14 +376,14 @@ function PartyMenu:update(dt) local ow = self.game.overworld if self.battle and self.onSwitch then -- SwitchStatsCancelText (core.asm PartyMenuOrRockOrRun) - items = { { label = "SWITCH", action = "battle_switch" }, - { label = "STATS", action = "stats" }, - { label = "CANCEL", action = "cancel" } } + items = { { label = Strings("SWITCH"), action = "battle_switch" }, + { label = Strings("STATS"), action = "stats" }, + { label = Strings("CANCEL"), action = "cancel" } } else -- STATS/SWITCH plus this mon's field moves (start_sub_menus.asm -- builds the same dynamic list) - items = { { label = "STATS", action = "stats" }, - { label = "SWITCH", action = "switch" } } + items = { { label = Strings("STATS"), action = "stats" }, + { label = Strings("SWITCH"), action = "switch" } } -- Field moves (HMs/TMs) are usable out of battle even when the mon -- is fainted -- Gen 1 does not require HP for Cut/Fly/Surf/etc. -- Battle still excludes this list via `not self.battle`. Softboiled @@ -395,35 +396,35 @@ function PartyMenu:update(dt) for _, mv in ipairs(mon.moves) do if mv.id == "FLY" and outside and self.game.save.inventory.THUNDERBADGE then - table.insert(items, { label = "FLY", action = "fly" }) + table.insert(items, { label = Strings("FLY"), action = "fly" }) elseif mv.id == "FLASH" and ow.dark and self.game.save.inventory.BOULDERBADGE then - table.insert(items, { label = "FLASH", action = "flash" }) + table.insert(items, { label = Strings("FLASH"), action = "flash" }) elseif mv.id == "CUT" and self.game.save.inventory.CASCADEBADGE then -- CUT/SURF/STRENGTH are party-menu field moves too -- (start_sub_menus.asm .outOfBattleMovePointers); listed here -- with the same list-time badge filter this file already uses -- for FLY/FLASH. The facing-tile/activation check happens on -- selection (useCutFieldMove/useSurfFieldMove). - table.insert(items, { label = "CUT", action = "cut" }) + table.insert(items, { label = Strings("CUT"), action = "cut" }) elseif mv.id == "SURF" and self.game.save.inventory.SOULBADGE then - table.insert(items, { label = "SURF", action = "surf" }) + table.insert(items, { label = Strings("SURF"), action = "surf" }) elseif mv.id == "STRENGTH" and self.game.save.inventory.RAINBOWBADGE then - table.insert(items, { label = "STRENGTH", action = "strength" }) + table.insert(items, { label = Strings("STRENGTH"), action = "strength" }) elseif mv.id == "SOFTBOILED" then - table.insert(items, { label = "SOFTBOILED", action = "softboiled" }) + table.insert(items, { label = Strings("SOFTBOILED"), action = "softboiled" }) elseif mv.id == "TELEPORT" and outside then -- TELEPORT works only OUTDOORS (start_sub_menus.asm -- .teleport -> CheckIfInOutsideMap); dark maps don't -- block it - table.insert(items, { label = "TELEPORT", action = "escape" }) + table.insert(items, { label = Strings("TELEPORT"), action = "escape" }) elseif mv.id == "DIG" and DIG_TILESETS[ow.map.def.tileset] and ow.map.id ~= "AGATHAS_ROOM" then -- DIG runs ItemUseEscapeRope (.dig sets wCurItem = -- ESCAPE_ROPE): usable in the dungeon tilesets of -- escape_rope_tilesets.asm minus Agatha's room, even in -- the dark (Rock Tunnel) - table.insert(items, { label = "DIG", action = "escape" }) + table.insert(items, { label = Strings("DIG"), action = "escape" }) end end end @@ -456,13 +457,13 @@ function PartyMenu:bottomMessage() return "Use on which one?" elseif self.tmhm then return self.game.data.text._PartyMenuUseTMText - or "Use TM on which\nPOKéMON?" + or Strings("Use TM on which\nPOKéMON?") elseif self.battle then return self.game.data.text._PartyMenuBattleText - or "Bring out which\nPOKéMON?" + or Strings("Bring out which\nPOKéMON?") else return self.game.data.text._PartyMenuNormalText - or "Choose a POKéMON." + or Strings("Choose a POKéMON.") end end @@ -480,7 +481,7 @@ function PartyMenu:draw() love.graphics.setColor(0, 0, 0, 1) local party = self.party or self.game.save.party if #party == 0 then - Font.draw("No POKéMON!", 16, 64) + Font.draw(Strings("No POKéMON!"), 16, 64) end local HudTiles = require("src.render.HudTiles") for i, mon in ipairs(party) do @@ -512,13 +513,13 @@ function PartyMenu:draw() end -- right-aligned so the shorter "ABLE" shares "NOT ABLE"'s right edge if can then - Font.draw("ABLE", 120, y + 8) + Font.draw(Strings("ABLE"), 120, y + 8) else - Font.draw("NOT ABLE", 88, y + 8) + Font.draw(Strings("NOT ABLE"), 88, y + 8) end else if mon.hp <= 0 then - Font.draw("FNT", 136, y) + Font.draw(Strings("FNT"), 136, y) elseif mon.status then Font.draw(mon.status, 136, y) end @@ -528,17 +529,22 @@ function PartyMenu:draw() love.graphics.setColor(0, 0, 0, 1) Font.draw(("%3d/%3d"):format(mon.hp, mon.stats.hp), 104, y + 8) end + -- home/pokemon.asm PartyMenuInit seeds wTopMenuItemY/X with 1/0, so the + -- cursor sits on the entry's *second* tile row (the level/HP line), + -- level with the middle of the two-row icon -- not on the name row that + -- entryY returns. Drawing it at y put it a tile too high (#278). + local cursorY = y + 8 if i == self.index then - Font.drawCode(Theme.cursor, 0, y) + Font.drawCode(Theme.cursor, 0, cursorY) end if i == self.swapFrom or i == self.softboiledFrom then - Font.drawCode(Theme.cursorHollow, 0, y) -- the unfilled swap arrow + Font.drawCode(Theme.cursorHollow, 0, cursorY) -- the unfilled swap arrow end end if self.swapFrom then - Font.draw("Move to where?", 8, 136) + Font.draw(Strings("Move to where?"), 8, 136) elseif self.softboiledFrom then - Font.draw("Use on which one?", 8, 136) + Font.draw(Strings("Use on which one?"), 8, 136) elseif self.tmhm then -- "Use TM on which\nPOKeMON?" in the standard bottom text box -- (party_menu.asm keeps the message box for the TM/HM menu); box + line @@ -546,14 +552,14 @@ function PartyMenu:draw() Font.drawBox(0, 12, 20, 6) love.graphics.setColor(0, 0, 0, 1) local prompt = self.game.data.text._PartyMenuUseTMText - or "Use TM on which\nPOKéMON?" + or Strings("Use TM on which\nPOKéMON?") local ly = 112 for line in (prompt .. "\n"):gmatch("([^\n]*)\n") do Font.draw(line, 8, ly) ly = ly + 16 end elseif self.pickOnly then - Font.draw("Use on which one?", 8, 136) + Font.draw(Strings("Use on which one?"), 8, 136) else -- default field party menu (StartMenu) and the battle voluntary-switch -- (BattleState:openParty): Gen1 prints PartyMenuNormalText / PartyMenuBattleText diff --git a/src/ui/PlayerPC.lua b/src/ui/PlayerPC.lua index 06a21f53..a2e3944d 100644 --- a/src/ui/PlayerPC.lua +++ b/src/ui/PlayerPC.lua @@ -10,6 +10,7 @@ local ChoiceBox = require("src.ui.ChoiceBox") local ListMenu = require("src.ui.ListMenu") local Menu = require("src.ui.Menu") local Sound = require("src.core.Sound") +local Strings = require("src.core.Strings") local PlayerPC = {} @@ -76,14 +77,14 @@ local function withdraw(game) askQuantity(game, list, pc[item.value] or 1, item.value, function(qty) local Bag = require("src.inventory.Bag") if not Bag.add(game.save, item.value, qty) then - list.footer = "You can't carry\nany more items." + list.footer = Strings("You can't carry\nany more items.") return end pc[item.value] = pc[item.value] - qty if pc[item.value] <= 0 then pc[item.value] = nil end refreshRow(list, pc, item.value) Sound.play(game.data, "Withdraw_Deposit") - list.footer = ("Withdrew\n%s."):format(itemName(game, item.value)) + list.footer = Strings("Withdrew\n%s.", itemName(game, item.value)) end) end, })) @@ -112,14 +113,14 @@ local function deposit(game) onChoose = function(item, list) askQuantity(game, list, inv[item.value] or 1, item.value, function(qty) if pcFull(game, pc, item.value) then - list.footer = "No room left to\nstore items." + list.footer = Strings("No room left to\nstore items.") return end require("src.inventory.Bag").remove(game.save, item.value, qty) pc[item.value] = (pc[item.value] or 0) + qty refreshRow(list, inv, item.value) Sound.play(game.data, "Withdraw_Deposit") - list.footer = ("%s was\nstored via PC."):format(itemName(game, item.value)) + list.footer = Strings("%s was\nstored via PC.", itemName(game, item.value)) end) end, })) @@ -132,7 +133,7 @@ local function toss(game) onChoose = function(item, list) local def = game.data.items[item.value] if (def and def.keyItem) or item.value:find("^HM_") then - list.footer = "That's too impor-\ntant to toss!" + list.footer = Strings("That's too impor-\ntant to toss!") return end local QuantityBox = require("src.ui.QuantityBox") @@ -140,13 +141,13 @@ local function toss(game) max = pc[item.value] or 1, onDone = function(qty) if not qty then return end - list.footer = ("Toss %s?"):format(itemName(game, item.value)) + list.footer = Strings("Toss %s?", itemName(game, item.value)) game.stack:push(ChoiceBox.new(game, function(yes) if yes then pc[item.value] = pc[item.value] - qty if pc[item.value] <= 0 then pc[item.value] = nil end refreshRow(list, pc, item.value) - list.footer = ("Threw away %s."):format(itemName(game, item.value)) + list.footer = Strings("Threw away %s.", itemName(game, item.value)) else list.footer = nil end @@ -163,10 +164,10 @@ function PlayerPC.new(game) -- keepOpen so B in the item lists returns here instead of dropping the -- whole PC session (players_pc.asm re-shows the PC menu); same pattern -- as BoxMenu's rows - { label = "WITHDRAW ITEM", keepOpen = true, onSelect = function() withdraw(game) end }, - { label = "DEPOSIT ITEM", keepOpen = true, onSelect = function() deposit(game) end }, - { label = "TOSS ITEM", keepOpen = true, onSelect = function() toss(game) end }, - { label = "LOG OFF" }, + { label = Strings("WITHDRAW ITEM"), keepOpen = true, onSelect = function() withdraw(game) end }, + { label = Strings("DEPOSIT ITEM"), keepOpen = true, onSelect = function() deposit(game) end }, + { label = Strings("TOSS ITEM"), keepOpen = true, onSelect = function() toss(game) end }, + { label = Strings("LOG OFF") }, -- silent PC session (BIT_NO_MENU_BUTTON_SOUND); players_pc.asm -- PlayersPCMenu TextBoxBorder (0,0) b=8 c=14 → 16x10 }, { tx = 0, ty = 0, tw = 16, th = 10, noSound = true }) diff --git a/src/ui/PokedexMenu.lua b/src/ui/PokedexMenu.lua index 7902c93c..f1c07571 100644 --- a/src/ui/PokedexMenu.lua +++ b/src/ui/PokedexMenu.lua @@ -1,6 +1,7 @@ -- Minimal Pokédex: dex-ordered list with seen/owned markers. local ListMenu = require("src.ui.ListMenu") +local Strings = require("src.core.Strings") local PokedexMenu = {} @@ -46,7 +47,7 @@ function PokedexMenu.new(game, opts) end end local list = ListMenu.new(game, "POKéDEX", items, { - footer = ("SEEN %d OWNED %d"):format(seen, owned), + footer = Strings("SEEN %d OWNED %d", seen, owned), pageJump = true, -- Left/Right page jumps like the original onCancel = opts.onCancel, -- B returns to the start menu when opened from it onChoose = function(item) @@ -57,16 +58,16 @@ function PokedexMenu.new(game, opts) local Menu = require("src.ui.Menu") local Screens = require("src.ui.Screens") game.stack:push(Menu.new(game, { - { label = "DATA", onSelect = function() + { label = Strings("DATA"), onSelect = function() Screens.push(game, "DexEntryMenu", item.value) end }, - { label = "CRY", keepOpen = true, onSelect = function() + { label = Strings("CRY"), keepOpen = true, onSelect = function() require("src.core.Sound").playCry(game.data, item.value) end }, - { label = "AREA", onSelect = function() + { label = Strings("AREA"), onSelect = function() Screens.push(game, "TownMap", { nestSpecies = item.value }) end }, - { label = "QUIT" }, + { label = Strings("QUIT") }, }, { tx = 12, ty = 8, tw = 8, th = 10 })) end, }) diff --git a/src/ui/QuarantineReport.lua b/src/ui/QuarantineReport.lua index 5e6d45d7..741114ed 100644 --- a/src/ui/QuarantineReport.lua +++ b/src/ui/QuarantineReport.lua @@ -6,6 +6,7 @@ local Font = require("src.render.Font") local SaveData = require("src.core.SaveData") +local Strings = require("src.core.Strings") local QuarantineReport = {} QuarantineReport.__index = QuarantineReport @@ -38,12 +39,12 @@ local function buildLines(report, meta) end local rows = {} for _, mon in ipairs(report.lostMons or {}) do - rows[#rows + 1] = ("%s (%s)"):format(mon.species or "?", mon.from or "?") + rows[#rows + 1] = Strings("%s (%s)", mon.species or "?", mon.from or "?") end section(lines, "Moved to LOST box:", rows) rows = {} for _, item in ipairs(report.lostItems or {}) do - rows[#rows + 1] = ("%s x%d"):format(item.id or "?", item.count or 1) + rows[#rows + 1] = Strings("%s x%d", item.id or "?", item.count or 1) end section(lines, "Items removed:", rows) rows = {} @@ -51,16 +52,16 @@ local function buildLines(report, meta) if map.to then rows[#rows + 1] = ("%s>%s"):format(map.id or "?", map.to) else - rows[#rows + 1] = ("%s (%s)"):format(map.id or "?", map.field or "?") + rows[#rows + 1] = Strings("%s (%s)", map.id or "?", map.field or "?") end end section(lines, "Location reset:", rows) rows = {} for _, mon in ipairs(report.restoredMons or {}) do - rows[#rows + 1] = ("%s to box %d"):format(mon.species or "?", mon.box or 0) + rows[#rows + 1] = Strings("%s to box %d", mon.species or "?", mon.box or 0) end for _, item in ipairs(report.restoredItems or {}) do - rows[#rows + 1] = ("%s x%d"):format(item.id or "?", item.count or 1) + rows[#rows + 1] = Strings("%s x%d", item.id or "?", item.count or 1) end section(lines, "Restored:", rows) local notice = SaveData.modsDiffNotice(report.modsDiff, meta) @@ -113,7 +114,7 @@ function QuarantineReport:draw() love.graphics.rectangle("fill", 0, 0, 160, 144) Font.drawBox(0, 0, 20, 18) love.graphics.setColor(0, 0, 0, 1) - Font.draw("LOAD REPORT", 8, 8) + Font.draw(Strings("LOAD REPORT"), 8, 8) for row = 1, VISIBLE do local line = self.lines[self.offset + row] if line then Font.draw(line, 8, 12 + row * 8) end @@ -121,7 +122,7 @@ function QuarantineReport:draw() if self.offset < self:maxOffset() then Font.drawCode(require("src.ui.Theme").moreArrow, 144, 124) end - Font.draw("A:CONTINUE", 8, 130) + Font.draw(Strings("A:CONTINUE"), 8, 130) love.graphics.setColor(1, 1, 1, 1) end diff --git a/src/ui/ShopMenu.lua b/src/ui/ShopMenu.lua index 39814e18..9d24a8f0 100644 --- a/src/ui/ShopMenu.lua +++ b/src/ui/ShopMenu.lua @@ -12,6 +12,7 @@ local ChoiceBox = require("src.ui.ChoiceBox") local ListMenu = require("src.ui.ListMenu") local Menu = require("src.ui.Menu") local QuantityBox = require("src.ui.QuantityBox") +local Strings = require("src.core.Strings") local ShopMenu = {} @@ -33,7 +34,7 @@ local function buy(game, stock) end local greet = txt(game, "_PokemartBuyingGreetingText", "Take your time.") local notEnough = txt(game, "_PokemartNotEnoughMoneyText", - "You don't have\nenough money.") + Strings("You don't have\nenough money.")) local list list = ListMenu.new(game, "BUY", items, { dialogue = true, @@ -56,7 +57,7 @@ local function buy(game, stock) end local cost = qty * def.price -- _PokemartTellBuyPriceText + yes/no confirm - list.footer = ("%s?\nThat will be\n¥%d. OK?"):format(def.name, cost) + list.footer = Strings("%s?\nThat will be\n¥%d. OK?", def.name, cost) game.stack:push(ChoiceBox.new(game, function(yes) if not yes then list.footer = greet @@ -68,13 +69,13 @@ local function buy(game, stock) end if not Bag.add(game.save, item.value, qty) then list.footer = txt(game, "_PokemartItemBagFullText", - "You can't carry\nany more items.") + Strings("You can't carry\nany more items.")) return end require("src.core.Sound").play(game.data, "Purchase") game.save.money = game.save.money - cost list.footer = txt(game, "_PokemartBoughtItemText", - "Here you are!\nThank you!") + Strings("Here you are!\nThank you!")) end)) end, })) @@ -112,7 +113,7 @@ local function sell(game) -- ITEM_NONE "0" from Blue's House before that pickup was fixed (#11). if not def or def.keyItem or item.value:find("^HM_") then list.footer = txt(game, "_PokemartUnsellableItemText", - "I can't put a\nprice on that.") + Strings("I can't put a\nprice on that.")) return end local unit = math.floor(def.price / 2) @@ -125,7 +126,7 @@ local function sell(game) return end -- _PokemartTellSellPriceText + yes/no confirm - list.footer = ("I can pay you\n¥%d for that."):format(unit * qty) + list.footer = Strings("I can pay you\n¥%d for that.", unit * qty) game.stack:push(ChoiceBox.new(game, function(yes) if not yes then list.footer = greet @@ -152,9 +153,9 @@ function ShopMenu.new(game, stock, onQuit) -- keepOpen: the mart menu stays underneath its list so closing the -- list lands back here; only QUIT (or B) leaves and fires onQuit local menu = Menu.new(game, { - { label = "BUY", keepOpen = true, onSelect = function() buy(game, stock) end }, - { label = "SELL", keepOpen = true, onSelect = function() sell(game) end }, - { label = "QUIT", onSelect = onQuit }, + { label = Strings("BUY"), keepOpen = true, onSelect = function() buy(game, stock) end }, + { label = Strings("SELL"), keepOpen = true, onSelect = function() sell(game) end }, + { label = Strings("QUIT"), onSelect = onQuit }, }, { tx = 0, ty = 0, tw = 8, th = 8 }) menu.onCancel = onQuit return menu diff --git a/src/ui/SlotMachine.lua b/src/ui/SlotMachine.lua index 4f218acb..353b414a 100644 --- a/src/ui/SlotMachine.lua +++ b/src/ui/SlotMachine.lua @@ -45,6 +45,7 @@ local Font = require("src.render.Font") local Sound = require("src.core.Sound") +local Strings = require("src.core.Strings") local SlotMachine = {} SlotMachine.__index = SlotMachine @@ -356,7 +357,7 @@ function SlotMachine:resolveWin(win) -- SlotReward300Func prints "Yeah!" (text_pause) before the flash; the port -- shows it in the box while the screen flashes. LinedUpText follows. self.yeah = (sym == "7") - self.message = ("%s lined up!\nScored %d coins!"):format(sym, pay) + self.message = Strings("%s lined up!\nScored %d coins!", sym, pay) -- .flashScreenLoop: flip rBGP, wait 5 frames, b times. The coins are not -- credited until the player dismisses the "lined up" text (see startPayout). self.stage = "flash" @@ -394,7 +395,7 @@ end -- delay then CloseTextDisplay), otherwise ask "One more go?". function SlotMachine:afterSpin() if coins(self) == 0 then - self.message = "Darn!\nRan out of coins!" + self.message = Strings("Darn!\nRan out of coins!") self.stage = "message" self.afterMessage = nil self.exitTimer = 60 @@ -515,7 +516,7 @@ function SlotMachine:update(dt) self.bet = 3 - self.betIndex if input:wasPressed("a") then if coins(self) < self.bet then - self.message = "Not enough\ncoins!" + self.message = Strings("Not enough\ncoins!") self.afterMessage = "bet" self.stage = "message" return @@ -671,8 +672,8 @@ function SlotMachine:drawBottom() local by = self.stage == "intro" and 6 or 11 Font.drawBox(13, by, 6, 5) love.graphics.setColor(0, 0, 0, 1) - Font.draw("YES", 15 * 8, (by + 1) * 8) - Font.draw("NO", 15 * 8, (by + 2) * 8) + Font.draw(Strings("YES"), 15 * 8, (by + 1) * 8) + Font.draw(Strings("NO"), 15 * 8, (by + 2) * 8) Font.drawCode(0xED, 14 * 8, (by + 1 + (self.yesno == 1 and 0 or 1)) * 8) end love.graphics.setColor(1, 1, 1, 1) @@ -723,8 +724,8 @@ function SlotMachine:drawPlain(art) love.graphics.setColor(1, 1, 1, 1) love.graphics.rectangle("fill", 0, 0, 160, 144) love.graphics.setColor(0, 0, 0, 1) - Font.draw("SLOT MACHINE", 32, 4) - Font.draw(("COINS %4d"):format(coins(self)), 8, 16) + Font.draw(Strings("SLOT MACHINE"), 32, 4) + Font.draw(Strings("COINS %4d", coins(self)), 8, 16) if art then self:drawReels(art) end love.graphics.setColor(0, 0, 0, 1) Font.draw(">", 12, 56) diff --git a/src/ui/StartMenu.lua b/src/ui/StartMenu.lua index 7a0db47a..2fbc1fe8 100644 --- a/src/ui/StartMenu.lua +++ b/src/ui/StartMenu.lua @@ -10,6 +10,7 @@ local Menu = require("src.ui.Menu") local Renderer = require("src.render.Renderer") local Runtime = require("src.mods.Runtime") local Screens = require("src.ui.Screens") +local Strings = require("src.core.Strings") local StartMenu = {} @@ -26,19 +27,19 @@ function StartMenu.new(game) -- POKéDEX: only after Oak hands it over if flags.EVENT_GOT_POKEDEX then - table.insert(items, { label = "POKéDEX", onSelect = function() + table.insert(items, { label = Strings("POKéDEX"), onSelect = function() Screens.push(game, "PokedexMenu", { onCancel = reopen }) end }) end -- POKéMON is always listed (draw_start_menu.asm prints it even with -- an empty party; selecting it then just no-ops) - table.insert(items, { label = "POKéMON", onSelect = function() + table.insert(items, { label = Strings("POKéMON"), onSelect = function() if #game.save.party == 0 then return end Screens.push(game, "PartyMenu", { onCancel = reopen }) end }) - table.insert(items, { label = "ITEM", onSelect = function() + table.insert(items, { label = Strings("ITEM"), onSelect = function() Screens.push(game, "BagMenu", { onCancel = reopen }) end }) @@ -50,7 +51,7 @@ function StartMenu.new(game) -- SAVE shows the player/badges/dex/time panel then asks to confirm -- (PrintSaveScreenText) - table.insert(items, { label = "SAVE", onSelect = function() + table.insert(items, { label = Strings("SAVE"), onSelect = function() local TextBox = require("src.render.TextBox") local ChoiceBox = require("src.ui.ChoiceBox") local badges = require("src.inventory.Badges").count(game.data, game.save) @@ -59,32 +60,32 @@ function StartMenu.new(game) owned = owned + 1 end local t = math.floor(game.save.playTime or 0) - local panel = ("PLAYER %s\nBADGES %d\nPOKéDEX %3d\nTIME %6d:%02d") - :format(game.save.player.name or "RED", badges, owned, - math.floor(t / 3600), math.floor(t / 60) % 60) + local panel = Strings("PLAYER %s\nBADGES %d\nPOKéDEX %3d\nTIME %6d:%02d", + game.save.player.name or "RED", badges, owned, + math.floor(t / 3600), math.floor(t / 60) % 60) game.stack:push(TextBox.new(game, - panel .. "\fWould you like to\nSAVE the game?", function() + panel .. Strings("\fWould you like to\nSAVE the game?"), function() game.stack:push(ChoiceBox.new(game, function(yes) if not yes then return end -- "Now saving..." beat before the write (save.asm -- NowSavingString), then GameSavedText + SFX_SAVE - game.stack:push(TextBox.new(game, "Now saving...", function() + game.stack:push(TextBox.new(game, Strings("Now saving..."), function() game:writeSave() require("src.core.Sound").play(game.data, "Save") game.stack:push(TextBox.new(game, - (game.save.player.name or "RED") .. " saved\nthe game!")) + Strings("%s saved\nthe game!", game.save.player.name or "RED"))) end)) end)) end)) end }) - table.insert(items, { label = "OPTION", onSelect = function() + table.insert(items, { label = Strings("OPTION"), onSelect = function() Screens.push(game, "OptionsMenu", { onCancel = reopen }) end }) -- LINK needs a party if #game.save.party > 0 then - table.insert(items, { label = "LINK", onSelect = function() + table.insert(items, { label = Strings("LINK"), onSelect = function() local LinkState = require("src.link.LinkState") game.stack:push(LinkState.new(game)) end }) @@ -94,7 +95,7 @@ function StartMenu.new(game) -- one discovered mod so a vanilla install's menu is unchanged local status = game.modStatus if status and #(status.available or {}) > 0 then - table.insert(items, { label = "MODS", onSelect = function() + table.insert(items, { label = Strings("MODS"), onSelect = function() Screens.push(game, "ManagerState") end }) end @@ -102,10 +103,10 @@ function StartMenu.new(game) -- the original's EXIT just closed the menu (CloseStartMenu); with a -- window close button covering that, QUIT instead power-cycles back -- to the title after a confirm (defaultNo guards accidental quits) - table.insert(items, { label = "QUIT", onSelect = function() + table.insert(items, { label = Strings("QUIT"), onSelect = function() local TextBox = require("src.render.TextBox") local ChoiceBox = require("src.ui.ChoiceBox") - game.stack:push(TextBox.new(game, "RETURN TO MAIN\nMENU?", function() + game.stack:push(TextBox.new(game, Strings("RETURN TO MAIN\nMENU?"), function() game.stack:push(ChoiceBox.new(game, function(yes) if yes then game:returnToTitle() end end, { defaultNo = true })) @@ -155,7 +156,7 @@ function StartMenu.new(game) love.graphics.setColor(0, 0, 0, 1) Font.draw(("%3d"):format(math.floor(safari.steps or 0)), 8, 8) Font.draw("/500", 32, 8) - Font.draw("BALL", 8, 24) + Font.draw(Strings("BALL"), 8, 24) Font.draw(("%2d"):format(math.floor(safari.balls or 0)), 48, 24) love.graphics.setColor(1, 1, 1, 1) end diff --git a/src/ui/SummaryMenu.lua b/src/ui/SummaryMenu.lua index b3bf2e6b..5f9bb088 100644 --- a/src/ui/SummaryMenu.lua +++ b/src/ui/SummaryMenu.lua @@ -12,6 +12,7 @@ local Font = require("src.render.Font") -- TypeChart.displayName maps it back to "PSYCHIC", like HallOfFame and the -- battle move-type box already do (#214). local TypeChart = require("src.battle.TypeChart") +local Strings = require("src.core.Strings") local SummaryMenu = {} SummaryMenu.__index = SummaryMenu @@ -90,7 +91,7 @@ function SummaryMenu:draw() drawLineBox(19, 1, 6, 10) HudTiles.drawHPBar(data, 11, 3, mon, 1) -- wHPBarType 1 Font.draw(("%3d/%3d"):format(mon.hp, mon.stats.hp), 96, 32) - Font.draw("STATUS/", 72, 48) + Font.draw(Strings("STATUS/"), 72, 48) Font.draw(mon.status or "OK", 128, 48) -- stats box (0,8) 10x10: names rows 9/11/13/15, values indented @@ -107,27 +108,27 @@ function SummaryMenu:draw() -- TYPE1/TYPE2/IDNo/OT column (10,9) with values indented (11,10) drawLineBox(19, 9, 8, 6) - Font.draw("TYPE1/", 80, 72) + Font.draw(Strings("TYPE1/"), 80, 72) Font.draw(def.types[1] and TypeChart.displayName(def.types[1]) or "", 88, 80) if def.types[2] then - Font.draw("TYPE2/", 80, 88) + Font.draw(Strings("TYPE2/"), 80, 88) Font.draw(TypeChart.displayName(def.types[2]), 88, 96) end - Font.draw("IDNo/", 80, 104) + Font.draw(Strings("IDNo/"), 80, 104) -- the trainer ID is rolled at new game (SaveData.newGame) and -- backfilled on load for old saves Font.draw(("%05d"):format(mon.otId or game.save.player.id or 0), 96, 112) - Font.draw("OT/", 80, 120) + Font.draw(Strings("OT/"), 80, 120) Font.draw(mon.ot or game.save.player.name or "RED", 96, 128) else -- page 2: EXP + the moves with PP (StatusScreen2) drawLineBox(19, 1, 6, 10) - Font.draw("EXP POINTS", 72, 24) + Font.draw(Strings("EXP POINTS"), 72, 24) Font.draw(("%d"):format(mon.exp), 96, 32) -- StatusScreen2: "LEVEL UP" at (9,5); next-exp PrintNumber 7 cols -- at (7,6); space at (14,6); PrintLevel at (16,6). The old -- "%d to L%d" string at x=88 overflowed the DrawLineBox edge. - Font.draw("LEVEL UP", 72, 40) + Font.draw(Strings("LEVEL UP"), 72, 40) local Growth = require("src.pokemon.Growth") local nextExp = mon.level < 100 and (Growth.expForLevel(def.growthRate, mon.level + 1) - mon.exp) or 0 @@ -141,7 +142,7 @@ function SummaryMenu:draw() if mv then local mdef = data.moves[mv.id] Font.draw(mdef.name, 16, y) - Font.draw("PP", 88, y + 8) + Font.draw(Strings("PP"), 88, y + 8) Font.draw(("%2d/%2d"):format(mv.pp, mdef.pp), 112, y + 8) else Font.draw("-", 16, y) diff --git a/src/ui/TitleState.lua b/src/ui/TitleState.lua index 1c727599..979d24d7 100644 --- a/src/ui/TitleState.lua +++ b/src/ui/TitleState.lua @@ -6,6 +6,7 @@ local Font = require("src.render.Font") local Music = require("src.core.Music") local GameVersion = require("src.core.GameVersion") +local Strings = require("src.core.Strings") local TitleState = {} TitleState.__index = TitleState @@ -165,19 +166,19 @@ function ContinueInfo:draw() -- box at (4,7), 8x14 content; labels double-spaced from (5,9) Font.drawBox(4, 7, 16, 10) love.graphics.setColor(0, 0, 0, 1) - Font.draw("PLAYER", 40, 72) + Font.draw(Strings("PLAYER"), 40, 72) Font.draw((save.player and save.player.name) or "RED", 96, 72) local badges = require("src.inventory.Badges").count(self.game.data, save) - Font.draw("BADGES", 40, 88) + Font.draw(Strings("BADGES"), 40, 88) Font.draw(("%2d"):format(badges), 128, 88) local owned = 0 for _ in pairs(save.pokedex and save.pokedex.owned or {}) do owned = owned + 1 end - Font.draw("POKéDEX", 40, 104) + Font.draw(Strings("POKéDEX"), 40, 104) Font.draw(("%3d"):format(owned), 120, 104) local t = math.floor(save.playTime or 0) - Font.draw("TIME", 40, 120) + Font.draw(Strings("TIME"), 40, 120) Font.draw(("%3d:%02d"):format(math.floor(t / 3600), math.floor(t / 60) % 60), 104, 120) love.graphics.setColor(1, 1, 1, 1) @@ -188,7 +189,7 @@ function TitleState:openMenu() local game = self.game local items = {} if hasSave() then - table.insert(items, { label = "CONTINUE", onSelect = function() + table.insert(items, { label = Strings("CONTINUE"), onSelect = function() -- peek at the save for the info window; fall through if the -- file can't be read local ok, loaded = pcall(require("src.core.SaveData").load) @@ -199,13 +200,13 @@ function TitleState:openMenu() end end }) end - table.insert(items, { label = "NEW GAME", onSelect = function() + table.insert(items, { label = Strings("NEW GAME"), onSelect = function() if self.onNewGame then self.onNewGame() end end }) - table.insert(items, { label = "OPTION", onSelect = function() + table.insert(items, { label = Strings("OPTION"), onSelect = function() require("src.ui.Screens").push(game, "OptionsMenu") end }) - table.insert(items, { label = "EXIT GAME", onSelect = function() + table.insert(items, { label = Strings("EXIT GAME"), onSelect = function() if love.event and love.event.quit then love.event.quit() end @@ -254,7 +255,7 @@ function TitleState:draw() love.graphics.draw(self.logo, 16, 8) else love.graphics.setColor(0, 0, 0, 1) - Font.draw(self.blue and "POKéMON BLUE" or "POKéMON RED", + Font.draw(self.blue and "POKéMON BLUE" or Strings("POKéMON RED"), (160 - 12 * 8) / 2, 24) love.graphics.setColor(1, 1, 1, 1) end @@ -290,7 +291,7 @@ function TitleState:draw() love.graphics.setColor(0, 0, 0, 1) -- the copyright row (tile 2,17); copyrightText because field.title's -- copyright key already names the extracted image strip - Font.draw(self.title.copyrightText or "2026 bois club games", 1, 136) + Font.draw(self.title.copyrightText or Strings("2026 bois club games"), 1, 136) love.graphics.setColor(1, 1, 1, 1) end diff --git a/src/ui/TradeAnim.lua b/src/ui/TradeAnim.lua index 9ba659fc..29f3dad5 100644 --- a/src/ui/TradeAnim.lua +++ b/src/ui/TradeAnim.lua @@ -4,6 +4,7 @@ local Font = require("src.render.Font") local Sound = require("src.core.Sound") local TextBox = require("src.render.TextBox") +local Strings = require("src.core.Strings") local TradeAnim = {} TradeAnim.__index = TradeAnim @@ -343,7 +344,7 @@ function TradeAnim:drawMonInfo(mon, ot, otId, boxTy) love.graphics.setColor(0, 0, 0, 1) Font.draw(no, 56, y0) Font.draw(speciesName(self.game, mon), 40, y0 + 16) - Font.draw("OT/" .. (ot or "????"), 40, y0 + 32) + Font.draw(Strings("OT/%s", ot or "????"), 40, y0 + 32) Font.draw(("IDNo.%05d"):format(otId or 0), 40, y0 + 48) love.graphics.setColor(1, 1, 1, 1) end diff --git a/src/ui/TrainerCard.lua b/src/ui/TrainerCard.lua index 0ef3d33b..3ea51ae8 100644 --- a/src/ui/TrainerCard.lua +++ b/src/ui/TrainerCard.lua @@ -4,8 +4,10 @@ -- are built from the real trainer_info.png frame tiles (the patterned -- band + line style). +local Assets = require("src.render.Assets") local Badges = require("src.inventory.Badges") local Font = require("src.render.Font") +local Strings = require("src.core.Strings") local TrainerCard = {} TrainerCard.__index = TrainerCard @@ -16,8 +18,11 @@ function TrainerCard:sgbPalettes(game) return require("src.render.PaletteFX").wholeNamed(game.data, "MEWMON") end +-- through Assets.resolve so an enabled mod's overrides/ shadows these the +-- same way it shadows every other generated asset local function tryImage(path) - local ok, img = pcall(love.graphics.newImage, path) + if not path then return nil end + local ok, img = pcall(love.graphics.newImage, Assets.resolve(path)) return ok and img or nil end @@ -62,7 +67,8 @@ function TrainerCard.new(game, opts) end end self.circle = tryImage("assets/generated/trainer_card/circle_tile.png") - self.pic = tryImage("assets/generated/trainer_card/red.png") + self.pic = tryImage(require("src.pokemon.Sprites").playerPath( + game.data, "front", { kind = "trainer_card" })) return self end @@ -114,7 +120,7 @@ function TrainerCard:draw() love.graphics.draw(self.pic, 104, 4) end love.graphics.setColor(0, 0, 0, 1) - Font.draw("NAME/" .. (save.player.name or "RED"), 16, 16) + Font.draw(Strings("NAME/%s", save.player.name or "RED"), 16, 16) Font.draw(("MONEY/¥%d"):format(save.money or 0), 16, 32) local t = math.floor(save.playTime or 0) Font.draw(("TIME/%3d:%02d"):format(math.floor(t / 3600), @@ -123,7 +129,7 @@ function TrainerCard:draw() -- the circle-dotted BADGES banner (TrainerInfo_BadgesText) self:frameBox(0, 8, 20, 3) love.graphics.setColor(0, 0, 0, 1) - Font.draw("BADGES", 56, 72) + Font.draw(Strings("BADGES"), 56, 72) if self.circle then love.graphics.setColor(1, 1, 1, 1) love.graphics.draw(self.circle, 48, 72) diff --git a/src/world/FieldDefaults.lua b/src/world/FieldDefaults.lua index f26dc38b..65565aff 100644 --- a/src/world/FieldDefaults.lua +++ b/src/world/FieldDefaults.lua @@ -79,6 +79,19 @@ local PLAYER_SPRITES = { bike = "SPRITE_RED_BIKE", fly = "SPRITE_BIRD", } +-- The player's own trainer art: RedPicBack (the battle back pic, up until +-- "Go!"), OldManPicBack (the catch tutorial fights in his place), and +-- RedPicFront (gfx/player/red.png, shared by Oak's intro, the trainer card +-- and the Hall of Fame). Asset paths, not sprite ids -- these are pics, +-- unrelated to the walking sheet in PLAYER_SPRITES above. Resolution runs +-- through Sprites.playerPath, so the player.sprite hook can still vary them +-- per save after content freezes. +local PLAYER_PICS = { + back = "assets/generated/battle/redb.png", + demoBack = "assets/generated/battle/oldmanb.png", + front = "assets/generated/trainer_card/red.png", +} + -- Route22Gate_Script rewrites wLastMap from the player's Y every frame, so -- the north exit leaves onto Route 23 and the south onto Route 22. Rules -- are ordered, first match wins, the last row is the default. @@ -106,6 +119,7 @@ FieldDefaults.FIELD = { fishing = FISHING, safari = SAFARI, playerSprites = PLAYER_SPRITES, + playerPics = PLAYER_PICS, lastMapRewrites = LAST_MAP_REWRITES, -- CheckIfInOutsideMap: what counts as "outside" for the wLastMap memory outsideTilesets = { "OVERWORLD", "PLATEAU" }, diff --git a/src/world/OverworldController.lua b/src/world/OverworldController.lua index 747fe81f..10adb599 100644 --- a/src/world/OverworldController.lua +++ b/src/world/OverworldController.lua @@ -22,6 +22,7 @@ local TextBox = require("src.render.TextBox") local Transition = require("src.render.Transition") local Warp = require("src.world.Warp") local Zoom = require("src.render.Zoom") +local Strings = require("src.core.Strings") -- isOverworld marks the live world state for WorldAPI's stack scan local OverworldState = { isOpaque = true, isOverworld = true } @@ -1312,10 +1313,10 @@ function OverworldState:goFishing(rod) Game.stack:push(TextBox.new(Game, ". . .", function() self.fishing = nil if not enc then - Game.stack:push(TextBox.new(Game, "Not even a nibble!")) + Game.stack:push(TextBox.new(Game, Strings("Not even a nibble!"))) return end - Game.stack:push(TextBox.new(Game, "Oh!\nIt's a bite!", function() + Game.stack:push(TextBox.new(Game, Strings("Oh!\nIt's a bite!"), function() local BattleState = require("src.battle.BattleState") local battle = BattleState.newWild(Game, enc.species, enc.level, { hooked = true }) if Game.save.safari and Map.inRegion(self.map.def, "SAFARI", "SAFARI_ZONE") then @@ -1482,29 +1483,66 @@ function OverworldState:tryBookshelf(fx, fy) if self.map.def.tileset == "MANSION" and self.map:tileAt(fx * 2, fy * 2) == 0x38 then Game.stack:push(TextBox.new(Game, t._DiglettSculptureText - or "It's a sculpture\nof DIGLETT.")) + or Strings("It's a sculpture\nof DIGLETT."))) return true end Game.stack:push(TextBox.new(Game, t._PokemonBooksText - or "Crammed full of\nPOKéMON books!")) + or Strings("Crammed full of\nPOKéMON books!"))) elseif kind == "stuff" then Game.stack:push(TextBox.new(Game, t._PokemonStuffText - or "There's a slew of\nPOKéMON stuff!")) + or Strings("There's a slew of\nPOKéMON stuff!"))) elseif kind == "elevator" then Game.stack:push(TextBox.new(Game, t._ElevatorText - or "An elevator!")) + or Strings("An elevator!"))) elseif kind == "statues" then -- IndigoPlateauStatues: the plaque, then one of the two lines -- keyed by the statue's column (XCoord bit 0) local line = (self.player.cellX % 2 == 0) and t._IndigoPlateauStatuesText2 or t._IndigoPlateauStatuesText3 Game.stack:push(TextBox.new(Game, - (t._IndigoPlateauStatuesText1 or "INDIGO PLATEAU") .. "\f" - .. (line or "POKéMON LEAGUE HQ"))) + (t._IndigoPlateauStatuesText1 or Strings("INDIGO PLATEAU")) .. "\f" + .. (line or Strings("POKéMON LEAGUE HQ")))) end return true end +-- Bench guys are the one hidden-event family whose extracted label is a +-- wrapper rather than the string itself: bench_guys.asm defines e.g. +-- PewterCityPokecenterBenchGuyText:: as `text_far _PewterCityPokecenterGuyText`, +-- so data/generated/field.lua carries the wrapper name while the text sits +-- under the far label. The two only coincide for Mt Moon and the Celadon +-- hotel, which is why every other bench guy answered with silence (#248). +-- pokered's own names are irregular here (Cerulean/Lavender/Vermilion drop +-- "City", Cinnabar drops "Island"), so this is a table, not a transform. +local BENCH_GUY_TEXT = { + ViridianCityPokecenterBenchGuyText = "_ViridianCityPokecenterGuyText", + PewterCityPokecenterBenchGuyText = "_PewterCityPokecenterGuyText", + CeruleanCityPokecenterBenchGuyText = "_CeruleanPokecenterGuyText", + LavenderCityPokecenterBenchGuyText = "_LavenderPokecenterGuyText", + VermilionCityPokecenterBenchGuyText = "_VermilionPokecenterGuyText", + CeladonCityPokecenterBenchGuyText = "_CeladonCityPokecenterGuyText", + FuchsiaCityPokecenterBenchGuyText = "_FuchsiaCityPokecenterGuyText", + CinnabarIslandPokecenterBenchGuyText = "_CinnabarPokecenterGuyText", + RockTunnelPokecenterBenchGuyText = "_RockTunnelPokecenterGuyText", +} + +-- SaffronCityPokecenterBenchGuyText is text_asm: he complains about ROCKET +-- until EVENT_BEAT_SILPH_CO_GIOVANNI, then thanks you for clearing them out. +-- Takes data/save rather than reading the Game upvalue so tests can resolve +-- every label in the table without standing a whole overworld up. +function OverworldState.benchGuyText(data, save, label) + if not label then return nil end + if label == "SaffronCityPokecenterBenchGuyText" then + local key = (save and save.flags and save.flags.EVENT_BEAT_SILPH_CO_GIOVANNI) + and "_SaffronCityPokecenterGuyText2" + or "_SaffronCityPokecenterGuyText1" + return data.text[key] + end + -- the direct name first, so a cache whose extractor already resolved the + -- far label keeps working without consulting the table + return data.text["_" .. label] or data.text[BENCH_GUY_TEXT[label] or ""] +end + -- Hidden events at the faced cell (data/events/hidden_events.asm): -- HiddenItems give their item once, HiddenCoins fill the COIN CASE, -- StartSlotMachine seats open the minigame. Taken spots persist in @@ -1519,7 +1557,7 @@ function OverworldState:tryHiddenObject(fx, fy) save.hiddenTaken = save.hiddenTaken or {} if save.hiddenTaken[key] then return false end if not require("src.inventory.Bag").add(save, h.item, 1) then - Game.stack:push(TextBox.new(Game, "You can't carry\nany more items!")) + Game.stack:push(TextBox.new(Game, Strings("You can't carry\nany more items!"))) return true end save.hiddenTaken[key] = true @@ -1527,7 +1565,7 @@ function OverworldState:tryHiddenObject(fx, fy) -- hidden items always play SFX_GET_ITEM_2 (hidden_items.asm) require("src.core.Sound").play(Game.data, "Get_Item2") Game.stack:push(TextBox.new(Game, - ("%s found\n%s!"):format(save.player.name, name))) + Strings("%s found\n%s!", save.player.name, name))) return true end end @@ -1541,7 +1579,7 @@ function OverworldState:tryHiddenObject(fx, fy) save.coins = math.min(9999, (save.coins or 0) + h.coins) require("src.core.Sound").play(Game.data, "Get_Item2") Game.stack:push(TextBox.new(Game, - ("%s found\n%d coins!"):format(save.player.name, h.coins))) + Strings("%s found\n%d coins!", save.player.name, h.coins))) return true end end @@ -1553,20 +1591,20 @@ function OverworldState:tryHiddenObject(fx, fy) if h.x == fx and h.y == fy then if h.state == "out_of_order" then Game.stack:push(TextBox.new(Game, txt._GameCornerOutOfOrderText - or "OUT OF ORDER\nThis is broken.")) + or Strings("OUT OF ORDER\nThis is broken."))) elseif h.state == "out_to_lunch" then Game.stack:push(TextBox.new(Game, txt._GameCornerOutToLunchText - or "OUT TO LUNCH\nThis is reserved.")) + or Strings("OUT TO LUNCH\nThis is reserved."))) elseif h.state == "keys" then Game.stack:push(TextBox.new(Game, txt._GameCornerSomeonesKeysText - or "Someone's keys!\nThey'll be back.")) + or Strings("Someone's keys!\nThey'll be back."))) elseif not save.inventory.COIN_CASE then Game.stack:push(TextBox.new(Game, txt._GameCornerCoinCaseText - or "A COIN CASE is\nrequired!")) + or Strings("A COIN CASE is\nrequired!"))) elseif (save.coins or 0) == 0 then -- AbleToPlaySlotsCheck: a COIN CASE with no coins can't play Game.stack:push(TextBox.new(Game, txt._GameCornerNoCoinsText - or "You don't have\nany coins!")) + or Strings("You don't have\nany coins!"))) else -- one machine per visit is secretly lucky -- (wLuckySlotHiddenEventIndex, engine/slots/game_corner_slots.asm) @@ -1609,7 +1647,7 @@ function OverworldState:tryHiddenObject(fx, fy) -- bench guys (data/events/bench_guys.asm) for _, h in ipairs(extras.benchGuys[self.map.id] or {}) do if h.x == fx and h.y == fy and (not h.facing or h.facing == facing) then - local text = h.text and Game.data.text["_" .. h.text] + local text = OverworldState.benchGuyText(Game.data, save, h.text) if text then Game.stack:push(TextBox.new(Game, text)) return true @@ -1626,7 +1664,7 @@ function OverworldState:tryHiddenObject(fx, fy) if gym then local key = save.inventory[gym.badge] and "_GymStatueText2" or "_GymStatueText1" local text = Game.data.text[key] - or "{RAM}\nPOKéMON GYM\nLEADER: {RAM}" + or Strings("{RAM}\nPOKéMON GYM\nLEADER: {RAM}") text = text:gsub("{RAM:wGymCityName}", gym.city) :gsub("{RAM:wGymLeaderName}", gym.leader) Game.stack:push(TextBox.new(Game, text)) @@ -1639,7 +1677,7 @@ function OverworldState:tryHiddenObject(fx, fy) for _, h in ipairs(extras.printTrash and extras.printTrash[self.map.id] or {}) do if h.x == fx and h.y == fy then Game.stack:push(TextBox.new(Game, txt._VermilionGymTrashText - or "Nope, there's\nonly trash here.")) + or Strings("Nope, there's\nonly trash here."))) return true end end @@ -1681,7 +1719,7 @@ function OverworldState:tryCardKeyDoor(fx, fy) local t = Game.data.text if not Game.save.inventory.CARD_KEY then Game.stack:push(TextBox.new(Game, - t._CardKeyFailText or "Darn! It needs a\nCARD KEY!")) + t._CardKeyFailText or Strings("Darn! It needs a\nCARD KEY!"))) return true end require("src.core.Sound").play(Game.data, "Go_Inside") @@ -1698,8 +1736,8 @@ function OverworldState:tryCardKeyDoor(fx, fy) end end Game.stack:push(TextBox.new(Game, - (t._CardKeySuccessText1 or "Bingo!") - .. (t._CardKeySuccessText2 or "\nThe CARD KEY\nopened the door!"))) + (t._CardKeySuccessText1 or Strings("Bingo!")) + .. (t._CardKeySuccessText2 or Strings("\nThe CARD KEY\nopened the door!")))) return true end @@ -1716,7 +1754,7 @@ function OverworldState:trashCanSwitch(canIndex) local t = Game.data.text local save = Game.save local tc = Game.data.field.hiddenExtras.trashCans - local trashText = t._VermilionGymTrashText or "Nope, there's\nonly trash here." + local trashText = t._VermilionGymTrashText or Strings("Nope, there's\nonly trash here.") -- "Don't do the trash can puzzle if it's already been done." if save.flags.EVENT_2ND_LOCK_OPENED then Game.stack:push(TextBox.new(Game, trashText)) @@ -1764,7 +1802,7 @@ function OverworldState:trashCanSwitch(canIndex) -- open. Game.stack:push(TextBox.new(Game, t._VermilionGymTrashSuccessText1 - or "Hey! There's a\nswitch under the\ntrash!\fThe 1st electric\nlock opened!", + or Strings("Hey! There's a\nswitch under the\ntrash!\fThe 1st electric\nlock opened!"), function() require("src.core.Sound").play(Game.data, "Switch") end)) return end @@ -1781,7 +1819,7 @@ function OverworldState:trashCanSwitch(canIndex) -- prints, so the beep fires as the box closes, not as it opens. Game.stack:push(TextBox.new(Game, t._VermilionGymTrashSuccessText3 - or "The 2nd electric\nlock opened!\fThe motorized door\nopened!", + or Strings("The 2nd electric\nlock opened!\fThe motorized door\nopened!"), function() require("src.core.Sound").play(Game.data, "Go_Inside") end)) else -- wrong can: ResetEvent EVENT_1ST_LOCK_OPENED and immediately @@ -1793,7 +1831,7 @@ function OverworldState:trashCanSwitch(canIndex) -- text prints, so the beep fires as the box closes, not as it opens. Game.stack:push(TextBox.new(Game, t._VermilionGymTrashFailText - or "Nope! There's\nonly trash here.\fHey! The electric\nlocks were reset!", + or Strings("Nope! There's\nonly trash here.\fHey! The electric\nlocks were reset!"), function() require("src.core.Sound").play(Game.data, "Denied") end)) end end @@ -1815,12 +1853,12 @@ function OverworldState:billsHousePC() if flags.EVENT_USED_CELL_SEPARATOR_ON_BILL or not flags.EVENT_BILL_SAID_USE_CELL_SEPARATOR then Game.stack:push(TextBox.new(Game, t._BillsHouseMonitorText - or "TELEPORTER is\ndisplayed on the\nPC monitor.")) + or Strings("TELEPORTER is\ndisplayed on the\nPC monitor."))) return end require("src.core.Music").stop() Game.stack:push(TextBox.new(Game, t._BillsHouseInitiatedText - or "{PLAYER} initiated\nTELEPORTER's Cell\nSeparator!", function() + or Strings("{PLAYER} initiated\nTELEPORTER's Cell\nSeparator!"), function() flags.EVENT_USED_CELL_SEPARATOR_ON_BILL = true require("src.core.Sound").play(Game.data, "Switch") self:queueScript({ @@ -1857,13 +1895,13 @@ function OverworldState:billsHousePokemonList() end, }) end - table.insert(items, { label = "CANCEL" }) + table.insert(items, { label = Strings("CANCEL") }) -- TextBoxBorder b=10,c=9 at (0,0) -> total tw=11, th=12 Game.stack:push(Menu.new(Game, items, { tx = 0, ty = 0, tw = 11, th = 12 })) end Game.stack:push(TextBox.new(Game, t._BillsHousePokemonListText1 - or "BILL's favorite\nPOKéMON list!", openList)) + or Strings("BILL's favorite\nPOKéMON list!"), openList)) end -- BillsHouseBillExitsMachineScript: human Bill appears inside the machine @@ -1957,7 +1995,7 @@ function OverworldState:trySurf(fx, fy) local p = self.player p.surfing = true require("src.core.Music").setSurfing(Game.data, true) - local text = (Game.data.text._SurfingGotOnText or "{PLAYER} got on\n{RAM:wNameBuffer}!") + local text = (Game.data.text._SurfingGotOnText or Strings("{PLAYER} got on\n{RAM:wNameBuffer}!")) :gsub("{RAM:wNameBuffer}", name) Game.stack:push(TextBox.new(Game, text, function() -- start_sub_menus.asm .surf: UseItem returns (mount + text done), @@ -2005,7 +2043,7 @@ function OverworldState:tryCut(fx, fy) -- gen 1 confirms nothing (engine/overworld/cut.asm UsedCut): the -- _UsedCutText message, then the tree vanishes with dust + SFX_CUT local name = mon.nickname or Game.data.pokemon[mon.species].name - local text = (Game.data.text._UsedCutText or "{RAM:wNameBuffer} hacked\naway with CUT!") + local text = (Game.data.text._UsedCutText or Strings("{RAM:wNameBuffer} hacked\naway with CUT!")) :gsub("{RAM:wNameBuffer}", name) Game.stack:push(TextBox.new(Game, text, function() self.cutBlocks = self.cutBlocks or {} @@ -2141,7 +2179,7 @@ function OverworldState:talkTo(npc) -- the string "0" as truthy, so screen it out and fall through to text. if d.item and d.item ~= "0" and d.item ~= 0 then if not require("src.inventory.Bag").add(Game.save, d.item, 1) then - Game.stack:push(TextBox.new(Game, "You can't carry\nany more items!")) + Game.stack:push(TextBox.new(Game, Strings("You can't carry\nany more items!"))) return end Game.save.itemsTaken = Game.save.itemsTaken or {} @@ -2157,7 +2195,7 @@ function OverworldState:talkTo(npc) require("src.core.Sound").play(Game.data, (ddef and ddef.keyItem) and "Get_Key_Item" or "Get_Item1") Game.stack:push(TextBox.new(Game, - ("%s found\n%s!"):format(Game.save.player.name, name))) + Strings("%s found\n%s!", Game.save.player.name, name))) return end @@ -2166,7 +2204,7 @@ function OverworldState:talkTo(npc) if d.pokemon then npc:facePlayer(self.player) local text = select(1, Game.data:resolveText(self.map.def.label, d.text)) - or "Gyaoo!" + or Strings("Gyaoo!") local BattleState = require("src.battle.BattleState") Game.stack:push(TextBox.new(Game, text, function() local battle = BattleState.newWild(Game, d.pokemon, d.level) @@ -2209,7 +2247,7 @@ function OverworldState:talkTo(npc) if entry then if entry.mart then npc:facePlayer(self.player) - Game.stack:push(TextBox.new(Game, "Hi there!\nMay I help you?", function() + Game.stack:push(TextBox.new(Game, Strings("Hi there!\nMay I help you?"), function() Screens.push(Game, "ShopMenu", entry.mart) unfreeze() end)) @@ -2253,7 +2291,7 @@ function OverworldState:openPC(onDone) -- when Bill hands over the SS Ticket) local metBill = flags.EVENT_MET_BILL or flags.EVENT_GOT_SS_TICKET table.insert(items, { - label = metBill and "BILL'S PC" or "SOMEONE'S PC", + label = metBill and "BILL'S PC" or Strings("SOMEONE'S PC"), onSelect = function() require("src.core.Sound").play(Game.data, "Enter_PC") Screens.push(Game, "BoxMenu") @@ -2273,7 +2311,7 @@ function OverworldState:openPC(onDone) -- Prof. Oak's dex rating only appears once you have the Pokédex if flags.EVENT_GOT_POKEDEX then table.insert(items, { - label = "PROF.OAK's PC", + label = Strings("PROF.OAK's PC"), onSelect = function() self:dexRating() done() @@ -2293,7 +2331,7 @@ function OverworldState:openPC(onDone) require("src.core.Sound").play(Game.data, "Turn_Off_PC") done() end - table.insert(items, { label = "LOG OFF", onSelect = logOff }) + table.insert(items, { label = Strings("LOG OFF"), onSelect = logOff }) -- pokered sets BIT_NO_MENU_BUTTON_SOUND for the whole PC session -- (engine/overworld/pokecenter_pc.asm / player_pc.asm); DisplayPCMainMenu -- calls TextBoxBorder with c=14 (interior width, +2 for the border), so @@ -2318,9 +2356,9 @@ function OverworldState:dexRating() local lo = math.floor(owned / 10) * 10 key = ("_DexRatingText_Own%dTo%d"):format(lo, lo + 9) end - local rating = Game.data.text[key] or "Keep it up!" + local rating = Game.data.text[key] or Strings("Keep it up!") local completion = Game.data.text._DexCompletionText - or "POKéDEX comp-\nletion is:\f{NUM:hDexRatingNumMonsSeen} POKéMON seen\n{NUM:hDexRatingNumMonsOwned} POKéMON owned\fPROF.OAK's\nRating:" + or Strings("POKéDEX comp-\nletion is:\f{NUM:hDexRatingNumMonsSeen} POKéMON seen\n{NUM:hDexRatingNumMonsOwned} POKéMON owned\fPROF.OAK's\nRating:") completion = completion :gsub("{NUM:hDexRatingNumMonsSeen[^}]*}", tostring(seen)) :gsub("{NUM:hDexRatingNumMonsOwned[^}]*}", tostring(owned)) @@ -2367,20 +2405,20 @@ end -- POKéMON" and "fighting fit". function OverworldState:nurseHeal(onDone, npc) local t = Game.data.text - local bye = t._PokemonCenterFarewellText or "We hope to see\nyou again!" + local bye = t._PokemonCenterFarewellText or Strings("We hope to see\nyou again!") local hello = t._PokemonCenterWelcomeText - or "Welcome to our\nPOKéMON CENTER!" + or Strings("Welcome to our\nPOKéMON CENTER!") if not Game.save.usedPokecenter then Game.save.usedPokecenter = true -- BIT_USED_POKECENTER hello = hello .. "\f" - .. (t._ShallWeHealYourPokemonText or "Shall we heal your\nPOKéMON?") + .. (t._ShallWeHealYourPokemonText or Strings("Shall we heal your\nPOKéMON?")) end Game.stack:push(TextBox.new(Game, hello, nil, { choice = function(yes) if not yes then Game.stack:push(TextBox.new(Game, bye, onDone)) return end - local need = t._NeedYourPokemonText or "OK. We'll need\nyour POKéMON." + local need = t._NeedYourPokemonText or Strings("OK. We'll need\nyour POKéMON.") Game.stack:push(TextBox.new(Game, need, function() -- the nurse turns to the machine, the map music stops, and the -- party heals before the machine runs (predef HealParty) @@ -2415,7 +2453,7 @@ end function OverworldState:finishNurseHeal(bye, onDone) local t = Game.data.text - local fit = t._PokemonFightingFitText or "Your POKéMON are\nfighting fit!" + local fit = t._PokemonFightingFitText or Strings("Your POKéMON are\nfighting fit!") Game.stack:push(TextBox.new(Game, fit .. "\f" .. bye, onDone)) end @@ -2427,21 +2465,21 @@ end -- for the original serial handshake; declining prints "Please come again!" function OverworldState:cableClubReceptionist(onDone) local t = Game.data.text - local welcome = t._CableClubNPCWelcomeText or "Welcome to the\nCable Club!" + local welcome = t._CableClubNPCWelcomeText or Strings("Welcome to the\nCable Club!") if not Game.save.flags.EVENT_GOT_POKEDEX then -- CableClubNPC .didNotConnect path before the pokedex Game.stack:push(TextBox.new(Game, welcome .. "\f" .. (t._CableClubNPCMakingPreparationsText - or "We're making\npreparations.\vPlease wait."), onDone)) + or Strings("We're making\npreparations.\vPlease wait.")), onDone)) return end local apply = t._CableClubNPCPleaseApplyHereHaveToSaveText - or "Please apply here.\fBefore opening\nthe link, we have\vto save the game." + or Strings("Please apply here.\fBefore opening\nthe link, we have\vto save the game.") Game.stack:push(TextBox.new(Game, welcome .. "\f" .. apply, nil, { choice = function(yes) if not yes then Game.stack:push(TextBox.new(Game, - t._CableClubNPCPleaseComeAgainText or "Please come\nagain!", onDone)) + t._CableClubNPCPleaseComeAgainText or Strings("Please come\nagain!"), onDone)) return end Game:writeSave() @@ -2476,7 +2514,7 @@ function OverworldState:engageTrainer(npc, onDone) local battleText = header and header.battle and Game.data.text[header.battle] if not battleText then battleText = select(1, Game.data:resolveText(self.map.def.label, d.text)) - or "I like shorts!\nThey're comfy and\neasy to wear!" + or Strings("I like shorts!\nThey're comfy and\neasy to wear!") end local wonText = header and header.won and Game.data.text[header.won] @@ -2555,11 +2593,11 @@ function OverworldState:checkVictoryRewards(trainerClass, partyIndex) if reward.badge then local name = Game.data.items[reward.badge] and Game.data.items[reward.badge].name or reward.badge - table.insert(lines, ("%s received\nthe %s!"):format(Game.save.player.name, name)) + table.insert(lines, Strings("%s received\nthe %s!", Game.save.player.name, name)) end if reward.item then local name = Game.stringBuffer or reward.item - table.insert(lines, ("%s received\n%s!"):format(Game.save.player.name, name)) + table.insert(lines, Strings("%s received\n%s!", Game.save.player.name, name)) end end if #lines > 0 then @@ -2753,7 +2791,7 @@ function OverworldState:applyFieldPoison() local queue = {} for _, mon in ipairs(fainted) do local name = mon.nickname or Game.data.pokemon[mon.species].name - table.insert(queue, ("%s\nfainted!"):format(name)) + table.insert(queue, Strings("%s\nfainted!", name)) end local alive = false for _, mon in ipairs(save.party) do @@ -2767,7 +2805,7 @@ function OverworldState:applyFieldPoison() end if not alive then Game.stack:push(TextBox.new(Game, - ("%s blacked\nout!"):format(save.player.name), function() + Strings("%s blacked\nout!", save.player.name), function() local Pokemon = require("src.pokemon.Pokemon") for _, mon in ipairs(save.party) do Pokemon.heal(mon) end save.money = math.floor(save.money @@ -2903,7 +2941,7 @@ function OverworldState:onStepComplete() if Game.save.repelSteps == 0 then -- no encounter on the exact wear-off step (wild_encounters.asm -- .lastRepelStep returns CantEncounter) - Game.stack:push(TextBox.new(Game, "REPEL's effect\nwore off.")) + Game.stack:push(TextBox.new(Game, Strings("REPEL's effect\nwore off."))) return end end @@ -3037,14 +3075,14 @@ function OverworldState:checkBadgeGate() -- Route22GateGuardGoRightAheadText plays sound_get_item_1 require("src.core.Sound").play(Game.data, "Get_Item1") Game.stack:push(TextBox.new(Game, - t["_" .. g.passText] or "Go right ahead!")) + t["_" .. g.passText] or Strings("Go right ahead!"))) end return false end -- Route22GateGuardNoBoulderbadgeText plays SFX_DENIED require("src.core.Sound").play(Game.data, "Denied") Game.stack:push(TextBox.new(Game, - (t["_" .. g.failText] or "You don't have the\nBOULDERBADGE yet!") + (t["_" .. g.failText] or Strings("You don't have the\nBOULDERBADGE yet!")) .. (t._Route22GateGuardICantLetYouPassText or ""), function() self:scriptMove(p, "down", 1) end)) @@ -3065,14 +3103,14 @@ function OverworldState:checkBadgeGate() -- Route23OhThatIsTheBadgeText plays sound_get_item_1 require("src.core.Sound").play(Game.data, "Get_Item1") local text = (t["_" .. g.passText] or - "Oh! That is the\n{RAM}!"):gsub("{RAM:wNameBuffer}", badgeName) + Strings("Oh! That is the\n{RAM}!")):gsub("{RAM:wNameBuffer}", badgeName) Game.stack:push(TextBox.new(Game, text)) return false end -- Route23YouDontHaveTheBadgeYetText plays SFX_DENIED require("src.core.Sound").play(Game.data, "Denied") local text = (t["_" .. g.failText] or - "You don't have the\n{RAM} yet!"):gsub("{RAM:wNameBuffer}", badgeName) + Strings("You don't have the\n{RAM} yet!")):gsub("{RAM:wNameBuffer}", badgeName) Game.stack:push(TextBox.new(Game, text, function() self:scriptMove(p, "down", 1) end)) @@ -3110,7 +3148,7 @@ function OverworldState:checkForcedMovement() Game.save.forcedBike = true require("src.core.Music").playMap(Game.data, self.map.id, true) else - Game.stack:push(TextBox.new(Game, "You need a\nBICYCLE for the\nCycling Road!", + Game.stack:push(TextBox.new(Game, Strings("You need a\nBICYCLE for the\nCycling Road!"), function() local back = ({ up = "down", down = "up", left = "right", right = "left" })[p.facing] @@ -3238,7 +3276,7 @@ function OverworldState:boulderIntoHole(npc) for i = #self.entities, 1, -1 do if self.entities[i] == npc then table.remove(self.entities, i) end end - Game.stack:push(TextBox.new(Game, "The boulder fell\nthrough the hole!")) + Game.stack:push(TextBox.new(Game, Strings("The boulder fell\nthrough the hole!"))) return true end end @@ -3268,7 +3306,7 @@ function OverworldState:safariStep() if not st or not self:inSafariStepZone() then return false end st.steps = st.steps - 1 if st.steps > 0 then return false end - self:safariGameOver("PA: Ding-dong!\nTime's up!") + self:safariGameOver(Strings("PA: Ding-dong!\nTime's up!")) return true end @@ -3277,7 +3315,7 @@ function OverworldState:safariGameOver(text) Game.save.safari = nil local t = Game.data.text Game.stack:push(TextBox.new(Game, - (text or "") .. "\f" .. (t._GameOverText or "PA: Your SAFARI\nGAME is over!"), + (text or "") .. "\f" .. (t._GameOverText or Strings("PA: Your SAFARI\nGAME is over!")), function() local exit_ = FieldDefaults.fieldValue(Game.data, "safari", "exitWarp") self:startWarpTo(exit_.map, exit_.x, exit_.y, exit_.facing or "down") @@ -3320,7 +3358,7 @@ function OverworldState:afterBattle(result, battle) else -- throwing the last SAFARI BALL ends the game if Game.save.safari and Game.save.safari.balls <= 0 then - self:safariGameOver("PA: You're out of\nSAFARI BALLs!") + self:safariGameOver(Strings("PA: You're out of\nSAFARI BALLs!")) end evolutions() end diff --git a/tests/drivers/bag_usetoss_bug284_test.lua b/tests/drivers/bag_usetoss_bug284_test.lua new file mode 100644 index 00000000..13cd77c2 --- /dev/null +++ b/tests/drivers/bag_usetoss_bug284_test.lua @@ -0,0 +1,87 @@ +-- Driver: the bag's USE/TOSS submenu box (#284). A manual eye check, not a +-- pass/fail run. +-- +-- pokered evidence. data/text_boxes.asm pins the whole thing as one entry: +-- +-- text_box_text USE_TOSS_MENU_TEMPLATE, 13, 10, 19, 14, UseTossText, 15, 11 +-- ; text box ID, upper-left X, upper-left Y, lower-right X, lower-right Y, +-- ; text pointer, text X, text Y +-- +-- so the box covers tiles (13,10)-(19,14) and "USE" starts at tile (15,11). +-- engine/menus/start_sub_menus.asm then sets wTopMenuItemY/X to 11/14 for +-- the cursor, and UseTossText is "USE" + next "TOSS", i.e. two rows. +-- +-- The bug: the port opened the submenu with a (12,10) 8x6 box, one column +-- too wide on the left and one row too tall at the bottom. The labels +-- themselves were already on the right pixel rows, but the extra bottom row +-- left them stranded near the top edge, which is what reads as "too high". +-- The fix passes the real geometry (13,10,7,5); src/ui/Menu.lua derives the +-- label and cursor columns from the box, so it needed no change and its +-- eight other callers are untouched. +-- +-- Do NOT run this under POKEPORT_SPEED: fast-forward scales only the logic +-- clock, so a sped-up run can capture a half-drawn frame. +-- +-- POKEPORT_DRIVER=tests/drivers/bag_usetoss_bug284_test.lua POKEPORT_IDENTITY=bug284 love . +return function(game) + local U = dofile("tests/drivers/util.lua") + local Pokemon = require("src.pokemon.Pokemon") + local Screens = require("src.ui.Screens") + + local function check(label, ok) + U.log(ok and "PASS" or "FAIL", label) + return ok + end + + -- ---- preconditions the eye cannot separate from the bug ---------------- + -- An empty bag, or an item whose branch skips the submenu, both end with + -- "no USE/TOSS box on screen", which looks identical to a broken box. + + game.save.party = { Pokemon.new(game.data, "CHARIZARD", 50) } + game.save.player.name = "bryan" + game.save.inventory = game.save.inventory or {} + game.save.inventory.POTION = 3 + check("a usable, tossable item is in the bag", + (game.save.inventory.POTION or 0) > 0) + + -- BICYCLE short-circuits straight past the submenu in the original + -- (start_sub_menus.asm `cp BICYCLE / jp z, .useOrTossItem`), and the port + -- mirrors that, so the first item must not be the bike. + check("POTION is not a submenu-skipping item", + game.data.items.POTION ~= nil and not game.data.items.POTION.keyItem) + + check("renderer is up", game.renderer ~= nil) + + U.teleport(game, "PALLET_TOWN", 10, 8, "down") + U.wait(20) + + Screens.push(game, "BagMenu") + U.wait(30) + U.shot(game, "bug284_bag_list.png") + + -- A on the first item opens USE/TOSS + U.tap(game, "a") + U.wait(30) + U.shot(game, "bug284_usetoss.png") + + U.log("........................................................") + U.log("LOOK NOW: the USE/TOSS box should be open over the bag list.") + U.log(" Screenshot: bug284_usetoss.png in the LOVE save dir.") + U.log(" RIGHT: a box hugging the bottom-right corner, its border running") + U.log(" tiles (13,10) to (19,14). USE and TOSS sit on alternating") + U.log(" rows with the cursor one column to their left, and there") + U.log(" is exactly one border row below TOSS -- no empty gap.") + U.log(" BUG #284 looks like: a roomier box whose labels crowd the top,") + U.log(" with a blank row of dead space under TOSS, and the whole") + U.log(" box starting one column further left than the original.") + U.log(" ALSO WRONG: labels pushed down but the box left oversized (that") + U.log(" moves the text off pokered's row 11), or the cursor column") + U.log(" no longer lining up one tile left of the labels.") + U.log("Compare against the original screenshot in issue #284.") + U.log("Input is yours from here: up/down moves between USE and TOSS.") + U.log("........................................................") + + while true do + coroutine.yield() + end +end diff --git a/tests/drivers/caterpillar_text_bug250_test.lua b/tests/drivers/caterpillar_text_bug250_test.lua new file mode 100644 index 00000000..5b639721 --- /dev/null +++ b/tests/drivers/caterpillar_text_bug250_test.lua @@ -0,0 +1,127 @@ +-- Driver: the Viridian caterpillar speech waits for the player (#250). +-- A manual eye check, not a pass/fail run -- nothing in this repo can judge +-- "the text went by too fast to read". +-- +-- pokered evidence. text/ViridianCity.asm spells the answer with three +-- different break markers, and they are not interchangeable: +-- +-- ViridianCityYoungster2CaterpieAndWeedleDescriptionText:: +-- text "CATERPIE has no" +-- line "poison, but" +-- cont "WEEDLE does." +-- +-- para "Watch out for its" +-- line "POISON STING!" +-- done +-- +-- src/render/TextBox.lua maps those to \n (second line), \v (scroll one +-- line, after the down-arrow and a button press) and \f (page break, clear +-- and wait). This text is not in data/generated/text.lua -- pokered +-- declares it without the leading underscore the extractor keys on -- so +-- the port carries a literal fallback, and that fallback had spelled both +-- `cont` and `para` as plain \n. Six lines then landed on one page with +-- nothing to wait on, so the whole speech scrolled past untouched. +-- +-- Do NOT run this under POKEPORT_SPEED. Fast-forward scales only the logic +-- clock while the typewriter and its SFX run on their own real-time +-- accumulator (src/core/Game.lua), which is precisely the pacing being +-- judged here. +-- +-- POKEPORT_DRIVER=tests/drivers/caterpillar_text_bug250_test.lua POKEPORT_IDENTITY=bug250 love . +return function(game) + local U = dofile("tests/drivers/util.lua") + + local function check(label, ok) + U.log(ok and "PASS" or "FAIL", label) + return ok + end + + -- ---- preconditions the eye cannot separate from the bug ---------------- + -- A missing handler, or an NPC who wandered off, both end in "no text", + -- which is not the same failure as "text that does not wait". + + local MAP = "VIRIDIAN_CITY" + local NPC = "VIRIDIANCITY_YOUNGSTER2" + local TEXT = "TEXT_VIRIDIANCITY_YOUNGSTER2" + + local mapScripts = require("data.scripts.init") + check("a hand-ported handler exists for " .. TEXT, + mapScripts.talkScript(MAP, TEXT) ~= nil) + + -- the fix itself: the paginator has to find a page break and a scroll in + -- the description, because that is what makes it wait for a button + local TextBox = require("src.render.TextBox") + local desc = "CATERPIE has no\npoison, but\vWEEDLE does.\fWatch out for its\nPOISON STING!" + local pages = TextBox.paginate(desc) + check("the description spans two pages (para -> \\f)", #pages == 2) + check("page 1 is three lines (text + line + cont)", + pages[1] ~= nil and #pages[1] == 3) + check("page 2 is two lines (para + line)", + pages[2] ~= nil and #pages[2] == 2) + -- the wait itself: contBefore marks the line the box holds on until the + -- player presses a button, which is the whole point of the fix + check("line 3 waits for a button before scrolling in", + pages.contBefore and pages.contBefore[1] and pages.contBefore[1][3] == true) + + U.teleport(game, MAP, 30, 26, "up") + U.wait(30) + + local function target() + for _, n in ipairs(game.overworld and game.overworld.npcs or {}) do + if n.def and n.def.name == NPC then return n end + end + return nil + end + + local npc = target() + check("the youngster is loaded on " .. MAP, npc ~= nil) + -- pin the wander: he strolls out from under the A press between reads + if npc then npc.wanders = false end + + -- objects live at ../pokered/data/maps/objects/ViridianCity.asm and in + -- data/generated/maps.lua; he is at (30,25), so (30,26) faces him. If a + -- map edit ever moves him, stand on any free walkable neighbour instead + -- of parking the player at a wall. + local function facingTarget() + local ow = game.overworld + if not (ow and npc) then return false end + local fx, fy = ow.player:facingCell() + return ow:npcAtCell(fx, fy) == npc + end + + if npc and not facingTarget() then + local ow = game.overworld + for _, s in ipairs({ { 0, 1, "up" }, { 1, 0, "left" }, + { -1, 0, "right" }, { 0, -1, "down" } }) do + local cx, cy = npc.cellX + s[1], npc.cellY + s[2] + if ow.map:isWalkableCell(cx, cy) and not ow:npcAtCell(cx, cy) then + U.log("approach cell blocked, standing on", cx, cy, "facing", s[3]) + U.teleport(game, MAP, cx, cy, s[3]) + U.wait(10) + break + end + end + end + check("player is standing in front of the youngster", facingTarget()) + + U.log("........................................................") + U.log("READ NOW: press A to talk, then answer YES to his question.") + U.log(" RIGHT: the answer stops and waits for you three times. After") + U.log(" 'WEEDLE does.' the box waits with a down-arrow, then the") + U.log(" page CLEARS before 'Watch out for its POISON STING!'.") + U.log(" BUG #250 looks like: all six lines pouring past in one go, the") + U.log(" box scrolling itself, and the conversation ending before") + U.log(" you have pressed anything.") + U.log(" ALSO WRONG: it waits but never clears the page (that is \\v where") + U.log(" pokered has para), or it clears after every single line") + U.log(" (that is \\f where pokered has line/cont).") + U.log("Control case: answer NO instead and you should get the one-line") + U.log(" 'Oh, OK then!', which needs no waits at all.") + U.log("Input is yours from here, so you can re-run the talk as often as") + U.log("you like.") + U.log("........................................................") + + while true do + coroutine.yield() + end +end diff --git a/tests/drivers/nidoran_cry_bug247_test.lua b/tests/drivers/nidoran_cry_bug247_test.lua new file mode 100644 index 00000000..5688fe69 --- /dev/null +++ b/tests/drivers/nidoran_cry_bug247_test.lua @@ -0,0 +1,150 @@ +-- Driver: manual audio check for the silent Pewter NIDORAN (#247). +-- +-- pokered scripts/PewterNidoranHouse.asm, PewterNidoranHouseNidoranText: +-- text_far _PewterNidoranHouseNidoranText +-- text_asm +-- ld a, NIDORAN_M +-- call PlayCry +-- call WaitForSoundToFinish +-- jp TextScriptEnd +-- so the box types "NIDORAN: Bowbow!" first and the male NIDORAN cry sounds +-- second, once the typewriter is done. The box then still waits for a +-- button: PewterNidoranHouse_Script is `jp EnableAutoTextBoxDrawing`, and +-- AutoTextBoxDrawingCommon (pokered home/window.asm) zeroes +-- wDoNotWaitForButtonPressAfterDisplayingText, so DisplayTextID falls through +-- to WaitForTextScrollButtonPress like any other NPC line. +-- data/scripts/flavor/pewter_nidoran_house.lua ported the dialogue row but +-- never the cry row, so the NIDORAN was silent. +-- +-- This is an audio fix, so nothing here can assert it. What this driver does +-- is check the halves an ear cannot check (the cry row is wired into the talk +-- script in the adjacency Commands.play_cry/show_text require, and the +-- NIDORAN_M cry sample resolves the way Sound.playCry looks it up), park the +-- player one cell below the NIDORAN already facing it, and then hand input +-- straight back so a human hears the cry live. +-- +-- Do NOT add POKEPORT_SPEED to this run: fast-forward scales only the logic +-- clock while audio runs on its own real-time 60 Hz accumulator +-- (src/core/Game.lua), so the cry would stop lining up with the box it +-- belongs to. +-- +-- POKEPORT_DRIVER=tests/drivers/nidoran_cry_bug247_test.lua \ +-- POKEPORT_IDENTITY=bug247 POKEPORT_TOUCH=0 POKEPORT_VERSION=red love . +return function(game) + local U = dofile("tests/drivers/util.lua") + local mapScripts = require("data.scripts.init") + + local MAP = "PEWTER_NIDORAN_HOUSE" + local NPC = "PEWTERNIDORANHOUSE_NIDORAN" + local TEXT = "TEXT_PEWTERNIDORANHOUSE_NIDORAN" + local SPECIES = "NIDORAN_M" + + local function check(label, ok) + U.log(ok and "PASS" or "FAIL", label) + return ok + end + + -- ---- preconditions the ear cannot check -------------------------------- + -- Commands.play_cry only stashes ctx.pendingCry; Commands.show_text is what + -- turns it into the box's opts.auto. The two rows therefore have to be + -- adjacent and in that order, or the cry is handed to the wrong box or + -- never consumed at all -- and either way the NIDORAN stays quiet. + local rows = mapScripts.talkScript(MAP, TEXT) + local cryAt, textAt + for i, row in ipairs(rows or {}) do + if row[1] == "play_cry" and not cryAt then cryAt = i end + if row[1] == "show_text" and not textAt then textAt = i end + end + local cry = cryAt and rows[cryAt] + check("talk script carries a play_cry row", cry ~= nil) + check("cry species is " .. SPECIES, cry ~= nil and cry[2] == SPECIES) + -- the third arg is play_cry's waitForButton form, which keeps + -- DisplayTextID's trailing WaitForTextScrollButtonPress instead of letting + -- the box pop itself the instant the cry goes quiet + check("cry row keeps the button wait", cry ~= nil and cry[3] == true) + check("play_cry sits immediately before show_text", + cryAt ~= nil and textAt == cryAt + 1) + + -- Sound.playCry reads data.audio.cries[species]; a missing or misspelled + -- key is a silent no-op with no error, which is indistinguishable by ear + -- from the bug itself + local cries = game.data.audio and game.data.audio.cries + check("data.audio.cries." .. SPECIES .. " resolves", + cries ~= nil and cries[SPECIES] ~= nil) + + local vol = game.save.options and game.save.options.sfxVol + U.log("audio device present:", love.audio ~= nil, + " SFX VOL (0-7):", tostring(vol)) + if not love.audio or vol == 0 then + U.log("WARNING: sound output is off, so nothing below will be audible;", + "raise SFX VOL in OPTION first") + end + + -- ---- park the player in front of the NIDORAN --------------------------- + -- pokered data/maps/objects/PewterNidoranHouse.asm puts the NIDORAN on + -- (4, 5) facing LEFT at the little boy on (3, 5), so the free approach cell + -- is the floor directly below it. + U.teleport(game, MAP, 4, 6, "up") + U.wait(10) + + local function nidoranIn(ow) + for _, n in ipairs(ow.npcs or {}) do + if n.def and n.def.name == NPC then return n end + end + return nil + end + + -- re-reads game.overworld every call: the fallback below teleports again, + -- which rebuilds the whole state and its npc list + local function facingTheMon() + local ow = game.overworld + local mon = ow and nidoranIn(ow) + if not mon then return false end + local fx, fy = ow.player:facingCell() + return ow:npcAtCell(fx, fy) == mon + end + + local ow = game.overworld + local mon = ow and nidoranIn(ow) + check("NIDORAN object loaded on " .. MAP, mon ~= nil) + + if mon and not facingTheMon() then + -- the hard-coded approach cell stopped working (map edit, or a mod moved + -- the object): take any free walkable neighbour and turn toward the mon. + -- {dx, dy, facing} is the offset from the mon to the stand cell plus the + -- direction that looks back at it, so +1 on x means facing left. + local sides = { + { 0, 1, "up" }, { 1, 0, "left" }, { -1, 0, "right" }, { 0, -1, "down" }, + } + for _, s in ipairs(sides) do + local cx, cy = mon.cellX + s[1], mon.cellY + s[2] + if ow.map:isWalkableCell(cx, cy) and not ow:npcAtCell(cx, cy) then + U.log("approach cell (4, 6) is blocked, standing on", + cx, cy, "facing", s[3]) + U.teleport(game, MAP, cx, cy, s[3]) + U.wait(10) + break + end + end + end + check("player is standing in front of the NIDORAN", facingTheMon()) + + -- ---- hand off, then stay out of the way -------------------------------- + U.log("........................................................") + U.log("LISTEN NOW: press A to talk to the NIDORAN in front of you.") + U.log(" RIGHT: the box types out \"NIDORAN: Bowbow!\", the male NIDORAN") + U.log(" cry sounds ONCE after the last character lands, then the") + U.log(" arrow blinks and the box waits for your A or B.") + U.log(" BUG #247 sounds like: the line types out and then nothing, dead") + U.log(" silence under the house music, arrow blinking.") + U.log(" ALSO WRONG: the cry fires while the text is still typing (row") + U.log(" order broken), or the box closes itself the moment the cry") + U.log(" ends with no button press (waitForButton lost).") + U.log("Input is yours from here on -- talk again as often as you like, and") + U.log("the little boy on the left is the silent control to compare against.") + U.log("........................................................") + + while true do + coroutine.yield() + end +end diff --git a/tests/drivers/party_cursor_bug278_test.lua b/tests/drivers/party_cursor_bug278_test.lua new file mode 100644 index 00000000..7fd40f7f --- /dev/null +++ b/tests/drivers/party_cursor_bug278_test.lua @@ -0,0 +1,96 @@ +-- Driver: party menu cursor alignment (#278). A manual eye check, not a +-- pass/fail run -- no assertion in this repo can judge where a triangle +-- sits against a reference screenshot. +-- +-- pokered evidence. home/pokemon.asm PartyMenuInit seeds the shared menu +-- cursor coordinates: +-- +-- ld hl, wTopMenuItemY +-- inc a ; a = 1 +-- ld [hli], a ; top menu item Y +-- xor a +-- ld [hli], a ; top menu item X +-- +-- and home/window.asm PlaceMenuCursor walks that many rows down from +-- hlcoord 0, 0. Meanwhile party_menu.asm RedrawPartyMenu_ starts the name +-- column at hlcoord 3, 0. So a party entry's name is on tile row 0 while +-- its cursor belongs on tile row 1: the level/HP line, level with the +-- middle of the two-row icon. +-- +-- The bug: PartyMenu drew the cursor at entryY(i), the name row, putting it +-- a full tile (8px) too high on every slot. The fix draws it at y + 8. +-- +-- Do NOT run this under POKEPORT_SPEED. Fast-forward scales only the logic +-- clock while rendering and audio run on their own real-time accumulators +-- (src/core/Game.lua), so a sped-up run can capture a half-drawn frame. +-- +-- POKEPORT_DRIVER=tests/drivers/party_cursor_bug278_test.lua POKEPORT_IDENTITY=bug278 love . +return function(game) + local U = dofile("tests/drivers/util.lua") + local Pokemon = require("src.pokemon.Pokemon") + local Screens = require("src.ui.Screens") + local PartyMenu = require("src.ui.PartyMenu") + + local function check(label, ok) + U.log(ok and "PASS" or "FAIL", label) + return ok + end + + -- ---- preconditions a human's eye cannot separate from the bug ---------- + -- A cursor drawn off-screen, or a party too short to show the stride, + -- both look exactly like "the offset is wrong". Check them first. + + -- the geometry contract the fix depends on: 16px stride, name row at the + -- top of each entry. If entryY ever changes, the +8 has to be revisited. + check("entryY stride is 16px", PartyMenu.entryY(2) - PartyMenu.entryY(1) == 16) + check("slot 1 name row is y=0", PartyMenu.entryY(1) == 0) + + game.save.party = { + Pokemon.new(game.data, "CHARIZARD", 50), + Pokemon.new(game.data, "PIKACHU", 30), + Pokemon.new(game.data, "SNORLAX", 77), + Pokemon.new(game.data, "BULBASAUR", 12), + } + game.save.player.name = "bryan" + check("party has enough slots to judge the stride", #game.save.party >= 3) + + -- the window actually rendered: a black frame is not an offset bug + check("renderer is up", game.renderer ~= nil) + + U.teleport(game, "PALLET_TOWN", 10, 8, "down") + U.wait(20) + + Screens.push(game, "PartyMenu") + U.wait(30) + check("party menu is on top", game.stack:top() ~= nil) + + U.shot(game, "bug278_party_cursor_slot1.png") + + -- move the cursor down so the stride is visible too: a fix that is right + -- on slot 1 and wrong further down would otherwise read as a pass + U.tap(game, "down") + U.wait(15) + U.shot(game, "bug278_party_cursor_slot2.png") + U.tap(game, "down") + U.wait(15) + U.shot(game, "bug278_party_cursor_slot3.png") + + U.log("........................................................") + U.log("LOOK NOW: the party menu is open. Screenshots were written to the") + U.log(" LOVE save dir as bug278_party_cursor_slot1/2/3.png.") + U.log(" RIGHT: the black triangle sits on the LOWER of each entry's two") + U.log(" rows, level with the LEVEL/HP line and with the middle of") + U.log(" the two-row party icon.") + U.log(" BUG #278 looks like: the triangle riding up on the NAME row, its") + U.log(" tip level with the first letter of the nickname.") + U.log(" ALSO WRONG: correct on slot 1 but drifting on slots 2-4 (that is") + U.log(" a stride bug, not an offset bug), or the triangle sliding") + U.log(" a half tile so it straddles both rows.") + U.log("Compare against the reference shot in issue #278. Input is yours") + U.log("from here: up/down re-checks every slot, B closes the menu.") + U.log("........................................................") + + while true do + coroutine.yield() + end +end diff --git a/tests/drivers/pokedex_nidoran_bug285_test.lua b/tests/drivers/pokedex_nidoran_bug285_test.lua new file mode 100644 index 00000000..2dbeeed8 --- /dev/null +++ b/tests/drivers/pokedex_nidoran_bug285_test.lua @@ -0,0 +1,102 @@ +-- Driver: Pokedex owned-ball alignment for NIDORAN (#285). A manual eye +-- check, not a pass/fail run. +-- +-- The list draws each owned entry's pokeball marker one blank glyph after +-- the name. ListMenu measured that gap with `#item.label`, the Lua string's +-- BYTE length, but the charmap's gender symbols are multi-byte UTF-8: the +-- eight glyphs of "NIDORAN" occupy ten bytes, so those two rows put +-- their ball 16px further right than every other species. +-- +-- src/render/Font.lua already says this out loud in Font.width's own +-- docstring ("callers that right-align with `#text * 8` mis-place them"), +-- which is exactly the call this fix switches to. +-- +-- This one has a built-in control case: NIDORINA, NIDORINO and NIDOKING sit +-- immediately around the two Nidoran rows in dex order and carry no gender +-- symbol, so a correct fix leaves all five balls in one vertical column. +-- +-- Do NOT run this under POKEPORT_SPEED: fast-forward scales only the logic +-- clock, so a sped-up run can capture a half-drawn frame. +-- +-- POKEPORT_DRIVER=tests/drivers/pokedex_nidoran_bug285_test.lua POKEPORT_IDENTITY=bug285 love . +return function(game) + local U = dofile("tests/drivers/util.lua") + local Pokemon = require("src.pokemon.Pokemon") + local Screens = require("src.ui.Screens") + local Font = require("src.render.Font") + + local function check(label, ok) + U.log(ok and "PASS" or "FAIL", label) + return ok + end + + -- ---- preconditions the eye cannot separate from the bug ---------------- + -- A dex with nothing owned shows no balls at all, which looks the same as + -- balls in the wrong place. Seed the neighbourhood and prove the premise. + + local dexRow = { "NIDORAN_F", "NIDORINA", "NIDOQUEEN", + "NIDORAN_M", "NIDORINO", "NIDOKING" } + + game.save.pokedex = game.save.pokedex or { seen = {}, owned = {} } + for _, id in ipairs(dexRow) do + game.save.pokedex.seen[id] = true + game.save.pokedex.owned[id] = true + end + game.save.party = { Pokemon.new(game.data, "NIDORAN_M", 10) } + game.save.player.name = "bryan" + + for _, id in ipairs(dexRow) do + check("owned: " .. id, game.save.pokedex.owned[id] == true) + end + + -- the premise itself: the gender names really are multi-byte, so byte + -- length and glyph width really do disagree for exactly these two rows + local male = game.data.pokemon.NIDORAN_M.name + local plain = game.data.pokemon.NIDORINO.name + U.log("NIDORAN male name:", male, "bytes:", #male, "glyph width:", Font.width(male)) + check("gender name is multi-byte (this is the whole bug)", + #male > Font.width(male) / 8) + check("a plain neighbour agrees byte-for-glyph", + #plain == Font.width(plain) / 8) + + check("renderer is up", game.renderer ~= nil) + + U.teleport(game, "PALLET_TOWN", 10, 8, "down") + U.wait(20) + + Screens.push(game, "PokedexMenu") + U.wait(30) + + -- Walk down far enough that the whole Nidoran block is on screen at once. + -- The list shows seven rows with the cursor on the last, so stopping at + -- dex 29 would leave NIDORAN female alone above six empty rows and there + -- would be nothing to compare her ball against. Dex 35 puts 029-035 in + -- view: both Nidoran rows plus four gender-free neighbours. + for _ = 1, 34 do + U.tap(game, "down") + U.wait(2) + end + U.wait(20) + U.shot(game, "bug285_pokedex_nidoran.png") + + U.log("........................................................") + U.log("LOOK NOW: the Pokedex list is parked on the NIDORAN block.") + U.log(" Screenshot: bug285_pokedex_nidoran.png in the LOVE save dir.") + U.log(" RIGHT: every owned row's pokeball sits one blank space after its") + U.log(" name, so NIDORAN, NIDORINA, NIDOQUEEN, NIDORAN,") + U.log(" NIDORINO and NIDOKING all show a ragged-but-consistent gap") + U.log(" of exactly one space.") + U.log(" BUG #285 looks like: the two NIDORAN rows alone kicking their") + U.log(" ball two extra characters to the right, out of line with") + U.log(" the four neighbours that have no gender symbol.") + U.log(" ALSO WRONG: every ball moving together (that would mean the base") + U.log(" offset changed, not the measurement), or the ball landing") + U.log(" on top of the last letter with no gap at all.") + U.log("Compare against the screenshot in issue #285. Input is yours from") + U.log("here: up/down scrolls, and any other owned species is a control.") + U.log("........................................................") + + while true do + coroutine.yield() + end +end diff --git a/tests/drivers/spearow_cry_bug251_test.lua b/tests/drivers/spearow_cry_bug251_test.lua new file mode 100644 index 00000000..eeec0554 --- /dev/null +++ b/tests/drivers/spearow_cry_bug251_test.lua @@ -0,0 +1,73 @@ +-- Driver: issue #251 -- "Silent Speary the Spearow". MANUAL LISTEN, not a +-- pass/fail run: the entire symptom is a missing sound, so nothing here can +-- assert it. This only parks the player nose to beak with Speary, fires the +-- line once at real speed, and then hands the pad back. +-- +-- pokered's ViridianNicknameHouseSpearowText (scripts/ViridianNicknameHouse.asm) +-- is a text_asm that PrintTexts "SPEARY: Tetweet!", then runs PlayCry with +-- SPEAROW and WaitForSoundToFinish before TextScriptEnd. PrintText returns as +-- soon as the string is placed, so the cry starts only once the box has typed +-- out; and because ViridianNicknameHouse_Script is `jp EnableAutoTextBoxDrawing` +-- (AutoTextBoxDrawingCommon zeroes wDoNotWaitForButtonPressAfterDisplayingText, +-- home/window.asm), DisplayTextID still falls through AfterDisplayingTextID into +-- WaitForTextScrollButtonPress afterwards -- the box holds for A/B, it does not +-- pop itself. The port's talk script carried no cry row at all, so Speary was +-- mute; the fix is the play_cry waitForButton form +-- (data/scripts/flavor/viridian_nickname_house.lua + TextBox's opts.auto.wait). +-- +-- Do NOT run this under POKEPORT_SPEED. The logic clock is what gets scaled, +-- the audio clock is not, so a fast-forwarded run desynchronises the exact +-- typing-then-cry ordering this driver exists to let a human judge. +return function(game) + local U = dofile("tests/drivers/util.lua") + + -- ViridianNicknameHouse_Object object 3 is SPRITE_BIRD at cell (5, 5) + -- (pokered/data/maps/objects/ViridianNicknameHouse.asm). (5, 6) is the + -- floor cell directly below it, so facing up puts Speary in the cell + -- OverworldState:interact reads for an A press (npcAtCell on facingCell). + U.teleport(game, "VIRIDIAN_NICKNAME_HOUSE", 5, 6, "up") + U.wait(30) -- let the map music settle before anything has to be heard over it + + -- That object_event is WALK / LEFT_RIGHT and NPC:update paces it with no + -- range clamp, so left alone Speary strolls out from under the A press + -- between listens. Pin the wander rather than setting npc.frozen: talkTo + -- freezes and unfreezes around the box, so a frozen flag would be cleared + -- the moment the first listen ends. + local speary + for _, n in ipairs(game.overworld.npcs) do + if n.def and n.def.name == "VIRIDIANNICKNAMEHOUSE_SPEAROW" then speary = n end + end + if speary then + speary.wanders = false + else + U.log("WARNING: Speary is not on this map; check the object list.") + end + + U.log("#251 Speary the Spearow -- this one is judged by ear, not by eye.") + U.log("About to talk to Speary. The box types out \"SPEARY: Tetweet!\".") + U.log("PASS: the moment the last letter lands, a short SPEAROW cry plays,") + U.log(" and only after it finishes does the down arrow start blinking.") + U.log("FAIL (the bug): dead silence. The arrow blinks the instant the text") + U.log(" finishes and no cry is ever heard.") + U.log("ALSO FAIL: the box closes itself when the cry ends, with no A press.") + U.wait(90) -- a beat to read the above before the sound happens + + U.tap(game, "a") + + -- Call the moment out on the frame the typewriter finishes, which is where + -- PrintText returns and PlayCry runs in the ROM. + for _ = 1, 600 do + local top = game.stack:top() + if top and top.pages and top.done then break end + U.wait(1) + end + U.log(">>> NOW: the SPEAROW cry should be sounding. <<<") + + -- popup_fake_save hand-off: never touch input again, so real keyboard and + -- controller play works from here on. + U.log("Controls are yours. A closes the box; A again replays the line") + U.log("(Speary is pinned in front of you, so it stays one button).") + while true do + coroutine.yield() + end +end diff --git a/tests/drivers/trainer_run_bug239_test.lua b/tests/drivers/trainer_run_bug239_test.lua new file mode 100644 index 00000000..67d9f078 --- /dev/null +++ b/tests/drivers/trainer_run_bug239_test.lua @@ -0,0 +1,97 @@ +-- Driver: the trainer-battle run refusal prints its third line (#239). +-- A manual eye check, not a pass/fail run. +-- +-- pokered evidence. The string is three lines wide in a two-line box, and +-- data/generated/text.lua keeps the original's markers: +-- +-- _NoRunningText = "No! There's no\nrunning from a\011trainer battle!" +-- +-- \011 is \v, which src/render/TextBox.lua treats as ContText: show the +-- down-arrow, wait for a button, then scroll the third line in. The port +-- had hard-coded the same words with three \n instead, so all three lines +-- were queued onto a page that only shows two, "trainer battle!" was never +-- reached, and the command menu came straight back. +-- +-- The fix reads _NoRunningText from the generated data, with a correctly +-- marked literal as the fallback for an older cache. +-- +-- Do NOT run this under POKEPORT_SPEED. Fast-forward scales only the logic +-- clock while the typewriter runs on its own real-time accumulator +-- (src/core/Game.lua), so the pause being judged here would desynchronize. +-- +-- POKEPORT_DRIVER=tests/drivers/trainer_run_bug239_test.lua POKEPORT_IDENTITY=bug239 love . +return function(game) + local U = dofile("tests/drivers/util.lua") + local Pokemon = require("src.pokemon.Pokemon") + local BattleState = require("src.battle.BattleState") + local TextBox = require("src.render.TextBox") + + local function check(label, ok) + U.log(ok and "PASS" or "FAIL", label) + return ok + end + + -- ---- preconditions the eye cannot separate from the bug ---------------- + -- A wild battle instead of a trainer one shows no refusal at all, which + -- is not the same failure as a refusal that truncates. + + local text = game.data.text and game.data.text._NoRunningText + check("_NoRunningText is in the generated text", type(text) == "string") + if type(text) == "string" then + local pages = TextBox.paginate(text) + check("the refusal is one page of three lines", + #pages == 1 and #pages[1] == 3) + check("line 3 waits for a button (\\v, not \\n)", + pages.contBefore and pages.contBefore[1] + and pages.contBefore[1][3] == true) + check("line 3 really is 'trainer battle!'", + pages[1][3] and pages[1][3]:find("trainer battle!", 1, true) ~= nil) + end + + game.save.party = { Pokemon.new(game.data, "CHARIZARD", 50) } + game.save.player.name = "bryan" + + U.teleport(game, "PALLET_TOWN", 10, 8, "down") + U.wait(20) + + -- any trainer class works; the refusal is keyed on kind == "trainer", + -- not on who the trainer is + local OPP = "OPP_YOUNGSTER" + check("trainer class " .. OPP .. " exists in the data", + game.data.trainers and game.data.trainers[OPP] ~= nil) + + local ok, battle = pcall(BattleState.newTrainer, game, OPP, 1) + check("trainer battle constructed", ok and battle ~= nil) + if not ok then + U.log("could not start", OPP, "->", tostring(battle)) + U.log("pick another class from game.data.trainers and re-run") + else + check("battle kind is trainer (wild battles let you run)", + battle.kind == "trainer") + game.stack:push(battle) + U.wait(120) -- let the intro text and the throw settle + end + + U.log("........................................................") + U.log("READ NOW: choose RUN from the battle command menu.") + U.log(" RIGHT: 'No! There's no / running from a' fills the box, it holds") + U.log(" with a down-arrow until you press a button, and only then") + U.log(" does 'trainer battle!' scroll in. The command menu comes") + U.log(" back after that.") + U.log(" BUG #239 looks like: the box showing the first two lines and the") + U.log(" command menu snapping straight back, so the sentence ends") + U.log(" mid-phrase at 'running from a'.") + U.log(" ALSO WRONG: all three lines appearing at once with no pause (the") + U.log(" box only holds two, so something is overflowing), or the") + U.log(" third line arriving on a cleared page (that is \\f, not") + U.log(" \\v -- the original scrolls, it does not blank the box).") + U.log("Control case: run from a WILD battle and you should get the escape") + U.log(" roll instead, with no refusal text at all.") + U.log("Input is yours from here, so RUN can be re-selected as often as") + U.log("you like.") + U.log("........................................................") + + while true do + coroutine.yield() + end +end diff --git a/tests/drivers/wild_cry_timing_bug303_test.lua b/tests/drivers/wild_cry_timing_bug303_test.lua new file mode 100644 index 00000000..0f798cbd --- /dev/null +++ b/tests/drivers/wild_cry_timing_bug303_test.lua @@ -0,0 +1,96 @@ +-- Manual listening test for issue #303 ("Wild Pokemon's cry plays later +-- than intended"). +-- +-- Symptom: entering a wild battle, the enemy cry did not sound with the +-- "Wild X appeared!" box. It only fired once the player pressed A to +-- clear that box, so the cry landed on top of "Go! CHARMANDER!". +-- +-- Cause: BattleState:enter queued the cry in the block it SHARED with the +-- trainer and link branches, i.e. after self:say(self.introText). The +-- battle message queue is strictly serial -- BattleState:updateQueue will +-- not pull the next row while self.current is set, and a text row clears +-- self.current only on A/B -- so the cry act could not run until the intro +-- box was dismissed. The fix queues the cry per branch, ahead of the say +-- on the wild path. +-- +-- pokered: PrintBeginningBattleText (engine/battle/common_text.asm) takes +-- the wild path through `call PlayCry` and only then reaches `call +-- PrintText` with WildMonAppearedText, whose _WildMonAppearedText +-- (data/text/text_2.asm:1236-1241) ends in `prompt`. Cry first, button +-- wait after. Trainer and link battles keep the opposite order on purpose +-- (EnemySendOutFirstMon, engine/battle/core.asm:1421-1434: PrintText, then +-- AnimateSendingOutMon, then PlayCry), so this driver exercises only the +-- wild path; hearing a trainer's mon cry after its send-out text is +-- correct, not a regression. +-- +-- This is an audio bug, so nothing here can assert it: the driver parks a +-- human at the exact moment and then hands the pad back. It deliberately +-- never presses A (the whole point is hearing the cry BEFORE any A press) +-- and must run at real speed, so do not set POKEPORT_SPEED. +-- +-- Run: +-- POKEPORT_IDENTITY=bug303 \ +-- POKEPORT_DRIVER=tests/drivers/wild_cry_timing_bug303_test.lua love . +return function(game) + local U = dofile("tests/drivers/util.lua") + local Pokemon = require("src.pokemon.Pokemon") + local TextBox = require("src.render.TextBox") + local BattleState = require("src.battle.BattleState") + + -- a healthy lead, or BattleState.newWild finds no Party.firstHealthy, + -- sets self.dead and pops straight back out of enter() + game.save.party = { Pokemon.new(game.data, "CHARMANDER", 12) } + + -- VIRIDIAN_FOREST, the south-west grass patch (cells 1-5 x 40-43). The + -- forced encounter below is one this map really rolls: PIKACHU is grass + -- slots 9 and 10 in both versions (data/wild/maps/ViridianForest.asm), + -- and its cry is the one a listener is least likely to mistake for an + -- SFX or for the battle theme's first bar. The corner also sits clear + -- of the forest's Bug Catchers, whose sight lines would otherwise open a + -- trainer battle -- the branch whose cry order is deliberately unchanged. + U.teleport(game, "VIRIDIAN_FOREST", 3, 42, "down") + local ow = game.overworld + + -- Gate the encounter on the player rather than on a frame count: an + -- audio test is worthless if the cry fires while they are still reaching + -- for the headphones. TextBox pops itself on A and then calls onDone. + local ready = false + game.stack:push(TextBox.new(game, "Cry timing test.\nPress A to start!", + function() ready = true end)) + U.log("issue #303 -- wild cry timing, MANUAL listening test") + U.log("nothing here asserts; your ears are the verdict") + U.log("press A in the game window when you are ready to listen") + while not ready do U.wait(1) end + U.wait(10) + + local battle = BattleState.newWild(game, "PIKACHU", 5) + -- the real encounter path (OverworldState:checkEncounter) hands the + -- result to afterBattle, which is what heals, blacks out and offers + -- evolutions; keep it so the hand-off below leaves a normal game behind + battle.onFinish = function(result) ow:afterBattle(result, battle) end + + U.log("LISTEN: PIKACHU's cry should sound the instant the battle screen") + U.log(" wipes in, under the 'Wild PIKACHU appeared!' box, while") + U.log(" it types out and BEFORE you press anything") + U.log("BROKEN: that box is silent, and the cry only arrives after your") + U.log(" A press, over 'Go! CHARMANDER!'") + -- pushBattle starts the battle theme and the wipe first + -- (audio/play_battle_music.asm runs before the transition), so the cry + -- is a beat away, not immediate + ow:pushBattle(battle) + + -- mark the moment in the terminal so the log line and the sound line up + for _ = 1, 600 do + if game.stack:top() == battle then break end + U.wait(1) + end + U.log("battle up: the cry belongs to THIS box, not the one after A") + + -- popup_fake_save hand-off: never touch input again, so the player owns + -- the A press that clears the intro box. Another listen costs nothing: + -- run or win, then pace the grass tuft for a fresh wild roll (the forest + -- rate is 8/256 a step, so re-running this driver is the quicker repeat). + while true do + coroutine.yield() + end +end diff --git a/tests/engine/gate_strings_coverage.lua b/tests/engine/gate_strings_coverage.lua new file mode 100644 index 00000000..18daa4e3 --- /dev/null +++ b/tests/engine/gate_strings_coverage.lua @@ -0,0 +1,158 @@ +-- Ratchet: player-visible text in the engine stays reachable from a mod. +-- +-- Every string the engine writes itself goes through src/core/Strings.lua so +-- a translation can replace it (#186, #245). That is easy to land once and +-- easy to erode: the next battle message someone adds is a bare literal +-- unless something notices, and nothing notices, because a bare literal +-- works perfectly in English. +-- +-- So this gate re-runs the sweep's own test. In the files whose job is +-- drawing text, a literal carrying a line marker (\n, \f, \v) has to be +-- inside a Strings(...) or Strings.source(...) call. The exceptions below +-- are the shapes that legitimately carry a marker without being prose, and +-- the list only shrinks: an entry that no longer matches anything fails too, +-- so it cannot rot into a standing excuse. +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.modkit") +local FsIo = require("tests.fs_io") + +-- Files that put text in front of the player. Everywhere else the same +-- literal shapes are ids, log lines, wire framing and asset paths. +local WATCHED_DIRS = { + "src/battle", "src/world", "src/ui", "src/inventory", "src/pokemon", + "src/script", +} +local WATCHED_FILES = { + "src/link/LinkState.lua", "src/link/LinkBattle.lua", + "src/link/Tournament.lua", "src/link/Net.lua", + "src/mods/ManagerState.lua", "src/import/RomImporter.lua", + "src/core/DiscordPresence.lua", +} + +-- A marker in one of these is not prose. Keep the reason with the rule: +-- a bare pattern list is unreviewable six months from now. +local ALLOWED = { + { pattern = "gmatch", why = "iterating lines, the marker is the separator" }, + { pattern = "gsub", why = "rewriting markers, not printing them" }, + { pattern = ":find%(", why = "searching for a marker" }, + { pattern = ":match%(", why = "matching on a marker" }, + { pattern = "table%.concat", why = "joining pages with \\f" }, + { pattern = '%.%. "\\f"', why = "page-join glue between two texts" }, + { pattern = "error%(", why = "a developer error, never drawn" }, + { pattern = "Logger%.", why = "a log line, never drawn" }, + { pattern = '== "\\v"', why = "comparing against a marker, not printing it" }, + { pattern = "txBuf", why = "newline-delimited wire framing, not text" }, +} + +-- Whole files inside a watched directory that are exempt, with the reason. +local EXEMPT_FILES = { + ["src/ui/OakSpeech.lua"] = + "the _OakSpeech* literals are fallbacks for extracted text ids, so they " + .. "are already translatable through the text registry", +} + +local function isAllowed(line) + for _, rule in ipairs(ALLOWED) do + if line:find(rule.pattern) then return rule end + end + return nil +end + +-- Blank out every Strings(...) / Strings.source(...) call span, parens +-- balanced, so a call wrapped across lines counts as covered. A per-line +-- test reported the continuation lines of three real calls as misses. +local function stripStringsCalls(body) + local out, i, n = {}, 1, #body + while i <= n do + local s, e = body:find("Strings%.?s?o?u?r?c?e?%(", i) + if not s then out[#out + 1] = body:sub(i) break end + out[#out + 1] = body:sub(i, s - 1) + local depth, j = 0, body:find("%(", s) + while j and j <= n do + local c = body:sub(j, j) + if c == "(" then depth = depth + 1 + elseif c == ")" then + depth = depth - 1 + if depth == 0 then break end + end + j = j + 1 + end + j = j or n + -- keep the newlines so reported line numbers stay honest + out[#out + 1] = (body:sub(s, j):gsub("[^\n]", "")) + i = j + 1 + end + return table.concat(out) +end + +-- literals carrying a line marker, ignoring ones already inside a Strings call +local function offenders(body) + body = stripStringsCalls(body) + local out = {} + local lineno = 0 + for line in (body .. "\n"):gmatch("(.-)\n") do + lineno = lineno + 1 + local code = line:gsub("^%s*", "") + local isComment = code:sub(1, 2) == "--" + if not isComment then + -- a double-quoted literal containing \n, \f or \v + for literal in line:gmatch('"[^"]*"') do + if literal:find("\\[nfv]") and not isAllowed(line) then + out[#out + 1] = { line = lineno, text = code:sub(1, 90) } + break + end + end + end + end + return out +end + +local files = {} +for _, dir in ipairs(WATCHED_DIRS) do + for _, path in ipairs(FsIo.luaFilesUnder(dir)) do files[#files + 1] = path end +end +for _, path in ipairs(WATCHED_FILES) do files[#files + 1] = path end + +T.check(#files > 20, "the gate actually found the watched files") + +local usedRules, bad = {}, {} +local scanned = 0 +for _, path in ipairs(files) do + if not EXEMPT_FILES[path] then + local handle = io.open(path, "rb") + if handle then + local body = handle:read("*a") + handle:close() + scanned = scanned + 1 + for _, hit in ipairs(offenders(body)) do + bad[#bad + 1] = ("%s:%d %s"):format(path, hit.line, hit.text) + end + -- track which allowances are still earning their place + for line in (body .. "\n"):gmatch("(.-)\n") do + local rule = isAllowed(line) + if rule then usedRules[rule.pattern] = true end + end + end + end +end + +T.check(scanned > 20, "the gate read the files it listed") +T.check(#bad == 0, + ("%d player-visible literal(s) are not routed through Strings(...):\n %s") + :format(#bad, table.concat(bad, "\n "))) + +-- the ratchet half: an allowance nothing matches any more is dead weight +for _, rule in ipairs(ALLOWED) do + T.check(usedRules[rule.pattern] ~= nil, + ("the %q allowance (%s) matches nothing any more -- delete it") + :format(rule.pattern, rule.why)) +end + +for path, why in pairs(EXEMPT_FILES) do + local handle = io.open(path, "rb") + T.check(handle ~= nil, ("exempt file %s still exists (%s)"):format(path, why)) + if handle then handle:close() end +end + +T.finish("gate_strings_coverage") diff --git a/tests/mod_qol_hooks_tests.lua b/tests/mod_qol_hooks_tests.lua index b76e90b8..6ce77dfe 100644 --- a/tests/mod_qol_hooks_tests.lua +++ b/tests/mod_qol_hooks_tests.lua @@ -260,6 +260,46 @@ do "unwrapped pokemon.icon is vanilla") end +-- ------- field.playerPics / player.sprite (the player's own trainer art) + +do + local Sprites = require("src.pokemon.Sprites") + local vanilla = {} + local path, tc = Sprites.playerPath(vanilla, "back", { kind = "battle" }) + check(path == "assets/generated/battle/redb.png" and tc == false, + "unhooked player.sprite returns the vanilla back pic") + check(Sprites.playerPath(vanilla, "back", { kind = "battle", demo = true }) + == "assets/generated/battle/oldmanb.png", + "the catch tutorial resolves the old man in the player's place") + check(Sprites.playerPath(vanilla, "front", { kind = "trainer_card" }) + == "assets/generated/trainer_card/red.png", + "the front pic is the one the intro / card / Hall of Fame share") + + -- the data seam: one key replaced, the rest still vanilla + local data = { field = { playerPics = { back = "mods/hero/art/back.png" } } } + check(Sprites.playerPath(data, "back", { kind = "battle" }) + == "mods/hero/art/back.png", + "field.playerPics replaces the back pic") + check(Sprites.playerPath(data, "front", { kind = "trainer_card" }) + == "assets/generated/trainer_card/red.png", + "field.playerPics inherits the keys it does not set") + + -- the live seam: content has frozen, the hook still picks per save + local unsub = wrap("player.sprite", function(next, p, ctx) + check(ctx.side == "back" and ctx.kind == "battle" and ctx.demo == false, + "player.sprite ctx carries side/kind/demo") + ctx.trueColor = true + return "mods/outfits/art/blue_back.png" + end) + path, tc = Sprites.playerPath(vanilla, "back", { kind = "battle" }) + check(path == "mods/outfits/art/blue_back.png" and tc == true, + "player.sprite can swap path + trueColor after content freeze") + unsub() + check(Sprites.playerPath(vanilla, "back", { kind = "battle" }) + == "assets/generated/battle/redb.png", + "unwrapped player.sprite is vanilla") +end + -- silence unused import warnings in strict environments check(Player ~= nil and Music ~= nil, "player/music modules load") diff --git a/tests/mod_strings_tests.lua b/tests/mod_strings_tests.lua new file mode 100644 index 00000000..652d778a --- /dev/null +++ b/tests/mod_strings_tests.lua @@ -0,0 +1,155 @@ +-- The `strings` registry and src/core/Strings.lua: the seam a translation +-- mod uses to replace the text the engine authors itself, as opposed to the +-- extracted dialogue that `text` already covered (#186, #245). +-- +-- Keyed by the English source, so the assertions here are mostly about what +-- happens at the edges: a source nothing translated, a context that +-- disambiguates two identical sources, and a translation whose format +-- directives do not match the source (which would otherwise raise inside +-- string.format in the middle of a battle). +package.path = "./?.lua;./?/init.lua;" .. package.path +if not _G.love then _G.love = require("tests.love_stub") end + +local Loader = require("src.mods.Loader") +local Strings = require("src.core.Strings") +local Logger = require("src.core.Logger") + +local S = require("tests.harness").suite("strings registry") +local check, eq = S.check, S.eq + +-- the fs surface the loader needs, backed by a flat path->content table +local function memfs(files) + return { + read = function(path) return files[path] end, + getInfo = function(path) + if files[path] then return { type = "file" } end + local prefix = path .. "/" + for key in pairs(files) do + if key:sub(1, #prefix) == prefix then return { type = "directory" } end + end + return nil + end, + load = function(path) + if not files[path] then return nil, "no file: " .. path end + return load(files[path], path) + end, + getDirectoryItems = function(path) + local seen, items = {}, {} + local prefix = path .. "/" + for key in pairs(files) do + if key:sub(1, #prefix) == prefix then + local child = key:sub(#prefix + 1):match("^[^/]+") + if child and not seen[child] then + seen[child] = true + items[#items + 1] = child + end + end + end + table.sort(items) + return items + end, + } +end + +local function manifestJson(id) + return ([[{"id":"%s","name":"%s","version":"1.0.0","entry":"main.lua","dependencies":[]}]]) + :format(id, id) +end + +-- ------- the seam is declared where a mod looks for it + +do + local Schemas = require("src.mods.Schemas") + local spec = Schemas.REGISTRIES["strings"] + check(spec ~= nil, [[the "strings" registry is declared]]) + eq(spec.target, "strings", "it writes through to Data.strings") + eq(spec.semantics, "record", "one entry per source string, like text") +end + +-- ------- no mod: S is an identity function + +do + Strings.load({}) + check(not Strings.active(), "no catalog means the layer is inert") + eq(Strings("But, it failed!"), "But, it failed!", "an unloaded catalog passes text through") + eq(Strings("Wild %s\nappeared!", "PIDGEY"), "Wild PIDGEY\nappeared!", + "formatting still happens with no catalog") +end + +-- ------- a translation mod supplies the catalog + +local files = { + ["mods/fr/manifest.json"] = manifestJson("fr"), + ["mods/fr/main.lua"] = [[ +return function(mod) + mod.content.strings:override("But, it failed!", "Mais cela echoue !") + mod.content.strings:override("Wild %s\nappeared!", "Un %s\nsauvage apparait !") + mod.content.strings:override("OFF", "NON") + mod.content.strings:override("options.musicFilter|OFF", "AUCUN") + mod.content.strings:override("%d of %d", "%d perdus") +end +]], +} +local data = { strings = {} } +local loader = Loader.new({ fs = memfs(files) }) +check(loader:load(data) == true, "the translation mod loads headlessly") +eq(data.strings["But, it failed!"], "Mais cela echoue !", + "mod.content.strings merges into Data.strings") + +Strings.load(data) +check(Strings.active(), "a non-empty catalog activates the layer") + +-- ------- lookup + +do + eq(Strings("But, it failed!"), "Mais cela echoue !", "a plain source translates") + eq(Strings("Nothing happened!"), "Nothing happened!", + "a source the translation missed stays in English") + eq(Strings("Wild %s\nappeared!", "RATTATA"), "Un RATTATA\nsauvage apparait !", + "a formatted source translates and then formats") +end + +-- ------- context disambiguates identical sources + +do + eq(Strings("OFF", "options.musicFilter"), "AUCUN", + "a context key wins over the bare source") + eq(Strings("OFF"), "NON", "the bare source is still reachable") + eq(Strings("OFF", "options.tilt"), "NON", + "an unlisted context falls back to the bare source") +end + +-- ------- a mistranslated format string must not crash a battle + +-- Logger.history is a 200-line ring buffer that drops from the front, so +-- absolute indices shift under a full aggregator run. Count matches +-- instead of slicing. +local function complaints() + local n = 0 + for _, line in ipairs(Logger.history) do + if line:find("format directives", 1, true) then n = n + 1 end + end + return n +end + +do + local before = complaints() + eq(Strings("%d of %d", 3, 6), "3 of 6", + "a translation with the wrong arity falls back to the English source") + check(complaints() > before, "the arity mismatch is reported") + + -- and the complaint does not repeat on every frame it draws + local mid = complaints() + Strings("%d of %d", 1, 2) + eq(complaints(), mid, "the arity complaint is not repeated") +end + +-- The catalog is module state, and the aggregator runs every suite in one +-- Lua process: leaving it loaded translated the battle text underneath the +-- suites that run after this one (parity_J asserted on "But, it failed!" +-- and got the French). Put it back before handing the process on. +Strings.load({}) +check(not Strings.active(), "the catalog is unloaded for the suites after this one") +eq(Strings("But, it failed!"), "But, it failed!", "English is restored") + +S.finish() diff --git a/tests/parity_F.lua b/tests/parity_F.lua index 18f440fb..e90d1084 100644 --- a/tests/parity_F.lua +++ b/tests/parity_F.lua @@ -8,6 +8,8 @@ -- EVENT_BEAT_ROUTE22_RIVAL_1ST_BATTLE and the one-shot -- EVENT_GOT_POKEBALLS_FROM_OAK flag (data/scripts/oaks_lab.lua). -- Also #137: starter give_pokemon runs AskName (nickname yes/no). +-- Also #235: the Viridian Mart parcel walk replays its simulated joypad +-- states in the order the original consumes them. package.path = "./?.lua;./?/init.lua;" .. package.path if not _G.love then _G.love = require("tests.love_stub") end local Data = require("src.core.Data") @@ -147,4 +149,37 @@ eq(Game.save.party[1] and Game.save.party[1].species, "CHARMANDER", eq(Game.save.party[1] and Game.save.party[1].nickname, nil, "declining nickname leaves the species name") +-- === #235: the parcel walk goes up first, then left === +-- ViridianMartDefaultScript feeds .PlayerMovement (PAD_LEFT 1, PAD_UP 2) +-- through DecodeRLEList into wSimulatedJoypadStatesEnd and then walks the +-- index *down*, so the list plays back to front: up, up, left. Ending on +-- the left step is what leaves the player facing the clerk; replaying it +-- front to back parked them at the counter facing up. +do + local parcelSave = SaveData.newGame() + Flags.set(parcelSave, "EVENT_GOT_STARTER") + local realSave = Game.save + Game.save = parcelSave + + local queued + local fakeOw = { queueScript = function(_, rows) queued = rows end } + local mart = mapScripts.get("VIRIDIAN_MART") + check(mart and type(mart.onEnter) == "function", + "VIRIDIAN_MART has an onEnter parcel script") + mart.onEnter(Game, fakeOw) + check(type(queued) == "table", "entering the mart queues the parcel script") + + local moves = {} + for _, row in ipairs(queued or {}) do + if row[1] == "move_player" then moves[#moves + 1] = { row[2], row[3] } end + end + eq(#moves, 2, "the walk is two move_player runs") + eq(moves[1] and moves[1][1], "up", "first leg is up (#235)") + eq(moves[1] and moves[1][2], 2, "first leg is two cells") + eq(moves[2] and moves[2][1], "left", "second leg is left, facing the clerk") + eq(moves[2] and moves[2][2], 1, "second leg is one cell") + + Game.save = realSave +end + S.finish() diff --git a/tests/parity_bench_guys.lua b/tests/parity_bench_guys.lua new file mode 100644 index 00000000..bea7545b --- /dev/null +++ b/tests/parity_bench_guys.lua @@ -0,0 +1,89 @@ +-- Parity test: Pokemon Center bench guys. +-- Self-contained: run via `luajit tests/parity_bench_guys.lua`; also +-- dofile'd by tests/run_tests.lua's aggregator. +-- +-- data/events/bench_guys.asm maps (map id, player facing) to a predef text. +-- The catch is that every entry in that table names a *wrapper* label: +-- PewterCityPokecenterBenchGuyText:: is `text_far _PewterCityPokecenterGuyText`, +-- so the extractor records the wrapper while the string lands under the far +-- label. Looking up "_" .. label therefore missed on 10 of the 12 benches +-- and those guys answered with nothing at all (#248). Only Mt Moon and the +-- Celadon hotel worked, because there the two labels happen to coincide. +-- +-- This asserts the invariant rather than the table: every bench guy the +-- generated data knows about has to resolve to real text. A new center, or +-- an extractor that starts recording far labels directly, both stay green. + +package.path = "./?.lua;./?/init.lua;" .. package.path +if not _G.love then _G.love = require("tests.love_stub") end +local Data = require("src.core.Data") +if not (Data.maps and Data.maps.PALLET_TOWN) then Data:load() end + +local S = require("tests.harness").suite("parity bench guys") +local check, eq = S.check, S.eq + +local OverworldState = require("src.world.OverworldController") +local resolve = OverworldState.benchGuyText + +local benchGuys = Data.field and Data.field.hiddenExtras + and Data.field.hiddenExtras.benchGuys +check(type(benchGuys) == "table", "generated data carries a benchGuys table") + +local noFlags = { flags = {} } + +-- BenchGuyTextPointers in pokered, minus the Safari rest houses the +-- extractor also files here without a text label (those NPCs are ordinary +-- map objects, not bench guys, and are out of scope for #248). +local expected = { + "CELADON_HOTEL", "CELADON_POKECENTER", "CERULEAN_POKECENTER", + "CINNABAR_POKECENTER", "FUCHSIA_POKECENTER", "LAVENDER_POKECENTER", + "MT_MOON_POKECENTER", "PEWTER_POKECENTER", "ROCK_TUNNEL_POKECENTER", + "SAFFRON_POKECENTER", "VERMILION_POKECENTER", "VIRIDIAN_POKECENTER", +} +for _, mapId in ipairs(expected) do + check(benchGuys[mapId] ~= nil, "bench guy present for " .. mapId) +end + +-- the invariant: every labelled bench guy resolves to a non-empty string +local labelled = 0 +for mapId, list in pairs(benchGuys) do + for _, h in ipairs(list) do + if h.text then + labelled = labelled + 1 + local text = resolve(Data, noFlags, h.text) + check(type(text) == "string" and #text > 0, + ("%s bench guy resolves (%s)"):format(mapId, h.text)) + end + end +end +eq(labelled, 12, "all twelve BenchGuyTextPointers entries carry a label") + +-- the one from the issue: the Pewter couch guy yawning about JIGGLYPUFF +do + local pewter = benchGuys.PEWTER_POKECENTER and benchGuys.PEWTER_POKECENTER[1] + check(pewter ~= nil, "Pewter bench guy entry exists") + local text = pewter and resolve(Data, noFlags, pewter.text) + check(text and text:find("JIGGLYPUFF", 1, true) ~= nil, + "Pewter couch guy talks about JIGGLYPUFF (#248)") + check(text and text:find("Snore", 1, true) ~= nil, + "Pewter couch guy trails off snoring") + -- bench_guys.asm only answers when the player faces the seat + eq(pewter and pewter.facing, "left", "Pewter bench guy is faced from the left") +end + +-- SaffronCityPokecenterBenchGuyText is text_asm, so it branches on the flag +do + local before = resolve(Data, { flags = {} }, "SaffronCityPokecenterBenchGuyText") + local after = resolve(Data, + { flags = { EVENT_BEAT_SILPH_CO_GIOVANNI = true } }, + "SaffronCityPokecenterBenchGuyText") + check(type(before) == "string" and #before > 0, "Saffron bench guy pre-Silph text") + check(type(after) == "string" and #after > 0, "Saffron bench guy post-Silph text") + check(before ~= after, "Saffron bench guy changes after Giovanni is beaten") +end + +-- a label the table has never heard of stays nil rather than throwing +eq(resolve(Data, noFlags, "NoSuchBenchGuyText"), nil, "unknown label resolves to nil") +eq(resolve(Data, noFlags, nil), nil, "missing label resolves to nil") + +S.finish() diff --git a/tests/parity_evolution_cancel.lua b/tests/parity_evolution_cancel.lua new file mode 100644 index 00000000..25ffe8a1 --- /dev/null +++ b/tests/parity_evolution_cancel.lua @@ -0,0 +1,71 @@ +-- Parity test: which evolutions honour a B press. +-- Self-contained: run via `luajit tests/parity_evolution_cancel.lua`; also +-- dofile'd by tests/run_tests.lua's aggregator. +-- +-- engine/movie/evolution.asm Evolution_CheckForCancel reads the joypad each +-- flash and, on B, checks wForceEvolution before honouring it: +-- +-- .pressedB +-- ld a, [wForceEvolution] +-- and a +-- jr nz, .notAllowedToCancel +-- +-- ItemUseEvoStone sets wForceEvolution before calling TryEvolvingMon, so a +-- stone evolution reads the press and throws it away (#290). Trade +-- evolutions never reach the poll at all, since evos_moves.asm routes +-- LINK_STATE_TRADING past it (#213). That leaves level-up and rare candy, +-- which run with wForceEvolution clear, as the only cancelable kinds. + +package.path = "./?.lua;./?/init.lua;" .. package.path +if not _G.love then _G.love = require("tests.love_stub") end +local Data = require("src.core.Data") +if not (Data.maps and Data.maps.PALLET_TOWN) then Data:load() end + +local S = require("tests.harness").suite("parity evolution cancel") +local check, eq = S.check, S.eq + +local Game = require("src.core.Game") +local SaveData = require("src.core.SaveData") +local StateStack = require("src.core.StateStack") +Game.data = Data +Game.save = SaveData.newGame() +Game.stack = StateStack; StateStack:init() +require("src.render.Font").load(Data) + +local EvolutionState = require("src.ui.EvolutionState") + +-- `via` is the evolution method id out of Evolution.METHODS, which is also +-- what data/generated/pokemon.lua stores on each evolution record. +local function cancelable(via) + local mon = { species = "PIKACHU", level = 20 } + local state = EvolutionState.new(Game, mon, "RAICHU", function() end, via) + return state.cancelable +end + +eq(cancelable("LEVEL"), true, "level-up evolutions honour B") +eq(cancelable("ITEM"), false, "stone evolutions ignore B (#290)") +eq(cancelable("TRADE"), false, "trade evolutions ignore B (#213)") + +-- rare candy walks the same level-up path, so it stays cancelable; an +-- unknown/mod method has no wForceEvolution equivalent and defaults to the +-- cancelable branch rather than silently locking the player in +eq(cancelable(nil), true, "an unspecified method stays cancelable") +eq(cancelable("MOD_CUSTOM"), true, "a mod method defaults to cancelable") + +-- the stone methods really are the ITEM records, so the id above is the one +-- the data uses (this is what ties the assertion to real content) +do + local stoneEvos, itemMethod = 0, false + for _, def in pairs(Data.pokemon) do + for _, evo in ipairs(def.evolutions or {}) do + if evo.method == "ITEM" then + stoneEvos = stoneEvos + 1 + if evo.item then itemMethod = true end + end + end + end + check(stoneEvos > 0, "the dex actually contains stone evolutions") + check(itemMethod, "stone evolution records name the stone item") +end + +S.finish() diff --git a/tests/parity_hof.lua b/tests/parity_hof.lua index 7247b5c7..b38175ab 100644 --- a/tests/parity_hof.lua +++ b/tests/parity_hof.lua @@ -149,10 +149,13 @@ local saved = savedRaw and SaveData.decode(savedRaw) or nil check(saved ~= nil, "save.lua written and decodable") eq(saved and saved.lastHeal and saved.lastHeal.map, "PALLET_TOWN", "wLastBlackoutMap := PALLET_TOWN before the save") -eq(saved and saved.player and saved.player.map, "REDS_HOUSE_2F", - "CONTINUE lands in the bedroom (post-game home, #103)") -eq(saved and saved.player and saved.player.x, 3, "bedroom spawn x") -eq(saved and saved.player and saved.player.y, 6, "bedroom spawn y") +-- #103 parked CONTINUE in the upstairs bedroom; #253 corrected that to the +-- blackout point the Hall of Fame script actually writes, so the player +-- resumes outside the front door like the original. +eq(saved and saved.player and saved.player.map, "PALLET_TOWN", + "CONTINUE lands at the front door, not the bedroom (#253)") +eq(saved and saved.player and saved.player.x, 5, "Pallet spawn x") +eq(saved and saved.player and saved.player.y, 6, "Pallet spawn y") eq(saved and saved.lastOutdoor and saved.lastOutdoor.id, "PALLET_TOWN", "LAST_MAP exits aim at Pallet Town, not Indigo") check(saved and saved.postGameHomeOk, "postGameHomeOk marks the relocate done") @@ -171,7 +174,7 @@ eq(destMap, "PALLET_TOWN", "Red's house LAST_MAP -> PALLET_TOWN") eq(dx, 5, "Pallet door x") eq(dy, 5, "Pallet door y") --- stuck-save rescue: HoF + hallOfFame + no postGameHomeOk -> bedroom +-- stuck-save rescue: HoF + hallOfFame + no postGameHomeOk -> Pallet Town local stuck = SaveData.newGame() stuck.player.map = "HALL_OF_FAME" stuck.player.x, stuck.player.y = 4, 2 @@ -180,7 +183,7 @@ stuck.hallOfFame = { { { species = "PIKACHU", level = 81 } } } check(SaveData.needsPostGameRescue(stuck), "pre-fix softlock save needs rescue") SaveData.applyPostGameHome(stuck, Data.field.boot) check(not SaveData.needsPostGameRescue(stuck), "rescue is one-shot") -eq(stuck.player.map, "REDS_HOUSE_2F", "rescue warps home") +eq(stuck.player.map, "PALLET_TOWN", "rescue warps to the blackout point") eq(stuck.lastOutdoor.id, "PALLET_TOWN", "rescue retargets LAST_MAP") -- `jp Init`: everything popped, boot sequence pushed (intro -> title) diff --git a/tests/parity_saffron_gate.lua b/tests/parity_saffron_gate.lua index f3a07e00..fd8ce5d0 100644 --- a/tests/parity_saffron_gate.lua +++ b/tests/parity_saffron_gate.lua @@ -29,6 +29,10 @@ local check, eq = S.check, S.eq -- The gate pushes real TextBoxes, which want a loaded Font atlas; the -- decision under test is which branch runs, not how the text renders. +-- Restored at the end of the file: run_tests.lua dofiles every parity suite +-- into one process, so a stub left in package.loaded is inherited by every +-- suite that sorts after this one. +local realTextBox = package.loaded["src.render.TextBox"] package.loaded["src.render.TextBox"] = { new = function(_, text, done) return { text = text, done = done } end, } @@ -117,4 +121,6 @@ for _, id in ipairs({ "ROUTE_5_GATE", "ROUTE_6_GATE", check(M[id] and M[id].onStep, id .. " has the guard trigger") end +package.loaded["src.render.TextBox"] = realTextBox + S.finish() diff --git a/tests/parity_text_markers.lua b/tests/parity_text_markers.lua new file mode 100644 index 00000000..c6cdbd0b --- /dev/null +++ b/tests/parity_text_markers.lua @@ -0,0 +1,107 @@ +-- Parity test: text that has to wait for the player. +-- Self-contained: run via `luajit tests/parity_text_markers.lua`; also +-- dofile'd by tests/run_tests.lua's aggregator. +-- +-- The extractor emits three different break markers and they are not +-- interchangeable (src/render/TextBox.lua): \n opens the box's second line, +-- \v scrolls one line in after the down-arrow and a button press +-- (pokered ContText), and \f breaks the page, clearing and waiting. +-- +-- Hand-written strings that spell `cont` or `para` as a plain \n put more +-- lines on a page than the two-line box can hold and give the player +-- nothing to press, so the text pours past by itself. Two of those shipped: +-- +-- #239 the trainer-battle run refusal, which lost "trainer battle!" +-- #250 the Viridian caterpillar description, which scrolled six lines by +-- +-- Pagination is what the box actually consumes, so asserting on it (rather +-- than on the raw string) is what ties these to the observable symptom. + +package.path = "./?.lua;./?/init.lua;" .. package.path +if not _G.love then _G.love = require("tests.love_stub") end +local Data = require("src.core.Data") +if not (Data.maps and Data.maps.PALLET_TOWN) then Data:load() end + +local S = require("tests.harness").suite("parity text markers") +local check, eq = S.check, S.eq + +require("src.render.Font").load(Data) +local TextBox = require("src.render.TextBox") + +-- a page the box can actually show, and every line past the second on it +-- reached there by a \v the player has to release +local function pageIsPlayable(page, conts) + if #page <= 2 then return true end + for i = 3, #page do + if not (conts and conts[i]) then return false end + end + return true +end + +local function assertPlayable(label, text) + local pages = TextBox.paginate(text) + for i, page in ipairs(pages) do + check(pageIsPlayable(page, pages.contBefore and pages.contBefore[i]), + ("%s: page %d never runs past the box unattended"):format(label, i)) + end + return pages +end + +-- === #239: "No! There's no running from a trainer battle!" === +-- The generated string is the authority; the port's literal is only a +-- fallback for a cache that predates it. +do + local generated = Data.text._NoRunningText + check(type(generated) == "string", "_NoRunningText is in the generated text") + if generated then + local pages = assertPlayable("_NoRunningText", generated) + eq(#pages, 1, "the refusal is one page") + eq(#pages[1], 3, "the refusal is three lines in a two-line box") + check(pages.contBefore[1][3] == true, + "'trainer battle!' scrolls in on a button press, not on its own (#239)") + check(generated:find("trainer battle!", 1, true) ~= nil, + "the third line is the part the bug swallowed") + end + + -- the in-code fallback has to carry the same markers, or a pre-#239 cache + -- reintroduces the bug + local fallback = "No! There's no\nrunning from a\vtrainer battle!" + local pages = assertPlayable("run-refusal fallback", fallback) + eq(#pages[1], 3, "the fallback is three lines too") + check(pages.contBefore[1][3] == true, "the fallback waits on line 3") +end + +-- === #250: the CATERPIE / WEEDLE description === +-- pokered/text/ViridianCity.asm declares this one without the leading +-- underscore the extractor keys on, so it is not in data/generated/text.lua +-- and the port carries a literal. That makes the literal load-bearing. +do + check(Data.text.ViridianCityYoungster2CaterpieAndWeedleDescriptionText == nil + and Data.text._ViridianCityYoungster2CaterpieAndWeedleDescriptionText == nil, + "the description really is absent from generated text (hence a literal)") + + local desc = "CATERPIE has no\npoison, but\vWEEDLE does.\fWatch out for its\nPOISON STING!" + local pages = assertPlayable("caterpillar description", desc) + eq(#pages, 2, "para breaks the description into two pages (#250)") + eq(#pages[1], 3, "page 1 is text + line + cont") + eq(#pages[2], 2, "page 2 is para + line") + check(pages.contBefore[1][3] == true, "'WEEDLE does.' waits for a button") + + -- and the shape the bug had: one page, six lines, nothing to press + local broken = "CATERPIE has no\npoison, but\nWEEDLE does.\n\nWatch out for its\nPOISON STING!" + local brokenPages = TextBox.paginate(broken) + eq(#brokenPages, 1, "the old spelling collapsed to a single page") + check(#brokenPages[1] > 2, "the old spelling overfilled that page") + check(not pageIsPlayable(brokenPages[1], brokenPages.contBefore[1]), + "the old spelling is exactly what this suite is here to catch") +end + +-- the ask that precedes it uses the generated string, which already carries +-- \v, so it is a live example of the marker done right +do + local ask = Data.text._ViridianCityYoungster2YouWantToKnowAboutText + check(type(ask) == "string", "the caterpillar question is in generated text") + if ask then assertPlayable("caterpillar question", ask) end +end + +S.finish() diff --git a/tests/parity_text_wrap.lua b/tests/parity_text_wrap.lua new file mode 100644 index 00000000..222f50c4 --- /dev/null +++ b/tests/parity_text_wrap.lua @@ -0,0 +1,205 @@ +-- Parity test: the dialogue box soft-wraps on glyphs, not bytes. +-- Self-contained: run via `luajit tests/parity_text_wrap.lua`; also +-- dofile'd by tests/run_tests.lua's aggregator. +-- +-- TextBox.paginate used to compare `#line` against maxCols. `#` is a byte +-- count, and 604 of the extracted strings carry a multi-byte charmap +-- sequence: "é" in POKéMON and POKéDEX, "♂"/"♀" on the NIDORANs, "×". +-- So the box measured "I study POKéMON as" as 19 wide and broke a line +-- that draws 18 glyphs, and when no space was in reach it cut *inside* the +-- é, leaving two invalid bytes that Font.encode renders as two spaces. +-- +-- Both symptoms are English-only bugs, but the same arithmetic is what +-- decides whether a translation mod can wrap at all: a kana page measured +-- in bytes wraps every fourth glyph (#186, #245). So the assertions here +-- come in two halves: the vanilla lines that must stop wrapping, and the +-- non-ASCII shapes a translator will actually hand the box. + +package.path = "./?.lua;./?/init.lua;" .. package.path +if not _G.love then _G.love = require("tests.love_stub") end +local Data = require("src.core.Data") +if not (Data.maps and Data.maps.PALLET_TOWN) then Data:load() end + +local S = require("tests.harness").suite("parity text wrap") +local check, eq = S.check, S.eq + +local Font = require("src.render.Font") +Font.load(Data) +local TextBox = require("src.render.TextBox") + +local COLS = 18 + +-- An oracle independent of Font.split: count UTF-8 lead bytes. Valid for +-- the extracted text because none of it spells a multi-char ASCII ligature +-- (``, `'d`); the ligature cases below are asserted separately. +local function codepoints(s) + local n = 0 + for i = 1, #s do + local b = s:byte(i) + if b < 0x80 or b > 0xBF then n = n + 1 end + end + return n +end + +-- Whole-string UTF-8 validity. Note this is not "the last byte is a lead +-- byte": a well-formed 3-byte kana ends on a continuation byte by +-- definition. What a torn cut leaves behind is a sequence whose +-- continuation bytes are missing or orphaned, which is what this catches. +local function isValidUtf8(s) + local i, n = 1, #s + while i <= n do + local b = s:byte(i) + local extra + if b < 0x80 then extra = 0 + elseif b >= 0xC2 and b <= 0xDF then extra = 1 + elseif b >= 0xE0 and b <= 0xEF then extra = 2 + elseif b >= 0xF0 and b <= 0xF4 then extra = 3 + else return false end -- a bare continuation byte, or an invalid lead + if i + extra > n then return false end + for k = i + 1, i + extra do + local c = s:byte(k) + if c < 0x80 or c > 0xBF then return false end + end + i = i + extra + 1 + end + return true +end + +-- ---------- Font.split is a partition, not a filter ---------- + +do + for _, sample in ipairs({ + "POKéMON", "NIDORAN♂", "A", "", "", "12 × 34", "I'd like that", + "たたかう", "Une créature", + }) do + local rebuilt = {} + for _, span in ipairs(Font.split(sample)) do + rebuilt[#rebuilt + 1] = sample:sub(span.from, span.to) + end + eq(table.concat(rebuilt), sample, ("split round-trips %q"):format(sample)) + end + + eq(#Font.split("POKéMON"), 7, "POKéMON is 7 glyphs (8 bytes)") + eq(#Font.split("NIDORAN♂"), 8, "NIDORAN♂ is 8 glyphs (10 bytes)") + eq(#Font.split(""), 2, "the PK/MN ligatures are one glyph each") + eq(#Font.split("I'd"), 2, "the 'd ligature is one glyph") +end + +-- ---------- the vanilla lines that were breaking ---------- + +-- Every line the extractor already broke for us fits the box by design. +-- Any of them that paginate splits again is a line the player sees wrapped +-- in a place the original never wrapped it. +do + local spurious = {} + for id, text in pairs(Data.text) do + if type(text) == "string" then + for line in (text .. "\n"):gmatch("(.-)[\n\11\12]") do + if line ~= "" and codepoints(line) <= COLS then + local pages = TextBox.paginate(line, COLS) + if #pages[1] > 1 then + spurious[#spurious + 1] = ("%s: %q"):format(id, line) + end + end + end + end + end + eq(#spurious, 0, "no extracted line that fits in 18 glyphs gets re-wrapped" + .. (spurious[1] and (" (e.g. " .. spurious[1] .. ")") or "")) +end + +-- The named ones, so a regression names itself instead of showing a count. +do + for _, line in ipairs({ + "I study POKéMON as", -- _OaksLabScientistText + "A sleeping POKéMON", -- _Route12SnorlaxText + "POKéMON are living", -- _BluesHouseDaisyWalkingText + "POKé FLUTE awakens", -- _CeladonPokecenterGentlemanText + "How's your POKéDEX", -- _PokemonTower2FRivalHowsYourDexText + }) do + eq(#line, 19, ("%q really is 19 bytes"):format(line)) + eq(codepoints(line), 18, ("%q really is 18 glyphs"):format(line)) + local pages = TextBox.paginate(line, COLS) + eq(#pages[1], 1, ("%q stays on one line"):format(line)) + end +end + +-- ---------- ASCII behaviour is untouched ---------- + +-- The fix must not move a single pure-ASCII break, or every hand-ported +-- script that was tuned against the old wrap shifts under it. +do + local function byteWrap(line, maxCols) + local out = {} + while #line > maxCols do + local cut = maxCols + for i = maxCols, 1, -1 do + if line:sub(i, i) == " " then cut = i break end + end + out[#out + 1] = line:sub(1, cut) + line = line:sub(cut + 1) + end + out[#out + 1] = line + return out + end + + for _, line in ipairs({ + "ABCDEFGH IJKLMNOPQ", + "ABCDEFGHIJKLMNOPQRSTUVWXYZ", + "the quick brown fox jumps over the lazy dog", + "SUPERLONGWORDWITHNOSPACESATALLHERE and more", + "trailing space at the end ", + "short", + }) do + local want = byteWrap(line, COLS) + local got = TextBox.paginate(line, COLS)[1] + eq(#got, #want, ("ASCII %q wraps into the same number of lines"):format(line)) + for i = 1, math.max(#got, #want) do + eq(got[i], want[i], ("ASCII %q line %d is unchanged"):format(line, i)) + end + end +end + +-- ---------- what a translation actually hands the box ---------- + +do + -- French: accented Latin, spaces to break on. Measured in bytes this + -- broke after "créature"; measured in glyphs it fills the box. + local fr = "Une créature très étrange apparait" + local lines = TextBox.paginate(fr, COLS)[1] + for i, line in ipairs(lines) do + check(codepoints(line) <= COLS, + ("french line %d fits in 18 glyphs (got %d)"):format(i, codepoints(line))) + check(isValidUtf8(line), + ("french line %d is not cut mid-character"):format(i)) + end + + -- Japanese: 3-byte kana and no spaces at all, the worst case for a + -- byte-wise cut. Byte wrapping gave 6 glyphs a line. + local jp = "ポケモンずかんをかんせいさせてください" + local jpLines = TextBox.paginate(jp, COLS)[1] + eq(#jpLines, 2, "19 kana with no spaces wrap into two lines, not four") + eq(codepoints(jpLines[1]), COLS, "the first kana line is a full 18 glyphs") + for i, line in ipairs(jpLines) do + check(isValidUtf8(line), + ("kana line %d is not cut mid-character"):format(i)) + end + + -- The exact shape that used to tear a character in half: 17 ASCII then a + -- 2-byte char, so the byte-18 cut landed inside it. + local torn = "ABCDEFGHIJKLMNOPQétrange" + local tornLines = TextBox.paginate(torn, COLS)[1] + for i, line in ipairs(tornLines) do + check(isValidUtf8(line), + ("torn line %d is not cut mid-character"):format(i)) + end + eq(table.concat(tornLines), torn, "wrapping loses no bytes") +end + +-- A glyph wider than the whole box still has to advance, or pushLine spins. +do + local pages = TextBox.paginate("ありがとう", 1) + check(#pages[1] >= 5, "a one-column box still emits one glyph per line") +end + +S.finish() diff --git a/tests/parity_tower_rival.lua b/tests/parity_tower_rival.lua index 6e734f05..a55cedca 100644 --- a/tests/parity_tower_rival.lua +++ b/tests/parity_tower_rival.lua @@ -12,6 +12,11 @@ if not _G.love then _G.love = require("tests.love_stub") end local S = require("tests.harness").suite("parity tower rival") local check, eq = S.check, S.eq +-- Both stubs are restored at the end of the file: run_tests.lua dofiles +-- every parity suite into one process, so anything left in package.loaded is +-- inherited by every suite that sorts after this one. +local realTextBox = package.loaded["src.render.TextBox"] +local realMusic = package.loaded["src.core.Music"] package.loaded["src.render.TextBox"] = { new = function(_, text, done) return { text = text, done = done } end, } @@ -135,4 +140,7 @@ do Commands.hide_object = realHide end +package.loaded["src.render.TextBox"] = realTextBox +package.loaded["src.core.Music"] = realMusic + S.finish() diff --git a/tools/gen_registry_docs.lua b/tools/gen_registry_docs.lua index 16c77efc..d86bcb09 100644 --- a/tools/gen_registry_docs.lua +++ b/tools/gen_registry_docs.lua @@ -97,6 +97,14 @@ for _, name in ipairs(names) do line("%s", spec.example) line("```") end + -- prose a registry needs beyond its schema (resolution order, the + -- guarantees a value carries). It lives on the spec rather than in the + -- page because this file is regenerated: hand-written paragraphs in the + -- output are deleted by the next run. + if spec.notes then + line("") + line("%s", spec.notes) + end end line("") diff --git a/tools/modkit.py b/tools/modkit.py index 094c0a89..9b29266e 100644 --- a/tools/modkit.py +++ b/tools/modkit.py @@ -6,6 +6,8 @@ Subcommands: scaffold [--profile content|overhaul|total_conversion] [--api 2] [--dest DIR] [--force] + translation [--language NAME] [--base auto|fixture|imported] + [--refresh] [--dest DIR] validate [--strict] [--base auto|fixture|imported] lint pack [-o out.modpkg] @@ -35,6 +37,7 @@ import os import re import subprocess import sys +import unicodedata import tempfile import zipfile from datetime import datetime, timezone @@ -1145,6 +1148,652 @@ def cmd_bounce(args, repo): return 0 if proc.returncode == 0 else 1 +# ----------------------------------------------------------- translation + +# Dumps the player-facing tables out of a loaded dataset as TSV, both fields +# already Lua-quoted so the generator can paste them straight into the +# catalogs without a second round of escaping. +TRANSLATION_DUMP = """-- generated by tools/modkit.py; dumps translatable data as TSV +package.path = "./?.lua;./?/init.lua;" .. package.path +if not _G.love then _G.love = require("tests.love_stub") end +local D = {{BASE}} + +-- Not %q: that escapes a newline as a backslash followed by a real line +-- break, which would split every dex entry across several TSV rows and +-- silently truncate it. Escape by hand so a value is always one line, and +-- keep the readable spellings (\\n, not \\10) because these land in the +-- comment a translator reads. +local ESCAPES = { ["\\\\"] = "\\\\\\\\", ['"'] = '\\\\"', + ["\\n"] = "\\\\n", ["\\r"] = "\\\\r", ["\\t"] = "\\\\t" } +local function esc(s) + local body = s:gsub('[%c\\\\"]', function(c) + return ESCAPES[c] or ("\\\\%d"):format(c:byte()) + end) + return '"' .. body .. '"' +end + +local function emit(kind, key, value) + if type(value) ~= "string" or value == "" then return end + io.write(kind, "\\t", esc(key), "\\t", esc(value), "\\n") +end + +for id, text in pairs(D.text or {}) do emit("dialogue", id, text) end +for id, def in pairs(D.pokemon or {}) do emit("species", id, def.name) end +for id, def in pairs(D.moves or {}) do emit("move", id, def.name) end +for id, def in pairs(D.items or {}) do emit("item", id, def.name) end +for id, def in pairs(D.trainers or {}) do emit("trainer", id, def.name) end +-- Data.statuses only exists once the mod merge has run, so fall back to the +-- engine's own records: they are what a mod-free boot puts there anyway. +local statuses = D.statuses +if not statuses or next(statuses) == nil then + statuses = require("src.battle.Status").RECORDS +end +for id, def in pairs(statuses or {}) do + emit("status", id, def.label) + if def.hudLabel and def.hudLabel ~= def.label then + emit("status_hud", id, def.hudLabel) + 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 + emit("dex", id, def.dexEntry.kind) + emit("dex_text", id, def.dexEntry.text) + end +end +""" + +# The engine's own literals, harvested from the Strings(...) call sites. +# Matches Strings("...") and Strings('...'), single line, which is how the +# sweep writes them; a call whose source string is built at runtime cannot +# be translated and is not meant to match here. +STRINGS_CALL = re.compile( + r'\bStrings(?:\.source)?\(' + r'\s*("(?:[^"\\]|\\.)*"|\'(?:[^\'\\]|\\.)*\')\s*(?:,|\))') + + +def _fold_ascii(text): + folded = unicodedata.normalize("NFKD", text or "") + folded = "".join(c for c in folded if not unicodedata.combining(c)) + folded = re.sub(r"[^A-Za-z0-9_-]+", "_", folded).strip("_-") + return folded.lower() + + +def ascii_mod_id(name, language=None): + r"""An engine-legal manifest id derived from what the author called it. + + src/mods/Manifest.lua matches `^[%w_%-]+$`, and Lua's %w is ASCII where + Python's \w is not, so "VersaoVermelha" with a tilde loads fine by + Python's rules and is rejected by the engine's. The directory keeps the + author's name (the loader keys on manifest.id, not the folder); this is + only the id. + + Accented Latin folds cleanly. A name written entirely in a non-Latin + script does not, and those are precisely the translations worth + supporting, so fall back to the --language name and then to a stable + digest rather than refusing to scaffold.""" + for candidate in (_fold_ascii(name), _fold_ascii(language)): + if candidate: + return candidate + digest = hashlib.sha1((name or "").encode("utf-8")).hexdigest()[:8] + return f"translation_{digest}" + + +def harvest_engine_strings(repo): + """Every literal the engine passes through src/core/Strings.lua, in + source order per file. Returns [(lua_literal, "path:line"), ...].""" + out, seen = [], set() + src = os.path.join(repo, "src") + for root, _dirs, names in os.walk(src): + for name in sorted(names): + if not name.endswith(".lua"): + continue + path = os.path.join(root, name) + rel = os.path.relpath(path, repo) + with open(path, encoding="utf-8") as handle: + body = handle.read() + for match in STRINGS_CALL.finditer(body): + literal = match.group(1) + if literal.startswith("'"): + # normalise to the double-quoted form the catalog uses + literal = '"' + literal[1:-1].replace('"', '\\"') + '"' + line = body.count("\n", 0, match.start()) + 1 + if literal in seen: + continue + seen.add(literal) + out.append((literal, f"{rel}:{line}")) + return out + + +def dump_dataset(repo, base): + """Run the dumper under luajit against the fixture or imported cache.""" + source = IMPORTED_BASE if base == "imported" else FIXTURE_BASE + body = TRANSLATION_DUMP.replace("{{BASE}}", source) + handle = tempfile.NamedTemporaryFile("w", suffix=".lua", delete=False, + dir=repo, encoding="utf-8") + handle.write(body) + handle.close() + try: + proc = subprocess.run([os.environ.get("LUA", "luajit"), handle.name], + cwd=repo, capture_output=True, text=True) + finally: + os.unlink(handle.name) + if proc.returncode != 0: + raise RuntimeError(proc.stderr.strip() or "dataset dump failed") + rows = [] + for line in proc.stdout.splitlines(): + parts = line.split("\t") + if len(parts) == 3: + rows.append(tuple(parts)) + return rows + + +def _catalog_file(title, note, entries, keyed_by_source=False): + """One lang/*.lua table: every value starts empty, and main.lua skips + empties so an unfinished catalog falls through to English. + + The English is deliberately NOT written alongside the ROM-derived keys. + Extracted script text and vanilla names are ROM content, and a mod that + shipped 2500 lines of them in comments would be redistributing the ROM + however good the intent. Those go to worksheet/, which is gitignored and + never packed. Engine-authored sources (lang/strings.lua) are this repo's + own Lua, so there the key IS the English and nothing is leaked.""" + out = [f"-- {title}", "--"] + out += ["-- " + line for line in note.strip().splitlines()] + out += ["", "return {"] + if not entries: + out.append(" -- nothing to translate here yet") + for key, _english in entries: + out.append(f" [{key}] = \"\",") + out += ["}", ""] + return "\n".join(out) + + +TRANSLATION_MAIN = '''-- {{name}}: a translation of the game into {{lang}}. +-- +-- Nothing here is translated yet. Every table under lang/ starts with +-- empty strings; fill one in and it takes effect on the next boot, and +-- anything still empty keeps rendering in English. That means a +-- half-finished translation is always playable, so you can ship early and +-- fill the long tail in later. +-- +-- Read TRANSLATING.md before the first edit; the font is the part people +-- get wrong. +return function(mod) + -- mod:read is the supported way into your own directory; the catalogs are + -- plain Lua tables, so read and run them rather than require()ing them. + local function catalog(name) + local rel = "lang/" .. name .. ".lua" + local body = mod:read(rel) + if not body then return {} end + local chunk, err = loadstring(body, rel) + if not chunk then + mod.log:warn("%s has a syntax error: %s", rel, tostring(err)) + return {} + end + local ok, table_ = pcall(chunk) + if not ok or type(table_) ~= "table" then + mod.log:warn("%s did not return a table: %s", rel, tostring(table_)) + return {} + end + return table_ + end + + -- An empty value means "not translated yet", never "translate to blank". + local function each(name, apply) + local n = 0 + for key, value in pairs(catalog(name)) do + if type(value) == "string" and value ~= "" then + apply(key, value) + n = n + 1 + end + end + return n + end + + -- ---- glyphs ------------------------------------------------------- + -- Register the sheet BEFORE anything asks for a glyph on it. base is + -- the first code the page owns; 0x100 and up is free space above the + -- vanilla pages, so a new alphabet never collides with them. + for id, page in pairs(catalog("font")) do + mod.content.font:register(id, page) + end + -- charmap: which byte sequence draws which code + for seq, code in pairs(catalog("charmap")) do + mod.content.font:register("charmap:" .. seq, { seq = seq, code = code }) + end + + -- ---- text --------------------------------------------------------- + local counts = {} + counts.dialogue = each("dialogue", function(id, value) + mod.content.text:override(id, value) + end) + counts.strings = each("strings", function(source, value) + mod.content.strings:override(source, value) + end) + counts.species = each("species_names", function(id, value) + mod.content.pokemon:patch(id, { name = value }) + end) + counts.moves = each("move_names", function(id, value) + mod.content.moves:patch(id, { name = value }) + end) + counts.items = each("item_names", function(id, value) + mod.content.items:patch(id, { name = value }) + end) + counts.trainers = each("trainer_names", function(id, value) + mod.content.trainers:patch(id, { name = value }) + end) + counts.statuses = each("status_labels", function(id, value) + mod.content.statuses:patch(id, { label = value }) + end) + + -- ---- name entry --------------------------------------------------- + -- The naming screen's letter grid. Leave lang/naming.lua returning nil + -- to keep the English alphabet. + local grid = catalog("naming") + if grid.upper then + mod.hooks:on("ui.naming.grid", function(base, ctx) + local want = ctx.lower and grid.lower or grid.upper + return want or base + end) + end + + mod.events:on("game.ready", function() + local total = 0 + for _, n in pairs(counts) do total = total + n end + mod.log:info("{{lang}}: %d strings translated", total) + end) +end +''' + +TRANSLATING_MD = '''# Translating into {{lang}} + +Everything the player can read is one of two kinds of string, and they live +in different places for a reason. + +| lang/ file | What it is | Key | +|---|---|---| +| `dialogue.lua` | Every line of extracted script text | the original label, e.g. `_PalletTownText1` | +| `strings.lua` | Text the engine itself writes: battle messages, menus, link play | the English source string | +| `species.lua` `moves.lua` `items.lua` `trainers.lua` | Names | the vanilla id | +| `statuses.lua` | `PSN`, `BRN`, ... as they appear in the HUD | the status id | +| `font.lua` `charmap.lua` | Your glyph sheet and what draws what | see below | +| `naming.lua` | The letter grid for entering names | - | + +Fill in a value and it takes effect. Leave it `""` and that string stays in +English, so the game is playable at every point along the way. + +## Where the English is + +The catalogs hold keys and *your* text, never the original English. The +English lives next door, in `{{id}}-worksheet/`, one tab-separated file per +catalog: + +``` +"_AbandonLearningText"\t"Abandon learning\\n{RAM:wStringBuffer}?" +``` + +That directory is deliberately outside the mod. Extracted script text and +the vanilla names are ROM content, and `modkit pack` zips everything under +the mod directory, so a worksheet kept inside would end up in your release +whatever a `.gitignore` said. Keep it beside the mod, never in it. + +`lang/strings.lua` is the exception: those sources are the engine's own Lua +rather than anything out of the ROM, so there the key *is* the English and +you can translate straight from it. + +## Start with the font, not the text + +The engine draws from **glyph pages**: an image of 8x8 cells plus a charmap +saying which byte sequence draws which cell. The vanilla pages sit at `$60` +and `$80`. Anything from `0x100` up is free, so a new alphabet is added +rather than swapped in: + +```lua +-- lang/font.lua +return { + {{lang_id}} = { + image = "assets/font/{{lang_id}}.png", + base = 0x100, -- first code this page owns + glyphsPerRow = 16, + -- advance = 8, -- set this if your glyphs are not 8px wide + }, +} +``` + +```lua +-- lang/charmap.lua: sequence -> code, in the same order as the sheet +return { + ["A"] = 0x100, + ["B"] = 0x101, +} +``` + +The sheet is a plain PNG, 16 glyphs to a row by default, each cell 8x8, +black on white like `assets/generated/font.png`. Codes run left to right, +top to bottom from `base`. + +Sequences are matched **longest first**, so a multi-byte character and a +multi-character ligature both work and neither shadows the other: + +```lua +["\\u{3042}"] = 0x120, -- one 3-byte character, one glyph +["ch"] = 0x121, -- two ASCII letters, one glyph +``` + +## Line length is counted in glyphs + +The dialogue box fits 18 glyphs a line, not 18 bytes. A 3-byte character +costs one column, and the engine will never cut a character in half. Your +own `\\n` line breaks are respected exactly as written, so break lines where +they read best rather than where they fit English. + +If your glyphs are not 8px wide, set `advance` on the page and the box +re-measures. + +## Format directives must survive + +Some sources carry `%s` or `%d`: + +```lua +["Wild %s\\nappeared!"] = "...", +``` + +Keep every directive, in a count that matches. Word order is yours to +change; the engine substitutes in the order the directives appear, so if +your language needs the name last, write the sentence with the `%s` last. +A translation whose directive count does not match the English is refused +at runtime and the English is drawn instead, with a line in the log saying +so - it will not crash a battle. + +## Checking your work + +```sh +python3 tools/modkit.py validate {{id}} --base imported +python3 tools/modkit.py translation {{id}} --refresh # pick up new engine strings +POKEPORT_DEV=1 scripts/run.sh # F5 hot-reloads lang/ +``` + +`--refresh` rewrites the catalogs from the current engine, keeping every +translation you have already written and reporting what changed. Run it +after pulling a new engine version. +''' + +TRANSLATION_README = '''# {{name}} + +A {{lang}} translation of the game. + +Generated with `python3 tools/modkit.py translation {{id}}`. See +`TRANSLATING.md` for how to work on it. + +## Status + +Nothing is translated yet: {{total}} strings are waiting in `lang/`. + +| Catalog | Entries | +|---|---| +{{table}} + +## Layout + +- `manifest.json` - identity and the engine version range +- `main.lua` - registers whatever is filled in and skips whatever is not +- `lang/` - the catalogs; this is the whole job +- `assets/font/` - your glyph sheet +''' + +FONT_README = '''Put your glyph sheet here. + +A page is a PNG of 8x8 cells, 16 per row by default, black on white. Codes +run left to right and top to bottom starting at the page's `base`, so the +first cell is `base`, the second `base + 1`, and so on. + +`assets/generated/font.png` in the player's cache is the vanilla sheet at +the same scale; open it alongside yours to match weight and baseline. + +Declare the sheet in `lang/font.lua` and map sequences to codes in +`lang/charmap.lua`. +''' + + +def cmd_translation(args, repo): + """Scaffold (or refresh) a translation mod: every player-visible string + the engine and the dataset know about, as empty catalogs to fill in.""" + dest = os.path.join(args.dest or os.path.join(repo, "mods"), args.id) + # Translations get named in the language they translate into + # ("VersaoVermelha", with the tilde), but the engine's manifest rule is + # Lua's `^[%w_%-]+$`, and Lua's %w is ASCII-only where Python's \w is not. + # A directory named in the target language is fine -- the loader keys on + # manifest.id, not the folder -- so keep the name the author asked for and + # derive an ASCII id for the manifest. + mod_id = ascii_mod_id(args.id, args.language) + exists = os.path.exists(dest) + if exists and not (args.refresh or args.force): + print(f"modkit: {dest} exists (use --refresh to update the catalogs)") + return 2 + + base = resolve_base(repo, args.base) + try: + rows = dump_dataset(repo, base) + except RuntimeError as err: + print(f"modkit: could not read the dataset ({err})") + return 1 + + grouped = {} + for kind, key, value in rows: + grouped.setdefault(kind, []).append((key, value)) + for entries in grouped.values(): + entries.sort() + + engine = harvest_engine_strings(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, + "Keyed by the English source, which is also what draws if you leave\n" + "an entry empty. Keep any %s / %d directives."), + ("species_names", "Species names", grouped.get("species", []), False, ""), + ("move_names", "Move names", grouped.get("move", []), False, ""), + ("item_names", "Item names", grouped.get("item", []), False, ""), + ("trainer_names", "Trainer class names", grouped.get("trainer", []), False, ""), + ("status_labels", "Status labels", grouped.get("status", []), False, + "Short enough for the battle HUD: the vanilla ones are three glyphs."), + ] + + # keep existing translations across a --refresh + previous = {} + if exists: + for name, *_ in catalogs: + path = os.path.join(dest, "lang", f"{name}.lua") + previous[name] = _read_existing_catalog(path) + + os.makedirs(os.path.join(dest, "lang"), exist_ok=True) + os.makedirs(os.path.join(dest, "assets", "font"), exist_ok=True) + + lang_name = args.language or args.id.replace("_", " ").title() + counts, changed, kept = {}, {}, {} + for name, title, entries, by_source, note in catalogs: + done = previous.get(name, {}) + body = _catalog_file(title, note or f"{title} for {lang_name}.", + entries, by_source) + if done: + body = _merge_catalog(body, done) + path = os.path.join(dest, "lang", f"{name}.lua") + with open(path, "w", encoding="utf-8") as handle: + handle.write(body) + counts[name] = len(entries) + kept[name] = sum(1 for key in done if any(key == k for k, _ in entries)) + changed[name] = len(done) - kept[name] + + engine_version_ = engine_version(repo) + subst = { + "{{id}}": mod_id, + "{{name}}": args.id, + "{{lang}}": lang_name, + "{{lang_id}}": re.sub(r"\W+", "_", args.id).lower(), + "{{game_version}}": engine_version_, + "{{next_major}}": str(int(engine_version_.split(".")[0]) + 1), + "{{profile}}": "content", + "{{extra}}": "", + "{{total}}": str(sum(counts.values())), + "{{table}}": "\n".join( + f"| `lang/{name}.lua` | {counts[name]} |" for name, *_ in catalogs), + } + + def emit(rel, template, overwrite=True): + path = os.path.join(dest, rel) + if os.path.exists(path) and not overwrite: + return + os.makedirs(os.path.dirname(path), exist_ok=True) + body = template + for key, value in subst.items(): + body = body.replace(key, value) + with open(path, "w", encoding="utf-8") as handle: + handle.write(body) + + # A refresh must never clobber hand-edited prose or a tuned manifest. + emit("manifest.json", MANIFEST_TEMPLATE, overwrite=not exists) + emit("main.lua", TRANSLATION_MAIN, overwrite=not exists) + emit("README.md", TRANSLATION_README, overwrite=not exists) + emit("TRANSLATING.md", TRANSLATING_MD) + emit("assets/font/README.md", FONT_README) + emit(".luarc.json", LUARC_TEMPLATE, overwrite=not exists) + for stub, body in (("font", FONT_STUB), ("charmap", CHARMAP_STUB), + ("naming", NAMING_STUB)): + emit(f"lang/{stub}.lua", body, overwrite=not exists) + + # The English reference, written as a SIBLING of the mod rather than + # inside it. Extracted text is ROM content: it can sit on the + # translator's disk, but `modkit pack` zips the whole mod directory, so + # anything under dest/ would end up in the distributable no matter what + # a .gitignore said. Keeping it outside is the only version of this + # that cannot leak. + work = dest + "-worksheet" + os.makedirs(work, exist_ok=True) + for name, title, entries, by_source, _note in catalogs: + if by_source: + continue # engine strings are our own source, already readable + lines = [f"# {title}: the English behind each key in lang/{name}.lua.", + "# Reference only, and deliberately outside the mod: this", + "# text comes out of the ROM, so it must not be shipped.", ""] + for key, english in entries: + lines.append(f"{key}\t{english}") + with open(os.path.join(work, f"{name}.txt"), "w", + encoding="utf-8") as handle: + handle.write("\n".join(lines) + "\n") + + if args.json: + print(json.dumps({"dest": dest, "base": base, "counts": counts, + "kept": kept, "orphaned": changed}, indent=2)) + elif not args.quiet: + verb = "refreshed" if exists else "created" + print(f"{verb} {dest} ({base} dataset)") + for name, *_ in catalogs: + line = f" lang/{name}.lua {counts[name]:5} entries" + if exists: + line += f" ({kept[name]} translated" + if changed[name]: + line += f", {changed[name]} orphaned" + line += ")" + print(line) + if base == "fixture": + print("\nnote: no imported dataset found, so the name and dialogue") + print("catalogs came from the three-species test fixture.") + print("Import a ROM and re-run with --refresh for the real set.") + print(f"\nnext: read {os.path.join(dest, 'TRANSLATING.md')}") + return 0 + + +FONT_STUB = '''-- Glyph pages this translation adds. Delete the entry if the vanilla +-- alphabet already covers your language. +-- +-- base is the first glyph code the page owns. 0x100 and up is free space +-- above the vanilla $60/$80 pages, so this adds an alphabet rather than +-- replacing one. Set `advance` if your glyphs are not 8px wide. +return { + -- {{lang_id}} = { + -- image = "assets/font/{{lang_id}}.png", + -- base = 0x100, + -- glyphsPerRow = 16, + -- }, +} +''' + +CHARMAP_STUB = '''-- Which byte sequence draws which glyph code. +-- +-- Sequences are matched longest-first, so a multi-byte character and a +-- multi-character ligature both work: "ch" can be one glyph even though +-- "c" is also mapped. Codes here must land inside a page declared in +-- lang/font.lua. +return { + -- ["A"] = 0x100, + -- ["B"] = 0x101, +} +''' + +NAMING_STUB = '''-- The naming screen's letter grid. Return an empty table to keep the +-- English alphabet. +-- +-- Each entry is a row of cells; a cell is whatever sequence your charmap +-- maps, so a multi-byte character is one cell. The row holding a single +-- "lower case" / "UPPER CASE" cell is the case switch, and the cell +-- spelled "ED" is the confirm. +return { + -- upper = { + -- { "A", "B", "C", "D", "E", "F", "G", "H", "I" }, + -- { "J", "K", "L", "M", "N", "O", "P", "Q", "R" }, + -- { "S", "T", "U", "V", "W", "X", "Y", "Z", " " }, + -- { "-", "?", "!", "/", ".", ",", "", "", "ED" }, + -- { "lower case" }, + -- }, + -- lower = { ... }, +} +''' + + +def _read_existing_catalog(path): + """Pull the filled-in values out of a catalog we wrote earlier, so a + refresh keeps the work. Deliberately a line scan rather than a Lua + parse: it has to survive a half-edited file.""" + done = {} + if not os.path.isfile(path): + return done + entry = re.compile(r'^\s*\[(.+?)\]\s*=\s*("(?:[^"\\]|\\.)*")\s*,') + with open(path, encoding="utf-8") as handle: + for line in handle: + match = entry.match(line) + if match and match.group(2) != '""': + done[match.group(1)] = match.group(2) + return done + + +def _merge_catalog(body, done): + """Re-apply saved translations to a freshly generated catalog, and park + anything whose key the engine no longer has in an ORPHANED block rather + than dropping the work on the floor.""" + used = set() + out = [] + entry = re.compile(r'^(\s*\[)(.+?)(\]\s*=\s*)""(,.*)$') + for line in body.splitlines(): + match = entry.match(line) + if match and match.group(2) in done: + key = match.group(2) + used.add(key) + line = f"{match.group(1)}{key}{match.group(3)}{done[key]}{match.group(4)}" + out.append(line) + orphans = [k for k in done if k not in used] + if orphans: + out += ["", "-- ORPHANED: these keys are no longer in the engine or the", + "-- dataset, most likely because the English changed. Move the", + "-- translation onto the new key above and delete the entry."] + out.append("-- {") + for key in sorted(orphans): + out.append(f"-- [{key}] = {done[key]},") + out.append("-- }") + return "\n".join(out) + "\n" + + # ---------------------------------------------------------------- docs def cmd_docs(args, repo): @@ -1215,6 +1864,16 @@ def main(argv): p.add_argument("--seconds", type=int, default=10) p.add_argument("--out") + p = sub.add_parser("translation", parents=[shared]) + p.add_argument("id") + p.add_argument("--language", help="display name, e.g. \"Francais\"") + p.add_argument("--dest") + p.add_argument("--base", default="auto", + choices=["auto", "fixture", "imported"]) + p.add_argument("--refresh", action="store_true", + help="re-harvest the catalogs, keeping existing work") + p.add_argument("--force", action="store_true") + p = sub.add_parser("docs", parents=[shared]) p.add_argument("--out") @@ -1244,6 +1903,7 @@ def main(argv): "lint": cmd_lint, "pack": cmd_pack, "bounce": cmd_bounce, + "translation": cmd_translation, "docs": cmd_docs, }[args.command] return handler(args, repo)