Compare commits

...

14 Commits

Author SHA1 Message Date
bryanthaboi 3b2e489325 Merge pull request #312 from bryanthaboi/dev
Lots of bug fixes and additional API greatness
2026-07-27 13:47:04 -04:00
bryanthaboi 10acfe0710 Merge main into dev to clear the #312 conflicts
main carried #299 (api upgrades, windows dev tools, android fixes) as a
single squash, while the same work reached dev through #297/#298/#269 and
was refined afterwards.  Every conflicting hunk is dev already being ahead
of that squash:

- src/import/RomImporter.lua, src/ui/OakSpeech.lua: dev routes the launcher
  and intro literals through src/core/Strings.lua, main still inlines them.
- src/pokemon/Sprites.lua: dev adds the Sprites.playerPath seam over
  field.playerPics; OakSpeech's player pic resolves through it instead of
  hardcoding trainer_card/red.png.
- tests/fs_io.lua: dev adds FsIo.globPrefix.
- tests/mod_qol_hooks_tests.lua, tests/mod_ui_tests.lua: dev adds the
  player.sprite, #270 submenu-cancel and #308 finish-once coverage.

Resolved to dev on all six; the merged tree is byte-identical to dev, so
this only records the ancestry.  T1/T2/T4 pass.
2026-07-27 13:44:27 -04:00
johnjohto 5113cfd951 Fire intro.oak_speech.finished only once (#308) (#309)
The shrink timeline called finish() every frame once past its end.
finish() emits finished and then pops the stack, so a listener that
pushes a screen (a warp, a menu) had that screen popped in the speech's
place: the speech stayed alive and re-fired the event every frame,
repeating the listener's side effects each time. Clear the shrink
state before finishing so the timeline cannot run finish() twice.

Co-authored-by: johnjohto <johnjohto@users.noreply.github.com>
2026-07-27 13:39:12 -04:00
johnjohto 43158d724b Add a fieldmove.eligibility hook to partyKnows (#310)
Mods that widen field-move rules (use an HM without teaching it, a
rental mon) had to monkey-patch partyKnows, the one function every
field-move path funnels through. Wrap the vanilla check in a hook
instead: next_ is the whole badge-and-knows-move check, so a wrapper
that calls it first keeps vanilla answers winning and only fills the
cases vanilla denies. No hook, no behavior change.

Co-authored-by: johnjohto <johnjohto@users.noreply.github.com>
2026-07-27 13:38:17 -04:00
bryanthaboi f0a88ea473 Bug squashing and translation mods (#311)
* audio timing stuff

* bug fixes and translation additions

* translation stuff

* Update modkit.py

* better asset resolution
2026-07-27 13:37:05 -04:00
johnjohto 31365d8dfd Keep the used-move text up during battle animations (#296) (#302)
The battle textbox redraws its text every frame, but only while a
message was current. Once the player dismissed "Enemy X used GUST!"
and the move animation started, nothing drew the text, so the box sat
empty for the whole animation. The original game's animations never
touch the textbox, so the text stays up. Draw the held lines while an
animation is playing; everything else is unchanged.

Co-authored-by: johnjohto <johnjohto@users.noreply.github.com>
2026-07-27 10:38:03 -04:00
johnjohto 531a719794 Return to the start menu when a pause submenu is cancelled (#270) (#300)
Selecting a start-menu row pops the start menu before the submenu is
pushed (Menu's generic A handler), so B in a submenu had no parent to
return to and dropped straight to the overworld.  pokered redisplays
the start menu instead (RedisplayStartMenu from the party/item/
trainer-card/option handlers), so give each vanilla submenu an
onCancel that re-opens the start menu; the saved cursor row
(wBattleAndStartSavedMenuItem) restores on re-entry.

- PokedexMenu/BagMenu forward opts.onCancel into their ListMenu
- TrainerCard dismisses back via onCancel on A or B
  (WaitForTextScrollButtonPress then RedisplayStartMenu)
- OptionsMenu fires onCancel from both B/START and the CANCEL row
- PlayerPC rows are keepOpen so B in WITHDRAW/DEPOSIT/TOSS returns to
  the PC root menu (players_pc.asm), matching the BoxMenu pattern

The SAVE flow is untouched: StartMenu_SaveReset falls through to
HoldTextDisplayOpen and never redisplays the menu.  Battle-opened
party/bag screens get no onCancel, so mid-battle cancel behavior is
unchanged.

Co-authored-by: johnjohto <johnjohto@users.noreply.github.com>
2026-07-27 10:05:15 -04:00
johnjohto ff90062741 Make the T3 content tier run on Windows (#269)
* Make ROM-free test tiers actually run on Windows

Suite discovery, the extension-point catalog scan, mod test-dir pickup,
and the meta-coverage corpus all shelled out to ls/find/test -d, which do
not exist in cmd.exe. Every listing came back empty on Windows, so tiers
ran 0 suites and still reported ALL TESTS PASSED.

Add portable probes to tests/fs_io.lua (same Unix commands on
Linux/macOS; dir /b and a shell-free rename-self existence check on
Windows) and rewire the four call sites to them. 15/15 engine suites and
2/2 modkit suites now genuinely run and pass on Windows.

Fixes #266

* Make the T3 content tier run on Windows

The content tier had the same Unix-shell assumptions as the tier
discovery fixed in #267, one level down:

- run_tests.lua redirected to /dev/null when chaining tier runners
- mod_runtime_tests.lua called ffi setenv/unsetenv, which msvcrt lacks
  (_putenv with an empty value unsets)
- modkit_tests.lua captured exit codes with POSIX '; echo 0' (cmd
  needs /v:on and !errorlevel!), called python3 (python on Windows),
  and used mkdir -p / rm -rf; cmd mkdir makes parents on its own and
  read -p as a directory name
- the save-editor suites globbed save backups with ls
- tools/save-editor/Catalog.lua scraped mod flags with ls; this one
  affects the real editor on Windows, not just tests

With a ROM imported, run_tests.lua goes from 12 failures to the 3 that
track the missing audio.lua generation (#268), which fails on any OS
and needs a maintainer decision.

---------

Co-authored-by: johnjohto <johnjohto@users.noreply.github.com>
2026-07-27 10:04:35 -04:00
bryanthaboi 3448fae3f5 Android SCALING fixes (#298)
* android fixes for mods and saves

* perhaps this is the true scaling android issue fix
2026-07-27 09:27:24 -04:00
bryanthaboi 25df838b45 android fixes for mods and saves (#297) 2026-07-27 09:05:57 -04:00
bryanthaboi be90e5b42c intro api upgrade (#294)
* intro api upgrade

* api updates

* fix tests

* updated templates
2026-07-27 08:37:11 -04:00
johnjohto 08e8dfc416 Make ROM-free test tiers actually run on Windows (#267)
Suite discovery, the extension-point catalog scan, mod test-dir pickup,
and the meta-coverage corpus all shelled out to ls/find/test -d, which do
not exist in cmd.exe. Every listing came back empty on Windows, so tiers
ran 0 suites and still reported ALL TESTS PASSED.

Add portable probes to tests/fs_io.lua (same Unix commands on
Linux/macOS; dir /b and a shell-free rename-self existence check on
Windows) and rewire the four call sites to them. 15/15 engine suites and
2/2 modkit suites now genuinely run and pass on Windows.

Fixes #266

Co-authored-by: johnjohto <johnjohto@users.noreply.github.com>
2026-07-27 07:06:53 -04:00
bryanthaboi 6a27be54de add CI to dev branch 2026-07-27 07:01:38 -04:00
bryanthaboi d4055209e6 pop up a fake save 2026-07-27 06:54:18 -04:00
87 changed files with 3989 additions and 836 deletions
+10 -2
View File
@@ -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" },
},
},
+7 -3
View File
@@ -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
@@ -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
},
},
},
+9 -1
View File
@@ -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" },
+34
View File
@@ -194,3 +194,37 @@ 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).
## 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.
+155 -121
View File
@@ -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 <USER>/<TARGET> 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: "<USER> / 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: "<PLAYER> 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
@@ -4312,7 +4343,10 @@ end
function BattleState:drawTextArea()
Font.drawBox(0, 12, 20, 6)
love.graphics.setColor(0, 0, 0, 1)
if self.phase == "messages" and self.current then
if self.phase == "messages" and (self.current or self.animPlaying) then
-- during the move animation self.current is nil but shown still holds
-- the "used X!" lines; keep drawing them like pokered, whose move
-- animations only touch sprites and never the textbox tilemap (#296)
-- rolling 2-line window: shown[1] at row y=112, shown[2] at y=128 (battle
-- text uses every other tile row, hlcoord *,14 / *,16). scrollPx animates
-- the lines up one row (ScrollTextUpOneLine) so a 3rd line scrolls into
@@ -4340,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
@@ -4351,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 <PK><MN> /
-- 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
@@ -4385,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)
+13 -12
View File
@@ -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
+59 -56
View File
@@ -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
+31 -21
View File
@@ -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 <USER>/<TARGET> 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
+2 -1
View File
@@ -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", {
+4 -3
View File
@@ -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
+7 -6
View File
@@ -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
+3
View File
@@ -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()
+13 -6
View File
@@ -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
+134
View File
@@ -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
+19 -19
View File
@@ -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 = {}
+48 -46
View File
@@ -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
+25 -24
View File
@@ -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
+46 -45
View File
@@ -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
+13 -9
View File
@@ -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
+35 -35
View File
@@ -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
+21 -20
View File
@@ -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
+31 -2
View File
@@ -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)),
+5 -4
View File
@@ -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
+34
View File
@@ -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.
+69 -20
View File
@@ -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
-- ("<PK>", "'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
+31 -4
View File
@@ -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
+29 -14
View File
@@ -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
-- <PROMPT> 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 <species>: 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 <species> [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 <r1> [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
+2 -1
View File
@@ -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
+2 -1
View File
@@ -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))
+31 -20
View File
@@ -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
@@ -372,6 +375,8 @@ function BagMenu.new(game, opts)
list = ListMenu.new(game, "ITEMS", buildItems(game), {
kind = "bag",
footer = ("¥%d"):format(game.save.money),
-- B returns to the start menu when the bag was opened from it
onCancel = opts.onCancel,
-- SELECT reorders items like the original bag (swap_items.asm)
onSelectKey = function(item, l)
if not item then return end
@@ -400,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")
@@ -424,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
+5 -2
View File
@@ -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
+26 -25
View File
@@ -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 <PK><MN>", keepOpen = true,
{ label = Strings("WITHDRAW <PK><MN>"), keepOpen = true,
onSelect = function() withdraw(game) end },
{ label = "DEPOSIT <PK><MN>", keepOpen = true,
{ label = Strings("DEPOSIT <PK><MN>"), keepOpen = true,
onSelect = function() deposit(game) end },
{ label = "RELEASE <PK><MN>", keepOpen = true,
{ label = Strings("RELEASE <PK><MN>"), 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 })
+3 -2
View File
@@ -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)
+6 -5
View File
@@ -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
+4 -3
View File
@@ -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
+19 -13
View File
@@ -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
+16 -11
View File
@@ -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 <LV> 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
+4 -3
View File
@@ -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)
+6 -2
View File
@@ -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)
+12 -11
View File
@@ -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
+3 -2
View File
@@ -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,
+18 -7
View File
@@ -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)
@@ -542,6 +549,10 @@ function OakSpeech:update(dt)
elseif s.frame >= 79 and s.frame <= 102 then
self.fadeLevel = math.floor((s.frame - 79) / 8) + 1
elseif s.frame > 102 then
-- clear before finish(): a finished-listener that pushes a state gets
-- ITS state popped in the speech's place, and a live shrink would call
-- finish() again next frame, re-firing the event every frame (#308)
self.shrink = nil
self:finish()
end
end
+25 -21
View File
@@ -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 },
@@ -328,7 +329,8 @@ local function buildRows(game)
return rows
end
function OptionsMenu.new(game)
function OptionsMenu.new(game, opts)
opts = opts or {}
local rows = buildRows(game)
local hooked = Runtime.call("ui.options.rows", sameRows, game, rows)
if type(hooked) == "table" then
@@ -337,8 +339,8 @@ function OptionsMenu.new(game)
Logger.error("ui.options.rows returned %s; keeping the vanilla rows",
type(hooked))
end
return setmetatable({ game = game, rows = rows, index = 1, scroll = 0 },
OptionsMenu)
return setmetatable({ game = game, rows = rows, index = 1, scroll = 0,
onCancel = opts.onCancel }, OptionsMenu)
end
function OptionsMenu:update(dt)
@@ -361,9 +363,11 @@ function OptionsMenu:update(dt)
changed = row.step(self.game, dir) and true or false
elseif input:wasPressed("a") then -- CANCEL
self.game.stack:pop()
if self.onCancel then self.onCancel() end
end
elseif input:wasPressed("b") or input:wasPressed("start") then
self.game.stack:pop()
if self.onCancel then self.onCancel() end
end
if changed and self.game.writeOptions then
self.game:writeOptions()
+39 -33
View File
@@ -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
+15 -11
View File
@@ -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
@@ -160,10 +161,13 @@ end
function PlayerPC.new(game)
game.save.pcItems = game.save.pcItems or {}
return Menu.new(game, {
{ label = "WITHDRAW ITEM", onSelect = function() withdraw(game) end },
{ label = "DEPOSIT ITEM", onSelect = function() deposit(game) end },
{ label = "TOSS ITEM", onSelect = function() toss(game) end },
{ label = "LOG OFF" },
-- 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 = 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 })
+9 -6
View File
@@ -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 = {}
@@ -9,7 +10,8 @@ function PokedexMenu:sgbPalettes(game)
return require("src.render.PaletteFX").wholeNamed(game.data, "BROWNMON")
end
function PokedexMenu.new(game)
function PokedexMenu.new(game, opts)
opts = opts or {}
local dex = game.save.pokedex or { seen = {}, owned = {} }
local byDex = {}
for species, def in pairs(game.data.pokemon) do
@@ -45,8 +47,9 @@ function PokedexMenu.new(game)
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)
if not item.value then return end
-- the DATA / CRY / AREA / QUIT choice (engine/menus/pokedex.asm
@@ -55,16 +58,16 @@ function PokedexMenu.new(game)
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,
})
+8 -7
View File
@@ -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
+10 -9
View File
@@ -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
+8 -7
View File
@@ -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)
+26 -20
View File
@@ -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 = {}
@@ -19,33 +20,38 @@ function StartMenu.new(game)
local flags = game.save.flags or {}
local items = {}
-- vanilla start submenus return here on B (RedisplayStartMenu): the
-- generic Menu pops the start menu when a row is selected, so each
-- submenu gets an onCancel that re-opens it
local function reopen() Screens.push(game, "StartMenu") end
-- POKéDEX: only after Oak hands it over
if flags.EVENT_GOT_POKEDEX then
table.insert(items, { label = "POKéDEX", onSelect = function()
Screens.push(game, "PokedexMenu")
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")
Screens.push(game, "PartyMenu", { onCancel = reopen })
end })
table.insert(items, { label = "ITEM", onSelect = function()
Screens.push(game, "BagMenu")
table.insert(items, { label = Strings("ITEM"), onSelect = function()
Screens.push(game, "BagMenu", { onCancel = reopen })
end })
-- the player's name opens the trainer card (StartMenu_TrainerInfo)
table.insert(items, { label = game.save.player.name or "RED",
onSelect = function()
Screens.push(game, "TrainerCard")
Screens.push(game, "TrainerCard", { onCancel = reopen })
end })
-- 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)
@@ -54,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,
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()
Screens.push(game, "OptionsMenu")
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 })
@@ -89,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
@@ -97,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 }))
@@ -150,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
+9 -8
View File
@@ -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)
+11 -10
View File
@@ -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
+2 -1
View File
@@ -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
+18 -6
View File
@@ -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
@@ -30,8 +35,10 @@ local function quads16(img, count, stride, x0, y0)
return q
end
function TrainerCard.new(game)
local self = setmetatable({ game = game }, TrainerCard)
function TrainerCard.new(game, opts)
opts = opts or {}
local self = setmetatable({ game = game, onCancel = opts.onCancel },
TrainerCard)
local img = tryImage("assets/generated/trainer_card/badges.png")
if img then
-- badges.2bpp is 8 stacked [face, badge] pairs (DrawBadges FaceBadgeTiles)
@@ -60,14 +67,19 @@ function TrainerCard.new(game)
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
function TrainerCard:update(dt)
local input = self.game.input
-- either button dismisses the card back to the start menu
-- (StartMenu_TrainerInfo: WaitForTextScrollButtonPress then
-- RedisplayStartMenu)
if input:wasPressed("a") or input:wasPressed("b") then
self.game.stack:pop()
if self.onCancel then self.onCancel() end
end
end
@@ -108,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),
@@ -117,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)
+14
View File
@@ -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" },
+115 -66
View File
@@ -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 }
@@ -1240,7 +1241,7 @@ end
-- (constants.hmBadges; distinct from constants.hmMoves, the forget gate).
-- Gen 1 allows field use from fainted party members (party menu + name
-- lookup for Cut/Surf messages); do not require mon.hp > 0 here.
function OverworldState:partyKnows(moveId)
local function partyKnowsVanilla(moveId)
local gate = (FieldDefaults.constant(Game.data, "hmBadges") or {})[moveId]
local badge = gate and gate.badge
if badge and not Game.save.inventory[badge] then
@@ -1254,6 +1255,17 @@ function OverworldState:partyKnows(moveId)
return nil
end
function OverworldState:partyKnows(moveId)
-- a mod may unlock a field move another way (an HM in the bag, a rental
-- mon); next_ is the whole vanilla check, so calling it first keeps
-- vanilla answers winning
if Runtime.wantsHook("fieldmove.eligibility") then
return Runtime.call("fieldmove.eligibility", partyKnowsVanilla, moveId,
{ save = Game.save, data = Game.data })
end
return partyKnowsVanilla(moveId)
end
-- The rejection loop shared by the Good and Super Rods
-- (item_effects.asm ItemUseGoodRod .RandomLoop / ReadSuperRodData): an
-- odd random byte is no bite; otherwise a 2-bit pick rerolls until it
@@ -1312,10 +1324,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 +1494,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 +1568,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 +1576,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 +1590,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 +1602,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 +1658,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 +1675,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 +1688,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 +1730,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 +1747,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 +1765,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 +1813,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 +1830,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 +1842,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 +1864,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 +1906,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 +2006,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 +2054,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 +2190,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 +2206,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 +2215,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 +2258,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 +2302,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 +2322,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 +2342,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 +2367,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 +2416,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 +2464,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 +2476,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 +2525,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 +2604,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 +2802,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 +2816,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 +2952,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 +3086,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 +3114,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 +3159,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 +3287,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 +3317,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 +3326,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 +3369,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
+87
View File
@@ -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
@@ -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
+150
View File
@@ -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
@@ -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
@@ -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<male>" 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<f>, NIDORINA, NIDOQUEEN, NIDORAN<m>,")
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
+73
View File
@@ -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
+97
View File
@@ -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
@@ -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
+158
View File
@@ -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")
+14
View File
@@ -64,6 +64,20 @@ function FsIo.luaFilesUnder(dir)
return files
end
-- full paths matching the shell's "prefix"* glob (e.g. save.lua.bak-*)
function FsIo.globPrefix(prefix)
local dir, base = tostring(prefix):match("^(.*[/\\])([^/\\]*)$")
if not dir then dir, base = "", tostring(prefix) end
if dir == "" then dir = "." end
local matches = {}
for _, name in ipairs(FsIo.listDir(dir)) do
if name:sub(1, #base) == base then
matches[#matches + 1] = dir .. name
end
end
return matches
end
-- existence probe that never shells out on Windows: directories do not
-- open() there at all, and rename-self succeeds for anything that exists
function FsIo.isDir(path)
+40
View File
@@ -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")
+12 -2
View File
@@ -190,15 +190,25 @@ Runtime.install(nullEvents, nullHooks)
-- POKEPORT_DATA_DIR points Data:load at another dataset root; the fixture
-- set is ROM-free, so this is what lets a runner boot with no data/generated
local ffi = require("ffi")
local setVar, unsetVar
if ffi.os == "Windows" then
-- msvcrt has no setenv/unsetenv; _putenv with an empty value unsets
ffi.cdef([[ int _putenv(const char *envstring); ]])
setVar = function(name, value) ffi.C._putenv(name .. "=" .. value) end
unsetVar = function(name) ffi.C._putenv(name .. "=") end
else
ffi.cdef([[
int setenv(const char *name, const char *value, int overwrite);
int unsetenv(const char *name);
]])
ffi.C.setenv("POKEPORT_DATA_DIR", "tests/fixture_data", 1)
setVar = function(name, value) ffi.C.setenv(name, value, 1) end
unsetVar = function(name) ffi.C.unsetenv(name) end
end
setVar("POKEPORT_DATA_DIR", "tests/fixture_data")
local Data = require("src.core.Data")
local fixture = setmetatable({}, { __index = Data })
local okLoad, loadErr = pcall(Data.load, fixture)
ffi.C.unsetenv("POKEPORT_DATA_DIR")
unsetVar("POKEPORT_DATA_DIR")
check(okLoad, "Data:load honours POKEPORT_DATA_DIR (" .. tostring(loadErr) .. ")")
check(okLoad and fixture.pokemon ~= nil and fixture.pokemon.FIXMON_A ~= nil,
"the override serves the fixture dataset")
+155
View File
@@ -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()
+98
View File
@@ -185,6 +185,83 @@ check(#menu.items == #VANILLA_START and menu.items[3].label == "ITEM",
check(logged("ui.start_menu.items returned"), "the degrade is logged")
hooks:removeOwner("bad")
-- ------- issue #270: B in a start submenu returns to the start menu
-- (RedisplayStartMenu), not to the overworld. The generic Menu pops the
-- start menu when a row is selected, so every vanilla submenu must carry
-- an onCancel that re-opens it.
local function gameForSubmenus()
local game = startGame()
game.save.inventory = {}
game.save.money = 0
game.save.pcItems = { POTION = 1 }
game.data.items = { POTION = { name = "POTION" } }
game.data.pokemon = {}
game.data.constants = {}
game.data.rulesets = {}
game.modStatus = { available = {} }
return game
end
local MenuClass = require("src.ui.Menu")
for _, rowLabel in ipairs({ "POKéDEX", "POKéMON", "ITEM", "RED", "OPTION" }) do
local game = gameForSubmenus()
local start = Screens.push(game, "StartMenu")
for i, item in ipairs(start.items) do
if item.label == rowLabel then start.index = i end
end
press(start, "a")
local sub = game.stack:top()
check(sub ~= start, rowLabel .. " opens a submenu over the overworld")
press(sub, "b")
local top = game.stack:top()
check(top ~= nil and top ~= sub and getmetatable(top) == MenuClass
and top.items[1].label == "POKéDEX",
"B from " .. rowLabel .. " re-opens the start menu")
check(#game.stack.states == 1,
"B from " .. rowLabel .. " leaves exactly the start menu on the stack")
check(top.index == start.index,
"the re-opened start menu restores the " .. rowLabel .. " cursor row")
end
-- the trainer card also dismisses on A, back to the start menu
local cardGame = gameForSubmenus()
local cardStart = Screens.push(cardGame, "StartMenu")
for i, item in ipairs(cardStart.items) do
if item.label == "RED" then cardStart.index = i end
end
press(cardStart, "a")
press(cardGame.stack:top(), "a")
check(getmetatable(cardGame.stack:top()) == MenuClass
and cardGame.stack:top().items[1].label == "POKéDEX",
"A on the trainer card also returns to the start menu")
-- the options CANCEL row takes the same path as B
local optCancelGame = gameForSubmenus()
local optStart = Screens.push(optCancelGame, "StartMenu")
for i, item in ipairs(optStart.items) do
if item.label == "OPTION" then optStart.index = i end
end
press(optStart, "a")
local optMenu = optCancelGame.stack:top()
optMenu.index = #optMenu.rows + 1 -- CANCEL
press(optMenu, "a")
check(getmetatable(optCancelGame.stack:top()) == MenuClass
and optCancelGame.stack:top().items[1].label == "POKéDEX",
"the options CANCEL row returns to the start menu")
-- the PC session (players_pc.asm): B in an item list returns to the PC's
-- root menu, which stays on the stack via keepOpen rows
local pcGame = gameForSubmenus()
local pcRoot = Screens.push(pcGame, "PlayerPC")
press(pcRoot, "a") -- WITHDRAW ITEM
check(#pcGame.stack.states == 2,
"WITHDRAW ITEM keeps the PC root menu underneath")
press(pcGame.stack:top(), "b")
check(pcGame.stack:top() == pcRoot,
"B in the withdraw list returns to the PC root menu")
press(pcRoot, "b")
check(#pcGame.stack.states == 0, "B on the PC root logs off to the overworld")
-- ------- ui.options.rows and the descriptor refactor
local OptionsMenu = require("src.ui.OptionsMenu")
local function optGame()
@@ -711,6 +788,27 @@ check(type("intro.oak_speech.started") == "string"
"intro.oak_speech lifecycle event names are stable")
events:removeOwner("fixture")
-- issue #308: finish runs once even when a finished-listener pushes a
-- screen (the pop in finish() then removes that screen instead of the
-- speech, and a live shrink re-fires the event every frame)
do
local fgame = { data = {}, stack = newStack(), input = newInput(),
save = { player = {} } }
local fspeech = OakSpeech.new(fgame, nil)
fspeech.shrink = { frame = 103 } -- past the shrink timeline's end
fgame.stack:push(fspeech)
fspeech.picReveal = nil -- skip the intro fade so update reaches shrink
local finishes = 0
events:on("intro.oak_speech.finished", function()
finishes = finishes + 1
fgame.stack:push({ pushedByListener = true }) -- the #308 trap
end, 0, "t308")
for _ = 1, 3 do fspeech:update(1 / 60) end
events:removeOwner("t308")
check(finishes == 1,
"finished fires once when a listener pushes a screen")
end
-- ModUI step helpers
local tiny = { { id = "a" }, { id = "c" } }
ModUI.insertStepAfter(tiny, "a", { id = "b" })
+33
View File
@@ -574,6 +574,39 @@ do
check(blocked == false and why == "tile", "a walled step still reports 'tile'")
end
do
-- fieldmove.eligibility: next_ is the whole vanilla partyKnows check,
-- so a wrapper can widen field-move rules without forking the engine
local save = {
inventory = { SOULBADGE = 1, THUNDERBADGE = 1, CASCADEBADGE = 1 },
party = { { species = "SQUIRTLE", moves = {} },
{ species = "PIDGEY", moves = { { id = "FLY" } } } },
}
local game = { save = save, data = Data }
check(bindGame(OW.partyKnows, game), "partyKnows binds a test Game")
check(OW:partyKnows("SURF") == nil, "no hook, no surf without a knower")
check(OW:partyKnows("FLY") == save.party[2], "the knower wins unwrapped")
withBuses(function(_, hooks)
local seen
hooks:wrap("fieldmove.eligibility", function(next_, moveId, ctx)
seen = { moveId = moveId, ctx = ctx }
local mon = next_(moveId, ctx)
if mon then return mon end -- vanilla first
if moveId == "SURF" then return ctx.save.party[1] end
return nil
end, 0, "rental")
check(OW:partyKnows("SURF") == save.party[1],
"the hook can offer a field move vanilla denies")
check(OW:partyKnows("FLY") == save.party[2],
"a vanilla knower still beats the hook")
check(OW:partyKnows("CUT") == nil,
"next_ still yields nil when the hook declines")
check(seen.moveId == "CUT" and seen.ctx.save == save
and seen.ctx.data == Data,
"the hook ctx carries moveId, save, and data")
end)
end
do
-- warp.destination reroutes one door without owning the warp table
local warpDef = Data.maps.PALLET_TOWN.warps[1]
+29 -11
View File
@@ -368,24 +368,38 @@ check(#empty.lines == 0, "empty report renders no rows")
-- ------- modkit CLI: scaffold -> validate green, lint gate red
local isWindows = package.config:sub(1, 1) == "\\"
-- luajit's pclose drops the exit status, so the shell reports it in-band
local function run(command)
local pipe = io.popen(command .. ' 2>&1; echo "EXIT:$?"')
if isWindows then
-- cmd expands %errorlevel% when the line is parsed, before the command
-- runs; /v:on defers !errorlevel! to execution time
command = 'cmd /v:on /c "' .. command .. ' 2>&1 & echo EXIT:!errorlevel!"'
else
command = command .. ' 2>&1; echo "EXIT:$?"'
end
local pipe = io.popen(command)
local output = pipe:read("*a")
pipe:close()
local code = tonumber(output:match("EXIT:(%d+)%s*$")) or -1
return output, code
end
local python = "python3"
local python = isWindows and "python" or "python3"
local haveTools = run(python .. " --version")
check(haveTools:find("Python 3", 1, true) ~= nil, "python3 available for modkit")
local tmp = os.tmpname()
os.remove(tmp)
local root = tmp .. "_modkit"
check(os.execute(("mkdir -p %q"):format(root)) == 0
or os.execute(("mkdir -p %q"):format(root)) == true, "scratch dir")
-- Windows tmpname is already a full path under TEMP; forward slashes keep
-- %q from doubling backslashes for cmd
local root = (isWindows and tmp:gsub("\\", "/") or tmp) .. "_modkit"
-- cmd mkdir creates parents on its own and treats -p as a directory name
local mkdir = isWindows and "mkdir" or "mkdir -p"
local rmdir = isWindows and "rmdir /s /q" or "rm -rf"
check(os.execute((mkdir .. " %q"):format(root)) == 0
or os.execute((mkdir .. " %q"):format(root)) == true, "scratch dir")
local out, code = run(("%s tools/modkit.py scaffold scaffy --dest %q")
:format(python, root))
@@ -433,7 +447,7 @@ if packed then packed:close() end
-- a bad mod trips the schema rule and the no-ROM-content gate
local bad = root .. "/badmod"
os.execute(("mkdir -p %q"):format(bad))
os.execute((mkdir .. " %q"):format(bad))
local function write(path, body)
local handle = assert(io.open(path, "wb"))
handle:write(body)
@@ -470,7 +484,7 @@ check(io.open(root .. "/bad.modpkg", "rb") == nil, "no package written on refusa
-- whether the machine has an imported dataset
local function ruleMod(id, manifestExtra, body)
local dir = root .. "/" .. id
os.execute(("mkdir -p %q"):format(dir))
os.execute((mkdir .. " %q"):format(dir))
write(dir .. "/manifest.json",
('{"id":"%s","name":"%s","version":"1.0.0","api":2,"entry":"main.lua"%s}')
:format(id, id, manifestExtra or ""))
@@ -625,7 +639,7 @@ if packed then packed:close() end
-- MK305 diffs shipped tables against the imported dataset; fake one under
-- a scratch repo root so the check exercises the same on ROM-less machines
local fake = root .. "/fakerepo"
os.execute(("mkdir -p %q %q"):format(
os.execute((mkdir .. " %q %q"):format(
fake .. "/data/generated", fake .. "/mods/dumper"))
local rows = {}
for index = 1, 12 do
@@ -645,8 +659,12 @@ check(code ~= 0, "bulk data-table dump fails lint: " .. out)
check(out:find("MK305", 1, true) ~= nil, "dump reported as MK305")
-- no interpreter means no verdict; the gate fails closed, never open
out, code = run(("MODKIT_LUAJIT=%q %s tools/modkit.py --repo %q lint %q")
:format(fake .. "/no-such-luajit", python, fake, fake .. "/mods/dumper"))
local missingLuajit = isWindows
and ('set "MODKIT_LUAJIT=%s" && %s tools/modkit.py --repo %q lint %q')
:format(fake .. "/no-such-luajit", python, fake, fake .. "/mods/dumper")
or ("MODKIT_LUAJIT=%q %s tools/modkit.py --repo %q lint %q")
:format(fake .. "/no-such-luajit", python, fake, fake .. "/mods/dumper")
out, code = run(missingLuajit)
check(code ~= 0, "lint fails when luajit is missing")
check(out:find("MK100", 1, true) ~= nil, "missing luajit reported as MK100")
@@ -656,7 +674,7 @@ out, code = run(("%s tools/modkit.py --repo %q lint %q")
check(code == 0, "dump check without a dataset stays a warning")
check(out:find("MK305 WARN", 1, true) ~= nil, "skipped dump check is reported")
os.execute(("rm -rf %q"):format(root))
os.execute((rmdir .. " %q"):format(root))
-- ------- restore shared runtime state for the suites that follow
+35
View File
@@ -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()
+89
View File
@@ -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()
+71
View File
@@ -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()
+9 -6
View File
@@ -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)
+6
View File
@@ -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()
+107
View File
@@ -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()
+205
View File
@@ -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
-- (`<PK>`, `'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", "", "<PK><MN>", "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("<PK><MN>"), 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()
+8
View File
@@ -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()
+9 -15
View File
@@ -56,15 +56,20 @@ do
end
local SaveIO = require("SaveIO")
local FsIo = require("tests.fs_io")
do
local path = SaveIO.defaultPath()
check(type(path) == "string" and #path > 0, "defaultPath nonempty")
check(path:match("save%.lua$"), "defaultPath ends with save.lua")
check(path:match("pokemon%-love2d"), "defaultPath uses game identity folder")
local uname = io.popen and io.popen("uname -s 2>/dev/null")
local sys = uname and uname:read("*l") or ""
local sys = ""
if package.config:sub(1, 1) ~= "\\" then
-- no uname on Windows; the macOS-only check below just skips there
local uname = io.popen("uname -s 2>/dev/null")
sys = uname and uname:read("*l") or ""
if uname then uname:close() end
end
if sys == "Darwin" then
check(path:match("/LOVE/"), "defaultPath on macOS includes LOVE folder")
end
@@ -92,14 +97,7 @@ do
loaded = assert(SaveIO.load(path))
eq(loaded.money, 99, "second save money")
local bakFiles = {}
local bakGlob = io.popen('ls -1 "' .. path .. '.bak-"* 2>/dev/null')
if bakGlob then
for line in bakGlob:lines() do
bakFiles[#bakFiles + 1] = line
end
bakGlob:close()
end
local bakFiles = FsIo.globPrefix(path .. ".bak-")
check(#bakFiles >= 1, "second save creates .bak-* sibling")
if #bakFiles >= 1 then
local bakData, berr = SaveIO.load(bakFiles[1])
@@ -376,11 +374,7 @@ do
os.remove(a); os.remove(b)
for _, path in ipairs({ a, b }) do
local bak = io.popen('ls -1 "' .. path .. '.bak-"* 2>/dev/null')
if bak then
for line in bak:lines() do os.remove(line) end
bak:close()
end
for _, bak in ipairs(FsIo.globPrefix(path .. ".bak-")) do os.remove(bak) end
end
end
+28 -1
View File
@@ -2860,6 +2860,32 @@ do
eq(ys[1], 112, "battle text line 1 at row 14 (y=112)")
eq(ys[2], 128, "battle text line 2 at row 16 (y=128)")
end
-- issue #296: the used-move text stays up during the move animation.
-- current is nil once the message is dismissed, but shown still holds the
-- lines; pokered's animations never touch the textbox tilemap.
do
local Font = require("src.render.Font")
local drawn, origCode, origBox = 0, Font.drawCode, Font.drawBox
Font.drawBox = function() end
Font.drawCode = function() drawn = drawn + 1 end
local battle = setmetatable({
phase = "messages",
current = nil,
animPlaying = true,
shown = { { 0x80 }, { 0x81 } },
}, BattleState)
battle:drawTextArea()
eq(drawn, 2, "text keeps drawing while the move animation plays")
-- without an animation the dismissed message is still hidden, so states
-- that clear the box (e.g. pushed UI rows) are unchanged
drawn = 0
battle.animPlaying = nil
battle:drawTextArea()
Font.drawCode, Font.drawBox = origCode, origBox
eq(drawn, 0, "no current message and no animation draws no text")
end
end
-- ================= BUGS.md batch: ledge-shadow =================
@@ -3178,12 +3204,13 @@ runSuites(orderedGlob("tests/parity_*.lua", {
-- always been; scripts/test.sh also runs them directly.
do
local lua = (arg and arg[-1]) or "luajit"
local devnull = package.config:sub(1, 1) == "\\" and "nul" or "/dev/null"
for _, tier in ipairs({ "tests/run_content_red.lua",
"tests/run_engine.lua", "tests/run_modkit.lua" }) do
local handle = io.open(tier, "r")
if handle then
handle:close()
local status = os.execute(("%q %s > /dev/null 2>&1"):format(lua, tier))
local status = os.execute(("%q %s > " .. devnull .. " 2>&1"):format(lua, tier))
check(status == 0 or status == true, tier:match("([^/]+)%.lua$") .. " tier")
end
end
+6 -1
View File
@@ -88,7 +88,8 @@ love.filesystem = memfs({
.. '"entry":"main.lua","dependencies":[],"api":2}',
[MOD_ROOT .. "/main.lua"] = MOD_MAIN,
})
os.execute('mkdir -p "' .. MOD_ROOT .. '"')
-- plain mkdir: cmd reads "-p" as a directory name, and mods/ always exists
os.execute('mkdir "' .. MOD_ROOT .. '"')
local diskMain = assert(io.open(MOD_ROOT .. "/main.lua", "w"))
diskMain:write(MOD_MAIN)
diskMain:close()
@@ -144,7 +145,11 @@ local ok, err = pcall(function()
end)
os.remove(MOD_ROOT .. "/main.lua")
if package.config:sub(1, 1) == "\\" then
os.execute('rmdir "' .. MOD_ROOT .. '"')
else
os.execute('rmdir "' .. MOD_ROOT .. '" 2>/dev/null')
end
os.remove(tmpPath)
love.filesystem = savedFS
-- leave shared singletons the way we found them (the fixture merged one
+8
View File
@@ -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("")
+660
View File
@@ -6,6 +6,8 @@
Subcommands:
scaffold <id> [--profile content|overhaul|total_conversion] [--api 2]
[--dest DIR] [--force]
translation <id> [--language NAME] [--base auto|fixture|imported]
[--refresh] [--dest DIR]
validate <id|path> [--strict] [--base auto|fixture|imported]
lint <id|path>
pack <mod-dir> [-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", " " },
-- { "-", "?", "!", "/", ".", ",", "<PK>", "<MN>", "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)
+11
View File
@@ -22,6 +22,17 @@ end
function Catalog.scrapeEvents(scriptDir, headerPath, listFiles, extraDirs)
listFiles = listFiles or function(dir)
local out = {}
if package.config:sub(1, 1) == "\\" then
-- cmd has no ls; dir /b prints bare names, so re-attach the directory
local p = io.popen(string.format('dir /b "%s\\*.lua" 2>nul', dir))
if p then
for line in p:lines() do
if line ~= "" then table.insert(out, dir .. "/" .. line) end
end
p:close()
end
return out
end
local p = io.popen(string.format('ls "%s"/*.lua 2>/dev/null', dir))
if p then
for line in p:lines() do