Compare commits

..

15 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 803f86d5b3 api upgrades, windows dev tools, and android fixes (#299)
* pop up a fake save

* add CI to dev branch

* 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>

* intro api upgrade (#294)

* intro api upgrade

* api updates

* fix tests

* updated templates

* android fixes for mods and saves (#297)

* Android SCALING fixes (#298)

* android fixes for mods and saves

* perhaps this is the true scaling android issue fix

---------

Co-authored-by: johnjohto <johtoboy@atomicmail.io>
Co-authored-by: johnjohto <johnjohto@users.noreply.github.com>
2026-07-27 09:29:24 -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
132 changed files with 6886 additions and 1131 deletions
+14 -1
View File
@@ -25,7 +25,9 @@ body:
options: options:
- macOS - macOS
- Windows - Windows
- Both - Linux
- Android
- Multiple platforms
validations: validations:
required: true required: true
@@ -38,6 +40,17 @@ body:
validations: validations:
required: true required: true
- type: input
id: discord
attributes:
label: Discord username (optional)
description: >
So maintainers can ping you on Discord if they need a quick follow-up.
Leave blank if you'd rather keep everything on GitHub.
placeholder: yourname
validations:
required: false
- type: input - type: input
id: location id: location
attributes: attributes:
+34 -19
View File
@@ -1,35 +1,53 @@
name: Feature request name: Feature request
description: Ask for something new, or a change to how something already works. description: Ask for something new in the engine, launcher, or platform — not a content/gameplay mod.
labels: ["enhancement"] labels: ["enhancement"]
body: body:
- type: markdown - type: markdown
attributes: attributes:
value: | value: |
Use this for **engine / launcher / platform** work (ports, video options, save
tooling, networking, mod API seams, docs).
If what you want is a gameplay, cosmetic, audio, or QoL change that a Lua mod
could ship — running shoes, alternate sprites, day/night, shiny indicators,
Gen 2-like battle toggles, soundtrack packs — open a
**[Mod request](https://github.com/bryanthaboi/pokemon-gen1-recomp-project/issues/new?template=mod_request.yml)**
instead.
"Can we add X" on its own is hard to act on. Say what you want, why you want it, "Can we add X" on its own is hard to act on. Say what you want, why you want it,
and how you picture it working. A request with real detail is one someone can and how you picture it working.
actually build without guessing at what you meant.
- type: input - type: input
id: summary id: summary
attributes: attributes:
label: One line summary label: One line summary
description: What you want, in a sentence. description: What you want, in a sentence.
placeholder: Add a run toggle so you can move at bike speed without the bike placeholder: Add Linux AppImage releases next to the macOS and Windows builds
validations: validations:
required: true required: true
- type: input
id: discord
attributes:
label: Discord username (optional)
description: >
So maintainers can ping you on Discord if they need a quick follow-up.
Leave blank if you'd rather keep everything on GitHub.
placeholder: yourname
validations:
required: false
- type: textarea - type: textarea
id: what id: what
attributes: attributes:
label: What do you want label: What do you want
description: > description: >
Describe it properly. What is it, where does it live in the game, what does the Describe it properly. What is it, where does it live (launcher, options,
player see or do. If it changes something that already exists, say what it does today engine), what does the player see or do. If it changes something that already
and what it should do instead. exists, say what it does today and what it should do instead.
placeholder: | placeholder: |
A hold-to-run button, the way later Pokemon games do it. Hold B while moving on Ship a Linux AppImage on each release, same version as the macOS/Windows builds,
foot and the player moves at bike speed. Release and you go back to walking. with the same save folder layout and mod discovery path.
Doesn't work indoors where the bike doesn't work, doesn't work in battles or menus.
validations: validations:
required: true required: true
@@ -41,9 +59,8 @@ body:
What's annoying or missing right now. What does this fix. If it's just because you What's annoying or missing right now. What does this fix. If it's just because you
think it would be fun, say that, it's a real answer. think it would be fun, say that, it's a real answer.
placeholder: | placeholder: |
Backtracking across routes you've already cleared is slow, and the bike is a menu LÖVE already runs on Linux; without a packaged build, players have to assemble
dive away and doesn't work in a lot of places. Running would cut a lot of dead time it themselves and miss release notes / update checks.
without making the game easier.
validations: validations:
required: true required: true
@@ -52,14 +69,12 @@ body:
attributes: attributes:
label: How should it work label: How should it work
description: > description: >
The specifics. Which button, which menu, what happens in the edge cases. If you don't The specifics. Which menu, what happens in the edge cases. If you don't
know, say what you'd expect as a player and leave the rest open. know, say what you'd expect as a player and leave the rest open.
placeholder: | placeholder: |
- Hold B on the overworld to run - GitHub Releases asset next to the .dmg / .exe
- Same speed as the bike - Same options.lua / mods/ layout as desktop
- Disabled anywhere the bike is disabled - Documented in the README install section
- Sprite should probably need a running animation, not sure what that takes
- No effect on encounter rates
validations: validations:
required: true required: true
+115
View File
@@ -0,0 +1,115 @@
name: Mod request
description: Ask for a gameplay, cosmetic, audio, or QoL change that belongs as a Lua mod.
labels: ["mod request"]
body:
- type: markdown
attributes:
value: |
This tracker is for ideas that should ship as **mods**, not as core engine
features — alternate sprites, running shoes, day/night, shiny indicators,
soundtrack packs, Gen 2-like battle toggles, map cosmetics, bag QoL, etc.
The engine already exposes a lot of this through registries and hooks
([modding wiki](https://github.com/bryanthaboi/pokemon-gen1-recomp-project/wiki)).
Use a **Feature request** instead for launcher / ports / video options /
networking / save tooling / new API seams.
- type: input
id: summary
attributes:
label: One line summary
description: What the mod should do, in a sentence.
placeholder: Hold B to run at bike speed on the overworld
validations:
required: true
- type: input
id: discord
attributes:
label: Discord username (optional)
description: >
So maintainers or mod authors can ping you on Discord if they pick this up.
Leave blank if you'd rather keep everything on GitHub.
placeholder: yourname
validations:
required: false
- type: textarea
id: what
attributes:
label: What should the mod do
description: >
Describe the player-facing behavior. What changes, where, what does the
player see or press. If it toggles from Options or a START-menu entry, say so.
placeholder: |
Hold B while walking outdoors to move at bike speed. Release to walk again.
Same places the bike is allowed; no effect in battles or menus.
validations:
required: true
- type: textarea
id: why
attributes:
label: Why is this worth doing as a mod
description: >
Why optional/modded rather than a core option. Who wants it on, who wants
vanilla left alone.
placeholder: |
Great for replaying and backtracking, but some people want a strict Gen 1
pace. A mod (or an opt-in mod option) keeps both camps happy.
validations:
required: true
- type: textarea
id: how
attributes:
label: How should it work
description: >
Buttons, menus, edge cases, whether it needs new art/audio. If you know a
hook or registry that fits (movement.speed, pokemon.sprite, rulesets, …),
mention it — otherwise leave it open.
placeholder: |
- Hold B on the overworld
- Same step timing as the bike
- Disabled where the bike is disabled
- Prefer hooks:wrap("movement.speed") if that still fits
validations:
required: true
- type: dropdown
id: vanilla
attributes:
label: With the mod off, is vanilla unchanged
options:
- Yes — parity when disabled
- No — it would replace something always-on
- Not sure
validations:
required: true
- type: dropdown
id: category
attributes:
label: Best-fit mod category
description: Same taxonomy as example mods (BALANCE, GRAPHICS, AUDIO, …).
options:
- GAMEPLAY / QoL
- GRAPHICS
- AUDIO
- BALANCE / ruleset
- CONTENT (maps, encounters, trainers)
- UI / TOOL
- TOTAL_CONVERSION-ish
- Not sure
validations:
required: true
- type: textarea
id: extra
attributes:
label: Anything else
description: >
Reference screenshots, other games/hacks that do it, related issues, or
"I'd like to try writing this myself." Leave blank if nothing comes to mind.
validations:
required: false
+3 -1
View File
@@ -14,7 +14,9 @@ name: ci
on: on:
push: push:
branches: [main] # Integration branch + release branch. PRs already run via pull_request
# (any base); this list is only for post-merge push runs.
branches: [dev, main]
pull_request: pull_request:
# a force-push while CI is mid-run should cancel the stale run, not queue # a force-push while CI is mid-run should cancel the stale run, not queue
+3
View File
@@ -10,6 +10,9 @@ name: Release
# - To force a specific version, either: # - To force a specific version, either:
# * run this workflow manually (Actions tab) and type it into "version", or # * run this workflow manually (Actions tab) and type it into "version", or
# * put "[release X.Y.Z]" anywhere in the commit message. # * put "[release X.Y.Z]" anywhere in the commit message.
#
# Branch model: day-to-day work merges to `dev`. Releases stay on `main` only
# so promoting `dev` -> `main` is the ship gate that cuts a build.
on: on:
push: push:
+10 -2
View File
@@ -1,13 +1,21 @@
-- pokered/scripts/PewterNidoranHouse.asm -- pokered/scripts/PewterNidoranHouse.asm
-- PewterNidoranHouseNidoranText: text_far _PewterNidoranHouseNidoranText, then -- PewterNidoranHouseNidoranText: text_far _PewterNidoranHouseNidoranText, then
-- text_asm plays the NIDORAN_M cry (PlayCry/WaitForSoundToFinish) before ending. -- 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 -- play_cry only stashes ctx.pendingCry for the very next show_text row, so it
-- only; the cry SFX is cosmetic and has no gameplay effect. -- 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 { return {
PEWTER_NIDORAN_HOUSE = { PEWTER_NIDORAN_HOUSE = {
talk = { talk = {
TEXT_PEWTERNIDORANHOUSE_NIDORAN = { TEXT_PEWTERNIDORANHOUSE_NIDORAN = {
{ "face_player" }, { "face_player" },
-- text_asm: ld a, NIDORAN_M / call PlayCry
{ "play_cry", "NIDORAN_M", true },
{ "show_text", "_PewterNidoranHouseNidoranText" }, { "show_text", "_PewterNidoranHouseNidoranText" },
}, },
}, },
+7 -3
View File
@@ -59,13 +59,17 @@ M.VIRIDIAN_CITY = {
-- ViridianCityYoungster2CaterpieAndWeedleDescriptionText are -- ViridianCityYoungster2CaterpieAndWeedleDescriptionText are
-- defined without a leading underscore in pokered/text/ViridianCity.asm -- defined without a leading underscore in pokered/text/ViridianCity.asm
-- and aren't present in data/generated/text.lua, so we fall back to -- 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) TEXT_VIRIDIANCITY_YOUNGSTER2 = function(game, ow, npc, done)
local t = text(game) local t = text(game)
ask(game, t._ViridianCityYoungster2YouWantToKnowAboutText 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 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 else
push(game, "Oh, OK then!", done) push(game, "Oh, OK then!", done)
end end
@@ -3,12 +3,16 @@
return { return {
VIRIDIAN_NICKNAME_HOUSE = { VIRIDIAN_NICKNAME_HOUSE = {
talk = { talk = {
-- ViridianNicknameHouseSpearowText: text_asm that shows the text -- ViridianNicknameHouseSpearowText: text_asm that PrintTexts the
-- then plays a SPEAROW cry (PlayCry/WaitForSoundToFinish). The -- line, then plays the SPEAROW cry (PlayCry/WaitForSoundToFinish)
-- cry playback isn't in the talk-script command vocabulary, so -- before TextScriptEnd. play_cry only arms the show_text row that
-- only the flavor line is ported here. -- 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 = { 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 -- StartSimulatingJoypadStates walks the player to the counter
-- (.PlayerMovement: PAD_LEFT 1, PAD_UP 2, door (3,7) -> counter (2,5)) -- (.PlayerMovement: PAD_LEFT 1, PAD_UP 2, door (3,7) -> counter (2,5))
-- and ViridianMartOaksParcelScript hands the parcel over. The player -- 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 -- never presses A. This matters beyond convenience: the parcel gates
-- Oak's Pokedex and the old man clearing Route 2, so vanilla guarantees -- Oak's Pokedex and the old man clearing Route 2, so vanilla guarantees
-- it on entry rather than letting you walk out without it. -- 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 if not f.EVENT_GOT_STARTER then return end
ow:queueScript({ ow:queueScript({
{ "show_text", "_ViridianMartClerkYouCameFromPalletTownText" }, { "show_text", "_ViridianMartClerkYouCameFromPalletTownText" },
{ "move_player", "left", 1 },
{ "move_player", "up", 2 }, { "move_player", "up", 2 },
{ "move_player", "left", 1 },
-- the quest text's last page is "{PLAYER} got\nOAK's PARCEL!" -- the quest text's last page is "{PLAYER} got\nOAK's PARCEL!"
{ "give_item", "OAKS_PARCEL", 1, "_ViridianMartClerkParcelQuestText" }, { "give_item", "OAKS_PARCEL", 1, "_ViridianMartClerkParcelQuestText" },
{ "set_flag", "EVENT_GOT_OAKS_PARCEL" }, { "set_flag", "EVENT_GOT_OAKS_PARCEL" },
+47 -15
View File
@@ -5,19 +5,39 @@ that runs before `Game:load`. Besides ROM import (see the file's own header)
it hosts a tabbed shell covering per-game save slots and a mod manager. This it hosts a tabbed shell covering per-game save slots and a mod manager. This
file documents the runtime model; the visual spec lives separately. file documents the runtime model; the visual spec lives separately.
## Android multi-ROM import ## Android multi-ROM / mod / save import
On Android, `love.system.pickFile()` opens the Storage Access Framework On Android, `love.system.pickFile([kind])` opens the Storage Access Framework
picker (`GameActivity.showRomFilePicker`); the chosen file is copied to picker (`GameActivity.showFilePicker`); the chosen file is copied into the app
`picked_rom.gb` in the app save directory. `RomImporter` then imports on save directory as:
refocus / Choose via `findPendingRom`: only a 1 MiB `.gb` whose SHA-1 maps
to a version that is **not** yet ready counts as pending. A leftover | `kind` | Destination |
`picked_rom.gb` from Red therefore cannot block Blue's Choose (issue #167). | --- | --- |
After a successful import the consumed save-dir `.gb` is removed. | nil / `"rom"` | `picked_rom.gb` (open) |
| `"mod"` | `picked_mod.zip` (open) |
| `"sav"` / `"save"` | `picked_save.sav` (open) |
Export uses a separate API: `love.system.createFile(suggestedName)`
`GameActivity.showCreateDocument` (`ACTION_CREATE_DOCUMENT`), which copies
staged `pending_export.sav` to the user-chosen URI and writes `export_done.flag`
for the launcher to acknowledge on refocus.
`RomImporter` then imports on refocus / Choose:
- **ROMs** via `findPendingRom`: only a 1 MiB `.gb` whose SHA-1 maps to a
version that is **not** yet ready counts as pending. A leftover
`picked_rom.gb` from Red therefore cannot block Blue's Choose (issue #167).
- **Mods** via `findPendingMod`: Prefer `picked_mod.zip`, or (on Choose) any
other `.zip` at the save-dir root (USB copy).
- **Saves** via `findPendingSav`: Prefer `picked_save.sav`, or (on Choose) any
other `.sav` at the save-dir root.
After a successful import the consumed save-dir file is removed.
**Manual check (device/emulator):** import Red → switch to Blue → Choose → **Manual check (device/emulator):** import Red → switch to Blue → Choose →
system file picker must appear (not a silent Red re-extract) → pick Blue → system file picker must appear (not a silent Red re-extract) → pick Blue →
Blue becomes ready beside Red. Blue becomes ready beside Red. On the MODS tab, Import mod .zip must open the
same system picker and install the chosen archive on return.
## Tab structure ## Tab structure
@@ -78,6 +98,10 @@ The launcher-facing API:
- `SaveData.createSlot(version)` -> new slot id, registered but with **no - `SaveData.createSlot(version)` -> new slot id, registered but with **no
save file written**. An empty slot means the title screen offers NEW GAME save file written**. An empty slot means the title screen offers NEW GAME
only, which needs no further changes. only, which needs no further changes.
- `SaveData.deleteSlot(version, slotId)` removes the slot's
main/`.bak`/`.tmp` files, drops it from the registry, and if it was active
points active at another remaining slot (or clears active when the list is
empty). The launcher's SAVE SLOT panel Delete control calls this.
## Launcher mod manager ## Launcher mod manager
@@ -114,6 +138,9 @@ before `Game:load`, so **it never loads a mod's entry chunk**; only
temp first (mount only reaches save-dir-relative paths), the same way temp first (mount only reaches save-dir-relative paths), the same way
`RomImporter` handles a dropped ROM. A failed copy rolls its partial tree `RomImporter` handles a dropped ROM. A failed copy rolls its partial tree
back, and every path unmounts and clears the staged temp file. back, and every path unmounts and clears the staged temp file.
- `LauncherMods.uninstall(id)` removes `mods/<id>/` and clears
`options.mods[id]` so a later reinstall starts from the loader's default
(enabled). The mods panel Delete control calls this and re-derives the list.
## Import / Export save ## Import / Export save
@@ -122,9 +149,10 @@ through `src/import/SaveFileIO.lua`, which sits on top of
`src/save_convert/SaveConvert.lua` and the slot API in `SaveData`. `src/save_convert/SaveConvert.lua` and the slot API in `SaveData`.
- **Import save** is live once the game's ROM is imported (playable). It opens - **Import save** is live once the game's ROM is imported (playable). It opens
a native `.sav` picker (`chooseSav`, the per-OS dialogs mirror `chooseZip`; a native `.sav` picker (`chooseSav` on desktop; on Android,
Android has no picker and shows a drop hint). `SaveFileIO.importToSlot` `love.system.pickFile("sav")``picked_save.sav`, same SAF path as ROMs).
reads the bytes (an absolute path, a dropped LOVE file, or raw bytes), `SaveFileIO.importToSlot` reads the bytes (an absolute path, a save-dir
relative name, a dropped LOVE file, or raw bytes),
guards the 32768-byte size, runs `SaveConvert.importSav` (which also rejects guards the 32768-byte size, runs `SaveConvert.importSav` (which also rejects
a bad main-data checksum), then registers a fresh slot (`SaveData.createSlot`), a bad main-data checksum), then registers a fresh slot (`SaveData.createSlot`),
writes it (`SaveData.writeSlot`), and makes it active (`SaveData.setActiveSlot`). writes it (`SaveData.writeSlot`), and makes it active (`SaveData.setActiveSlot`).
@@ -136,9 +164,13 @@ through `src/import/SaveFileIO.lua`, which sits on top of
slot, encodes it back with `SaveConvert.exportSav` (a slot never keeps slot, encodes it back with `SaveConvert.exportSav` (a slot never keeps
`rawImport`, so this is a zero-filled template export, which is valid), and `rawImport`, so this is a zero-filled template export, which is valid), and
writes `exports/gen1recomp-<version>-<slotId>.sav` in the save directory writes `exports/gen1recomp-<version>-<slotId>.sav` in the save directory
(`love.filesystem.createDirectory("exports")`). It returns the absolute path (`love.filesystem.createDirectory("exports")`). On desktop it returns the
(`love.filesystem.getSaveDirectory()`), which the notice line shows with a absolute path (`love.filesystem.getSaveDirectory()`), which the notice line
desktop "Open folder" affordance (`love.system.openURL("file://" .. dir)`). shows with an "Open folder" affordance (`love.system.openURL("file://" .. dir)`).
On Android the bytes are also staged as `pending_export.sav` and
`love.system.createFile(suggestedName)` opens `ACTION_CREATE_DOCUMENT` so the
player can save to Downloads / Drive / etc.; on return `export_done.flag`
makes focus show "Save exported."
- **Drag-drop.** `filedropped` routes a `.sav` to the import path for the - **Drag-drop.** `filedropped` routes a `.sav` to the import path for the
currently active game tab; when a non-game tab (mods, or the locked yellow currently active game tab; when a non-game tab (mods, or the locked yellow
placeholder) is showing it defaults to red, the always-present first game placeholder) is showing it defaults to red, the always-present first game
+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 the last controller restores it immediately. Layout re-derives from the
window size on rotation. Desktop testing: `POKEPORT_TOUCH=1 love .` forces 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). 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.
+5 -5
View File
@@ -43,11 +43,11 @@ The embedded `game.love` deliberately excludes `data/generated/`,
`assets/generated/`, and any ROM. It contains the first-boot Lua importer and `assets/generated/`, and any ROM. It contains the first-boot Lua importer and
`tools/rom_manifest.json`. `tools/rom_manifest.json`.
ROM import on Android uses `love.system.pickFile()` ROM / mod / save import on Android uses `love.system.pickFile([kind])`
`GameActivity.showRomFilePicker` (Storage Access Framework), which copies the `GameActivity.showFilePicker` (Storage Access Framework), which copies the
chosen file to `picked_rom.gb` under the app save directory. `RomImporter` chosen file under the app save directory as `picked_rom.gb`,
imports pending (not-yet-ready) `.gb` files from that folder on Choose / `picked_mod.zip`, or `picked_save.sav`. `RomImporter` imports pending files
refocus; see `docs/launcher.md` (Android multi-ROM import). The APK payload from that folder on Choose / refocus; see `docs/launcher.md`. The APK payload
itself remains data-free (no embedded ROM or generated cache). itself remains data-free (no embedded ROM or generated cache).
### SDK / NDK ### SDK / NDK
+1 -1
View File
@@ -18,4 +18,4 @@ android.useAndroidX=true
android.defaults.buildfeatures.buildconfig=true android.defaults.buildfeatures.buildconfig=true
android.nonTransitiveRClass=true android.nonTransitiveRClass=true
android.nonFinalResIds=true android.nonFinalResIds=true
app.name=Pokemon Red app.name=gen1recomp
@@ -183,13 +183,37 @@ void vibrate(double seconds)
env->DeleteLocalRef(activity); env->DeleteLocalRef(activity);
} }
bool showFilePicker() bool showFilePicker(const char *destFilename)
{ {
if (destFilename == nullptr || destFilename[0] == '\0')
destFilename = "picked_rom.gb";
JNIEnv *env = (JNIEnv*) SDL_AndroidGetJNIEnv(); JNIEnv *env = (JNIEnv*) SDL_AndroidGetJNIEnv();
jclass activity = env->FindClass("org/love2d/android/GameActivity"); jclass activity = env->FindClass("org/love2d/android/GameActivity");
jmethodID method = env->GetStaticMethodID(activity, "showRomFilePicker", "()Z"); jmethodID method = env->GetStaticMethodID(activity, "showFilePicker",
jboolean result = env->CallStaticBooleanMethod(activity, method); "(Ljava/lang/String;)Z");
jstring jname = env->NewStringUTF(destFilename);
jboolean result = env->CallStaticBooleanMethod(activity, method, jname);
env->DeleteLocalRef(jname);
env->DeleteLocalRef(activity);
return result;
}
bool showCreateDocument(const char *suggestedName)
{
if (suggestedName == nullptr || suggestedName[0] == '\0')
suggestedName = "export.sav";
JNIEnv *env = (JNIEnv*) SDL_AndroidGetJNIEnv();
jclass activity = env->FindClass("org/love2d/android/GameActivity");
jmethodID method = env->GetStaticMethodID(activity, "showCreateDocument",
"(Ljava/lang/String;)Z");
jstring jname = env->NewStringUTF(suggestedName);
jboolean result = env->CallStaticBooleanMethod(activity, method, jname);
env->DeleteLocalRef(jname);
env->DeleteLocalRef(activity); env->DeleteLocalRef(activity);
return result; return result;
@@ -63,9 +63,16 @@ void vibrate(double seconds);
* Shows the system's "pick a document" UI (Storage Access Framework). * Shows the system's "pick a document" UI (Storage Access Framework).
* Returns true if the picker was launched; the picked file (if any) is * Returns true if the picker was launched; the picked file (if any) is
* copied asynchronously by GameActivity.onActivityResult into the app's * copied asynchronously by GameActivity.onActivityResult into the app's
* external save directory, not returned here -- see src/import/RomImporter.lua. * external save directory under destFilename (default picked_rom.gb), not
* returned here -- see src/import/RomImporter.lua.
**/ **/
bool showFilePicker(); bool showFilePicker(const char *destFilename = nullptr);
/**
* Shows ACTION_CREATE_DOCUMENT so Lua can export a staged pending_export.sav
* to a user-chosen location. suggestedName is the dialog default filename.
**/
bool showCreateDocument(const char *suggestedName = nullptr);
/* /*
* Helper functions for the filesystem module * Helper functions for the filesystem module
@@ -22,6 +22,8 @@
#include "common/config.h" #include "common/config.h"
#include "System.h" #include "System.h"
#include <cstring>
#if defined(LOVE_MACOSX) #if defined(LOVE_MACOSX)
#include <CoreServices/CoreServices.h> #include <CoreServices/CoreServices.h>
#elif defined(LOVE_IOS) #elif defined(LOVE_IOS)
@@ -180,11 +182,32 @@ void System::vibrate(double seconds) const
#endif #endif
} }
bool System::pickFile() const bool System::pickFile(const char *kind) const
{ {
#ifdef LOVE_ANDROID #ifdef LOVE_ANDROID
return love::android::showFilePicker(); const char *dest = "picked_rom.gb";
if (kind != nullptr)
{
if (strcmp(kind, "mod") == 0)
dest = "picked_mod.zip";
else if (strcmp(kind, "sav") == 0 || strcmp(kind, "save") == 0)
dest = "picked_save.sav";
else if (strcmp(kind, "rom") == 0)
dest = "picked_rom.gb";
}
return love::android::showFilePicker(dest);
#else #else
LOVE_UNUSED(kind);
return false;
#endif
}
bool System::createFile(const char *suggestedName) const
{
#ifdef LOVE_ANDROID
return love::android::showCreateDocument(suggestedName);
#else
LOVE_UNUSED(suggestedName);
return false; return false;
#endif #endif
} }
@@ -111,9 +111,21 @@ public:
* Android only for now; the result (if any) is not returned here -- see * Android only for now; the result (if any) is not returned here -- see
* love::android::showFilePicker and src/import/RomImporter.lua. * love::android::showFilePicker and src/import/RomImporter.lua.
* *
* @param kind Optional pick kind: nullptr/"rom" -> picked_rom.gb,
* "mod" -> picked_mod.zip, "sav"/"save" -> picked_save.sav.
* @return Whether the picker was shown. * @return Whether the picker was shown.
**/ **/
virtual bool pickFile() const; virtual bool pickFile(const char *kind = nullptr) const;
/**
* Shows the platform's native "create / save a file" UI (Android SAF
* ACTION_CREATE_DOCUMENT). Copies staged pending_export.sav from the app
* save directory to the user-chosen URI. See GameActivity.showCreateDocument.
*
* @param suggestedName Default filename shown in the dialog.
* @return Whether the create dialog was shown.
**/
virtual bool createFile(const char *suggestedName = nullptr) const;
/** /**
* Gets if the user is playing music on background. * Gets if the user is playing music on background.
@@ -97,7 +97,15 @@ int w_vibrate(lua_State *L)
int w_pickFile(lua_State *L) int w_pickFile(lua_State *L)
{ {
luax_pushboolean(L, instance()->pickFile()); const char *kind = luaL_optstring(L, 1, nullptr);
luax_pushboolean(L, instance()->pickFile(kind));
return 1;
}
int w_createFile(lua_State *L)
{
const char *suggested = luaL_optstring(L, 1, nullptr);
luax_pushboolean(L, instance()->createFile(suggested));
return 1; return 1;
} }
@@ -117,6 +125,7 @@ static const luaL_Reg functions[] =
{ "openURL", w_openURL }, { "openURL", w_openURL },
{ "vibrate", w_vibrate }, { "vibrate", w_vibrate },
{ "pickFile", w_pickFile }, { "pickFile", w_pickFile },
{ "createFile", w_createFile },
{ "hasBackgroundMusic", w_hasBackgroundMusic }, { "hasBackgroundMusic", w_hasBackgroundMusic },
{ 0, 0 } { 0, 0 }
}; };
@@ -22,12 +22,15 @@ package org.love2d.android;
import org.libsdl.app.SDLActivity; import org.libsdl.app.SDLActivity;
import java.io.BufferedInputStream;
import java.io.BufferedOutputStream; import java.io.BufferedOutputStream;
import java.io.File; import java.io.File;
import java.io.FileInputStream;
import java.io.FileNotFoundException; import java.io.FileNotFoundException;
import java.io.FileOutputStream; import java.io.FileOutputStream;
import java.io.IOException; import java.io.IOException;
import java.io.InputStream; import java.io.InputStream;
import java.io.OutputStream;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.HashMap; import java.util.HashMap;
import java.util.List; import java.util.List;
@@ -61,13 +64,24 @@ public class GameActivity extends SDLActivity {
protected final int[] recordAudioRequestDummy = new int[1]; protected final int[] recordAudioRequestDummy = new int[1];
public static final int EXTERNAL_STORAGE_REQUEST_CODE = 2; public static final int EXTERNAL_STORAGE_REQUEST_CODE = 2;
public static final int RECORD_AUDIO_REQUEST_CODE = 3; public static final int RECORD_AUDIO_REQUEST_CODE = 3;
public static final int ROM_PICKER_REQUEST_CODE = 4; public static final int FILE_PICKER_REQUEST_CODE = 4;
// Mirrors conf.lua's t.identity ("pokemon-love2d"): where the picked ROM public static final int FILE_CREATE_REQUEST_CODE = 5;
/** @deprecated Prefer FILE_PICKER_REQUEST_CODE; kept for older call sites. */
public static final int ROM_PICKER_REQUEST_CODE = FILE_PICKER_REQUEST_CODE;
// Mirrors conf.lua's t.identity ("pokemon-love2d"): where the picked file
// is dropped so RomImporter's existing folder scan finds it -- see // is dropped so RomImporter's existing folder scan finds it -- see
// src/import/RomImporter.lua and Filesystem::setIdentity (sets Android's // src/import/RomImporter.lua and Filesystem::setIdentity (sets Android's
// save directory to getExternalFilesDir()/save/<identity>). // save directory to getExternalFilesDir()/save/<identity>).
private static final String ROM_SAVE_IDENTITY = "pokemon-love2d"; private static final String ROM_SAVE_IDENTITY = "pokemon-love2d";
private static final String PICKED_ROM_FILENAME = "picked_rom.gb"; private static final String PICKED_ROM_FILENAME = "picked_rom.gb";
private static final String PICKED_MOD_FILENAME = "picked_mod.zip";
private static final String PICKED_SAVE_FILENAME = "picked_save.sav";
private static final String PENDING_EXPORT_FILENAME = "pending_export.sav";
private static final String EXPORT_DONE_FILENAME = "export_done.flag";
// Destination basename for the in-flight SAF pick (set by showFilePicker).
private String pendingPickFilename = PICKED_ROM_FILENAME;
// Suggested download name for the in-flight SAF create (set by showCreateDocument).
private String pendingCreateSuggestedName = "export.sav";
private static boolean immersiveActive = false; private static boolean immersiveActive = false;
private static boolean needToCopyGameInArchive = false; private static boolean needToCopyGameInArchive = false;
private boolean storagePermissionUnnecessary = false; private boolean storagePermissionUnnecessary = false;
@@ -341,59 +355,185 @@ public class GameActivity extends SDLActivity {
/** /**
* Shows the system document picker (Storage Access Framework) so the * Shows the system document picker (Storage Access Framework) so the
* player can pick their ROM from anywhere (Downloads, Drive, etc.) * player can pick a ROM / mod / save from anywhere (Downloads, Drive,
* without needing to know where the app's external files folder is. * etc.) without needing to know where the app's external files folder
* Requires API 19+ (ACTION_OPEN_DOCUMENT); the picked file (if any) * is. Requires API 19+ (ACTION_OPEN_DOCUMENT); the picked file (if any)
* arrives later in onActivityResult, not synchronously here. * arrives later in onActivityResult, not synchronously here.
*
* @param destFilename basename under the app save identity (e.g.
* picked_rom.gb, picked_mod.zip, picked_save.sav)
*/ */
@Keep @Keep
public static boolean showRomFilePicker() { public static boolean showFilePicker(String destFilename) {
if (android.os.Build.VERSION.SDK_INT < 19) return false; if (android.os.Build.VERSION.SDK_INT < 19) return false;
GameActivity self = (GameActivity) mSingleton; GameActivity self = (GameActivity) mSingleton;
if (self == null) return false; if (self == null) return false;
if (destFilename == null || destFilename.length() == 0) {
destFilename = PICKED_ROM_FILENAME;
}
// Reject path separators so a hostile JNI caller cannot escape the
// save identity directory.
if (destFilename.indexOf('/') >= 0 || destFilename.indexOf('\\') >= 0) {
Log.d("GameActivity", "refusing unsafe picker dest: " + destFilename);
return false;
}
self.pendingPickFilename = destFilename;
Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT); Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT);
intent.addCategory(Intent.CATEGORY_OPENABLE); intent.addCategory(Intent.CATEGORY_OPENABLE);
intent.setType("*/*"); intent.setType("*/*");
try { try {
self.startActivityForResult(intent, ROM_PICKER_REQUEST_CODE); self.startActivityForResult(intent, FILE_PICKER_REQUEST_CODE);
return true; return true;
} catch (Exception e) { } catch (Exception e) {
Log.d("GameActivity", "could not open ROM file picker: " + e.getMessage()); Log.d("GameActivity", "could not open file picker: " + e.getMessage());
return false; return false;
} }
} }
/** ROM convenience wrapper; prefer showFilePicker with an explicit name. */
@Keep
public static boolean showRomFilePicker() {
return showFilePicker(PICKED_ROM_FILENAME);
}
/** Mod .zip convenience wrapper used by love.system.pickFile("mod"). */
@Keep
public static boolean showModFilePicker() {
return showFilePicker(PICKED_MOD_FILENAME);
}
/** Battery .sav convenience wrapper used by love.system.pickFile("sav"). */
@Keep
public static boolean showSaveFilePicker() {
return showFilePicker(PICKED_SAVE_FILENAME);
}
/**
* Shows ACTION_CREATE_DOCUMENT so the player can save a staged export
* (pending_export.sav in the app save identity) to Downloads / Drive /
* etc. Suggested name is the dialog's default filename.
*/
@Keep
public static boolean showCreateDocument(String suggestedName) {
if (android.os.Build.VERSION.SDK_INT < 19) return false;
GameActivity self = (GameActivity) mSingleton;
if (self == null) return false;
if (suggestedName == null || suggestedName.length() == 0) {
suggestedName = "export.sav";
}
if (suggestedName.indexOf('/') >= 0 || suggestedName.indexOf('\\') >= 0) {
Log.d("GameActivity", "refusing unsafe create name: " + suggestedName);
return false;
}
File source = new File(
new File(self.getExternalFilesDir(null), "save"),
ROM_SAVE_IDENTITY + "/" + PENDING_EXPORT_FILENAME);
if (!source.isFile()) {
Log.d("GameActivity", "no pending export at " + source);
return false;
}
self.pendingCreateSuggestedName = suggestedName;
Intent intent = new Intent(Intent.ACTION_CREATE_DOCUMENT);
intent.addCategory(Intent.CATEGORY_OPENABLE);
intent.setType("application/octet-stream");
intent.putExtra(Intent.EXTRA_TITLE, suggestedName);
try {
self.startActivityForResult(intent, FILE_CREATE_REQUEST_CODE);
return true;
} catch (Exception e) {
Log.d("GameActivity", "could not open create-document picker: " + e.getMessage());
return false;
}
}
private File saveIdentityDir() {
return new File(new File(getExternalFilesDir(null), "save"), ROM_SAVE_IDENTITY);
}
private boolean copyFileToUri(File source, Uri destUri) {
InputStream in = null;
OutputStream out = null;
try {
in = new BufferedInputStream(new FileInputStream(source));
out = getContentResolver().openOutputStream(destUri);
if (out == null) return false;
byte[] buf = new byte[8192];
int n;
while ((n = in.read(buf)) != -1) {
out.write(buf, 0, n);
}
out.flush();
return true;
} catch (IOException e) {
Log.d("GameActivity", "copy to URI failed: " + e.getMessage());
return false;
} finally {
try { if (in != null) in.close(); } catch (IOException ignored) {}
try { if (out != null) out.close(); } catch (IOException ignored) {}
}
}
@Override @Override
protected void onActivityResult(int requestCode, int resultCode, Intent data) { protected void onActivityResult(int requestCode, int resultCode, Intent data) {
super.onActivityResult(requestCode, resultCode, data); super.onActivityResult(requestCode, resultCode, data);
if (requestCode != ROM_PICKER_REQUEST_CODE) return; if (requestCode == FILE_CREATE_REQUEST_CODE) {
if (resultCode != RESULT_OK || data == null || data.getData() == null) {
Log.d("GameActivity", "create-document cancelled");
return;
}
File source = new File(saveIdentityDir(), PENDING_EXPORT_FILENAME);
if (!source.isFile()) {
Log.d("GameActivity", "pending export missing at result time");
return;
}
Uri uri = data.getData();
if (copyFileToUri(source, uri)) {
// Signal Lua on next focus that the SAF export finished.
File flag = new File(saveIdentityDir(), EXPORT_DONE_FILENAME);
try {
FileOutputStream fos = new FileOutputStream(flag, false);
fos.write("ok".getBytes());
fos.close();
} catch (IOException e) {
Log.d("GameActivity", "could not write export_done flag: " + e.getMessage());
}
// Keep pending_export.sav so a retry still works; Lua may remove it.
} else {
Log.d("GameActivity", "could not write export to " + uri);
}
return;
}
if (requestCode != FILE_PICKER_REQUEST_CODE) return;
if (resultCode != RESULT_OK || data == null || data.getData() == null) { if (resultCode != RESULT_OK || data == null || data.getData() == null) {
Log.d("GameActivity", "ROM picker returned no file (cancelled?)"); Log.d("GameActivity", "file picker returned no file (cancelled?)");
return; return;
} }
Uri uri = data.getData(); Uri uri = data.getData();
File destDir = new File(new File(getExternalFilesDir(null), "save"), ROM_SAVE_IDENTITY); File destDir = saveIdentityDir();
if (!destDir.exists() && !destDir.mkdirs()) { if (!destDir.exists() && !destDir.mkdirs()) {
Log.d("GameActivity", "could not create " + destDir); Log.d("GameActivity", "could not create " + destDir);
return; return;
} }
File destFile = new File(destDir, PICKED_ROM_FILENAME); String destName = pendingPickFilename != null
? pendingPickFilename : PICKED_ROM_FILENAME;
File destFile = new File(destDir, destName);
InputStream source; InputStream source;
try { try {
source = getContentResolver().openInputStream(uri); source = getContentResolver().openInputStream(uri);
} catch (FileNotFoundException e) { } catch (FileNotFoundException e) {
Log.d("GameActivity", "could not open picked ROM: " + e.getMessage()); Log.d("GameActivity", "could not open picked file: " + e.getMessage());
return; return;
} }
if (source == null) { if (source == null) {
Log.d("GameActivity", "ContentResolver returned no stream for picked ROM"); Log.d("GameActivity", "ContentResolver returned no stream for picked file");
return; return;
} }
if (!copyAssetFile(source, destFile.getPath())) { if (!copyAssetFile(source, destFile.getPath())) {
Log.d("GameActivity", "could not copy picked ROM to " + destFile); Log.d("GameActivity", "could not copy picked file to " + destFile);
} }
} }
+5 -3
View File
@@ -1,6 +1,6 @@
# Example mod gallery # Example mod gallery
Seven reference mods, one per modder persona. Each is small enough to read Eight reference mods, one per modder persona. Each is small enough to read
in one sitting, exercises a different slice of the mod API, and is a real, in one sitting, exercises a different slice of the mod API, and is a real,
runnable, tested mod — not a snippet. runnable, tested mod — not a snippet.
@@ -16,6 +16,7 @@ Copy the one closest to what you want to build.
| 5 | [`example_weather`](example_weather) | Mechanic designer | `MECHANIC` | Rain that scales WATER and FIRE damage, behind a ruleset | | 5 | [`example_weather`](example_weather) | Mechanic designer | `MECHANIC` | Rain that scales WATER and FIRE damage, behind a ruleset |
| 6 | [`example_dexnav`](example_dexnav) | Tool builder | `TOOL` | A START-menu dex overlay with an inter-mod API | | 6 | [`example_dexnav`](example_dexnav) | Tool builder | `TOOL` | A START-menu dex overlay with an inter-mod API |
| 7 | [`example_mini_conversion`](example_mini_conversion) | TC team | `TOTAL_CONVERSION` | Sable Cove: one town, three species, one badge | | 7 | [`example_mini_conversion`](example_mini_conversion) | TC team | `TOTAL_CONVERSION` | Sable Cove: one town, three species, one badge |
| 8 | [`example_silly_oak`](example_silly_oak) | Intro author | `UI` | Extra Oak questions, sprite swaps, answers in `mod.save` |
## None of these load by default ## None of these load by default
@@ -46,7 +47,8 @@ and `field`, and exercises:
- both buses — `events:on` / `events:emit` and `hooks:wrap` — across - both buses — `events:on` / `events:emit` and `hooks:wrap` — across
`music.select`, `battle.damage`, `ui.start_menu.items`, `ui.options.rows`, `music.select`, `battle.damage`, `ui.start_menu.items`, `ui.options.rows`,
`battle.started`, `battle.turn_started`, `battle.ended`, `flag.changed`, `battle.started`, `battle.turn_started`, `battle.ended`, `flag.changed`,
`game.ready` and `assets.transformed` `game.ready`, `assets.transformed`, `intro.oak_speech.build`,
`intro.oak_speech.answered` and `intro.oak_speech.finished`
- `mod.save`, `mod.options`, `mod.exports`, `mod.commands`, `mod.ui`, - `mod.save`, `mod.options`, `mod.exports`, `mod.commands`, `mod.ui`,
`mod:read`, `mod.log` and `mod.path` `mod:read`, `mod.log` and `mod.path`
- asset transforms, the `trueColor` opt-out, script labels and `choice`, - asset transforms, the `trueColor` opt-out, script labels and `choice`,
@@ -66,7 +68,7 @@ mods/examples/<id>/
.modkitignore keeps the suite out of the distributed package .modkitignore keeps the suite out of the distributed package
``` ```
`tests/mod_examples_tests.lua` in the engine's own suite loads all seven `tests/mod_examples_tests.lua` in the engine's own suite loads all eight
together and asserts the above, so the gallery cannot rot. together and asserts the above, so the gallery cannot rot.
These example mods arent perfect and are just examples to show you basics of wahts possible, but way more than just this is possible. These example mods arent perfect and are just examples to show you basics of wahts possible, but way more than just this is possible.
@@ -0,0 +1,12 @@
# Changelog
Format: [keep a changelog](https://keepachangelog.com/en/1.1.0/).
Version headings match `manifest.json`'s `version`.
## 1.0.0
### Added
- `intro.oak_speech.build` wrap that injects toast / MEW / snack / rival-trust / pineapple beats.
- Answers written to `mod.save` via `intro.oak_speech.answered`.
- Custom `toast_kid.png` sprite shown mid-speech.
+43
View File
@@ -0,0 +1,43 @@
# Silly Oak Intro Example
Hooks Oak's NEW GAME speech: extra questions, sprite swaps (Oak, rival,
player, MEW, and a custom Toast Kid pic), and answers stored in `mod.save`.
## Try it (play through yourself)
```sh
rm -rf mods/example_silly_oak
cp -r mods/examples/example_silly_oak mods/
love .
```
Then **NEW GAME** and mash A / pick the menus. Disable or delete
`mods/example_silly_oak` when you're done so vanilla boots clean.
## Headless check
```sh
luajit mods/examples/example_silly_oak/tests/example_silly_oak_test.lua
```
## Auto driver (screenshots + save asserts)
```sh
rm -rf mods/example_silly_oak
cp -r mods/examples/example_silly_oak mods/
SHOT_DIR=/tmp/silly_oak POKEPORT_IDENTITY=silly_oak_driver_test \
POKEPORT_DRIVER=tests/drivers/silly_oak_intro_test.lua POKEPORT_SPEED=8 love .
```
`POKEPORT_IDENTITY` keeps this run's save out of your normal slot.
## What it demonstrates
| Seam | Where |
|---|---|
| `hooks:wrap("intro.oak_speech.build")` | `main.lua` — reshape the step list |
| `mod.ui.insertStepAfter` / `insertStepBefore` | `main.lua` — anchored on vanilla step ids |
| step kinds `say` / `yesno` / `choice` | `main.lua` |
| pics: `"oak"`, `"rival"`, `"player"`, pokemon, custom image | `main.lua` |
| `events:on("intro.oak_speech.answered")` | `main.lua``mod.save` |
| `events:on("intro.oak_speech.finished")` | `main.lua` |
Binary file not shown.

After

Width:  |  Height:  |  Size: 245 B

+107
View File
@@ -0,0 +1,107 @@
-- Gallery entry: reshape Oak's intro speech with extra questions, sprite
-- swaps (oak / rival / player / pokemon / a custom image), and answers
-- that land in mod.save.
--
-- Uses hooks:wrap("intro.oak_speech.build") plus intro.oak_speech.answered.
return function(mod)
local toastPic = mod.path .. "/assets/toast_kid.png"
mod.hooks:wrap("intro.oak_speech.build", function(next, steps, speech)
steps = next(steps, speech)
-- after oak says hello, immediately derail
mod.ui.insertStepAfter(steps, "oak_welcome", {
id = "silly_quiz_intro",
kind = "say",
pic = "oak",
text = "Before we start,\nI have a few\vquestions.\fImportant ones.\nScientific ones.",
})
mod.ui.insertStepAfter(steps, "silly_quiz_intro", {
id = "silly_toast",
kind = "yesno",
pic = "oak",
saveKey = "likes_toast",
text = "Do you like\ntoast?",
})
-- brand new sprite mid-speech
mod.ui.insertStepAfter(steps, "silly_toast", {
id = "silly_toast_kid",
kind = "say",
pic = { type = "image", path = toastPic },
reveal = "fade",
saveKey = nil,
text = "This is Toast Kid.\nHe is not a\vPOKéMON.\fHe just showed up\none day.\fAnyway.",
})
-- existing mon with a wipe + cry, parked after the real demo mon
mod.ui.insertStepAfter(steps, "demo_mon", {
id = "silly_mew",
kind = "say",
pic = { type = "pokemon", id = "MEW" },
reveal = "wipe",
cry = "MEW",
text = "This is MEW.\nPlease do not\vtell anyone\vI showed you.",
})
mod.ui.insertStepAfter(steps, "silly_mew", {
id = "silly_snack",
kind = "choice",
pic = "oak",
saveKey = "snack",
text = "Pick a snack.\nThis goes on\vyour permanent\vrecord.",
choices = { "BERRIES", "LEFTOVERS", "OLD ROD" },
})
-- swap to rival pic for a loaded question before naming him
mod.ui.insertStepBefore(steps, "ask_rival_name", {
id = "silly_trust",
kind = "choice",
pic = "rival",
reveal = "fade",
saveKey = "trusts_rival",
text = "Look at this kid.\nTrustworthy?",
choices = { "SURE", "NO" },
values = { true, false },
})
-- player pic for one last bit after both names are set
mod.ui.insertStepAfter(steps, "name_rival", {
id = "silly_pineapple",
kind = "yesno",
pic = "player",
saveKey = "pineapple_on_pizza",
text = "{PLAYER}. Be honest.\nPineapple on\vpizza?",
})
mod.ui.insertStepAfter(steps, "silly_pineapple", {
id = "silly_closing",
kind = "say",
pic = "oak",
text = "Great. Terrible.\nI have notes.\fLet's pretend this\nwas normal.",
})
return steps
end)
-- every answered step with a saveKey lands in mod.save (and therefore
-- save.modData[mod.id] once the slot is written)
mod.events:on("intro.oak_speech.answered", function(ev)
if not ev.saveKey then return end
mod.save:set(ev.saveKey, ev.value)
mod.log:info("intro answer %s = %s", tostring(ev.saveKey), tostring(ev.value))
end)
mod.events:on("intro.oak_speech.finished", function(ev)
local answers = ev.answers or {}
for key, value in pairs(answers) do
if mod.save:get(key) == nil then
mod.save:set(key, value)
end
end
mod.save:set("quiz_done", true)
mod.log:info("silly oak quiz done")
end)
end
@@ -0,0 +1,15 @@
{
"id": "example_silly_oak",
"name": "Silly Oak Intro Example",
"version": "1.0.0",
"api": 2,
"entry": "main.lua",
"profile": "content",
"category": "UI",
"game_version": ">=0.0.0-0 <2.0.0",
"priority": 100,
"dependencies": [],
"optional_dependencies": [],
"conflicts": [],
"description": "Reshapes Oak's intro speech with extra questions, sprite swaps, and answers saved to mod.save."
}
+21
View File
@@ -0,0 +1,21 @@
-- Sharing metadata for the manager detail pane.
return {
summary = "Oak asks dumb questions during the intro and remembers your answers.",
author = "Pokemon Gen 1 Recompilation Project",
contact = "https://github.com/bryanthaboi/pokemon-gen1-recomp-project",
tags = { "intro", "ui", "oak", "hooks" },
differences = {
changed = {
"Oak's NEW GAME speech gains extra questions and sprite beats",
},
added = {
"mod.save keys: likes_toast, snack, trusts_rival, pineapple_on_pizza, quiz_done",
"Custom Toast Kid pic mid-intro",
},
known = { "vanilla naming and the shrink-away still run" },
},
credits = {
{ who = "Pokemon Gen 1 Recompilation Project", for_ = "the intro.oak_speech hooks" },
},
compat = { engine = ">=0.0.0 <2.0.0", modApi = 2 },
}
@@ -0,0 +1,159 @@
-- Standalone: luajit mods/examples/example_silly_oak/tests/example_silly_oak_test.lua
-- Covers the intro.oak_speech build hook, step helpers, sprite descriptors,
-- and answers landing in mod.save.
--
-- Needs an imported ROM dataset (data/generated/). Headless CI and a
-- fresh checkout without a ROM skip cleanly -- the gallery is also
-- covered by tests/mod_examples_tests.lua when generated data is present.
package.path = "./?.lua;./?/init.lua;" .. package.path
local function hasGenerated()
local handle = io.open("data/generated/constants.lua", "r")
if handle then handle:close() return true end
return false
end
if not hasGenerated() then
print("example_silly_oak_test skipped (needs data/generated/)")
os.exit(0)
end
local T = require("tests.modkit")
local Runtime = require("src.mods.Runtime")
local OakSpeech = require("src.ui.OakSpeech")
local Data = require("src.core.Data")
Data:load()
local run = T.sdk.loadMod("mods/examples/example_silly_oak", { data = Data })
T.eq(#run.errors, 0, "loads clean (" .. tostring(run.errors[1]) .. ")")
local mod = run.mod
T.check(mod ~= nil and mod.state == "loaded", "mod reached the loaded state")
local ModUI = require("src.ui.ModUI")
local bucket = function()
return run.loader.modSave.example_silly_oak or {}
end
local toastPath = (mod.path or "mods/examples/example_silly_oak")
.. "/assets/toast_kid.png"
-- ------- build hook injects every silly beat around vanilla anchors
local speech = OakSpeech.new({
data = Data,
save = { player = { name = "RED", rival = "BLUE" } },
stack = { push = function() end, pop = function() end },
}, nil)
local steps = speech:buildSteps()
local ids = {}
for _, step in ipairs(steps) do ids[#ids + 1] = step.id end
local function has(id)
for _, x in ipairs(ids) do if x == id then return true end end
return false
end
T.check(has("oak_welcome") and has("name_player") and has("shrink"),
"vanilla anchors still present")
T.check(has("silly_quiz_intro") and has("silly_toast") and has("silly_toast_kid"),
"toast quiz beats injected")
T.check(has("silly_mew") and has("silly_snack"),
"MEW reveal and snack choice injected")
T.check(has("silly_trust") and has("silly_pineapple") and has("silly_closing"),
"rival trust + pineapple beats injected")
-- order: toast kid before demo_mon, mew after demo_mon, trust before rival ask
local function indexOf(id)
for i, x in ipairs(ids) do if x == id then return i end end
return 0
end
T.check(indexOf("silly_toast_kid") < indexOf("demo_mon"),
"Toast Kid shows before the demo mon")
T.check(indexOf("demo_mon") < indexOf("silly_mew"),
"MEW shows after the demo mon")
T.check(indexOf("silly_trust") < indexOf("ask_rival_name"),
"trust question is before rival naming")
T.check(indexOf("name_rival") < indexOf("silly_pineapple")
and indexOf("silly_pineapple") < indexOf("legend"),
"pineapple lands between rival name and the legend beat")
-- ------- step shapes cover choice / yesno / custom image / pokemon
local byId = {}
for _, step in ipairs(steps) do byId[step.id] = step end
T.eq(byId.silly_toast.kind, "yesno", "toast is a yes/no")
T.eq(byId.silly_toast.saveKey, "likes_toast", "toast writes likes_toast")
T.eq(byId.silly_snack.kind, "choice", "snack is a multi choice")
T.eq(#byId.silly_snack.choices, 3, "snack has three options")
T.check(byId.silly_toast_kid.pic and byId.silly_toast_kid.pic.type == "image",
"Toast Kid uses a custom image pic")
T.check(byId.silly_mew.pic and byId.silly_mew.pic.type == "pokemon"
and byId.silly_mew.pic.id == "MEW" and byId.silly_mew.cry == "MEW",
"MEW beat uses pokemon pic + cry")
T.eq(byId.silly_trust.pic, "rival", "trust question shows the rival pic")
-- ------- resolvePic covers trainer / pokemon / player / image shorthand
local oakImg = OakSpeech.resolvePic({ data = Data }, "oak", speech)
local rivalImg = OakSpeech.resolvePic({ data = Data }, "rival", speech)
local playerImg = OakSpeech.resolvePic({ data = Data }, "player", speech)
local mewImg, mewFlip = OakSpeech.resolvePic({ data = Data },
{ type = "pokemon", id = "MEW", flip = true }, speech)
local customImg = OakSpeech.resolvePic({ data = Data },
{ type = "image", path = toastPath }, speech)
-- headless love stub may return nil images; the call itself must not throw
T.check(oakImg == speech.oakPic or oakImg == nil or type(oakImg) == "userdata"
or type(oakImg) == "table",
"oak shorthand resolves without error")
T.check(rivalImg == speech.rivalPic or rivalImg == nil or type(rivalImg) == "userdata"
or type(rivalImg) == "table",
"rival shorthand resolves without error")
T.check(playerImg == speech.playerPic or playerImg == nil
or type(playerImg) == "userdata" or type(playerImg) == "table",
"player shorthand resolves without error")
T.check(mewFlip == true, "pokemon flip flag is honored")
T.check(customImg ~= nil or true, "custom image path is accepted")
-- ------- answered event writes mod.save (loader.modSave bucket)
Runtime.emit("intro.oak_speech.answered", {
saveKey = "likes_toast", value = true, label = "YES", index = 1,
step = byId.silly_toast, speech = speech,
})
Runtime.emit("intro.oak_speech.answered", {
saveKey = "snack", value = "OLD ROD", label = "OLD ROD", index = 3,
step = byId.silly_snack, speech = speech,
})
Runtime.emit("intro.oak_speech.answered", {
saveKey = "trusts_rival", value = false, label = "NO", index = 2,
step = byId.silly_trust, speech = speech,
})
Runtime.emit("intro.oak_speech.answered", {
saveKey = "pineapple_on_pizza", value = true, label = "YES", index = 1,
step = byId.silly_pineapple, speech = speech,
})
Runtime.emit("intro.oak_speech.finished", {
speech = speech, answers = speech.answers,
})
local saved = bucket()
T.eq(saved.likes_toast, true, "likes_toast saved")
T.eq(saved.snack, "OLD ROD", "snack saved")
T.eq(saved.trusts_rival, false, "trusts_rival saved")
T.eq(saved.pineapple_on_pizza, true, "pineapple_on_pizza saved")
T.eq(saved.quiz_done, true, "quiz_done stamped on finish")
-- ------- ModUI step helpers (public surface)
local tiny = {
{ id = "a", kind = "say" },
{ id = "b", kind = "say" },
}
ModUI.insertStepAfter(tiny, "a", { id = "mid", kind = "choice" })
T.eq(tiny[2].id, "mid", "insertStepAfter lands behind the anchor")
ModUI.insertStepBefore(tiny, "b", { id = "pre_b", kind = "yesno" })
T.eq(tiny[3].id, "pre_b", "insertStepBefore lands ahead of the anchor")
ModUI.removeStep(tiny, "mid")
T.check(tiny[2].id ~= "mid", "removeStep drops by id")
run.release()
T.finish("example_silly_oak")
+177 -127
View File
@@ -26,6 +26,7 @@ local Status = require("src.battle.Status")
local TrainerAI = require("src.battle.TrainerAI") local TrainerAI = require("src.battle.TrainerAI")
local TurnOrder = require("src.battle.TurnOrder") local TurnOrder = require("src.battle.TurnOrder")
local TypeChart = require("src.battle.TypeChart") local TypeChart = require("src.battle.TypeChart")
local Strings = require("src.core.Strings")
local BattleState = {} local BattleState = {}
BattleState.__index = 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 -- charge-turn texts by move id; the move record's chargeText field wins
-- (ChargeEffect's per-move text pointers) -- (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 = { local CHARGE_TEXT = {
FLY = "%s\nflew up high!", FLY = Strings.source("%s\nflew up high!"),
DIG = "%s\ndug a hole!", DIG = Strings.source("%s\ndug a hole!"),
RAZOR_WIND = "%s\nmade a whirlwind!", RAZOR_WIND = Strings.source("%s\nmade a whirlwind!"),
SOLARBEAM = "%s\ntook in sunlight!", SOLARBEAM = Strings.source("%s\ntook in sunlight!"),
SKULL_BASH = "%s\nlowered its head!", SKULL_BASH = Strings.source("%s\nlowered its head!"),
SKY_ATTACK = "%s\nis glowing!", SKY_ATTACK = Strings.source("%s\nis glowing!"),
} }
-- pokered's <USER>/<TARGET> text macros (home/text.asm -- pokered's <USER>/<TARGET> text macros (home/text.asm
@@ -299,8 +304,13 @@ local function makeBattler(data, mon, isPlayer, save)
curStats = mon.stats, curStats = mon.stats,
curTypes = def.types, curTypes = def.types,
curMoves = mon.moves, curMoves = mon.moves,
sprite = getImage(isPlayer and def.spriteBack or def.spriteFront, sprite = (function()
monPalette(data, mon.species), def.trueColor), local Sprites = require("src.pokemon.Sprites")
local path, tc = Sprites.path(data, mon.species,
isPlayer and "back" or "front",
{ mon = mon, kind = "battle" })
return getImage(path, monPalette(data, mon.species), tc)
end)(),
} }
end end
@@ -318,13 +328,16 @@ BattleState.makeBattler = makeBattler
function BattleState:speciesSprite(species, isPlayerSide) function BattleState:speciesSprite(species, isPlayerSide)
local def = self.data.pokemon[species] local def = self.data.pokemon[species]
if not def then return nil end if not def then return nil end
local Sprites = require("src.pokemon.Sprites")
local path, tc = Sprites.path(self.data, species,
isPlayerSide and "back" or "front", { kind = "battle" })
local PaletteFX = require("src.render.PaletteFX") local PaletteFX = require("src.render.PaletteFX")
local colors = PaletteFX.monPal(self.data, species, true) local colors = PaletteFX.monPal(self.data, species, true)
local name = "GRAYMON" local name = "GRAYMON"
if PaletteFX.usesGbcPack() then name = "redpp:GRAYMON" end if PaletteFX.usesGbcPack() then name = "redpp:GRAYMON" end
return getImage(isPlayerSide and def.spriteBack or def.spriteFront, return getImage(path,
colors and { name = name, colors = colors } or nil, colors and { name = name, colors = colors } or nil,
def.trueColor) tc)
end end
local function markSeen(game, species) local function markSeen(game, species)
@@ -408,9 +421,9 @@ function BattleState.newWild(game, species, level, opts)
self.enemy = makeBattler(game.data, Pokemon.new(game.data, species, level), false) self.enemy = makeBattler(game.data, Pokemon.new(game.data, species, level), false)
markSeen(game, species) markSeen(game, species)
if opts and opts.hooked then 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 else
self.introText = ("Wild %s\nappeared!"):format(self.enemy.name) self.introText = Strings("Wild %s\nappeared!", self.enemy.name)
end end
return self return self
end end
@@ -531,7 +544,7 @@ function BattleState.newTrainer(game, oppClass, partyIndex)
-- wEnemyMonSpecies2 before the intro's SET_PAL_BATTLE -- wEnemyMonSpecies2 before the intro's SET_PAL_BATTLE
-- (engine/battle/core.asm:6682, engine/gfx/palettes.asm SetPal_Battle) -- (engine/battle/core.asm:6682, engine/gfx/palettes.asm SetPal_Battle)
self.trainerPic = getImage(self.trainer.pic, namedPalette(game.data, "MEWMON")) 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 return self
end end
@@ -545,7 +558,7 @@ function BattleState:makeGhost()
-- (engine/battle/core.asm InitWildBattle .isGhost) -- (engine/battle/core.asm InitWildBattle .isGhost)
self.enemy.sprite = getImage("assets/generated/battle/front/ghost.png", self.enemy.sprite = getImage("assets/generated/battle/front/ghost.png",
monPalette(self.data, self.enemy.mon.species)) monPalette(self.data, self.enemy.mon.species))
self.introText = "The GHOST\nappeared!" self.introText = Strings("The GHOST\nappeared!")
end end
-- The old man's catch tutorial (BATTLE_TYPE_OLD_MAN, -- The old man's catch tutorial (BATTLE_TYPE_OLD_MAN,
@@ -939,10 +952,10 @@ function BattleState:sendOutText(name)
if e and e.hp > 0 and math.floor(e.stats.hp / 4) > 0 then 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)) pct = math.floor(e.hp * 25 / math.floor(e.stats.hp / 4))
end end
if pct >= 70 then return ("Go! %s!"):format(name) end if pct >= 70 then return Strings("Go! %s!", name) end
if pct >= 40 then return ("Do it! %s!"):format(name) end if pct >= 40 then return Strings("Do it! %s!", name) end
if pct >= 10 then return ("Get'm! %s!"):format(name) end if pct >= 10 then return Strings("Get'm! %s!", name) end
return ("The enemy's weak!\nGet'm! %s!"):format(name) return Strings("The enemy's weak!\nGet'm! %s!", name)
end end
-- audio/play_battle_music.asm: gym leaders (wGymLeaderNo) get the -- audio/play_battle_music.asm: gym leaders (wGymLeaderNo) get the
@@ -1012,37 +1025,54 @@ function BattleState:enter()
-- MonsterPalettes[0] = PAL_MEWMON (wBattleMonSpecies is still 0 when -- MonsterPalettes[0] = PAL_MEWMON (wBattleMonSpecies is still 0 when
-- the intro's SET_PAL_BATTLE runs -- SetPal_Battle, -- the intro's SET_PAL_BATTLE runs -- SetPal_Battle,
-- engine/gfx/palettes.asm:28) -- engine/gfx/palettes.asm:28)
self.playerBackPic = getImage(self.demo -- field.playerPics picks the pic (the catch tutorial's old man fights in
and "assets/generated/battle/oldmanb.png" -- the player's place), then the player.sprite hook gets the last word so
or "assets/generated/battle/redb.png", -- a mod can vary it per save. It loads through getImage like every other
namedPalette(self.data, "MEWMON")) -- 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 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) self:say(self.introText)
if self.kind == "trainer" then 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() self:act(function()
-- EnemySendOutFirstMon (core.asm:1421-1434): after the text the -- EnemySendOutFirstMon (core.asm:1421-1434): after the text the
-- pic grows out of the ball (AnimateSendingOutMon), then the cry -- pic grows out of the ball (AnimateSendingOutMon), then the cry
self.showEnemyTrainer = false self.showEnemyTrainer = false
self:startGrowIn(self.enemy) self:startGrowIn(self.enemy)
end) end)
queueEnemyCry()
elseif self.kind == "link" then elseif self.kind == "link" then
-- Colosseum has no foe trainer pic, but the enemy mon still grows -- 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" -- out of the ball after "X sent out Y!" (not the wild "already there"
-- intro that LinkBattle previously inherited from newWild). -- intro that LinkBattle previously inherited from newWild).
self.enemySendingOut = true 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.enemy.name))
self:act(function() self:act(function()
self.enemySendingOut = false self.enemySendingOut = false
self:startGrowIn(self.enemy) self:startGrowIn(self.enemy)
end) end)
end queueEnemyCry()
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)
end end
if not self.safari and not self.demo then if not self.safari and not self.demo then
self:say(self:sendOutText(self.player.name)) self:say(self:sendOutText(self.player.name))
@@ -1210,7 +1240,7 @@ function BattleState:update(dt)
if self.phase == "menu" and self.safari then if self.phase == "menu" and self.safari then
if self.safari.balls <= 0 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.phase = "messages"
self.result = "run" self.result = "run"
self.afterQueue = "finish" self.afterQueue = "finish"
@@ -1265,7 +1295,7 @@ function BattleState:update(dt)
if input:wasPressed("a") then if input:wasPressed("a") then
local choice = ({ "fight", "pkmn", "item", "run" })[self.menuIndex] local choice = ({ "fight", "pkmn", "item", "run" })[self.menuIndex]
if choice == "fight" and self.ghost then 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.phase = "messages"
self.afterQueue = "menu" self.afterQueue = "menu"
self:act(function() self:act(function()
@@ -1282,7 +1312,7 @@ function BattleState:update(dt)
end end
if not self:playerHasPP() then if not self:playerHasPP() then
-- _NoMovesLeftText, then Struggle engages -- _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 }) self:resolveTurn({ id = "STRUGGLE", pp = 1, struggle = true })
return return
end end
@@ -1324,11 +1354,11 @@ function BattleState:update(dt)
end end
local mv = moves[self.moveIndex] local mv = moves[self.moveIndex]
if self.player.disabledSlot == self.moveIndex then if self.player.disabledSlot == self.moveIndex then
self:say("The move is\ndisabled!") self:say(Strings("The move is\ndisabled!"))
self.phase = "messages" self.phase = "messages"
self.afterQueue = "menu" self.afterQueue = "menu"
elseif mv.pp <= 0 then 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.phase = "messages"
self.afterQueue = "menu" self.afterQueue = "menu"
else else
@@ -1372,7 +1402,7 @@ function BattleState:resolveMimic(user, target, move, moveInst)
table.insert(self.queue, self.nextInsert, { wait = 50 }) table.insert(self.queue, self.nextInsert, { wait = 50 })
if target.invulnerable if target.invulnerable
or not self:accuracyRoll(move, user, target) then or not self:accuracyRoll(move, user, target) then
self:sayNext("But, it failed!") self:sayNext(Strings("But, it failed!"))
return return
end end
local slots = {} local slots = {}
@@ -1382,7 +1412,7 @@ function BattleState:resolveMimic(user, target, move, moveInst)
if #slots == 0 then if #slots == 0 then
-- .getRandomMove rerolls empty slots forever; a moveless target -- .getRandomMove rerolls empty slots forever; a moveless target
-- can't happen in practice, so just fail instead of hanging -- can't happen in practice, so just fail instead of hanging
self:sayNext("But, it failed!") self:sayNext(Strings("But, it failed!"))
return return
end end
if user.isPlayer and self.kind ~= "link" then if user.isPlayer and self.kind ~= "link" then
@@ -1443,7 +1473,7 @@ function BattleState:applyMimic(user, target, moveInst, slot)
entry.mimic = true entry.mimic = true
self:animNext("MIMIC", user.isPlayer) self:animNext("MIMIC", user.isPlayer)
-- _MimicLearnedMoveText: "<USER> / learned / MOVE!" -- _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)) self.data.moves[src.id].name))
end end
@@ -1483,7 +1513,7 @@ function BattleState:openOldManBag()
self:ui(function() self:ui(function()
local list local list
list = ListMenu.new(game, "ITEMS", { 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) script = function(l)
l.scriptTimer = (l.scriptTimer or 0) + 1 l.scriptTimer = (l.scriptTimer or 0) + 1
@@ -1513,7 +1543,7 @@ function BattleState:oldManThrow()
self.phase = "messages" self.phase = "messages"
self.afterQueue = "finish" self.afterQueue = "finish"
self.result = "run" -- nothing is kept; wBattleResult only ends the demo 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() self:act(function()
require("src.core.Sound").play(self.data, "Ball_Toss") require("src.core.Sound").play(self.data, "Ball_Toss")
-- ItemUseBall's beat before the toss chain (like throwBall) -- ItemUseBall's beat before the toss chain (like throwBall)
@@ -1523,7 +1553,7 @@ function BattleState:oldManThrow()
self:actNext(function() self:actNext(function()
require("src.core.Sound").play(self.data, "Caught_Mon") require("src.core.Sound").play(self.data, "Caught_Mon")
end) 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)
end end
@@ -2275,7 +2305,7 @@ function BattleState:executeAction(user, target, action)
-- ghost battles: the ghost never attacks; its whole turn is the -- ghost battles: the ghost never attacks; its whole turn is the
-- GetOutText (ExecuteEnemyMove -> PrintGhostText, core.asm:5462-5463) -- GetOutText (ExecuteEnemyMove -> PrintGhostText, core.asm:5462-5463)
if self.ghost and not user.isPlayer then 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 return
end end
@@ -2312,8 +2342,8 @@ function BattleState:executeAction(user, target, action)
self.aiUses = self:aiUsesFor() self.aiUses = self:aiUsesFor()
markSeen(self.game, self.enemy.mon.species) markSeen(self.game, self.enemy.mon.species)
-- _AIBattleWithdrawText: "X with-/drew Y!" -- _AIBattleWithdrawText: "X with-/drew Y!"
self:sayNext(("%s with-\ndrew %s!"):format(self.trainer.name, oldName)) self:sayNext(Strings("%s with-\ndrew %s!", self.trainer.name, oldName))
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))
return return
end end
@@ -2326,7 +2356,7 @@ function BattleState:executeAction(user, target, action)
-- 3392): sleep/freeze/held/flinch keep the mon recharging next turn -- 3392): sleep/freeze/held/flinch keep the mon recharging next turn
if self:preRechargeChecks(user, target) then return end if self:preRechargeChecks(user, target) then return end
user.mustRecharge = nil user.mustRecharge = nil
self:sayNext(("%s\nmust recharge!"):format(displayName(user))) self:sayNext(Strings("%s\nmust recharge!", displayName(user)))
return return
end end
if action.special == "bound" then if action.special == "bound" then
@@ -2371,8 +2401,8 @@ function BattleState:statusOnomatopoeia(user, kind)
anim = isPlayer and "CONF_PLAYER_ANIM" or "CONF_ANIM" anim = isPlayer and "CONF_PLAYER_ANIM" or "CONF_ANIM"
end end
local text = kind == "sleep" local text = kind == "sleep"
and (displayName(user) .. "\nis fast asleep!") and Strings("%s\nis fast asleep!", displayName(user))
or (displayName(user) .. "\nis confused!") or Strings("%s\nis confused!", displayName(user))
if kind == "sleep" and isPlayer then if kind == "sleep" and isPlayer then
self:animNext(anim, isPlayer) self:animNext(anim, isPlayer)
self:sayNext(text) self:sayNext(text)
@@ -2411,18 +2441,18 @@ function BattleState:preRechargeChecks(user, target)
user.sleepTurns = (user.sleepTurns or 1) - 1 user.sleepTurns = (user.sleepTurns or 1) - 1
if user.sleepTurns <= 0 then if user.sleepTurns <= 0 then
mon.status = nil mon.status = nil
self:sayNext(displayName(user) .. "\nwoke up!") self:sayNext(Strings("%s\nwoke up!", displayName(user)))
else else
self:statusOnomatopoeia(user, "sleep") self:statusOnomatopoeia(user, "sleep")
end end
return true return true
end end
if mon.status == "FRZ" then if mon.status == "FRZ" then
self:sayNext(displayName(user) .. "\nis frozen solid!") self:sayNext(Strings("%s\nis frozen solid!", displayName(user)))
return true return true
end end
if target.trappingTurns then if target.trappingTurns then
self:sayNext(displayName(user) .. "\ncan't move!") self:sayNext(Strings("%s\ncan't move!", displayName(user)))
return true return true
end end
if user.flinched then if user.flinched then
@@ -2430,7 +2460,7 @@ function BattleState:preRechargeChecks(user, target)
-- player recharges, so the flinch eats the recharge turn and the -- player recharges, so the flinch eats the recharge turn and the
-- flag survives (the Hyper Beam flinch glitch) -- flag survives (the Hyper Beam flinch glitch)
user.flinched = false user.flinched = false
self:sayNext(displayName(user) .. "\nflinched!") self:sayNext(Strings("%s\nflinched!", displayName(user)))
return true return true
end end
return false return false
@@ -2451,7 +2481,7 @@ function BattleState:statusInterrupt(user, target)
{ id = "CONFUSED", power = 40, type = "NORMAL", accuracy = 100 }, { id = "CONFUSED", power = 40, type = "NORMAL", accuracy = 100 },
{ rng = self.rng, forceCrit = false, typeless = true, { rng = self.rng, forceCrit = false, typeless = true,
screens = target }) screens = target })
self:sayNext("It hurt itself in\nits confusion!") self:sayNext(Strings("It hurt itself in\nits confusion!"))
self:clearVolatiles(user, true) self:clearVolatiles(user, true)
self:applyDamage(user, dmg) self:applyDamage(user, dmg)
if user.mon.hp <= 0 then self:onFaint(user) end if user.mon.hp <= 0 then self:onFaint(user) end
@@ -2539,7 +2569,7 @@ function BattleState:performMove(user, target, moveInst, isCalled)
self.moveAnimRow = nil self.moveAnimRow = nil
if not (user.thrashTurns and moveInst == user.thrashMove and user.thrashAnnounced) then 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 -- the move's animation plays right after the announcement; the
-- damage path attaches the target's hit blink to this row so the -- damage path attaches the target's hit blink to this row so the
-- blink follows the animation (pokered's order). Mimic is the -- blink follows the animation (pokered's order). Mimic is the
@@ -2599,8 +2629,10 @@ function BattleState:performMove(user, target, moveInst, isCalled)
self:animNext(chargeAnim, user.isPlayer) self:animNext(chargeAnim, user.isPlayer)
end end
local chargeText = move.chargeText or CHARGE_TEXT[move.id] local chargeText = move.chargeText or CHARGE_TEXT[move.id]
or "%s\nis charging up!" or Strings.source("%s\nis charging up!")
self:sayNext(chargeText:format(displayName(user))) -- 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 return
end end
@@ -2622,7 +2654,7 @@ function BattleState:performMove(user, target, moveInst, isCalled)
-- SleepEffect/PoisonEffect/... call PlayCurrentMoveAnimation only -- SleepEffect/PoisonEffect/... call PlayCurrentMoveAnimation only
-- after the effect lands; a miss skips it -- after the effect lands; a miss skips it
self:cancelMoveAnim() self:cancelMoveAnim()
self:sayNext(("%s's\nattack missed!"):format(displayName(user))) self:sayNext(Strings("%s's\nattack missed!", displayName(user)))
return return
end end
local msgs = record.run(ctx) local msgs = record.run(ctx)
@@ -2640,7 +2672,7 @@ function BattleState:performMove(user, target, moveInst, isCalled)
if move.power == 0 and not (record and record.kind == "full") then if move.power == 0 and not (record and record.kind == "full") then
MoveEffects.warnUnknown(move.effect) MoveEffects.warnUnknown(move.effect)
self:cancelMoveAnim() self:cancelMoveAnim()
self:sayNext("But, it failed!") self:sayNext(Strings("But, it failed!"))
return return
end end
@@ -2649,7 +2681,7 @@ function BattleState:performMove(user, target, moveInst, isCalled)
end end
function BattleState:continueTrapping(user, target) 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 -- .MultiturnMoveCheck (core.asm:3554-3566) prints AttackContinuesText
-- then jumps to GetPlayerAnimationType, so the trapping move's full -- then jumps to GetPlayerAnimationType, so the trapping move's full
-- animation replays each locked turn (same damage, animation shown). -- animation replays each locked turn (same damage, animation shown).
@@ -2672,15 +2704,15 @@ end
function BattleState:continueBide(user, target) function BattleState:continueBide(user, target)
user.bideTurns = user.bideTurns - 1 user.bideTurns = user.bideTurns - 1
if user.bideTurns > 0 then if user.bideTurns > 0 then
self:sayNext(("%s\nis storing energy!"):format(displayName(user))) self:sayNext(Strings("%s\nis storing energy!", displayName(user)))
return return
end end
self:sayNext(("%s\nunleashed energy!"):format(displayName(user))) self:sayNext(Strings("%s\nunleashed energy!", displayName(user)))
local dmg = (user.bideDamage or 0) * 2 local dmg = (user.bideDamage or 0) * 2
user.bideTurns, user.bideDamage = nil, nil user.bideTurns, user.bideDamage = nil, nil
if dmg <= 0 then if dmg <= 0 then
self:cancelMoveAnim() self:cancelMoveAnim()
self:sayNext("But, it failed!") self:sayNext(Strings("But, it failed!"))
return return
end end
self:applyDamage(target, dmg) self:applyDamage(target, dmg)
@@ -2699,9 +2731,9 @@ function BattleState:applyDamage(target, dmg)
target.substituteHP = target.substituteHP - dmg target.substituteHP = target.substituteHP - dmg
if target.substituteHP <= 0 then if target.substituteHP <= 0 then
target.substituteHP = nil target.substituteHP = nil
self:sayNext(("%s's\nSUBSTITUTE broke!"):format(displayName(target))) self:sayNext(Strings("%s's\nSUBSTITUTE broke!", displayName(target)))
else 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 end
return dmg return dmg
end end
@@ -2713,7 +2745,7 @@ function BattleState:applyDamage(target, dmg)
end end
if target.rageMove and dealt > 0 then if target.rageMove and dealt > 0 then
target.stages.attack = math.min(6, (target.stages.attack or 0) + 1) 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 end
return dealt return dealt
end end
@@ -2752,7 +2784,7 @@ function BattleState:onFaint(battler)
self:actNext(function() self:playVictoryMusic() end) self:actNext(function() self:playVictoryMusic() end)
end end
-- _EnemyMonFaintedText "Enemy X fainted!" / _PlayerMonFaintedText -- _EnemyMonFaintedText "Enemy X fainted!" / _PlayerMonFaintedText
self:sayNext(("%s\nfainted!"):format(displayName(battler))) self:sayNext(Strings("%s\nfainted!", displayName(battler)))
if battler.isPlayer then if battler.isPlayer then
self:act(function() self:playerMonFainted() end) self:act(function() self:playerMonFainted() end)
else else
@@ -2801,19 +2833,19 @@ function BattleState:enemyMonFainted()
-- _BoostedText / _WithExpAllText end in the CONT code (\v, "...\011" -- _BoostedText / _WithExpAllText end in the CONT code (\v, "...\011"
-- in data/generated/text.lua): the box waits for A/B + ▼ then scrolls -- 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). -- 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 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 elseif mon.traded then
tail = "a boosted\v" .. tail text = Strings.source("%s gained\na boosted\v%d EXP. Points!")
end end
self:sayNext(("%s gained\n" .. tail):format(name, gained)) self:sayNext(Strings(text, name, gained))
end end
-- per level: GrewLevelText -> the stats window (PrintStatsBox) -> -- per level: GrewLevelText -> the stats window (PrintStatsBox) ->
-- the move-learn checks (experience.asm:245-256) -- the move-learn checks (experience.asm:245-256)
local game = self.game local game = self.game
for _, lv in ipairs(levels) do 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() self:uiNext(function()
require("src.core.Sound").play(game.data, "Level_Up") require("src.core.Sound").play(game.data, "Level_Up")
return StatBox.new(game, mon) return StatBox.new(game, mon)
@@ -2886,10 +2918,10 @@ function BattleState:enemyMonFainted()
if style ~= "set" and partyCount > 1 and self.player.mon.hp > 0 then if style ~= "set" and partyCount > 1 and self.player.mon.hp > 0 then
-- _TrainerAboutToUseText: "X is" / "about to use" then cont nick, -- _TrainerAboutToUseText: "X is" / "about to use" then cont nick,
-- then para "Will PLAYER" / "change POKéMON?" with YES/NO. -- then para "Will PLAYER" / "change POKéMON?" with YES/NO.
self:say(("%s is\nabout to use"):format(self.trainer.name)) self:say(Strings("%s is\nabout to use", self.trainer.name))
self:say(("%s!"):format(nextName)) self:say(Strings("%s!", nextName))
self:sayChoice( 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) function(yes)
if not yes then return end if not yes then return end
local game = self.game local game = self.game
@@ -2922,7 +2954,7 @@ function BattleState:enemyMonFainted()
-- (AnimateSendingOutMon) with the cry; no POOF -- that animation -- (AnimateSendingOutMon) with the cry; no POOF -- that animation
-- belongs to the player-side SendOutMon (core.asm:1757-1762) -- belongs to the player-side SendOutMon (core.asm:1757-1762)
self.enemySendingOut = true 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:actNext(function()
self.enemySendingOut = false self.enemySendingOut = false
self:startGrowIn(self.enemy) self:startGrowIn(self.enemy)
@@ -2966,9 +2998,9 @@ function BattleState:enemyMonFainted()
-- prize money -- prize money
self:actNext(function() self:playVictoryMusic() end) self:actNext(function() self:playVictoryMusic() end)
-- _TrainerDefeatedText: "<PLAYER> defeated\nTRAINER!" -- _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.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 end
self.result = "win" self.result = "win"
self.afterQueue = "finish" self.afterQueue = "finish"
@@ -2984,7 +3016,7 @@ function BattleState:learnMove(mon, moveId)
if #mon.moves < 4 then if #mon.moves < 4 then
table.insert(mon.moves, { id = moveId, pp = mdef.pp }) table.insert(mon.moves, { id = moveId, pp = mdef.pp })
Runtime.emit("pokemon.move_learned", { mon = mon, moveId = moveId }) 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)) mdef.name))
return return
end end
@@ -3029,14 +3061,14 @@ function BattleState:playerMonFainted()
if self.oppClass == "OPP_RIVAL1" then if self.oppClass == "OPP_RIVAL1" then
local TextBox = require("src.render.TextBox") local TextBox = require("src.render.TextBox")
local raw = (self.data.text and self.data.text._Rival1WinText) 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)) self:sayNext(TextBox.substitute(self.game, raw))
end end
-- Oak's Lab starter rival: Rival1WinText only (no blackout lines). -- Oak's Lab starter rival: Rival1WinText only (no blackout lines).
-- Any other wipe, including Route 22 RIVAL1, still blacks out. -- Any other wipe, including Route 22 RIVAL1, still blacks out.
if not BattleState.isOaksLabStarterRival(self) then if not BattleState.isOaksLabStarterRival(self) then
self:sayNext(("%s is out of\nuseable POKéMON!"):format(self.game.save.player.name)) self:sayNext(Strings("%s is out of\nuseable POKéMON!", self.game.save.player.name))
self:sayNext(("%s blacked\nout!"):format(self.game.save.player.name)) self:sayNext(Strings("%s blacked\nout!", self.game.save.player.name))
end end
self.result = "lose" self.result = "lose"
self.afterQueue = "finish" self.afterQueue = "finish"
@@ -3049,7 +3081,7 @@ function BattleState:playerMonFainted()
-- battles go straight to the party menu (the menu-phase guard). -- battles go straight to the party menu (the menu-phase guard).
if self.kind ~= "wild" then return end if self.kind ~= "wild" then return end
local game = self.game 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() self:ui(function()
local ChoiceBox = require("src.ui.ChoiceBox") local ChoiceBox = require("src.ui.ChoiceBox")
return ChoiceBox.new(game, function(yes) return ChoiceBox.new(game, function(yes)
@@ -3057,11 +3089,11 @@ function BattleState:playerMonFainted()
local pSpd = (game.save.party[1].stats or { speed = 0 }).speed or 0 local pSpd = (game.save.party[1].stats or { speed = 0 }).speed or 0
if self:runRoll(pSpd, TurnOrder.effectiveSpeed(self.enemy)) then if self:runRoll(pSpd, TurnOrder.effectiveSpeed(self.enemy)) then
require("src.core.Sound").play(self.data, "Run") require("src.core.Sound").play(self.data, "Run")
self:say("Got away safely!") self:say(Strings("Got away safely!"))
self.result = "run" self.result = "run"
self.afterQueue = "finish" self.afterQueue = "finish"
else else
self:say("Can't escape!") self:say(Strings("Can't escape!"))
end end
end) end)
end) end)
@@ -3081,7 +3113,7 @@ function BattleState:openReplacementMenu()
forceSwitch = true, forceSwitch = true,
onSwitch = function(mon) onSwitch = function(mon)
if mon.hp <= 0 then 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 return -- the menu-phase guard reopens the menu
end end
self:restoreMimicked(self.player) self:restoreMimicked(self.player)
@@ -3125,7 +3157,7 @@ function BattleState:safariAction(choice)
if choice == "run" then if choice == "run" then
require("src.core.Sound").play(self.data, "Run") require("src.core.Sound").play(self.data, "Run")
self:say("Got away safely!") self:say(Strings("Got away safely!"))
self.result = "run" self.result = "run"
self.afterQueue = "finish" self.afterQueue = "finish"
return return
@@ -3133,7 +3165,7 @@ function BattleState:safariAction(choice)
if choice == "ball" then if choice == "ball" then
st.balls = st.balls - 1 st.balls = st.balls - 1
self:say(("%s used\nSAFARI BALL!"):format(playerName)) self:say(Strings("%s used\nSAFARI BALL!", playerName))
self:act(function() self:act(function()
require("src.core.Sound").play(self.data, "Ball_Toss") require("src.core.Sound").play(self.data, "Ball_Toss")
self.lastBall = "SAFARI_BALL" self.lastBall = "SAFARI_BALL"
@@ -3150,7 +3182,7 @@ function BattleState:safariAction(choice)
self:actNext(function() self:actNext(function()
require("src.core.Sound").play(self.data, "Caught_Mon") require("src.core.Sound").play(self.data, "Caught_Mon")
end) 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 -- same ItemUseBall .captured flow as a regular ball
self:act(function() self:storeCaughtMon() end) self:act(function() self:storeCaughtMon() end)
else else
@@ -3162,12 +3194,12 @@ function BattleState:safariAction(choice)
end end
if choice == "bait" then 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.safariCatchRate = math.floor(self.safariCatchRate / 2)
self.baitFactor = math.min(255, self.baitFactor + self.rng(1, 5)) self.baitFactor = math.min(255, self.baitFactor + self.rng(1, 5))
self.escapeFactor = 0 self.escapeFactor = 0
else -- rock 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.safariCatchRate = math.min(255, self.safariCatchRate * 2)
self.escapeFactor = math.min(255, self.escapeFactor + self.rng(1, 5)) self.escapeFactor = math.min(255, self.escapeFactor + self.rng(1, 5))
self.baitFactor = 0 self.baitFactor = 0
@@ -3183,13 +3215,13 @@ end
function BattleState:safariEnemyTurn() function BattleState:safariEnemyTurn()
if self.baitFactor > 0 then if self.baitFactor > 0 then
self.baitFactor = self.baitFactor - 1 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 elseif self.escapeFactor > 0 then
self.escapeFactor = self.escapeFactor - 1 self.escapeFactor = self.escapeFactor - 1
if self.escapeFactor == 0 then if self.escapeFactor == 0 then
self.safariCatchRate = self.enemy.def.catchRate self.safariCatchRate = self.enemy.def.catchRate
end end
self:sayNext(("Wild %s\nis angry!"):format(self.enemy.name)) self:sayNext(Strings("Wild %s\nis angry!", self.enemy.name))
end end
self:act(function() self:act(function()
local speed = self.enemy.curStats.speed % 256 local speed = self.enemy.curStats.speed % 256
@@ -3205,7 +3237,7 @@ function BattleState:safariEnemyTurn()
fled = self.rng(0, 255) < b fled = self.rng(0, 255) < b
end end
if fled then if fled then
self:sayNext(("Wild %s\nran!"):format(self.enemy.name)) self:sayNext(Strings("Wild %s\nran!", self.enemy.name))
self:actNext(function() self:actNext(function()
require("src.core.Sound").play(self.data, "Run") require("src.core.Sound").play(self.data, "Run")
startPicKind(self:picFxFor(self.enemy), "slideOff") startPicKind(self:picFxFor(self.enemy), "slideOff")
@@ -3258,7 +3290,12 @@ function BattleState:tryRun()
self.phase = "messages" self.phase = "messages"
self.afterQueue = "menu" self.afterQueue = "menu"
if self.kind == "trainer" then 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 return
end end
-- modified in-battle speeds (stat stages + paralysis), like the -- modified in-battle speeds (stat stages + paralysis), like the
@@ -3267,11 +3304,11 @@ function BattleState:tryRun()
TurnOrder.effectiveSpeed(self.enemy)) TurnOrder.effectiveSpeed(self.enemy))
if escaped then if escaped then
require("src.core.Sound").play(self.data, "Run") require("src.core.Sound").play(self.data, "Run")
self:say("Got away safely!") self:say(Strings("Got away safely!"))
self.result = "run" self.result = "run"
self.afterQueue = "finish" self.afterQueue = "finish"
else else
self:say("Can't escape!") self:say(Strings("Can't escape!"))
self:act(function() self:act(function()
self:executeAction(self.enemy, self.player, self:enemyAction()) self:executeAction(self.enemy, self.player, self:enemyAction())
end) end)
@@ -3304,13 +3341,13 @@ end
function BattleState:ballMissMessage(shakes) function BattleState:ballMissMessage(shakes)
local t = self.data.text local t = self.data.text
if shakes == 0 then 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 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 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 end
return t._ItemUseBallText04 or "Shoot! It was so\nclose too!" return t._ItemUseBallText04 or Strings("Shoot! It was so\nclose too!")
end end
-- AskName (engine/menus/naming_screen.asm): ClearSprites, wild field blank, -- AskName (engine/menus/naming_screen.asm): ClearSprites, wild field blank,
@@ -3321,7 +3358,7 @@ function BattleState:askNicknameUI(mon, displayName)
self.lockedBall = nil self.lockedBall = nil
self.blankForAskName = true self.blankForAskName = true
local TextBox = require("src.render.TextBox") 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 local label = game.data.text and game.data.text._DoYouWantToNicknameText
if label then if label then
-- extractor CONT is \t; TextBox scrolls on \n/\v -- extractor CONT is \t; TextBox scrolls on \n/\v
@@ -3332,7 +3369,7 @@ function BattleState:askNicknameUI(mon, displayName)
self.blankForAskName = false self.blankForAskName = false
if not yes then return end if not yes then return end
pcall(Screens.push, game, "NamingScreen", { pcall(Screens.push, game, "NamingScreen", {
title = "NICKNAME?", maxLen = 10, title = Strings("NICKNAME?"), maxLen = 10,
onDone = function(name) onDone = function(name)
if name and #name > 0 then mon.nickname = name end if name and #name > 0 then mon.nickname = name end
end, end,
@@ -3360,7 +3397,7 @@ function BattleState:storeCaughtMon()
stampOT(game.save, self.enemy.mon) stampOT(game.save, self.enemy.mon)
if isNew then if isNew then
-- _ItemUseBallText06 + ShowPokedexData -- _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() self:uiNext(function()
return self:buildScreen("DexEntryMenu", species) return self:buildScreen("DexEntryMenu", species)
end) end)
@@ -3381,10 +3418,10 @@ function BattleState:storeCaughtMon()
askCaughtNickname() askCaughtNickname()
-- _ItemUseBallText07/08 keyed on EVENT_MET_BILL -- _ItemUseBallText07/08 keyed on EVENT_MET_BILL
local pc = (game.save.flags and game.save.flags.EVENT_MET_BILL) local pc = (game.save.flags and game.save.flags.EVENT_MET_BILL)
and "BILL's PC" or "someone's PC" and "BILL's PC" or Strings("someone's PC")
self:sayNext(("%s was\ntransferred to\n%s!"):format(self.enemy.name, pc)) self:sayNext(Strings("%s was\ntransferred to\n%s!", self.enemy.name, pc))
else else
self:sayNext("But every BOX\nis full!") self:sayNext(Strings("But every BOX\nis full!"))
end end
end end
Runtime.emit("pokemon.caught", { Runtime.emit("pokemon.caught", {
@@ -3438,13 +3475,13 @@ end
-- called by BagMenu when a ball is thrown -- called by BagMenu when a ball is thrown
function BattleState:throwBall(ball) 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.data.items[ball].name))
self:act(function() self:act(function()
require("src.core.Sound").play(self.data, "Ball_Toss") require("src.core.Sound").play(self.data, "Ball_Toss")
if self.kind ~= "wild" then if self.kind ~= "wild" then
self:sayNext("The TRAINER\nblocked the BALL!") self:sayNext(Strings("The TRAINER\nblocked the BALL!"))
self:sayNext("Don't be a thief!") self:sayNext(Strings("Don't be a thief!"))
return return
end end
if self.ghost then if self.ghost then
@@ -3454,8 +3491,8 @@ function BattleState:throwBall(ball)
-- ($10 anim data, no wobbles), and the turn is spent like any -- ($10 anim data, no wobbles), and the turn is spent like any
-- failed throw -- failed throw
self:animNext(self:tossAnimFor(ball), true, nil, ball) self:animNext(self:tossAnimFor(ball), true, nil, ball)
self:sayNext("It dodged the\nthrown BALL!") self:sayNext(Strings("It dodged the\nthrown BALL!"))
self:sayNext("This POKéMON\ncan't be caught!") self:sayNext(Strings("This POKéMON\ncan't be caught!"))
self:act(function() self:act(function()
self:executeAction(self.enemy, self.player, self:enemyAction()) self:executeAction(self.enemy, self.player, self:enemyAction())
end) end)
@@ -3478,7 +3515,7 @@ function BattleState:throwBall(ball)
self:actNext(function() self:actNext(function()
require("src.core.Sound").play(self.data, "Caught_Mon") require("src.core.Sound").play(self.data, "Caught_Mon")
end) 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) self:act(function() self:storeCaughtMon() end)
else else
self:sayNext(self:ballMissMessage(shakes)) self:sayNext(self:ballMissMessage(shakes))
@@ -3498,9 +3535,9 @@ function BattleState:openParty()
battle = self, battle = self,
onSwitch = function(mon) onSwitch = function(mon)
if mon == self.player.mon then 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 elseif mon.hp <= 0 then
self:say("There's no will\nto fight!") self:say(Strings("There's no will\nto fight!"))
else else
self:resolveSwitch(mon) self:resolveSwitch(mon)
end end
@@ -3526,7 +3563,7 @@ end
function BattleState:finish() function BattleState:finish()
if self.payDay and self.result == "win" then if self.payDay and self.result == "win" then
self.game.save.money = self.game.save.money + self.payDay 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.payDay = nil
self.afterQueue = "finish" self.afterQueue = "finish"
self.phase = "messages" self.phase = "messages"
@@ -3573,9 +3610,11 @@ local HudTiles = require("src.render.HudTiles")
local hudTile = HudTiles.tile local hudTile = HudTiles.tile
local drawHPBar = HudTiles.drawHPBar 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 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) return tx * 8 + (n <= 2 and 16 or n <= 4 and 8 or 0)
end end
@@ -3868,8 +3907,11 @@ function BattleState:colorMode()
if g and g.newCanvas and g.setScissor and g.setShader and g.getCanvas if g and g.newCanvas and g.setScissor and g.setShader and g.getCanvas
and love.image and PaletteFX.pack(self.data) and love.image and PaletteFX.pack(self.data)
and PaletteFX.shader() then and PaletteFX.shader() then
local ok1, bg = pcall(g.newCanvas, 160, 144) -- 160x144 real pixels, not DPI units, or the colored battle background
local ok2, wv = pcall(g.newCanvas, 160, 144) -- resamples against the UI canvas on mobile (#208; PixelCanvas.lua)
local PixelCanvas = require("src.render.PixelCanvas")
local ok1, bg = pcall(PixelCanvas.new, 160, 144)
local ok2, wv = pcall(PixelCanvas.new, 160, 144)
if ok1 and ok2 and bg and wv then if ok1 and ok2 and bg and wv then
self.bgCanvas, self.waveCanvas = bg, wv self.bgCanvas, self.waveCanvas = bg, wv
ready = true ready = true
@@ -4301,7 +4343,10 @@ end
function BattleState:drawTextArea() function BattleState:drawTextArea()
Font.drawBox(0, 12, 20, 6) Font.drawBox(0, 12, 20, 6)
love.graphics.setColor(0, 0, 0, 1) 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 -- 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 -- text uses every other tile row, hlcoord *,14 / *,16). scrollPx animates
-- the lines up one row (ScrollTextUpOneLine) so a 3rd line scrolls into -- the lines up one row (ScrollTextUpOneLine) so a 3rd line scrolls into
@@ -4329,9 +4374,9 @@ function BattleState:drawTextArea()
-- -- next to FIGHT (9,14) for the first 80 frames, then ITEM (9,16) -- -- next to FIGHT (9,14) for the first 80 frames, then ITEM (9,16)
Font.drawBox(8, 12, 12, 6) Font.drawBox(8, 12, 12, 6)
love.graphics.setColor(0, 0, 0, 1) 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.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) Font.drawCode(0xED, 72, (self.demoTimer or 0) <= 80 and 112 or 128)
elseif self.phase == "menu" then elseif self.phase == "menu" then
local col = (self.menuIndex - 1) % 2 local col = (self.menuIndex - 1) % 2
@@ -4340,16 +4385,16 @@ function BattleState:drawTextArea()
-- SAFARI_BATTLE_MENU_TEMPLATE: full-width box, "BALLx BAIT / -- SAFARI_BATTLE_MENU_TEMPLATE: full-width box, "BALLx BAIT /
-- THROW ROCK RUN" from (2,14) -- THROW ROCK RUN" from (2,14)
Font.drawBox(0, 12, 20, 6) Font.drawBox(0, 12, 20, 6)
Font.draw("BALLx", 16, 112); Font.draw("BAIT", 112, 112) Font.draw(Strings("BALLx"), 16, 112); Font.draw(Strings("BAIT"), 112, 112)
Font.draw("THROW ROCK", 16, 128); Font.draw("RUN", 112, 128) 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) Font.drawCode(0xED, (col == 0 and 8 or 104), 112 + row * 16)
else else
-- BATTLE_MENU_TEMPLATE: box (8,12)-(19,17), "FIGHT <PK><MN> / -- BATTLE_MENU_TEMPLATE: box (8,12)-(19,17), "FIGHT <PK><MN> /
-- ITEM RUN" from (10,14); cursor columns 9 / 15 -- ITEM RUN" from (10,14); cursor columns 9 / 15
Font.drawBox(8, 12, 12, 6) 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.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) Font.drawCode(0xED, (col == 0 and 72 or 120), 112 + row * 16)
end end
elseif self.phase == "moveSelect" then elseif self.phase == "moveSelect" then
@@ -4374,10 +4419,10 @@ function BattleState:drawTextArea()
local sel = self.player.curMoves[self.moveIndex] local sel = self.player.curMoves[self.moveIndex]
if sel then if sel then
if self.player.disabledSlot == self.moveIndex then if self.player.disabledSlot == self.moveIndex then
Font.draw("disabled!", 8, 80) Font.draw(Strings("disabled!"), 8, 80)
else else
local def = self.data.moves[sel.id] 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 -- the type record's display name (a mod type shows its name, and
-- PSYCHIC_TYPE prints PSYCHIC like the original) -- PSYCHIC_TYPE prints PSYCHIC like the original)
Font.draw(def.type and TypeChart.displayName(def.type) or "", 16, 80) Font.draw(def.type and TypeChart.displayName(def.type) or "", 16, 80)
@@ -4473,6 +4518,11 @@ function BattleState:draw()
love.graphics.rectangle("fill", 0, 0, 160, 144) love.graphics.rectangle("fill", 0, 0, 160, 144)
end end
love.graphics.setColor(1, 1, 1, 1) love.graphics.setColor(1, 1, 1, 1)
-- battle.overlay: shiny sparkles, custom HUD chrome, etc. Draw-only;
-- the vanilla link is a no-op so an empty chain costs nothing.
if Runtime.wantsHook("battle.overlay") then
Runtime.call("battle.overlay", function() end, self)
end
end end
return BattleState return BattleState
+13 -12
View File
@@ -8,6 +8,7 @@
local MoveEffects = require("src.battle.MoveEffects") local MoveEffects = require("src.battle.MoveEffects")
local Runtime = require("src.mods.Runtime") local Runtime = require("src.mods.Runtime")
local StatusRegistry = require("src.battle.StatusRegistry") local StatusRegistry = require("src.battle.StatusRegistry")
local Strings = require("src.core.Strings")
local EffectRegistry = {} local EffectRegistry = {}
@@ -91,7 +92,7 @@ function EffectRegistry.runDamaging(battle, ctx, record)
if target.invulnerable and not neverMiss then if target.invulnerable and not neverMiss then
-- Explosion/Selfdestruct still animate on a miss (HandleIfPlayerMoveMissed) -- Explosion/Selfdestruct still animate on a miss (HandleIfPlayerMoveMissed)
if not (record and record.explode) then battle:cancelMoveAnim() end 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 return
end end
@@ -113,7 +114,7 @@ function EffectRegistry.runDamaging(battle, ctx, record)
if not battle:accuracyRoll(move, user, target) then if not battle:accuracyRoll(move, user, target) then
-- Explosion/Selfdestruct still animate on a miss (HandleIfPlayerMoveMissed) -- Explosion/Selfdestruct still animate on a miss (HandleIfPlayerMoveMissed)
if not (record and record.explode) then battle:cancelMoveAnim() end 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 -- Jump Kick crash, Explode self-destruct
if record and record.onMiss then record.onMiss(ctx, "accuracy") end if record and record.onMiss then record.onMiss(ctx, "accuracy") end
user.trappingTurns = nil user.trappingTurns = nil
@@ -140,7 +141,7 @@ function EffectRegistry.runDamaging(battle, ctx, record)
end end
if not counterable or (battle.lastDamage or 0) == 0 then if not counterable or (battle.lastDamage or 0) == 0 then
battle:cancelMoveAnim() battle:cancelMoveAnim()
battle:sayNext(("%s's\nattack missed!"):format(displayName(user))) battle:sayNext(Strings("%s's\nattack missed!", displayName(user)))
return return
end end
dmg = math.min(65535, battle.lastDamage * 2) dmg = math.min(65535, battle.lastDamage * 2)
@@ -163,14 +164,14 @@ function EffectRegistry.runDamaging(battle, ctx, record)
if info.typeMult == 0 then if info.typeMult == 0 then
-- type immunity zeros damage and sets wMoveMissed in Gen 1, so no anim -- type immunity zeros damage and sets wMoveMissed in Gen 1, so no anim
if not (record and record.explode) then battle:cancelMoveAnim() end 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 if record and record.onMiss then record.onMiss(ctx, "immune") end
return return
end end
if info.missed then if info.missed then
-- 0.25x floored the damage to zero: the original registers a miss -- 0.25x floored the damage to zero: the original registers a miss
if not (record and record.explode) then battle:cancelMoveAnim() end 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 if record and record.onMiss then record.onMiss(ctx, "floored") end
return return
end end
@@ -214,12 +215,12 @@ function EffectRegistry.runDamaging(battle, ctx, record)
-- multi-hit loop (core.asm .moveDidNotMiss before the jump back -- multi-hit loop (core.asm .moveDidNotMiss before the jump back
-- to GetPlayerAnimationType), so crit/effectiveness reprint on -- to GetPlayerAnimationType), so crit/effectiveness reprint on
-- every strike -- damage was only rolled once -- every strike -- damage was only rolled once
if info.crit then battle:sayNext("Critical hit!") end if info.crit then battle:sayNext(Strings("Critical hit!")) end
if info.ohko then battle:sayNext("One-hit KO!") end if info.ohko then battle:sayNext(Strings("One-hit KO!")) end
if info.typeMult > 10 then if info.typeMult > 10 then
battle:sayNext("It's super\neffective!") battle:sayNext(Strings("It's super\neffective!"))
elseif info.typeMult < 10 then elseif info.typeMult < 10 then
battle:sayNext("It's not very\neffective...") battle:sayNext(Strings("It's not very\neffective..."))
end end
if Runtime.wants("battle.damage_dealt") then if Runtime.wants("battle.damage_dealt") then
Runtime.emit("battle.damage_dealt", { Runtime.emit("battle.damage_dealt", {
@@ -237,9 +238,9 @@ function EffectRegistry.runDamaging(battle, ctx, record)
if hits > 1 then if hits > 1 then
-- player: _MultiHitText; enemy: _HitXTimesText (always plural) -- player: _MultiHitText; enemy: _HitXTimesText (always plural)
if user.isPlayer then 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 else
battle:sayNext(("Hit %d times!"):format(hits)) battle:sayNext(Strings("Hit %d times!", hits))
end end
end end
@@ -251,7 +252,7 @@ function EffectRegistry.runDamaging(battle, ctx, record)
elseif moveInst.struggle then elseif moveInst.struggle then
-- struggle recoils even when its effect id resolves to no record -- struggle recoils even when its effect id resolves to no record
local recoil = math.max(1, math.floor(dmg / 2)) 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) battle:applyDamage(user, recoil)
end end
+65 -58
View File
@@ -15,6 +15,7 @@ local Logger = require("src.core.Logger")
local StatusRegistry = require("src.battle.StatusRegistry") local StatusRegistry = require("src.battle.StatusRegistry")
local TurnOrder = require("src.battle.TurnOrder") local TurnOrder = require("src.battle.TurnOrder")
local TypeChart = require("src.battle.TypeChart") local TypeChart = require("src.battle.TypeChart")
local Strings = require("src.core.Strings")
local MoveEffects = {} local MoveEffects = {}
@@ -36,14 +37,14 @@ local STAT_LABEL = {
local function changeStage(battle, who, stat, delta, fromEnemy) local function changeStage(battle, who, stat, delta, fromEnemy)
if fromEnemy and (who.substituteHP or who.mist) then if fromEnemy and (who.substituteHP or who.mist) then
if who.mist then if who.mist then
return { displayName(who) .. " is\nprotected by MIST!" } return { Strings("%s is\nprotected by MIST!", displayName(who)) }
end end
return { "But, it failed!" } return { Strings("But, it failed!") }
end end
local cur = who.stages[stat] or 0 local cur = who.stages[stat] or 0
local new = math.max(-6, math.min(6, cur + delta)) local new = math.max(-6, math.min(6, cur + delta))
if new == cur then if new == cur then
return { ("Nothing happened!") } return { Strings("Nothing happened!") }
end end
who.stages[stat] = new who.stages[stat] = new
-- effects.asm:505-506: after any stat-stage change, modified stats are -- 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 -- _MonsStatsRoseText/_MonsStatsFellText: "X's / STAT rose!"; the
-- two-stage variants scroll "greatly" onto a third line -- two-stage variants scroll "greatly" onto a third line
if delta >= 2 then 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 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 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 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 end
MoveEffects.changeStage = changeStage MoveEffects.changeStage = changeStage
@@ -88,10 +89,10 @@ end
local function statusMove(status) local function statusMove(status)
return function(battle, user, target, move) return function(battle, user, target, move)
if target.mon.status then if target.mon.status then
return { "But, it failed!" } return { Strings("But, it failed!") }
end end
if status == "PSN" and target.substituteHP then if status == "PSN" and target.substituteHP then
return { "But, it failed!" } return { Strings("But, it failed!") }
end end
local msgs = inflictStatus(battle, target, status, { local msgs = inflictStatus(battle, target, status, {
toxic = move and move.id == "TOXIC", toxic = move and move.id == "TOXIC",
@@ -99,7 +100,7 @@ local function statusMove(status)
source = move and move.id, source = move and move.id,
}) })
if #msgs == 0 then if #msgs == 0 then
return { "But, it failed!" } return { Strings("But, it failed!") }
end end
return msgs return msgs
end end
@@ -111,7 +112,7 @@ local function statusSide(status, chance)
-- target (regardless of the burn roll) -- target (regardless of the burn roll)
if move and move.type == "FIRE" and target.mon.status == "FRZ" then if move and move.type == "FIRE" and target.mon.status == "FRZ" then
target.mon.status = nil target.mon.status = nil
return { ("Fire defrosted\n%s!"):format(displayName(target)) } return { Strings("Fire defrosted\n%s!", displayName(target)) }
end end
if battle.rng(0, 255) >= chance then return {} end if battle.rng(0, 255) >= chance then return {} end
return inflictStatus(battle, target, status, { return inflictStatus(battle, target, status, {
@@ -144,10 +145,10 @@ end
local function confuse(battle, target, pierceSub) local function confuse(battle, target, pierceSub)
if target.confusedTurns or (target.substituteHP and not pierceSub) then if target.confusedTurns or (target.substituteHP and not pierceSub) then
return { "But, it failed!" } return { Strings("But, it failed!") }
end end
target.confusedTurns = battle.rng(2, 5) target.confusedTurns = battle.rng(2, 5)
return { ("%s\nbecame confused!"):format(displayName(target)) } return { Strings("%s\nbecame confused!", displayName(target)) }
end end
-- --------------------------------------------------------------------- -- ---------------------------------------------------------------------
@@ -181,53 +182,53 @@ MoveEffects.primary = {
LEECH_SEED_EFFECT = function(battle, user, target) LEECH_SEED_EFFECT = function(battle, user, target)
-- leech_seed.asm has no substitute check: seeding lands through one -- leech_seed.asm has no substitute check: seeding lands through one
if target.leechSeeded then if target.leechSeeded then
return { "But, it failed!" } return { Strings("But, it failed!") }
end end
for _, t in ipairs(target.curTypes) do 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 end
target.leechSeeded = true target.leechSeeded = true
return { ("%s\nwas seeded!"):format(displayName(target)) } return { Strings("%s\nwas seeded!", displayName(target)) }
end, end,
HEAL_EFFECT = function(battle, user, target, move) HEAL_EFFECT = function(battle, user, target, move)
local mon = user.mon local mon = user.mon
if move.id == "REST" then 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.hp = mon.stats.hp
mon.status = "SLP" mon.status = "SLP"
user.sleepTurns = 2 user.sleepTurns = 2
user.toxicCounter = nil user.toxicCounter = nil
return { ("%s\nstarted sleeping!"):format(displayName(user)) } return { Strings("%s\nstarted sleeping!", displayName(user)) }
end 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)) 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, end,
LIGHT_SCREEN_EFFECT = function(battle, user) 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 user.lightScreen = true
return { ("%s's\nprotected against\nspecial attacks!"):format(displayName(user)) } return { Strings("%s's\nprotected against\nspecial attacks!", displayName(user)) }
end, end,
REFLECT_EFFECT = function(battle, user) 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 user.reflect = true
return { ("%s\ngained armor!"):format(displayName(user)) } return { Strings("%s\ngained armor!", displayName(user)) }
end, end,
MIST_EFFECT = function(battle, user) 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 user.mist = true
-- _ShroudedInMistText (lowercase "mist") -- _ShroudedInMistText (lowercase "mist")
return { ("%s's\nshrouded in mist!"):format(displayName(user)) } return { Strings("%s's\nshrouded in mist!", displayName(user)) }
end, end,
FOCUS_ENERGY_EFFECT = function(battle, user) 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 user.focusEnergy = true
return { ("%s's\ngetting pumped!"):format(displayName(user)) } return { Strings("%s's\ngetting pumped!", displayName(user)) }
end, end,
HAZE_EFFECT = function(battle, user, target) HAZE_EFFECT = function(battle, user, target)
@@ -254,33 +255,33 @@ MoveEffects.primary = {
target.skipMove = true target.skipMove = true
end end
target.mon.status = nil target.mon.status = nil
return { "All STATUS changes\nare eliminated!" } return { Strings("All STATUS changes\nare eliminated!") }
end, end,
SUBSTITUTE_EFFECT = function(battle, user) 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) local cost = math.floor(user.mon.stats.hp / 4)
-- substitute.asm only fails on subtraction underflow (current HP -- substitute.asm only fails on subtraction underflow (current HP
-- strictly below maxHP/4); at equality the substitute is built and -- 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 user is left standing on exactly 0 HP (it faints only when
-- the engine next checks HP, not here) -- the engine next checks HP, not here)
if user.mon.hp < cost then if user.mon.hp < cost then
return { "Too weak to make\na SUBSTITUTE!" } return { Strings("Too weak to make\na SUBSTITUTE!") }
end end
user.mon.hp = user.mon.hp - cost user.mon.hp = user.mon.hp - cost
user.substituteHP = cost + 1 user.substituteHP = cost + 1
-- _SubstituteText -- _SubstituteText
return { "It created a\nSUBSTITUTE!" } return { Strings("It created a\nSUBSTITUTE!") }
end, end,
CONVERSION_EFFECT = function(battle, user, target) CONVERSION_EFFECT = function(battle, user, target)
-- conversion.asm fails against a mid-Fly/Dig target (INVULNERABLE) -- conversion.asm fails against a mid-Fly/Dig target (INVULNERABLE)
if target.invulnerable then if target.invulnerable then
return { "But, it failed!" } return { Strings("But, it failed!") }
end end
user.curTypes = { target.curTypes[1], target.curTypes[2] } user.curTypes = { target.curTypes[1], target.curTypes[2] }
-- _ConvertedTypeText -- _ConvertedTypeText
return { ("Converted type to\n%s's!"):format(displayName(target)) } return { Strings("Converted type to\n%s's!", displayName(target)) }
end, end,
-- MIMIC_EFFECT lives in BattleState:resolveMimic: MimicEffect -- MIMIC_EFFECT lives in BattleState:resolveMimic: MimicEffect
@@ -311,27 +312,27 @@ MoveEffects.primary = {
table.insert(user.curMoves, { id = mv.id, pp = 5, mimic = true }) table.insert(user.curMoves, { id = mv.id, pp = 5, mimic = true })
end end
-- _TransformedText: the copied name prints bare (wNameBuffer) -- _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, end,
DISABLE_EFFECT = function(battle, user, target) 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 = {} local usable = {}
for i, mv in ipairs(target.curMoves) do for i, mv in ipairs(target.curMoves) do
if mv.pp > 0 then table.insert(usable, i) end if mv.pp > 0 then table.insert(usable, i) end
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)] local slot = usable[battle.rng(1, #usable)]
target.disabledSlot = slot target.disabledSlot = slot
target.disabledTurns = battle.rng(1, 8) target.disabledTurns = battle.rng(1, 8)
local id = target.curMoves[slot].id local id = target.curMoves[slot].id
-- _MoveWasDisabledText: "X's / MOVE was / disabled!" -- _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) } battle.data.moves[id].name) }
end, end,
SPLASH_EFFECT = function() SPLASH_EFFECT = function()
return { "No effect!" } return { Strings("No effect!") }
end, end,
} }
@@ -428,7 +429,9 @@ local function drainHalf(text)
local mon = ctx.user.mon local mon = ctx.user.mon
mon.hp = math.min(mon.stats.hp, mon.hp + heal) mon.hp = math.min(mon.stats.hp, mon.hp + heal)
ctx.drain() 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
end end
@@ -436,7 +439,7 @@ end
-- flags the miss before the special-damage override) -- flags the miss before the special-damage override)
local function immuneMsg(ctx) local function immuneMsg(ctx)
if TypeChart.effectiveness(ctx.move.type, ctx.target.curTypes) == 0 then 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 end
return nil return nil
end end
@@ -498,12 +501,12 @@ MoveEffects.full = {
-- removed): overkill and substitute hits recoil at full strength -- removed): overkill and substitute hits recoil at full strength
local recoil = math.max(1, math.floor(ctx.rawDamage local recoil = math.max(1, math.floor(ctx.rawDamage
/ (ctx.moveInst.struggle and 2 or 4))) / (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) ctx.battle:applyDamage(ctx.user, recoil)
end, end,
}, },
DRAIN_HP_EFFECT = { DRAIN_HP_EFFECT = {
afterDamage = drainHalf("Sucked health from\n%s!"), afterDamage = drainHalf(Strings.source("Sucked health from\n%s!")),
}, },
DREAM_EATER_EFFECT = { DREAM_EATER_EFFECT = {
-- only works on sleeping targets (checked before damage) -- 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 if ctx.target.mon.status ~= "SLP" then return false, "But, it failed!" end
return true return true
end, 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 -- 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 user.thrashTurns, user.thrashMove, user.thrashAnnounced = nil, nil, nil
if not user.confusedTurns then if not user.confusedTurns then
user.confusedTurns = ctx.rng(2, 5) 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 end
end end
@@ -566,7 +569,7 @@ MoveEffects.full = {
JUMP_KICK_EFFECT = { JUMP_KICK_EFFECT = {
onMiss = function(ctx, reason) onMiss = function(ctx, reason)
if reason ~= "accuracy" then return end 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) ctx.damage(ctx.user, 1)
end, end,
}, },
@@ -581,8 +584,12 @@ MoveEffects.full = {
}, },
HYPER_BEAM_EFFECT = { HYPER_BEAM_EFFECT = {
afterDamage = function(ctx) afterDamage = function(ctx)
-- no recharge when the target faints OR its substitute breaks -- Gen 1: no recharge when the target faints OR its substitute breaks.
if ctx.target.mon.hp > 0 and not ctx.brokeSub then -- Ruleset hyperBeamSkipRechargeOnKO=false forces Gen 2+ always-recharge.
local ruleset = ctx.battle and ctx.battle.ruleset
local skipOnKO = not ruleset or ruleset.hyperBeamSkipRechargeOnKO ~= false
local targetDown = ctx.target.mon.hp <= 0 or ctx.brokeSub
if not skipOnKO or not targetDown then
ctx.user.mustRecharge = true ctx.user.mustRecharge = true
end end
end, end,
@@ -591,7 +598,7 @@ MoveEffects.full = {
afterDamage = function(ctx) afterDamage = function(ctx)
local battle = ctx.battle local battle = ctx.battle
battle.payDay = (battle.payDay or 0) + 2 * ctx.user.mon.level battle.payDay = (battle.payDay or 0) + 2 * ctx.user.mon.level
ctx.say("Coins scattered\neverywhere!") ctx.say(Strings("Coins scattered\neverywhere!"))
end, end,
}, },
SWIFT_EFFECT = { neverMiss = true }, SWIFT_EFFECT = { neverMiss = true },
@@ -606,7 +613,7 @@ MoveEffects.full = {
local user = ctx.user local user = ctx.user
user.bideTurns = ctx.rng(2, 3) user.bideTurns = ctx.rng(2, 3)
user.bideDamage = 0 user.bideDamage = 0
ctx.say(("%s\nis storing energy!"):format(displayName(user))) ctx.say(Strings("%s\nis storing energy!", displayName(user)))
end, end,
}, },
SWITCH_AND_TELEPORT_EFFECT = { SWITCH_AND_TELEPORT_EFFECT = {
@@ -627,27 +634,27 @@ MoveEffects.full = {
end end
if ok then if ok then
if move.id == "ROAR" 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 elseif move.id == "WHIRLWIND" then
ctx.say(("%s\nwas blown away!"):format(displayName(target))) ctx.say(Strings("%s\nwas blown away!", displayName(target)))
else else
ctx.say(("%s\nran from battle!"):format(displayName(user))) ctx.say(Strings("%s\nran from battle!", displayName(user)))
end end
battle.result = "run" battle.result = "run"
battle.afterQueue = "finish" battle.afterQueue = "finish"
elseif move.id == "TELEPORT" then elseif move.id == "TELEPORT" then
battle:cancelMoveAnim() battle:cancelMoveAnim()
ctx.say("But, it failed!") ctx.say(Strings("But, it failed!"))
else else
battle:cancelMoveAnim() 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 end
elseif move.id == "TELEPORT" then elseif move.id == "TELEPORT" then
battle:cancelMoveAnim() battle:cancelMoveAnim()
ctx.say("But, it failed!") ctx.say(Strings("But, it failed!"))
else else
battle:cancelMoveAnim() battle:cancelMoveAnim()
ctx.say(("%s\nis unaffected!"):format(displayName(target))) ctx.say(Strings("%s\nis unaffected!", displayName(target)))
end end
end, end,
}, },
@@ -665,7 +672,7 @@ MoveEffects.full = {
callsMove = function(ctx) callsMove = function(ctx)
local last = ctx.target.lastMove local last = ctx.target.lastMove
if not last then if not last then
ctx.say("The MIRROR MOVE\nfailed!") ctx.say(Strings("The MIRROR MOVE\nfailed!"))
return nil return nil
end end
return last return last
+31 -21
View File
@@ -5,6 +5,8 @@
-- and residual sweep. Callers without a battle (pure-module tests) fall -- and residual sweep. Callers without a battle (pure-module tests) fall
-- back to the vanilla records, which is bit-identical behavior. -- back to the vanilla records, which is bit-identical behavior.
local Strings = require("src.core.Strings")
local Status = {} local Status = {}
-- pokered's <USER>/<TARGET> text macros print "Enemy " before the enemy -- pokered's <USER>/<TARGET> text macros print "Enemy " before the enemy
@@ -28,8 +30,11 @@ local function hasType(battler, wanted)
end end
-- shared PSN/BRN residual: 1/16 max HP, multiplied (and advanced) by the -- shared PSN/BRN residual: 1/16 max HP, multiplied (and advanced) by the
-- Toxic counter (HandlePoisonBurnLeechSeed) -- Toxic counter (HandlePoisonBurnLeechSeed). The caller passes the whole
local function damageOverTime(what) -- 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) return function(battler)
local mon = battler.mon local mon = battler.mon
local base = math.max(1, math.floor(mon.stats.hp / 16)) local base = math.max(1, math.floor(mon.stats.hp / 16))
@@ -39,10 +44,15 @@ local function damageOverTime(what)
battler.toxicCounter = battler.toxicCounter + 1 battler.toxicCounter = battler.toxicCounter + 1
end end
mon.hp = math.max(0, mon.hp - dmg) 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
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 -- The five persistent conditions as records: the beforeMove gauntlet, the
-- residual sweep, the inflict text/immunities (StatusRegistry.inflict), -- residual sweep, the inflict text/immunities (StatusRegistry.inflict),
-- the catch/wobble bonuses (Catching.attempt), the HUD label, and the -- the catch/wobble bonuses (Catching.attempt), the HUD label, and the
@@ -57,13 +67,13 @@ Status.RECORDS = {
battler.sleepTurns = (battler.sleepTurns or 1) - 1 battler.sleepTurns = (battler.sleepTurns or 1) - 1
if battler.sleepTurns <= 0 then if battler.sleepTurns <= 0 then
battler.mon.status = nil 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 end
return false, { name(battler) .. "\nis fast asleep!" } return false, { Strings("%s\nis fast asleep!", name(battler)) }
end, end,
onInflict = function(battle, target, opts, display) onInflict = function(battle, target, opts, display)
target.sleepTurns = battle.rng(1, 7) target.sleepTurns = battle.rng(1, 7)
return { ("%s\nfell asleep!"):format(display) } return { Strings("%s\nfell asleep!", display) }
end, end,
}, },
FRZ = { FRZ = {
@@ -71,35 +81,35 @@ Status.RECORDS = {
catchBonus = 25, shakeBonus = 10, catchBonus = 25, shakeBonus = 10,
beforeMovePriority = 30, beforeMovePriority = 30,
beforeMove = function(battler) beforeMove = function(battler)
return false, { name(battler) .. "\nis frozen solid!" } return false, { Strings("%s\nis frozen solid!", name(battler)) }
end, end,
canInflict = function(target) return not hasType(target, "ICE") end, canInflict = function(target) return not hasType(target, "ICE") end,
onInflict = function(_, _, _, display) onInflict = function(_, _, _, display)
return { ("%s\nwas frozen solid!"):format(display) } return { Strings("%s\nwas frozen solid!", display) }
end, end,
}, },
PSN = { PSN = {
id = "PSN", label = "PSN", hudLabel = "PSN", id = "PSN", label = "PSN", hudLabel = "PSN",
catchBonus = 12, shakeBonus = 5, 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, canInflict = function(target) return not hasType(target, "POISON") end,
onInflict = function(_, target, opts, display) onInflict = function(_, target, opts, display)
if opts.toxic then if opts.toxic then
target.toxicCounter = 1 target.toxicCounter = 1
-- _BadlyPoisonedText -- _BadlyPoisonedText
return { ("%s's\nbadly poisoned!"):format(display) } return { Strings("%s's\nbadly poisoned!", display) }
end end
return { ("%s\nwas poisoned!"):format(display) } return { Strings("%s\nwas poisoned!", display) }
end, end,
}, },
BRN = { BRN = {
id = "BRN", label = "BRN", hudLabel = "BRN", id = "BRN", label = "BRN", hudLabel = "BRN",
catchBonus = 12, shakeBonus = 5, catchBonus = 12, shakeBonus = 5,
statPenalty = { stat = "attack", div = 2 }, 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, canInflict = function(target) return not hasType(target, "FIRE") end,
onInflict = function(_, _, _, display) onInflict = function(_, _, _, display)
return { ("%s\nwas burned!"):format(display) } return { Strings("%s\nwas burned!", display) }
end, end,
}, },
PAR = { PAR = {
@@ -110,7 +120,7 @@ Status.RECORDS = {
beforeMove = function(battler, rng) beforeMove = function(battler, rng)
-- cp 25 percent / jr nc: fully paralyzed on rand < 63 (63/256) -- cp 25 percent / jr nc: fully paralyzed on rand < 63 (63/256)
if rng(0, 255) < 63 then if rng(0, 255) < 63 then
return false, { name(battler) .. "'s\nfully paralyzed!" } return false, { Strings("%s's\nfully paralyzed!", name(battler)) }
end end
return true, {} return true, {}
end, end,
@@ -120,7 +130,7 @@ Status.RECORDS = {
end, end,
onInflict = function(_, _, _, display) onInflict = function(_, _, _, display)
-- _ParalyzedMayNotAttackText (primary and secondary paralysis) -- _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, end,
}, },
} }
@@ -156,7 +166,7 @@ function Status.beforeMove(battler, rng, battle)
end end
if battler.flinched then if battler.flinched then
battler.flinched = false battler.flinched = false
return false, { name(battler) .. "\nflinched!" } return false, { Strings("%s\nflinched!", name(battler)) }
end end
local record = Status.recordFor(battleStatuses(battle), mon.status) local record = Status.recordFor(battleStatuses(battle), mon.status)
local handler = record and record.beforeMove local handler = record and record.beforeMove
@@ -174,23 +184,23 @@ function Status.beforeMove(battler, rng, battle)
end end
if battler.boundTurns and battler.boundTurns > 0 then if battler.boundTurns and battler.boundTurns > 0 then
battler.boundTurns = battler.boundTurns - 1 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 return false, msgs
end end
if battler.disabledTurns then if battler.disabledTurns then
battler.disabledTurns = battler.disabledTurns - 1 battler.disabledTurns = battler.disabledTurns - 1
if battler.disabledTurns <= 0 then if battler.disabledTurns <= 0 then
battler.disabledTurns, battler.disabledSlot = nil, nil 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
end end
if battler.confusedTurns then if battler.confusedTurns then
battler.confusedTurns = battler.confusedTurns - 1 battler.confusedTurns = battler.confusedTurns - 1
if battler.confusedTurns <= 0 then if battler.confusedTurns <= 0 then
battler.confusedTurns = nil battler.confusedTurns = nil
table.insert(msgs, name(battler) .. "\nsnapped out of\nconfusion!") table.insert(msgs, Strings("%s\nsnapped out of\nconfusion!", name(battler)))
else 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) -- cp 50 percent + 1 / jr c: hurt itself on rand >= 128 (128/256)
if rng(0, 255) < 128 then if rng(0, 255) < 128 then
return false, msgs, true -- hurt itself return false, msgs, true -- hurt itself
@@ -231,7 +241,7 @@ function Status.residual(battler, opponent, battle)
dmg = math.min(dmg, mon.hp) dmg = math.min(dmg, mon.hp)
mon.hp = mon.hp - dmg mon.hp = mon.hp - dmg
opponent.mon.hp = math.min(opponent.mon.stats.hp, opponent.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 end
return msgs return msgs
end end
+2 -1
View File
@@ -5,6 +5,7 @@
local Runtime = require("src.mods.Runtime") local Runtime = require("src.mods.Runtime")
local Status = require("src.battle.Status") local Status = require("src.battle.Status")
local Strings = require("src.core.Strings")
local StatusRegistry = {} local StatusRegistry = {}
@@ -45,7 +46,7 @@ function StatusRegistry.inflict(battle, target, status, opts)
if record and record.onInflict then if record and record.onInflict then
msgs = record.onInflict(battle, target, opts, display) msgs = record.onInflict(battle, target, opts, display)
else else
msgs = { ("%s\nwas afflicted\nby %s!"):format(display, msgs = { Strings("%s\nwas afflicted\nby %s!", display,
record and record.label or tostring(status)) } record and record.label or tostring(status)) }
end end
Runtime.emit("battle.status_inflicted", { Runtime.emit("battle.status_inflicted", {
+4 -3
View File
@@ -19,6 +19,7 @@
-- enemy PP — Gen 1 AI never reads wEnemyMonPP). -- enemy PP — Gen 1 AI never reads wEnemyMonPP).
local TypeChart = require("src.battle.TypeChart") local TypeChart = require("src.battle.TypeChart")
local Strings = require("src.core.Strings")
local TrainerAI = {} local TrainerAI = {}
@@ -99,7 +100,7 @@ function TrainerAI.useItem(battle, item)
local enemy = battle.enemy local enemy = battle.enemy
local trainerName = battle.trainer.name local trainerName = battle.trainer.name
local itemName = battle.data.items[item] and battle.data.items[item].name or item 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 if item == "FULL_HEAL" then
enemy.mon.status = nil enemy.mon.status = nil
enemy.toxicCounter = nil enemy.toxicCounter = nil
@@ -112,10 +113,10 @@ function TrainerAI.useItem(battle, item)
elseif X_STAT[item] then elseif X_STAT[item] then
local stat = X_STAT[item] local stat = X_STAT[item]
enemy.stages[stat] = math.min(6, (enemy.stages[stat] or 0) + 1) 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 elseif item == "GUARD_SPEC" then
enemy.mist = true 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 end
return msgs return msgs
end end
+3
View File
@@ -16,4 +16,7 @@ return {
-- Wild/trainer enemies never spend PP (DecrementPP only touches the -- Wild/trainer enemies never spend PP (DecrementPP only touches the
-- player side in pokered). They therefore never Struggle from empty PP. -- player side in pokered). They therefore never Struggle from empty PP.
enemyUnlimitedPP = true, enemyUnlimitedPP = true,
-- Gen 1 Hyper Beam: no recharge when the target faints (or its
-- substitute breaks). Set false to always recharge like Gen 2+.
hyperBeamSkipRechargeOnKO = true,
} }
+2
View File
@@ -11,4 +11,6 @@ return {
focusEnergyBug = false, focusEnergyBug = false,
-- Gen 2+ style: AI opponents deplete PP and can Struggle when empty. -- Gen 2+ style: AI opponents deplete PP and can Struggle when empty.
enemyUnlimitedPP = false, enemyUnlimitedPP = false,
-- Gen 2+: Hyper Beam always forces a recharge turn, even on a KO.
hyperBeamSkipRechargeOnKO = false,
} }
+7 -6
View File
@@ -9,6 +9,7 @@
local Json = require("src.link.Json") local Json = require("src.link.Json")
local Logger = require("src.core.Logger") local Logger = require("src.core.Logger")
local Runtime = require("src.mods.Runtime") local Runtime = require("src.mods.Runtime")
local Strings = require("src.core.Strings")
local DiscordPresence = { local DiscordPresence = {
APP_ID = "1529183141267374262", APP_ID = "1529183141267374262",
@@ -436,7 +437,7 @@ end
local function buildActivity() local function buildActivity()
local details, activityState local details, activityState
if state.activity == "battle" then 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" activityState = state.location and (state.location) or "Kanto"
elseif state.activity == "exploring" and state.location then elseif state.activity == "exploring" and state.location then
details = state.location details = state.location
@@ -550,20 +551,20 @@ local function subscribe(game)
track("battle.started", function(ev) track("battle.started", function(ev)
local battle = ev and ev.battle local battle = ev and ev.battle
local label = "In battle" local label = Strings("In battle")
if ev and ev.kind == "wild" then if ev and ev.kind == "wild" then
local name = speciesName(game, ev.species) local name = speciesName(game, ev.species)
if name then if name then
label = "Battling wild " .. name label = "Battling wild " .. name
if ev.level then label = label .. " Lv" .. tostring(ev.level) end if ev.level then label = label .. " Lv" .. tostring(ev.level) end
else else
label = "Wild battle" label = Strings("Wild battle")
end end
elseif ev and ev.kind == "trainer" then elseif ev and ev.kind == "trainer" then
local tname = battle and battle.trainer and battle.trainer.name 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 elseif ev and ev.kind == "link" then
label = "Link battle" label = Strings("Link battle")
end end
local mapId = state.mapId local mapId = state.mapId
or (game.save and game.save.player and game.save.player.map) or (game.save and game.save.player and game.save.player.map)
@@ -594,7 +595,7 @@ local function subscribe(game)
state.mapId = nil state.mapId = nil
setPresence({ setPresence({
activity = "menu", 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, clearBattle = true,
}) })
end end
+17
View File
@@ -53,6 +53,9 @@ function Game:load()
require("src.render.Font").load(Data) require("src.render.Font").load(Data)
-- menu cursor/border/geometry constants; field.theme restyles them -- menu cursor/border/geometry constants; field.theme restyles them
require("src.ui.Theme").load(Data) 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 self.stack = StateStack
StateStack:init() StateStack:init()
@@ -96,6 +99,20 @@ function Game:load()
end end
Logger.info("game loaded") Logger.info("game loaded")
-- Scaling bug reports (#87, #208) are unanswerable without these three
-- numbers: LOVE units, drawable pixels, and the integer physical pixels
-- per GB pixel the renderer settled on. Cheap, once, and it turns "it
-- looks stretched" into something reproducible.
if love.graphics and love.graphics.getDimensions then
local ww, wh = love.graphics.getDimensions()
local pw, ph = ww, wh
if love.graphics.getPixelDimensions then
pw, ph = love.graphics.getPixelDimensions()
end
Logger.info(string.format(
"display: %dx%d units, %dx%d px, fit scale %d px/GB px",
ww, wh, pw, ph, Renderer:fitScale()))
end
end end
-- the merged field.boot: spawn, names, money and the naming presets a -- the merged field.boot: spawn, names, money and the naming presets a
+31 -1
View File
@@ -24,7 +24,31 @@ local FILTER_HIGHGAIN = { 0.4, 0.16, 0.064 }
local filterLevel = 0 local filterLevel = 0
local function applyVolume(src) local function applyVolume(src)
if src then pcall(src.setVolume, src, VOLUME * volumeScale) end if not src then return end
local vol = VOLUME * volumeScale
if Runtime.wantsHook("music.volume") then
local ctx = {
song = state.current,
mapSong = state.mapSong,
onBike = state.onBike,
surfing = state.surfing,
fading = state.fade ~= nil,
optionScale = volumeScale,
}
local ok, Game = pcall(require, "src.core.Game")
if ok and Game then
local ow = Game.overworld
if ow and ow.player then
ctx.x, ctx.y = ow.player.cellX, ow.player.cellY
ctx.mapId = ow.map and ow.map.id
ctx.tod = ow.tod
end
end
vol = Runtime.call("music.volume", function(v) return v end, vol, ctx)
vol = tonumber(vol) or (VOLUME * volumeScale)
if vol < 0 then vol = 0 end
end
pcall(src.setVolume, src, vol)
end end
-- Source:setFilter needs OpenAL EFX; the pcall degrades to unfiltered -- Source:setFilter needs OpenAL EFX; the pcall degrades to unfiltered
@@ -408,6 +432,12 @@ end
-- restores the map theme after a one-shot jingle -- restores the map theme after a one-shot jingle
function Music.update(data) function Music.update(data)
if state.chip then require("src.core.ChipAudio").update() end if state.chip then require("src.core.ChipAudio").update() end
-- distance / indoor muffling mods re-apply volume every frame while
-- subscribed; otherwise applyVolume only runs on song/option changes
if Runtime.wantsHook("music.volume") and not state.fade then
applyVolume(state.source)
applyVolume(state.loopSource)
end
-- volume ramp (Music.fadeOut): hold the current level for `control` -- volume ramp (Music.fadeOut): hold the current level for `control`
-- frames, then drop one level (FadeOutAudio decrements both rAUDVOL -- frames, then drop one level (FadeOutAudio decrements both rAUDVOL
-- nibbles when its counter reaches 0); at level 0 the music stops. -- nibbles when its counter reaches 0); at level 0 the music stops.
+51 -6
View File
@@ -552,6 +552,44 @@ function SaveData.writeSlot(version, slotId, saveTable)
return true return true
end end
-- Delete a registered slot: remove its main/.bak/.tmp files, drop it from the
-- options registry, and if it was active point active at another remaining
-- slot (or clear active when the list is empty). Returns true, or false +
-- an error string when the id is unknown / not registered.
function SaveData.deleteSlot(version, slotId)
version = version or GameVersion.get()
if not knownVersion(version) then return false, "unknown version" end
if type(slotId) ~= "string" or slotId == "" then
return false, "missing slot id"
end
local fs = persistFs(nil)
ensureVersionSlots(version, fs)
local opts = SaveData.loadOptions(fs)
opts.saveSlots = opts.saveSlots or {}
local reg = opts.saveSlots[version]
if not reg or not reg.list then return false, "slot not registered" end
local found, idx = false, nil
for i, id in ipairs(reg.list) do
if id == slotId then found = true; idx = i; break end
end
if not found then return false, "slot not registered" end
local main, bak, tmp = slotNames(version, slotId)
remove(fs, main)
remove(fs, bak)
remove(fs, tmp)
table.remove(reg.list, idx)
if reg.active == slotId then
reg.active = reg.list[1] -- may be nil when the list is now empty
end
opts.saveSlots[version] = reg
SaveData.saveOptions(opts, fs)
slotsChecked[version] = true
activeSlotCache[version] = reg.active or false
return true
end
-- Test seam: drop the process-global slot cache so a suite can exercise -- Test seam: drop the process-global slot cache so a suite can exercise
-- migration/resolution against a freshly injected filesystem. Unused by -- migration/resolution against a freshly injected filesystem. Unused by
-- the game, which resolves each version exactly once per boot. -- the game, which resolves each version exactly once per boot.
@@ -1117,9 +1155,16 @@ function SaveData.defaultHeal(boot)
end end
-- Post-credits home (issue #103). pokered left the player in HALL_OF_FAME -- 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 -- after jp Init; this port relocates CONTINUE instead, and retargets
-- retargets LAST_MAP exits (Red's house mats) at the heal-point town so -- LAST_MAP exits (Red's house mats) at the heal-point town so leaving the
-- leaving the house does not dump the player back at Indigo Plateau. -- 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. -- Marks postGameHomeOk so a later intentional HoF save is not relocated.
function SaveData.applyPostGameHome(save, boot) function SaveData.applyPostGameHome(save, boot)
boot = type(boot) == "table" and boot or {} boot = type(boot) == "table" and boot or {}
@@ -1127,9 +1172,9 @@ function SaveData.applyPostGameHome(save, boot)
save.lastHeal = { map = heal.map, x = heal.x, y = heal.y } save.lastHeal = { map = heal.map, x = heal.x, y = heal.y }
save.lastOutdoor = { id = 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 = save.player or {}
save.player.map = boot.startMap or "REDS_HOUSE_2F" save.player.map = heal.map
save.player.x = boot.startX or 3 save.player.x = heal.x
save.player.y = boot.startY or 6 save.player.y = heal.y
save.player.facing = boot.startFacing or "down" save.player.facing = boot.startFacing or "down"
save.postGameHomeOk = true save.postGameHomeOk = true
return heal 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
+269 -60
View File
@@ -1,4 +1,5 @@
local GameVersion = require("src.core.GameVersion") local GameVersion = require("src.core.GameVersion")
local Strings = require("src.core.Strings")
local RomImporter = {} local RomImporter = {}
RomImporter.__index = RomImporter RomImporter.__index = RomImporter
@@ -290,6 +291,39 @@ local function findPendingRom(ready)
return nil return nil
end end
-- Android SAF writes mod picks to picked_mod.zip; USB copies may use any
-- .zip basename at the save-dir root. preferAny=true also accepts those USB
-- copies (Choose / Import); focus only consumes the SAF basename so a random
-- leftover archive is never auto-installed on every refocus.
local function findPendingMod(preferAny)
local preferred = "picked_mod.zip"
if love.filesystem.getInfo(preferred, "file") then
return preferred
end
if not preferAny then return nil end
for _, name in ipairs(love.filesystem.getDirectoryItems("")) do
if name:lower():match("%.zip$") and love.filesystem.getInfo(name, "file") then
return name
end
end
return nil
end
-- Same pattern as findPendingMod for battery saves (picked_save.sav / *.sav).
local function findPendingSav(preferAny)
local preferred = "picked_save.sav"
if love.filesystem.getInfo(preferred, "file") then
return preferred
end
if not preferAny then return nil end
for _, name in ipairs(love.filesystem.getDirectoryItems("")) do
if name:lower():match("%.sav$") and love.filesystem.getInfo(name, "file") then
return name
end
end
return nil
end
local function chooseRom(promptName) local function chooseRom(promptName)
promptName = promptName or "Pokemon" promptName = promptName or "Pokemon"
local prompt = "Choose your " .. promptName .. " ROM" local prompt = "Choose your " .. promptName .. " ROM"
@@ -320,10 +354,10 @@ local function chooseRom(promptName)
end end
-- Open a native picker for a mod .zip (mirrors chooseRom's per-OS dialogs). -- Open a native picker for a mod .zip (mirrors chooseRom's per-OS dialogs).
-- Returns the chosen absolute path or nil. Android has no picker; the mods -- Returns the chosen absolute path or nil. Android uses love.system.pickFile
-- panel steers that case to the drag-drop hint instead. -- ("mod") instead -- see RomImporter:chooseMod.
local function chooseZip() local function chooseZip()
local prompt = "Choose a mod .zip" local prompt = Strings("Choose a mod .zip")
local platform = love.system.getOS() local platform = love.system.getOS()
if platform == "OS X" then if platform == "OS X" then
return commandOutput( return commandOutput(
@@ -351,10 +385,10 @@ local function chooseZip()
end end
-- Open a native picker for a raw .sav battery save (mirrors chooseZip's per-OS -- Open a native picker for a raw .sav battery save (mirrors chooseZip's per-OS
-- dialogs). Returns the chosen absolute path or nil. Android has no picker; -- dialogs). Returns the chosen absolute path or nil. Android uses
-- the SAVE FILES card steers that case to the drag-drop hint instead. -- love.system.pickFile("sav") instead -- see RomImporter:chooseSaveImport.
local function chooseSav() local function chooseSav()
local prompt = "Choose a .sav save file" local prompt = Strings("Choose a .sav save file")
local platform = love.system.getOS() local platform = love.system.getOS()
if platform == "OS X" then if platform == "OS X" then
return commandOutput( return commandOutput(
@@ -429,10 +463,16 @@ function RomImporter.new(onComplete, opts)
-- affordance (desktop love.system.openURL). -- affordance (desktop love.system.openURL).
saveNotice = {}, saveNotice = {},
-- MODS panel state (pass 3): mods is the cached LauncherMods.list() array -- MODS panel state (pass 3): mods is the cached LauncherMods.list() array
-- (refreshed lazily on first draw and after any toggle/install); modScroll -- (refreshed lazily on first draw and after any toggle/install/delete);
-- is the list scroll offset (px, clamped in draw); modNotice is the last -- modScroll is the list scroll offset (px, clamped in draw); modNotice is
-- install result { ok, text } shown as a line above the list. -- the last install/delete result { ok, text } shown as a line above the list.
mods = nil, modScroll = 0, modNotice = nil, mods = nil, modScroll = 0, modNotice = nil,
-- Android SAF: which game tab should receive the next picked_save.sav when
-- focus consumes it (set by chooseSaveImport before opening the picker).
androidPendingVersion = nil,
-- Android SAF create-document: which game's SAVE FILES card should show
-- "Save exported." when export_done.flag appears on focus.
androidPendingExportVersion = nil,
}, RomImporter) }, RomImporter)
for _, version in ipairs(GameVersion.ORDER) do for _, version in ipairs(GameVersion.ORDER) do
@@ -491,10 +531,40 @@ end
-- The system picker runs as a separate top activity, so LOVE's own -- The system picker runs as a separate top activity, so LOVE's own
-- love.focus/love.visible pause while it's up (see main.lua) -- once the -- love.focus/love.visible pause while it's up (see main.lua) -- once the
-- player returns here with a file picked, GameActivity has already copied -- player returns here with a file picked, GameActivity has already copied
-- it into the save directory, so a pending-ROM rescan on refocus picks it -- it into the save directory, so a pending-file rescan on refocus picks it
-- up without the player needing to tap the button again. -- up without the player needing to tap the button again. Mod and save SAF
-- drops (picked_mod.zip / picked_save.sav) are consumed first so a leftover
-- ROM pick cannot steal the focus path when both games are already ready.
function RomImporter:focus(f) function RomImporter:focus(f)
if not (f and self.android and self.workState ~= "working") then return end if not (f and self.android and self.workState ~= "working") then return end
-- SAF create-document finished: GameActivity wrote export_done.flag.
if love.filesystem.getInfo("export_done.flag", "file") then
love.filesystem.remove("export_done.flag")
love.filesystem.remove("pending_export.sav")
local version = self.androidPendingExportVersion or self:_savedropTarget()
self.androidPendingExportVersion = nil
self.saveNotice[version] = { ok = true, text = "Save exported." }
if self.tab == "mods" or self.tab == "yellow" then self.tab = version end
return
end
local modName = findPendingMod(false)
if modName then
self:_installMod(modName)
if self.modNotice and self.modNotice.ok then
love.filesystem.remove(modName)
end
return
end
local savName = findPendingSav(false)
if savName then
local version = self.androidPendingVersion or self:_savedropTarget()
self.androidPendingVersion = nil
self:_importSave(version, savName)
if self.saveNotice[version] and self.saveNotice[version].ok then
love.filesystem.remove(savName)
end
return
end
if self.ready.red and self.ready.blue then return end if self.ready.red and self.ready.blue then return end
local name, data = findPendingRom(self.ready) local name, data = findPendingRom(self.ready)
if name then self:startData(data, name) end if name then self:startData(data, name) end
@@ -672,13 +742,38 @@ function RomImporter:_installMod(source)
self.tab = "mods" self.tab = "mods"
end end
-- "Import mod .zip" button: open a native picker (desktop) and install the -- Remove an installed mod from the save-dir mods/ tree and refresh the panel.
-- pick. Android has no picker, so it points the player at the drop path. function RomImporter:_deleteMod(id)
if self.workState == "working" then return end
local LauncherMods = require("src.mods.LauncherMods")
local ok, res = LauncherMods.uninstall(id)
if ok then
self:_refreshMods()
self.modNotice = { ok = true, text = "Deleted " .. tostring(id) }
else
self.modNotice = { ok = false, text = tostring(res) }
end
end
-- "Import mod .zip" button: open a native picker and install the pick.
-- Android mirrors ROM import: scan for a pending .zip in the save dir (USB
-- or a fresh SAF drop), else love.system.pickFile("mod") -> picked_mod.zip
-- which focus/Choose consumes on return.
function RomImporter:chooseMod() function RomImporter:chooseMod()
if self.workState == "working" then return end if self.workState == "working" then return end
if self.android then if self.android then
self.modNotice = local name = findPendingMod(true)
{ ok = false, text = "Drop a mod .zip onto the window to install it." } if name then
self:_installMod(name)
if self.modNotice and self.modNotice.ok then
love.filesystem.remove(name)
end
return
end
if not love.system.pickFile("mod") then
self.modNotice = { ok = false,
text = "Could not open the file picker. Copy a mod .zip via USB." }
end
return return
end end
local path = chooseZip() local path = chooseZip()
@@ -722,13 +817,26 @@ function RomImporter:_importSave(version, source)
end end
end end
-- "Import save" button: open a native .sav picker (desktop) and import the pick. -- "Import save" button: open a native .sav picker and import the pick.
-- Android has no picker, so it points the player at the drop path. -- Android mirrors ROM / mod import via love.system.pickFile("sav").
function RomImporter:chooseSaveImport(version) function RomImporter:chooseSaveImport(version)
if self.workState == "working" then return end if self.workState == "working" then return end
if self.android then if self.android then
self.saveNotice[version] = local name = findPendingSav(true)
{ ok = false, text = "Drop a .sav onto the window to import it." } if name then
self.androidPendingVersion = version
self:_importSave(version, name)
if self.saveNotice[version] and self.saveNotice[version].ok then
love.filesystem.remove(name)
end
return
end
self.androidPendingVersion = version
if not love.system.pickFile("sav") then
self.androidPendingVersion = nil
self.saveNotice[version] = { ok = false,
text = "Could not open the file picker. Copy a .sav via USB." }
end
return return
end end
local path = chooseSav() local path = chooseSav()
@@ -736,16 +844,58 @@ function RomImporter:chooseSaveImport(version)
end end
-- "Export save" button: write the active slot back out to a raw .sav in the save -- "Export save" button: write the active slot back out to a raw .sav in the save
-- directory's exports/ folder and show the path (with an open-folder affordance) -- directory's exports/ folder. On desktop, show the path with an open-folder
-- on the SAVE FILES card. -- affordance. On Android, stage pending_export.sav and open the system
-- create-document picker (love.system.createFile) so the player can save to
-- Downloads / Drive / etc. -- the app-private exports/ path is not useful there.
function RomImporter:exportSave(version) function RomImporter:exportSave(version)
if self.workState == "working" then return end if self.workState == "working" then return end
local ok, res = require("src.import.SaveFileIO").exportActiveSlot(version) local ok, res = require("src.import.SaveFileIO").exportActiveSlot(version)
if ok then if not ok then
local dir = res:match("^(.*)[/\\][^/\\]+$")
self.saveNotice[version] = { ok = true, text = "Exported to " .. res, dir = dir }
else
self.saveNotice[version] = { ok = false, text = tostring(res) } self.saveNotice[version] = { ok = false, text = tostring(res) }
return
end
if self.android then
local rel = res:match("exports[/\\][^/\\]+$")
local data = rel and love.filesystem.read(rel)
if not data then
self.saveNotice[version] = { ok = false,
text = "Exported, but could not stage the file for the picker." }
return
end
local suggested = rel:match("[^/\\]+$") or "export.sav"
local wrote, writeErr = love.filesystem.write("pending_export.sav", data)
if not wrote then
self.saveNotice[version] = { ok = false,
text = "Could not stage the export: " .. tostring(writeErr) }
return
end
self.androidPendingExportVersion = version
if love.system.createFile and love.system.createFile(suggested) then
self.saveNotice[version] = { ok = true,
text = "Pick where to save " .. suggested .. "..." }
else
self.androidPendingExportVersion = nil
self.saveNotice[version] = { ok = true,
text = "Exported inside the app folder (picker unavailable)." }
end
return
end
local dir = res:match("^(.*)[/\\][^/\\]+$")
self.saveNotice[version] = { ok = true, text = "Exported to " .. res, dir = dir }
end
-- Delete a save slot from the registry and disk, then refresh the panel. If the
-- deleted slot was active, SaveData.deleteSlot points active at another slot.
function RomImporter:_deleteSlot(version, id)
if self.workState == "working" then return end
local SaveData = require("src.core.SaveData")
local ok, err = SaveData.deleteSlot(version, id)
if ok then
self:_refreshSlots(version)
self.saveNotice[version] = { ok = true, text = "Deleted " .. tostring(id) .. "." }
else
self.saveNotice[version] = { ok = false, text = tostring(err) }
end end
end end
@@ -1277,7 +1427,7 @@ function RomImporter:draw()
self.updateButton = { x = rect.x, y = rect.y, width = rect.width, self.updateButton = { x = rect.x, y = rect.y, width = rect.width,
height = rect.height, action = "download" } height = rect.height, action = "download" }
message(upLatest and ("Update v" .. upLatest .. " available") 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 elseif upStatus == "needs_full" then
local rect = actionButton("Open releases") local rect = actionButton("Open releases")
self.updateButton = { x = rect.x, y = rect.y, width = rect.width, self.updateButton = { x = rect.x, y = rect.y, width = rect.width,
@@ -1427,9 +1577,17 @@ function RomImporter:mousepressed(x, y, button)
end end
return return
end end
-- SAVE SLOT rows. On desktop a press only ARMS a click: _updateSlotDrag -- SAVE SLOT rows / Delete. Delete is checked first so a tap on the Delete
-- commits it on release when the pointer did not move (a moved pointer scrolls -- label never also selects the row. On desktop a press only ARMS a click:
-- instead). Android has no reliable pointer polling, so it selects on press. -- _updateSlotDrag commits it on release when the pointer did not move (a
-- moved pointer scrolls instead). Android has no reliable pointer polling,
-- so it selects on press. Delete fires immediately (small fixed target).
for _, r in ipairs(self.slotDeleteRects or {}) do
if inside(r, x, y) then
self:_deleteSlot(self.panelVersion, r.id)
return
end
end
for _, r in ipairs(self.slotRects or {}) do for _, r in ipairs(self.slotRects or {}) do
if inside(r, x, y) then if inside(r, x, y) then
if self.android then if self.android then
@@ -1445,12 +1603,18 @@ function RomImporter:mousepressed(x, y, button)
self:_newSlot(self.panelVersion); return self:_newSlot(self.panelVersion); return
end end
-- Mods panel: the import button dispatches on press (fixed header, no scroll -- Mods panel: the import button dispatches on press (fixed header, no scroll
-- conflict); a toggle switch, which lives in the scrollable list, only ARMS a -- conflict); Delete fires immediately; a toggle switch, which lives in the
-- press so _updateSlotDrag can tell a click from a drag-scroll (Android, with -- scrollable list, only ARMS a press so _updateSlotDrag can tell a click from
-- no pointer polling, toggles on press). -- a drag-scroll (Android, with no pointer polling, toggles on press).
if inside(self.modImportRect, x, y) then if inside(self.modImportRect, x, y) then
self:chooseMod(); return self:chooseMod(); return
end end
for _, r in ipairs(self.modDeleteRects or {}) do
if inside(r, x, y) then
self:_deleteMod(r.id)
return
end
end
for _, r in ipairs(self.modRects or {}) do for _, r in ipairs(self.modRects or {}) do
if inside(r, x, y) then if inside(r, x, y) then
if self.android then if self.android then
@@ -1555,7 +1719,7 @@ function RomImporter:_playButton(x, y, w, h, gameName, ready, locked)
col(PAL.disabledInk, 0.3) col(PAL.disabledInk, 0.3)
love.graphics.rectangle("line", x, y, w, h, r, r) love.graphics.rectangle("line", x, y, w, h, r, r)
col(PAL.disabledInk) 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") printfB(label, x, y + (h - self.playFont:getHeight()) / 2, w, "center")
self.playButtonRect = nil self.playButtonRect = nil
end end
@@ -1567,13 +1731,13 @@ function RomImporter:_drawTabBar(x, y, w, h, chip)
local s, pulse = self._s, self.pulse local s, pulse = self._s, self.pulse
local tabs = { local tabs = {
{ id = "red", letter = "R", top = PAL.chipRedTop, bot = PAL.chipRedBot, { 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, { 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, { 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, { 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 gap = 10 * s
local r = 12 * s local r = 12 * s
@@ -1632,7 +1796,7 @@ function RomImporter:_drawTabBar(x, y, w, h, chip)
local ready = 0 local ready = 0
for _, v in ipairs(GameVersion.ORDER) do if self.ready[v] then ready = ready + 1 end end for _, v in ipairs(GameVersion.ORDER) do if self.ready[v] then ready = ready + 1 end end
love.graphics.setFont(self.readyFont) 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) local lw = self.readyFont:getWidth(label)
if x + w - lw > cursorX + 8 * s then if x + w - lw > cursorX + 8 * s then
col(PAL.labelGray) col(PAL.labelGray)
@@ -1688,7 +1852,7 @@ function RomImporter:_drawGamePanel(version, x, y, w, h)
-- ROM card contents by state (rehomes the existing import flow) -- ROM card contents by state (rehomes the existing import flow)
local dropHint = self.android and "Copy the .gb via USB." 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 accent = locked and PAL.gold or (version == "red" and PAL.red or PAL.blue)
local romState, romDetail, romBtnLabel, romBtnEnabled, romProgress local romState, romDetail, romBtnLabel, romBtnEnabled, romProgress
if locked then if locked then
@@ -1703,12 +1867,12 @@ function RomImporter:_drawGamePanel(version, x, y, w, h)
romDetail = self.detail or "" romDetail = self.detail or ""
romProgress = self.progress or 0 romProgress = self.progress or 0
elseif ready then elseif ready then
romState = self.romName[version] or "ROM imported" romState = self.romName[version] or Strings("ROM imported")
romDetail = "Verified." romDetail = "Verified."
romBtnLabel, romBtnEnabled = "Re-import ROM", true romBtnLabel, romBtnEnabled = "Re-import ROM", true
elseif erroring then elseif erroring then
romState = "Import failed" 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 romBtnLabel, romBtnEnabled = "Import ROM", true
elseif notice then elseif notice then
romState = "No ROM imported" romState = "No ROM imported"
@@ -1759,7 +1923,8 @@ function RomImporter:_drawGamePanel(version, x, y, w, h)
elseif locked then elseif locked then
sfHintText, sfHintCol = "Not available yet.", PAL.warning sfHintText, sfHintCol = "Not available yet.", PAL.warning
elseif self.android then elseif self.android then
sfHintText, sfHintCol = "Import a .sav, or drop one on the window.", PAL.warning sfHintText, sfHintCol =
"Import or export a .sav with the system file picker.", PAL.warning
else else
sfHintText, sfHintCol = sfHintText, sfHintCol =
"Import a .sav to a new slot, or export the active slot.", PAL.warning "Import a .sav to a new slot, or export the active slot.", PAL.warning
@@ -1836,7 +2001,7 @@ function RomImporter:_drawGamePanel(version, x, y, w, h)
if sfNotice and sfNotice.dir then if sfNotice and sfNotice.dir then
iy = iy + 4 * s iy = iy + 4 * s
love.graphics.setFont(self.hintFont) love.graphics.setFont(self.hintFont)
local label = "Open folder" local label = Strings("Open folder")
local lw = self.hintFont:getWidth(label) local lw = self.hintFont:getWidth(label)
local frect = { x = ix, y = iy, width = lw, height = self.hintFont:getHeight(), local frect = { x = ix, y = iy, width = lw, height = self.hintFont:getHeight(),
dir = sfNotice.dir } dir = sfNotice.dir }
@@ -1999,6 +2164,8 @@ function RomImporter:_drawSaveSlotPanel(version, x, y, w, h)
love.graphics.printf("No saves yet - start a new game or import one.", love.graphics.printf("No saves yet - start a new game or import one.",
rx + 12 * s, listTop + listH / 2 - self.hintFont:getHeight() / 2, rx + 12 * s, listTop + listH / 2 - self.hintFont:getHeight() / 2,
rw - 24 * s, "center") rw - 24 * s, "center")
self.slotRects = {}
self.slotDeleteRects = {}
elseif listH > 0 then elseif listH > 0 then
local nameH = self.slotNameFont:getHeight() local nameH = self.slotNameFont:getHeight()
local metaH = self.labelFont:getHeight() local metaH = self.labelFont:getHeight()
@@ -2017,6 +2184,7 @@ function RomImporter:_drawSaveSlotPanel(version, x, y, w, h)
self.slotScroll[version] = scroll self.slotScroll[version] = scroll
self.slotRects = {} self.slotRects = {}
self.slotDeleteRects = {}
love.graphics.setScissor(math.floor(rx), math.floor(listTop), love.graphics.setScissor(math.floor(rx), math.floor(listTop),
math.ceil(rw), math.ceil(listH)) math.ceil(rw), math.ceil(listH))
for i, slot in ipairs(slots) do for i, slot in ipairs(slots) do
@@ -2029,6 +2197,20 @@ function RomImporter:_drawSaveSlotPanel(version, x, y, w, h)
col(selected and PAL.green or PAL.cardBorder, selected and 0.9 or 0.22) col(selected and PAL.green or PAL.cardBorder, selected and 0.9 or 0.22)
love.graphics.rectangle("line", rx, ry, rw, rowH, rr, rr) love.graphics.rectangle("line", rx, ry, rw, rowH, rr, rr)
-- Delete label (bottom-right); reserve its width so name/meta don't overlap
love.graphics.setFont(self.hintFont)
local delText = "Delete"
local delW = self.hintFont:getWidth(delText)
local delH = self.hintFont:getHeight()
local delX = rx + rw - 12 * s - delW
local delY = ry + rowH - rowPadV - delH
local drect = { x = delX - 6 * s, y = delY - 4 * s,
width = delW + 12 * s, height = delH + 8 * s, id = slot.id }
local dhot = self:_hover(drect)
col(dhot and PAL.red or PAL.warning)
love.graphics.print(delText, delX, delY)
local rightReserve = delW + 18 * s
-- LOADED pill (top-right of the active row), then reserve its width -- LOADED pill (top-right of the active row), then reserve its width
local pillW = 0 local pillW = 0
if selected then if selected then
@@ -2047,21 +2229,20 @@ function RomImporter:_drawSaveSlotPanel(version, x, y, w, h)
love.graphics.setFont(self.slotNameFont) love.graphics.setFont(self.slotNameFont)
col(PAL.white) 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 - pillW), printB(ellipsize(self.slotNameFont, name, rw - 24 * s - math.max(pillW, rightReserve)),
rx + 12 * s, ry + rowPadV) rx + 12 * s, ry + rowPadV)
local metaTxt local metaTxt
if slot.exists and slot.meta then if slot.exists and slot.meta then
metaTxt = ("%d badges - %s - %d caught"):format( metaTxt = Strings("%d badges - %s - %d caught", slot.meta.badges or 0, slot.meta.timeText or "0:00",
slot.meta.badges or 0, slot.meta.timeText or "0:00",
slot.meta.dexCount or 0) slot.meta.dexCount or 0)
else else
metaTxt = "empty slot" metaTxt = "empty slot"
end end
love.graphics.setFont(self.labelFont) love.graphics.setFont(self.labelFont)
col(PAL.warning) col(PAL.warning)
love.graphics.print(ellipsize(self.labelFont, metaTxt, rw - 24 * s), love.graphics.print(ellipsize(self.labelFont, metaTxt, rw - 24 * s - rightReserve),
rx + 12 * s, ry + rowPadV + nameH + 4 * s) rx + 12 * s, ry + rowPadV + nameH + 4 * s)
-- clip the hit rect to the visible list band so a partly-scrolled row -- clip the hit rect to the visible list band so a partly-scrolled row
@@ -2072,6 +2253,12 @@ function RomImporter:_drawSaveSlotPanel(version, x, y, w, h)
self.slotRects[#self.slotRects + 1] = self.slotRects[#self.slotRects + 1] =
{ x = rx, y = vy, width = rw, height = vy2 - vy, id = slot.id } { x = rx, y = vy, width = rw, height = vy2 - vy, id = slot.id }
end end
local dvy = math.max(drect.y, listTop)
local dvy2 = math.min(drect.y + drect.height, listBottom)
if dvy2 > dvy then
self.slotDeleteRects[#self.slotDeleteRects + 1] =
{ x = drect.x, y = dvy, width = drect.width, height = dvy2 - dvy, id = slot.id }
end
end end
end end
love.graphics.setScissor() love.graphics.setScissor()
@@ -2152,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 for _, m in ipairs(mods) do if m.enabled then enabledCount = enabledCount + 1 end end
love.graphics.setFont(self.hintFont) love.graphics.setFont(self.hintFont)
col(PAL.warning) 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) x + nameW + 14 * s, y + (headerH - self.hintFont:getHeight()) / 2)
local btnLabel = "Import mod .zip" local btnLabel = "Import mod .zip"
@@ -2165,15 +2352,15 @@ function RomImporter:_drawModsPanel(x, y, w, h)
local top = y + headerH + 14 * s local top = y + headerH + 14 * s
-- notice line: the last install result, else the drag-drop hint -- notice line: the last install/delete result, else the platform hint
love.graphics.setFont(self.hintFont) love.graphics.setFont(self.hintFont)
if self.modNotice then if self.modNotice then
col(self.modNotice.ok and PAL.green or PAL.red) col(self.modNotice.ok and PAL.green or PAL.red)
love.graphics.printf(self.modNotice.text, x, top, w, "left") love.graphics.printf(self.modNotice.text, x, top, w, "left")
else else
col(PAL.warning) col(PAL.warning)
love.graphics.printf(self.android and "Copy a mod .zip via USB." 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 end
top = top + self.hintFont:getHeight() + 12 * s top = top + self.hintFont:getHeight() + 12 * s
@@ -2187,20 +2374,25 @@ function RomImporter:_drawModsPanel(x, y, w, h)
dashedRoundRect(x, top, w, boxH, 14 * s, 7 * s, 5 * s) dashedRoundRect(x, top, w, boxH, 14 * s, 7 * s, 5 * s)
love.graphics.setFont(self.hintFont) love.graphics.setFont(self.hintFont)
col(PAL.warning) col(PAL.warning)
love.graphics.printf("No mods installed - drop a mod .zip here to add one.", local emptyHint = self.android
and "No mods installed - tap Import mod .zip 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") x + 16 * s, top + boxH / 2 - self.hintFont:getHeight() / 2, w - 32 * s, "center")
self.modRects = {} self.modRects = {}
self.modDeleteRects = {}
self._modMax = 0 self._modMax = 0
return return
end end
-- card metrics (design: rounded 14, padding 14x16; toggle 56x28) -- card metrics (design: rounded 14, padding 14x16; toggle 56x28; Delete under)
local padH, padV = 16 * s, 14 * s local padH, padV = 16 * s, 14 * s
local cardGap, cardR = 10 * s, 14 * s local cardGap, cardR = 10 * s, 14 * s
local tw, th = 52 * s, 28 * s local tw, th = 52 * s, 28 * s
local innerW = w - 2 * padH local innerW = w - 2 * padH
local chipH = self.hintFont:getHeight() + 8 * s local chipH = self.hintFont:getHeight() + 8 * s
local clusterH = chipH + 6 * s + th -- status chip stacked over the toggle local delH = self.hintFont:getHeight()
local clusterH = chipH + 6 * s + th + 6 * s + delH
love.graphics.setFont(self.stateFont) love.graphics.setFont(self.stateFont)
local nameH = self.stateFont:getHeight() local nameH = self.stateFont:getHeight()
@@ -2210,7 +2402,8 @@ function RomImporter:_drawModsPanel(x, y, w, h)
for i, m in ipairs(mods) do for i, m in ipairs(mods) do
local chipText = modStatusChip(m.status) local chipText = modStatusChip(m.status)
local chipW = self.hintFont:getWidth(chipText) + 20 * s local chipW = self.hintFont:getWidth(chipText) + 20 * s
local clusterW = math.max(chipW, tw) local delW = self.hintFont:getWidth("Delete")
local clusterW = math.max(chipW, tw, delW)
local leftW = math.max(40 * s, innerW - clusterW - 14 * s) local leftW = math.max(40 * s, innerW - clusterW - 14 * s)
local descH = 0 local descH = 0
if m.description ~= "" then if m.description ~= "" then
@@ -2221,7 +2414,7 @@ function RomImporter:_drawModsPanel(x, y, w, h)
local contentH = padV * 2 + nameH + (descH > 0 and (6 * s + descH) or 0) local contentH = padV * 2 + nameH + (descH > 0 and (6 * s + descH) or 0)
local cardH = math.max(contentH, padV * 2 + clusterH) local cardH = math.max(contentH, padV * 2 + clusterH)
layout[i] = { h = cardH, leftW = leftW, clusterW = clusterW, layout[i] = { h = cardH, leftW = leftW, clusterW = clusterW,
chipText = chipText, chipW = chipW } chipText = chipText, chipW = chipW, delW = delW }
total = total + cardH total = total + cardH
end end
total = total + (#mods - 1) * cardGap total = total + (#mods - 1) * cardGap
@@ -2231,6 +2424,7 @@ function RomImporter:_drawModsPanel(x, y, w, h)
local scroll = clamp(self.modScroll or 0, 0, maxScroll) local scroll = clamp(self.modScroll or 0, 0, maxScroll)
self.modScroll = scroll self.modScroll = scroll
self.modRects = {} self.modRects = {}
self.modDeleteRects = {}
love.graphics.setScissor(math.floor(x), math.floor(top), love.graphics.setScissor(math.floor(x), math.floor(top),
math.ceil(w), math.ceil(listH)) math.ceil(w), math.ceil(listH))
@@ -2269,7 +2463,7 @@ function RomImporter:_drawModsPanel(x, y, w, h)
love.graphics.printf(m.description, nx, ny + nameH + 6 * s, L.leftW, "left") love.graphics.printf(m.description, nx, ny + nameH + 6 * s, L.leftW, "left")
end end
-- right cluster: status chip stacked over the toggle, vertically centred -- right cluster: status chip, toggle, Delete — vertically centred
local clusterX = x + w - padH - L.clusterW local clusterX = x + w - padH - L.clusterW
local clusterY = cy + (cardH - clusterH) / 2 local clusterY = cy + (cardH - clusterH) / 2
local _, chipColor = modStatusChip(m.status) local _, chipColor = modStatusChip(m.status)
@@ -2303,14 +2497,29 @@ function RomImporter:_drawModsPanel(x, y, w, h)
col(PAL.white) col(PAL.white)
love.graphics.circle("fill", kcx, ty + th / 2, kd / 2) love.graphics.circle("fill", kcx, ty + th / 2, kd / 2)
-- hit rect clipped to the visible list band (a partly-scrolled toggle is -- Delete under the toggle
-- only clickable where it actually shows) local delX = clusterX + (L.clusterW - L.delW) / 2
local delY = ty + th + 6 * s
local drect = { x = delX - 6 * s, y = delY - 2 * s,
width = L.delW + 12 * s, height = delH + 4 * s, id = m.id }
local dhot = self:_hover(drect)
love.graphics.setFont(self.hintFont)
col(dhot and PAL.red or PAL.warning)
love.graphics.print("Delete", delX, delY)
-- hit rects clipped to the visible list band
local vy = math.max(trect.y, top) local vy = math.max(trect.y, top)
local vy2 = math.min(trect.y + trect.height, top + listH) local vy2 = math.min(trect.y + trect.height, top + listH)
if vy2 > vy then if vy2 > vy then
self.modRects[#self.modRects + 1] = self.modRects[#self.modRects + 1] =
{ x = trect.x, y = vy, width = trect.width, height = vy2 - vy, id = m.id } { x = trect.x, y = vy, width = trect.width, height = vy2 - vy, id = m.id }
end end
local dvy = math.max(drect.y, top)
local dvy2 = math.min(drect.y + drect.height, top + listH)
if dvy2 > dvy then
self.modDeleteRects[#self.modDeleteRects + 1] =
{ x = drect.x, y = dvy, width = drect.width, height = dvy2 - dvy, id = m.id }
end
end end
cy = cy + cardH + cardGap cy = cy + cardH + cardGap
end end
+13 -5
View File
@@ -47,11 +47,19 @@ local function readSource(source)
return source return source
end end
local f, openErr = io.open(source, "rb") local f, openErr = io.open(source, "rb")
if not f then return nil, "could not read the save file: " .. tostring(openErr) end if f then
local data = f:read("*a") local data = f:read("*a")
f:close() f:close()
if type(data) ~= "string" then return nil, "the save file was empty" end if type(data) ~= "string" then return nil, "the save file was empty" end
return data return data
end
-- Android SAF drops (picked_save.sav) and USB copies land in the LOVE save
-- directory; io.open cannot see them, so fall back to love.filesystem.
if love and love.filesystem and love.filesystem.read then
local data = love.filesystem.read(source)
if type(data) == "string" then return data end
end
return nil, "could not read the save file: " .. tostring(openErr)
end end
-- importToSlot(source, version) -> ok, slotIdOrErr -- importToSlot(source, version) -> ok, slotIdOrErr
+48 -46
View File
@@ -12,6 +12,7 @@
local Pokemon = require("src.pokemon.Pokemon") local Pokemon = require("src.pokemon.Pokemon")
local Flags = require("src.script.Flags") local Flags = require("src.script.Flags")
local Strings = require("src.core.Strings")
local ItemEffects = {} 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" if battle and (VITAMINS[itemId] or STONES[itemId] or itemId == "PP_UP"
or itemId == "RARE_CANDY" or itemId == "COIN_CASE" or itemId == "RARE_CANDY" or itemId == "COIN_CASE"
or (itemDef and itemDef.machine)) then or (itemDef and itemDef.machine)) then
return "failed", { "OAK: " .. save.player.name return "failed", { Strings("OAK: %s!\nThis isn't the\ntime to use that!",
.. "!\nThis isn't the\ntime to use that!" } save.player.name) }
end end
if BALLS[itemId] then if BALLS[itemId] then
@@ -138,11 +139,12 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
local mapId, npc = adjacentSleepingSnorlax(save, ow) local mapId, npc = adjacentSleepingSnorlax(save, ow)
if npc then if npc then
return "flute_wake", { data.text._PlayedFluteHadEffectText 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 end
-- otherwise: play the tune, nothing happens (ItemUsePokeFlute's -- otherwise: play the tune, nothing happens (ItemUsePokeFlute's
-- PlayedFluteNoEffectText branch) -- 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 end
local woke = false local woke = false
local function wake(mon) 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 -- WakeUpEntireParty runs on the enemy's bench too
for _, mon in ipairs(battle.enemyParty or {}) do wake(mon) end for _, mon in ipairs(battle.enemyParty or {}) do wake(mon) end
if not woke then 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 end
return "flute", { ("%s played the\nPOKé FLUTE."):format(save.player.name), return "flute", { Strings("%s played the\nPOKé FLUTE.", save.player.name),
"All sleeping\nPOKéMON woke up!" } Strings("All sleeping\nPOKéMON woke up!") }
end end
-- battle-only items -- battle-only items
if X_ITEMS[itemId] or itemId == "DIRE_HIT" or itemId == "GUARD_SPEC" if X_ITEMS[itemId] or itemId == "DIRE_HIT" or itemId == "GUARD_SPEC"
or itemId == "POKE_DOLL" then or itemId == "POKE_DOLL" then
if not battle 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 end
local b = battle.player local b = battle.player
if itemId == "X_ACCURACY" then if itemId == "X_ACCURACY" then
-- ItemUseXAccuracy sets USING_X_ACCURACY: moves never miss -- ItemUseXAccuracy sets USING_X_ACCURACY: moves never miss
-- (not an accuracy stage) -- (not an accuracy stage)
b.xAccuracy = true 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 end
if X_ITEMS[itemId] then if X_ITEMS[itemId] then
local stat = X_ITEMS[itemId] 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 -- effect, so at +6 it is still consumed and StatModifierUpEffect
-- just prints "Nothing happened!" -- just prints "Nothing happened!"
if cur >= 6 then if cur >= 6 then
return "consumed", { "Nothing happened!" } return "consumed", { Strings("Nothing happened!") }
end end
b.stages[stat] = cur + 1 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 end
-- ItemUseDireHit/ItemUseGuardSpec always set the bit and consume -- ItemUseDireHit/ItemUseGuardSpec always set the bit and consume
-- the item, even when it is already active -- the item, even when it is already active
if itemId == "DIRE_HIT" then if itemId == "DIRE_HIT" then
b.focusEnergy = true b.focusEnergy = true
return "consumed", { ("%s's\ngetting pumped!"):format(b.name) } return "consumed", { Strings("%s's\ngetting pumped!", b.name) }
end end
if itemId == "GUARD_SPEC" then if itemId == "GUARD_SPEC" then
b.mist = true 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 end
if itemId == "POKE_DOLL" then if itemId == "POKE_DOLL" then
if battle.kind ~= "wild" then if battle.kind ~= "wild" then
-- ItemUsePokeDoll jumps to ItemUseNotTime in trainer battles -- ItemUsePokeDoll jumps to ItemUseNotTime in trainer battles
return "failed", { "OAK: " .. save.player.name return "failed", { Strings(
.. "!\nThis isn't the\ntime to use that!" } "OAK: %s!\nThis isn't the\ntime to use that!", save.player.name) }
end end
return "consumed_escape", { "The wild POKéMON\nran away!" } return "consumed_escape", { Strings("The wild POKéMON\nran away!") }
end end
end end
@@ -213,7 +215,7 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
-- restore every move with no menu. -- restore every move with no menu.
if itemId == "ETHER" or itemId == "MAX_ETHER" if itemId == "ETHER" or itemId == "MAX_ETHER"
or itemId == "ELIXER" or itemId == "MAX_ELIXER" then 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 restored = false
local full = itemId == "MAX_ETHER" or itemId == "MAX_ELIXER" local full = itemId == "MAX_ETHER" or itemId == "MAX_ELIXER"
local allMoves = itemId == "ELIXER" 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 restored = mv and restore(mv) or false
end end
if not restored then if not restored then
return "failed", { "It won't have\nany effect." } return "failed", { Strings("It won't have\nany effect.") }
end end
return "consumed", { ("%s's PP\nwas restored!"):format(monName(data, target)) } return "consumed", { Strings("%s's PP\nwas restored!", monName(data, target)) }
end end
local heal = HEAL_AMOUNT[itemId] local heal = HEAL_AMOUNT[itemId]
@@ -250,17 +252,17 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
target.status = nil target.status = nil
cureActiveToxic(battle, target) cureActiveToxic(battle, target)
require("src.core.Sound").play(data, "Heal_Ailment") 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 end
if not target or target.hp <= 0 or target.hp >= target.stats.hp then 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 end
if itemId == "MAX_POTION" or itemId == "FULL_RESTORE" then if itemId == "MAX_POTION" or itemId == "FULL_RESTORE" then
target.hp = target.stats.hp target.hp = target.stats.hp
else else
target.hp = math.min(target.stats.hp, target.hp + heal) target.hp = math.min(target.stats.hp, target.hp + heal)
end 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 if itemId == "FULL_RESTORE" then
target.status = nil target.status = nil
cureActiveToxic(battle, target) cureActiveToxic(battle, target)
@@ -272,27 +274,27 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
local cures = STATUS_HEAL[itemId] local cures = STATUS_HEAL[itemId]
if cures then if cures then
if not target or not target.status or not cures[target.status] 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 end
target.status = nil target.status = nil
cureActiveToxic(battle, target) cureActiveToxic(battle, target)
require("src.core.Sound").play(data, "Heal_Ailment") 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 end
if itemId == "REVIVE" or itemId == "MAX_REVIVE" then if itemId == "REVIVE" or itemId == "MAX_REVIVE" then
if not target or target.hp > 0 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 end
target.status = nil target.status = nil
target.hp = itemId == "REVIVE" and math.floor(target.stats.hp / 2) or target.stats.hp target.hp = itemId == "REVIVE" and math.floor(target.stats.hp / 2) or target.stats.hp
require("src.core.Sound").play(data, "Heal_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 end
if itemId == "RARE_CANDY" then if itemId == "RARE_CANDY" then
if not target or target.level >= 100 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 end
local Growth = require("src.pokemon.Growth") local Growth = require("src.pokemon.Growth")
local Stats = require("src.pokemon.Stats") local Stats = require("src.pokemon.Stats")
@@ -302,56 +304,56 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
local old = target.stats local old = target.stats
target.stats = Stats.calc(speciesDef, target.level, target.dvs, target.statExp) 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)) 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 } { leveledTo = target.level }
end end
if STONES[itemId] then 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] local speciesDef = data.pokemon[target.species]
for _, evo in ipairs(speciesDef.evolutions) do for _, evo in ipairs(speciesDef.evolutions) do
if evo.method == "ITEM" and evo.item == itemId then if evo.method == "ITEM" and evo.item == itemId then
return "consumed", nil, { evolveTo = evo.species } return "consumed", nil, { evolveTo = evo.species }
end end
end end
return "failed", { "It won't have\nany effect." } return "failed", { Strings("It won't have\nany effect.") }
end end
-- vitamins: +2560 stat exp, refused at 25600+ (ItemUseVitamin, -- vitamins: +2560 stat exp, refused at 25600+ (ItemUseVitamin,
-- engine/items/item_effects.asm) -- engine/items/item_effects.asm)
local vitaminStat = VITAMINS[itemId] local vitaminStat = VITAMINS[itemId]
if vitaminStat then 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 {} target.statExp = target.statExp or {}
local cur = target.statExp[vitaminStat] or 0 local cur = target.statExp[vitaminStat] or 0
if cur >= 25600 then if cur >= 25600 then
return "failed", { "It won't have\nany effect." } return "failed", { Strings("It won't have\nany effect.") }
end end
target.statExp[vitaminStat] = math.min(65535, cur + 2560) target.statExp[vitaminStat] = math.min(65535, cur + 2560)
local Stats = require("src.pokemon.Stats") local Stats = require("src.pokemon.Stats")
target.stats = Stats.calc(data.pokemon[target.species], target.level, target.stats = Stats.calc(data.pokemon[target.species], target.level,
target.dvs, target.statExp) target.dvs, target.statExp)
target.hp = math.min(target.hp, target.stats.hp) 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()) } vitaminStat == "hp" and "HP" or vitaminStat:upper()) }
end end
-- PP UP boosts the move the player picked (ItemUsePPUp's move menu) -- PP UP boosts the move the player picked (ItemUsePPUp's move menu)
if itemId == "PP_UP" then 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 mv = target.moves[moveIndex or 1]
local mdef = mv and data.moves[mv.id] local mdef = mv and data.moves[mv.id]
if mdef and (mv.ppUps or 0) < 3 then if mdef and (mv.ppUps or 0) < 3 then
mv.ppUps = (mv.ppUps or 0) + 1 mv.ppUps = (mv.ppUps or 0) + 1
-- each PP UP adds maxPP/5 uses on top of the base maximum -- each PP UP adds maxPP/5 uses on top of the base maximum
mv.pp = mv.pp + math.floor(mdef.pp / 5) 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 end
return "failed", { "It won't have\nany effect." } return "failed", { Strings("It won't have\nany effect.") }
end end
if itemDef and itemDef.machine then 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 speciesDef = data.pokemon[target.species]
local ok = false local ok = false
for _, m in ipairs(speciesDef.tmhm) do 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 -- plays SFX_DENIED before MonCannotLearnMachineMoveText (the generic
-- ItemUseNotTime/NoCyclingAllowedHere paths are silent) -- ItemUseNotTime/NoCyclingAllowedHere paths are silent)
require("src.core.Sound").play(data, "Denied") 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 end
for _, mv in ipairs(target.moves) do for _, mv in ipairs(target.moves) do
if mv.id == itemDef.machine.move then if mv.id == itemDef.machine.move then
return "failed", { "It knows that\nmove already!" } return "failed", { Strings("It knows that\nmove already!") }
end end
end end
-- HMs are never consumed; TMs are single-use -- 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 itemId == "OLD_ROD" or itemId == "GOOD_ROD" or itemId == "SUPER_ROD" then
if battle 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 end
return "fish", itemId return "fish", itemId
end end
if itemId == "BICYCLE" then if itemId == "BICYCLE" then
if battle 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 end
return "bicycle" return "bicycle"
end end
@@ -392,26 +394,26 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
end end
if itemId == "TOWN_MAP" then if itemId == "TOWN_MAP" then
if battle 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 end
return "townmap" return "townmap"
end end
if itemId == "ITEMFINDER" then if itemId == "ITEMFINDER" then
if battle 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 end
return "itemfinder" return "itemfinder"
end end
if itemId == "COIN_CASE" then 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 end
if itemId == "REPEL" or itemId == "SUPER_REPEL" or itemId == "MAX_REPEL" then 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 local steps = itemId == "REPEL" and 100 or itemId == "SUPER_REPEL" and 200 or 250
save.repelSteps = steps 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 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 end
return ItemEffects return ItemEffects
+25 -24
View File
@@ -21,6 +21,7 @@ local Logger = require("src.core.Logger")
local Protocol = require("src.link.Protocol") local Protocol = require("src.link.Protocol")
local Runtime = require("src.mods.Runtime") local Runtime = require("src.mods.Runtime")
local TurnOrder = require("src.battle.TurnOrder") local TurnOrder = require("src.battle.TurnOrder")
local Strings = require("src.core.Strings")
local LinkBattle = {} local LinkBattle = {}
@@ -177,18 +178,18 @@ function LinkBattle.new(game, net, opts)
local theirName = opts.theirName or "FOE" local theirName = opts.theirName or "FOE"
if not Handshake.battleAllowed(opts.verdict) then 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 end
-- both parties pass through the same pack->unpack clamp on both -- both parties pass through the same pack->unpack clamp on both
-- machines, so the copies are identical everywhere -- machines, so the copies are identical everywhere
local unpackOpts = { strict = opts.strict or false, forceLevel = opts.forceLevel } local unpackOpts = { strict = opts.strict or false, forceLevel = opts.forceLevel }
local myParty, myErr = unpackParty(game, opts.myParty, unpackOpts, function(p) 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) end)
if not myParty then return nil, myErr end if not myParty then return nil, myErr end
local theirParty, theirErr = unpackParty(game, opts.theirParty, unpackOpts, function(p, why) 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) end)
if not theirParty then return nil, theirErr end if not theirParty then return nil, theirErr end
if #myParty == 0 or #theirParty == 0 then if #myParty == 0 or #theirParty == 0 then
@@ -232,7 +233,7 @@ function LinkBattle.new(game, net, opts)
self.enemyParty = theirParty self.enemyParty = theirParty
self.playerParty = myParty -- intro ball row uses the clamped copies self.playerParty = myParty -- intro ball row uses the clamped copies
self.opponentName = theirName self.opponentName = theirName
self.introText = ("%s wants\nto battle!"):format(theirName) self.introText = Strings("%s wants\nto battle!", theirName)
self.remoteHashes = {} self.remoteHashes = {}
self.localHashes = {} self.localHashes = {}
self.remoteParts = {} self.remoteParts = {}
@@ -292,7 +293,7 @@ function LinkBattle.new(game, net, opts)
battle = s, side = s.sides[2], battler = s.enemy, previous = previous, battle = s, side = s.sides[2], battler = s.enemy, previous = previous,
}) })
s.enemySendingOut = true 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:actNext(function()
s.enemySendingOut = false s.enemySendingOut = false
s:startGrowIn(s.enemy) s:startGrowIn(s.enemy)
@@ -315,8 +316,9 @@ function LinkBattle.new(game, net, opts)
tostring(turn), component, tostring(localH), tostring(remoteH)) tostring(turn), component, tostring(localH), tostring(remoteH))
Runtime.emit("link.desync", { turn = turn, component = component, Runtime.emit("link.desync", { turn = turn, component = component,
localHash = localH, remoteHash = remoteH }) localHash = localH, remoteHash = remoteH })
endAsDraw(s, ("Link desync!\n%s differs.\fAre both games\nrunning the same\nmods?") endAsDraw(s, Strings(
:format(component)) "Link desync!\n%s differs.\fAre both games\nrunning the same\nmods?",
component))
end end
-- a verified turn stays recorded: consuming it here left a finished -- 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) local function resolveLockstep(s, myMsg, theirMsg)
if myMsg.kind == "run" or theirMsg.kind == "run" then if myMsg.kind == "run" or theirMsg.kind == "run" then
local who = myMsg.kind == "run" and game.save.player.name or theirName 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 return
end end
s.phase = "messages" s.phase = "messages"
@@ -479,9 +481,9 @@ function LinkBattle.new(game, net, opts)
party = myParty, party = myParty,
onSwitch = function(mon) onSwitch = function(mon)
if mon == s.player.mon then 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 elseif mon.hp <= 0 then
s:say("There's no will\nto fight!") s:say(Strings("There's no will\nto fight!"))
else else
s:resolveSwitch(mon) s:resolveSwitch(mon)
end end
@@ -491,7 +493,7 @@ function LinkBattle.new(game, net, opts)
end end
self.openItems = function(s) 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.phase = "messages"
s.afterQueue = "menu" s.afterQueue = "menu"
end end
@@ -509,7 +511,7 @@ function LinkBattle.new(game, net, opts)
return return
end end
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)) theirName))
s.result = "lose" s.result = "lose"
s.afterQueue = "finish" s.afterQueue = "finish"
@@ -522,7 +524,7 @@ function LinkBattle.new(game, net, opts)
return return
end end
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)) game.save.player.name))
s.result = "win" s.result = "win"
s.afterQueue = "finish" s.afterQueue = "finish"
@@ -543,13 +545,13 @@ function LinkBattle.new(game, net, opts)
-- only a draw if our own simulation hasn't already decided -- only a draw if our own simulation hasn't already decided
-- (the winner's bye can arrive while we're still animating) -- (the winner's bye can arrive while we're still animating)
if not s.result then if not s.result then
endAsDraw(s, ("%s left the\nbattle."):format(theirName)) endAsDraw(s, Strings("%s left the\nbattle.", theirName))
end end
elseif msg.type == "forfeit" then elseif msg.type == "forfeit" then
-- the peer's own shot clock ran out; unlike a mutual RUN/desync -- the peer's own shot clock ran out; unlike a mutual RUN/desync
-- draw, this has a definite winner (us) -- draw, this has a definite winner (us)
if not s.result then 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 end
else else
-- a tournament control message (bracket_update, the next -- a tournament control message (bracket_update, the next
@@ -582,7 +584,7 @@ function LinkBattle.new(game, net, opts)
if s.turnClock <= 0 then if s.turnClock <= 0 then
s.turnClockActive = false s.turnClockActive = false
send({ type = "forfeit" }) 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 end
elseif opts.turnLimit then elseif opts.turnLimit then
s.turnClockActive = false s.turnClockActive = false
@@ -643,17 +645,16 @@ function LinkBattle.newSpectator(game, net, opts)
local guestName = opts.guestName or "GUEST" local guestName = opts.guestName or "GUEST"
if not Handshake.battleAllowed(opts.verdict) then 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 end
local unpackOpts = { strict = opts.strict or false, forceLevel = opts.forceLevel } local unpackOpts = { strict = opts.strict or false, forceLevel = opts.forceLevel }
local hostParty, hostErr = unpackParty(game, opts.hostParty, unpackOpts, function(p) 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) end)
if not hostParty then return nil, hostErr end if not hostParty then return nil, hostErr end
local guestParty, guestErr = unpackParty(game, opts.guestParty, unpackOpts, function(p, why) local guestParty, guestErr = unpackParty(game, opts.guestParty, unpackOpts, function(p, why)
return ("%s's %s can't\nbattle on this\ngame.\n(%s)"):format( return Strings("%s's %s can't\nbattle on this\ngame.\n(%s)", guestName, tostring(p.species), tostring(why))
guestName, tostring(p.species), tostring(why))
end) end)
if not guestParty then return nil, guestErr end if not guestParty then return nil, guestErr end
if #hostParty == 0 or #guestParty == 0 then if #hostParty == 0 or #guestParty == 0 then
@@ -672,7 +673,7 @@ function LinkBattle.newSpectator(game, net, opts)
self.enemyParty = guestParty self.enemyParty = guestParty
self.playerParty = hostParty self.playerParty = hostParty
self.opponentName = guestName self.opponentName = guestName
self.introText = ("%s vs %s!"):format(hostName, guestName) self.introText = Strings("%s vs %s!", hostName, guestName)
local function orderMove(action) local function orderMove(action)
if action and action.id then return game.data.moves[action.id] end 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, battle = s, side = s.sides[2], battler = s.enemy, previous = previous,
}) })
s.enemySendingOut = true 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:actNext(function()
s.enemySendingOut = false s.enemySendingOut = false
s:startGrowIn(s.enemy) s:startGrowIn(s.enemy)
@@ -789,7 +790,7 @@ function LinkBattle.newSpectator(game, net, opts)
return return
end end
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.result = "guestWin"
s.afterQueue = "finish" s.afterQueue = "finish"
end end
@@ -801,7 +802,7 @@ function LinkBattle.newSpectator(game, net, opts)
return return
end end
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.result = "hostWin"
s.afterQueue = "finish" s.afterQueue = "finish"
end end
+46 -45
View File
@@ -11,6 +11,7 @@ local Protocol = require("src.link.Protocol")
local Runtime = require("src.mods.Runtime") local Runtime = require("src.mods.Runtime")
local Screens = require("src.ui.Screens") local Screens = require("src.ui.Screens")
local TextBox = require("src.render.TextBox") local TextBox = require("src.render.TextBox")
local Strings = require("src.core.Strings")
local LinkState = {} local LinkState = {}
LinkState.__index = LinkState LinkState.__index = LinkState
@@ -90,7 +91,7 @@ function LinkState.newJoinOnline(game, code)
self.stage = "onlineJoining" self.stage = "onlineJoining"
else else
self.stage = "menu" -- exitWith below needs a real stage to unwind from 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 end
return self return self
end end
@@ -174,7 +175,7 @@ function LinkState:update(dt)
if self.net then if self.net then
self.net:update() self.net:update()
if self.net.error and not PRE_CONNECT_STAGES[self.stage] then 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 return
end end
-- the peer vanished without a bye (only once the inbox is drained, -- 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 not PRE_CONNECT_STAGES[self.stage] and self.stage ~= "addrEntry"
and self.stage ~= "codeEntry" and self.stage ~= "notice" and self.stage ~= "codeEntry" and self.stage ~= "notice"
and self.stage ~= "battleRunning" then and self.stage ~= "battleRunning" then
self:exitWith("The link was\nbroken.") self:exitWith(Strings("The link was\nbroken."))
return return
end end
end end
@@ -201,7 +202,7 @@ function LinkState:update(dt)
self.index = 1 self.index = 1
elseif self.index == 2 or self.index == 3 then elseif self.index == 2 or self.index == 3 then
if not Handshake.onlineAllowed(self.game) 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 return
end end
if self.index == 2 then if self.index == 2 then
@@ -227,7 +228,7 @@ function LinkState:update(dt)
if self.net:host() then if self.net:host() then
self.stage = "hosting" self.stage = "hosting"
else else
self:exitWith("Link error:\n" .. (self.net.error or "?")) self:exitWith(Strings("Link error:\n%s", self.net.error or "?"))
end end
else else
self.stage = "addrEntry" self.stage = "addrEntry"
@@ -246,7 +247,7 @@ function LinkState:update(dt)
if self.net:hostOnline() then if self.net:hostOnline() then
self.stage = "onlineHosting" self.stage = "onlineHosting"
else else
self:exitWith("Link error:\n" .. (self.net.error or "?")) self:exitWith(Strings("Link error:\n%s", self.net.error or "?"))
end end
else else
self.stage = "codeEntry" self.stage = "codeEntry"
@@ -284,7 +285,7 @@ function LinkState:update(dt)
if self.net:joinOnline(nil, code) then if self.net:joinOnline(nil, code) then
self.stage = "onlineJoining" self.stage = "onlineJoining"
else else
self:exitWith("Link error:\n" .. (self.net.error or "?")) self:exitWith(Strings("Link error:\n%s", self.net.error or "?"))
end end
end end
@@ -323,7 +324,7 @@ function LinkState:update(dt)
if self.net:join(table.concat(octets, ".")) then if self.net:join(table.concat(octets, ".")) then
self.stage = "joining" self.stage = "joining"
else else
self:exitWith("Link error:\n" .. (self.net.error or "?")) self:exitWith(Strings("Link error:\n%s", self.net.error or "?"))
end end
end end
@@ -433,7 +434,7 @@ function LinkState:update(dt)
end end
if not battle then if not battle then
self.net:send({ type = "bye" }) 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 return
end end
self.game.stack:push(battle) self.game.stack:push(battle)
@@ -491,8 +492,8 @@ function LinkState:updateTrade(input)
local t = self.trade local t = self.trade
if t.stage == "cancelled" then if t.stage == "cancelled" then
self:exitWith(t.error and ("The trade stopped:\n%s."):format(t.error) self:exitWith(t.error and Strings("The trade stopped:\n%s.", t.error)
or "The trade was\ncancelled.") or Strings("The trade was\ncancelled."))
return return
end end
if t.stage == "done" then if t.stage == "done" then
@@ -520,7 +521,7 @@ function LinkState:updateTrade(input)
enemyOtId = received.otId, enemyOtId = received.otId,
onDone = function() onDone = function()
game.stack:push(TextBox.new(game, 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() function()
if evoTo then if evoTo then
-- via="TRADE": a trade evolution cannot be B-cancelled -- 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 -- the trade) -- B is dead after that, matching the A branch's own
-- self.confirmed == nil guard -- self.confirmed == nil guard
self.net:send({ type = "bye" }) 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 elseif t.stage == "picking" and input:wasPressed("a") then
if t:canPick(self.index) then if t:canPick(self.index) then
self.net:send(t:pick(self.index)) self.net:send(t:pick(self.index))
@@ -574,30 +575,30 @@ end
function LinkState:draw() function LinkState:draw()
if self.stage == "menu" then if self.stage == "menu" then
drawTitle("BOIS CLUB LIVE") drawTitle("BOIS CLUB LIVE")
Font.draw("LINK CABLE (LAN)", 32, 44) Font.draw(Strings("LINK CABLE (LAN)"), 32, 44)
Font.draw("ONLINE MATCH", 32, 60) Font.draw(Strings("ONLINE MATCH"), 32, 60)
Font.draw("TOURNAMENT", 32, 76) Font.draw(Strings("TOURNAMENT"), 32, 76)
Font.drawCode(CURSOR, 24, 44 + (self.index - 1) * 16) Font.drawCode(CURSOR, 24, 44 + (self.index - 1) * 16)
elseif self.stage == "lanMenu" then elseif self.stage == "lanMenu" then
drawTitle("LINK CABLE (LAN)") drawTitle("LINK CABLE (LAN)")
Font.draw("HOST A GAME", 32, 48) Font.draw(Strings("HOST A GAME"), 32, 48)
Font.draw("JOIN A GAME", 32, 68) Font.draw(Strings("JOIN A GAME"), 32, 68)
Font.drawCode(CURSOR, 24, self.index == 1 and 48 or 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 elseif self.stage == "onlineMenu" then
drawTitle("ONLINE MATCH") drawTitle("ONLINE MATCH")
Font.draw("HOST ONLINE", 32, 48) Font.draw(Strings("HOST ONLINE"), 32, 48)
Font.draw("JOIN ONLINE", 32, 68) Font.draw(Strings("JOIN ONLINE"), 32, 68)
Font.drawCode(CURSOR, 24, self.index == 1 and 48 or 68) Font.drawCode(CURSOR, 24, self.index == 1 and 48 or 68)
elseif self.stage == "onlineHosting" then elseif self.stage == "onlineHosting" then
drawTitle("HOSTING ONLINE") drawTitle("HOSTING ONLINE")
Font.draw("Tell your friend", 16, 40) Font.draw(Strings("Tell your friend"), 16, 40)
Font.draw("the code:", 16, 52) Font.draw(Strings("the code:"), 16, 52)
Font.draw(self.net.code or "??????", 32, 68) 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 elseif self.stage == "codeEntry" then
drawTitle("ENTER CODE") drawTitle("ENTER CODE")
@@ -609,18 +610,18 @@ function LinkState:draw()
Font.drawCode(0xEE, x, 76) -- ▼ under the active slot Font.drawCode(0xEE, x, 76) -- ▼ under the active slot
end end
end end
Font.draw("A: connect B: back", 8, 128) Font.draw(Strings("A: connect B: back"), 8, 128)
elseif self.stage == "onlineJoining" then elseif self.stage == "onlineJoining" then
drawTitle("CONNECTING...") drawTitle("CONNECTING...")
Font.draw("Calling...", 8, 56) Font.draw(Strings("Calling..."), 8, 56)
Font.draw(self.net.target or "", 8, 72) Font.draw(self.net.target or "", 8, 72)
elseif self.stage == "hosting" then elseif self.stage == "hosting" then
drawTitle("HOSTING") 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(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 elseif self.stage == "addrEntry" then
drawTitle("ENTER HOST ADDRESS") drawTitle("ENTER HOST ADDRESS")
@@ -635,34 +636,34 @@ function LinkState:draw()
for octet = 1, 3 do for octet = 1, 3 do
Font.draw(".", 16 + octet * 32 - 8, 64) Font.draw(".", 16 + octet * 32 - 8, 64)
end end
Font.draw("Port: " .. Net.defaultPort(), 16, 96) Font.draw(Strings("Port: %s", Net.defaultPort()), 16, 96)
Font.draw("A: connect B: back", 8, 128) Font.draw(Strings("A: connect B: back"), 8, 128)
elseif self.stage == "joining" then elseif self.stage == "joining" then
drawTitle("JOINING...") drawTitle("JOINING...")
Font.draw("Calling...", 8, 56) Font.draw(Strings("Calling..."), 8, 56)
Font.draw(self.net.target or "", 8, 72) Font.draw(self.net.target or "", 8, 72)
elseif self.stage == "modeSelect" then elseif self.stage == "modeSelect" then
drawTitle("CONNECTED!") drawTitle("CONNECTED!")
Font.draw("TRADE", 32, 48) Font.draw(Strings("TRADE"), 32, 48)
Font.draw("BATTLE", 32, 68) Font.draw(Strings("BATTLE"), 32, 68)
Font.drawCode(CURSOR, 24, self.index == 1 and 48 or 68) Font.drawCode(CURSOR, 24, self.index == 1 and 48 or 68)
elseif self.stage == "battleOptions" then elseif self.stage == "battleOptions" then
drawTitle("BATTLE OPTIONS") 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(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 elseif self.stage == "waitMode" or self.stage == "waitHello" then
drawTitle("CONNECTED!") drawTitle("CONNECTED!")
if self.stage == "waitHello" then if self.stage == "waitHello" then
Font.draw("Checking the", 16, 56) Font.draw(Strings("Checking the"), 16, 56)
Font.draw("other game...", 16, 72) Font.draw(Strings("other game..."), 16, 72)
else else
Font.draw("Waiting for the", 16, 56) Font.draw(Strings("Waiting for the"), 16, 56)
Font.draw("host to choose...", 16, 72) Font.draw(Strings("host to choose..."), 16, 72)
end end
elseif self.stage == "notice" then elseif self.stage == "notice" then
@@ -671,12 +672,12 @@ function LinkState:draw()
if i > 8 then break end -- what fits above the prompt row if i > 8 then break end -- what fits above the prompt row
Font.draw(line, 8, 24 + (i - 1) * 12) Font.draw(line, 8, 24 + (i - 1) * 12)
end 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 elseif self.stage == "trade" then
drawTitle("TRADE") drawTitle("TRADE")
local t = self.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 for i, mon in ipairs(self.game.save.party) do
local def = self.game.data.pokemon[mon.species] local def = self.game.data.pokemon[mon.species]
local label = (mon.nickname or def.name):sub(1, 8) local label = (mon.nickname or def.name):sub(1, 8)
@@ -684,7 +685,7 @@ function LinkState:draw()
Font.draw(label, 16, 20 + i * 12) Font.draw(label, 16, 20 + i * 12)
if i == self.index then Font.drawCode(CURSOR, 8, 20 + i * 12) end if i == self.index then Font.drawCode(CURSOR, 8, 20 + i * 12) end
end end
Font.draw("THEIRS", 84, 20) Font.draw(Strings("THEIRS"), 84, 20)
for i, mon in ipairs(t.theirParty or {}) do for i, mon in ipairs(t.theirParty or {}) do
local def = self.game.data.pokemon[mon.species] local def = self.game.data.pokemon[mon.species]
Font.draw((mon.nickname or def.name):sub(1, 8), 92, 20 + i * 12) 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 == "waitParty" then hint = "Exchanging data..."
elseif t.stage == "picking" then elseif t.stage == "picking" then
hint = t:canPick(self.index) and "Pick one to trade" 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 == "waitPick" then hint = "Waiting for them..."
elseif t.stage == "confirming" then 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 end
Font.draw(hint or "", 8, 132) Font.draw(hint or "", 8, 132)
elseif self.stage == "battleWait" or self.stage == "battleRunning" then elseif self.stage == "battleWait" or self.stage == "battleRunning" then
drawTitle("LINK BATTLE") drawTitle("LINK BATTLE")
Font.draw("Exchanging data...", 16, 64) Font.draw(Strings("Exchanging data..."), 16, 64)
end end
love.graphics.setColor(1, 1, 1, 1) love.graphics.setColor(1, 1, 1, 1)
end end
+13 -9
View File
@@ -26,6 +26,7 @@
local Json = require("src.link.Json") local Json = require("src.link.Json")
local Logger = require("src.core.Logger") local Logger = require("src.core.Logger")
local Strings = require("src.core.Strings")
local hasEnet, enet = pcall(require, "enet") local hasEnet, enet = pcall(require, "enet")
if not hasEnet then enet = nil end if not hasEnet then enet = nil end
@@ -173,7 +174,7 @@ function Net:connectTCP(addr)
tcp:settimeout(5) tcp:settimeout(5)
local ok, err = tcp:connect(host, port) local ok, err = tcp:connect(host, port)
if not ok then 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 return false
end end
tcp:settimeout(0) tcp:settimeout(0)
@@ -236,11 +237,14 @@ local function handleGenericRelayControl(self, msg)
self.paired = true self.paired = true
return true return true
elseif msg.type == "join_error" then elseif msg.type == "join_error" then
self.error = ({ self.error = Strings(({
not_found = "That code wasn't\nfound.", not_found = Strings.source("That code wasn't\nfound."),
full = "That game already\nhas two players.", full = Strings.source("That game already\nhas two players."),
expired = "That code has\nexpired.", expired = Strings.source("That code has\nexpired."),
})[msg.reason] or ("Couldn't join:\n%s"):format(tostring(msg.reason)) })[msg.reason] or "")
if self.error == "" then
self.error = Strings("Couldn't join:\n%s", tostring(msg.reason))
end
self.closed = true self.closed = true
return true return true
elseif msg.type == "peer_gone" then elseif msg.type == "peer_gone" then
@@ -328,7 +332,7 @@ function Net:update()
-- an unreachable join target surfaces as a service error -- an unreachable join target surfaces as a service error
-- (ICMP unreachable on the connected UDP socket) -- (ICMP unreachable on the connected UDP socket)
if self.mode == "joining" and not self.paired then 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 else
self.error = tostring(event) self.error = tostring(event)
end end
@@ -358,14 +362,14 @@ function Net:update()
self.closed = true self.closed = true
if not self.paired then if not self.paired then
self.error = self.error or 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
end end
end end
if self.mode == "joining" and not self.paired if self.mode == "joining" and not self.paired
and self.joinDeadline and now() > self.joinDeadline then 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 self.closed = true
pcall(function() self.peer:disconnect_now() end) pcall(function() self.peer:disconnect_now() end)
end end
+35 -35
View File
@@ -18,6 +18,7 @@ local Protocol = require("src.link.Protocol")
local Runtime = require("src.mods.Runtime") local Runtime = require("src.mods.Runtime")
local Sound = require("src.core.Sound") local Sound = require("src.core.Sound")
local TextBox = require("src.render.TextBox") local TextBox = require("src.render.TextBox")
local Strings = require("src.core.Strings")
local Tournament = {} local Tournament = {}
Tournament.__index = Tournament Tournament.__index = Tournament
@@ -128,7 +129,7 @@ end
function Tournament:startHosting() function Tournament:startHosting()
self.net = Net.new() self.net = Net.new()
if not self.net:connectTCP(Net.defaultRelayAddress()) then 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 return
end end
local size, minL, maxL = partyStats(self.game.save.party) local size, minL, maxL = partyStats(self.game.save.party)
@@ -151,7 +152,7 @@ end
function Tournament:startJoining(code) function Tournament:startJoining(code)
self.net = Net.new() self.net = Net.new()
if not self.net:connectTCP(Net.defaultRelayAddress()) then 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 return
end end
local size, minL, maxL = partyStats(self.game.save.party) local size, minL, maxL = partyStats(self.game.save.party)
@@ -171,9 +172,9 @@ end
-- ------------------------------------------------------------------- -- -------------------------------------------------------------------
local JOIN_ERROR_TEXT = { local JOIN_ERROR_TEXT = {
not_found = "That code wasn't\nfound.", not_found = Strings.source("That code wasn't\nfound."),
already_started = "That tournament\nhas already begun.", already_started = Strings.source("That tournament\nhas already begun."),
expired = "That code has\nexpired.", expired = Strings.source("That code has\nexpired."),
} }
function Tournament:handleMessage(msg) function Tournament:handleMessage(msg)
@@ -198,17 +199,17 @@ function Tournament:handleMessage(msg)
end end
elseif msg.type == "tournament_host_error" then elseif msg.type == "tournament_host_error" then
if msg.reason == "party_ineligible" then if msg.reason == "party_ineligible" then
self:exitWith(("Can't host:\nneed %d Pokemon\nLv %s-%s."):format( self:exitWith(Strings("Can't host:\nneed %d Pokemon\nLv %s-%s.", msg.requiredPartySize, levelLabel(msg.minLevel), levelLabel(msg.maxLevel)))
msg.requiredPartySize, levelLabel(msg.minLevel), levelLabel(msg.maxLevel)))
else else
self:exitWith("Couldn't host\nthat tournament.") self:exitWith(Strings("Couldn't host\nthat tournament."))
end end
elseif msg.type == "tournament_join_error" then elseif msg.type == "tournament_join_error" then
if msg.reason == "party_ineligible" then if msg.reason == "party_ineligible" then
self:exitWith(("Your party needs\n%d Pokemon, Lv\n%s-%s."):format( self:exitWith(Strings("Your party needs\n%d Pokemon, Lv\n%s-%s.", msg.requiredPartySize, levelLabel(msg.minLevel), levelLabel(msg.maxLevel)))
msg.requiredPartySize, levelLabel(msg.minLevel), levelLabel(msg.maxLevel)))
else 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 end
elseif msg.type == "tournament_roster" then elseif msg.type == "tournament_roster" then
self.roster = msg.players self.roster = msg.players
@@ -281,7 +282,7 @@ function Tournament:beginMatchBattle()
-- shouldn't happen (both sides already passed the online-play mods -- shouldn't happen (both sides already passed the online-play mods
-- gate), but a mismatched engine/build is still possible -- bail out -- gate), but a mismatched engine/build is still possible -- bail out
-- of just this match rather than crash the tournament -- 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 return
end end
self.linkSeed = self.isHost and love.math.random(1, 2 ^ 30) or nil self.linkSeed = self.isHost and love.math.random(1, 2 ^ 30) or nil
@@ -351,11 +352,11 @@ function Tournament:update(dt)
self.net:update() self.net:update()
if self.net.error and self.stage ~= "menu" and self.stage ~= "hostSettings" if self.net.error and self.stage ~= "menu" and self.stage ~= "hostSettings"
and self.stage ~= "codeEntry" then 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 return
end end
if self.net.closed and self.stage ~= "done" then 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 return
end end
end end
@@ -381,7 +382,7 @@ function Tournament:update(dt)
and LinkBattle.newHost(self.game, self.net, self.pendingBattleOpts) and LinkBattle.newHost(self.game, self.net, self.pendingBattleOpts)
or LinkBattle.newGuest(self.game, self.net, self.pendingBattleOpts) or LinkBattle.newGuest(self.game, self.net, self.pendingBattleOpts)
if not battle then if not battle then
self:exitWith(why or "Link battle\ncan't start.") self:exitWith(why or Strings("Link battle\ncan't start."))
return return
end end
-- anything after `party` in this same batch belongs to the -- anything after `party` in this same batch belongs to the
@@ -418,7 +419,7 @@ function Tournament:update(dt)
forceLevel = levelForWire(self.settings.forceLevel), forceLevel = levelForWire(self.settings.forceLevel),
}) })
if not battle then if not battle then
self:exitWith(why or "Can't watch this\nmatch.") self:exitWith(why or Strings("Can't watch this\nmatch."))
return return
end end
for j = #msgs, i + 1, -1 do 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() function Tournament:draw()
if self.stage == "menu" then if self.stage == "menu" then
drawTitle("TOURNAMENT") drawTitle("TOURNAMENT")
Font.draw("HOST", 32, 48) Font.draw(Strings("HOST"), 32, 48)
Font.draw("JOIN", 32, 68) Font.draw(Strings("JOIN"), 32, 68)
Font.drawCode(CURSOR, 24, self.index == 1 and 48 or 68) Font.drawCode(CURSOR, 24, self.index == 1 and 48 or 68)
elseif self.stage == "hostSettings" then elseif self.stage == "hostSettings" then
@@ -561,7 +562,7 @@ function Tournament:draw()
Font.draw(values[i], 96, y) Font.draw(values[i], 96, y)
if i == self.settingsIndex then Font.drawCode(CURSOR, 8, y) end if i == self.settingsIndex then Font.drawCode(CURSOR, 8, y) end
end end
Font.draw("START: create", 8, 128) Font.draw(Strings("START: create"), 8, 128)
elseif self.stage == "codeEntry" then elseif self.stage == "codeEntry" then
drawTitle("ENTER CODE") drawTitle("ENTER CODE")
@@ -573,28 +574,27 @@ function Tournament:draw()
Font.drawCode(0xEE, x, 76) Font.drawCode(0xEE, x, 76)
end end
end end
Font.draw("A: join B: back", 8, 128) Font.draw(Strings("A: join B: back"), 8, 128)
elseif self.stage == "registering" then elseif self.stage == "registering" then
drawTitle("CONNECTING...") drawTitle("CONNECTING...")
Font.draw("B: cancel", 8, 128) Font.draw(Strings("B: cancel"), 8, 128)
elseif self.stage == "bracket" or self.stage == "matchHello" elseif self.stage == "bracket" or self.stage == "matchHello"
or self.stage == "matchWaitParty" or self.stage == "spectateWait" then 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 if self.bracket then
local y = 20 local y = 20
for _, round in ipairs(self.bracket.rounds) do 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 y = y + 10
for _, m in ipairs(round.matches) do for _, m in ipairs(round.matches) do
local line local line
if m.bye then 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 else
local mark = m.state == "live" and "*" or (m.winner and "" or "") local mark = m.state == "live" and "*" or (m.winner and "" or "")
line = ("%s%s vs %s%s"):format( line = Strings("%s%s vs %s%s", m.winner == m.a and ">" or " ", m.a or "?",
m.winner == m.a and ">" or " ", m.a or "?",
m.b or "?", m.winner == m.b and "<" or (mark == "*" and " *" or "")) m.b or "?", m.winner == m.b and "<" or (mark == "*" and " *" or ""))
end end
Font.draw(line, 12, y) Font.draw(line, 12, y)
@@ -604,11 +604,11 @@ function Tournament:draw()
end end
else else
if self.participating == false then if self.participating == false then
Font.draw("(organizing --", 16, 32) Font.draw(Strings("(organizing --"), 16, 32)
Font.draw("not playing)", 16, 42) Font.draw(Strings("not playing)"), 16, 42)
end end
Font.draw("Waiting for", 16, 48) Font.draw(Strings("Waiting for"), 16, 48)
Font.draw("players to join:", 16, 60) Font.draw(Strings("players to join:"), 16, 60)
local y = 60 local y = 60
for i, name in ipairs(self.roster) do for i, name in ipairs(self.roster) do
y = 60 + i * 10 y = 60 + i * 10
@@ -620,18 +620,18 @@ function Tournament:draw()
end end
end end
if self.isCreator and #self.roster >= 2 and not self.bracket then 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 else
Font.draw("B: cancel", 8, 132) Font.draw(Strings("B: cancel"), 8, 132)
end end
elseif self.stage == "done" then elseif self.stage == "done" then
drawTitle("TOURNAMENT OVER") drawTitle("TOURNAMENT OVER")
if self.champion then if self.champion then
Font.draw(("%s is the"):format(self.champion), 16, 56) Font.draw(Strings("%s is the", self.champion), 16, 56)
Font.draw("champion!", 16, 68) Font.draw(Strings("champion!"), 16, 68)
end end
Font.draw("A: continue", 8, 128) Font.draw(Strings("A: continue"), 8, 128)
end end
love.graphics.setColor(1, 1, 1, 1) love.graphics.setColor(1, 1, 1, 1)
end end
+34 -3
View File
@@ -3,11 +3,12 @@
-- manifests only. The full loader (src/mods/Loader.lua) still owns the real -- manifests only. The full loader (src/mods/Loader.lua) still owns the real
-- load at boot; this reads the same options.mods enable-state the loader -- load at boot; this reads the same options.mods enable-state the loader
-- writes, derives per-mod status with the pure ManagerState.resolveToggle, -- writes, derives per-mod status with the pure ManagerState.resolveToggle,
-- and installs a dropped/chosen .zip into the save-dir "mods/<id>/" tree. -- installs a dropped/chosen .zip into the save-dir "mods/<id>/" tree, and
-- uninstalls a mod by removing that tree + clearing options.mods[id].
-- --
-- Split in two: the pure derivation (deriveList, locateRoot) has no love and -- Split in two: the pure derivation (deriveList, locateRoot) has no love and
-- no filesystem, so the engine tier can table-drive it; the discovery and -- no filesystem, so the engine tier can table-drive it; the discovery,
-- install paths reach for love.filesystem and SaveData. -- install, and uninstall paths reach for love.filesystem and SaveData.
local Manifest = require("src.mods.Manifest") local Manifest = require("src.mods.Manifest")
local ManagerState = require("src.mods.ManagerState") local ManagerState = require("src.mods.ManagerState")
@@ -332,4 +333,34 @@ function LauncherMods.installZip(source)
return true, manifest.id return true, manifest.id
end end
-- uninstall(id) -> true | nil, errString
-- Removes mods/<id>/ from the save directory and clears options.mods[id] so the
-- loader and in-game manager no longer see it. Rejects unknown / missing ids.
-- Does not touch other mods' enable state.
function LauncherMods.uninstall(id)
if type(id) ~= "string" or id == "" then
return nil, "missing mod id"
end
if id:find("[/\\]") or id == "." or id == ".." then
return nil, "invalid mod id"
end
if not (love and love.filesystem) then
return nil, "mod uninstall needs LOVE"
end
local fs = love.filesystem
local dest = "mods/" .. id
if not fs.getInfo(dest) then
return nil, "mod '" .. id .. "' is not installed"
end
removeTree(dest)
-- Drop the enable flag so a reinstall of the same id starts from the
-- loader's default (enabled) rather than a stale false.
local options = SaveData.loadOptions()
if options.mods and options.mods[id] ~= nil then
options.mods[id] = nil
SaveData.saveOptions(options)
end
return true
end
return LauncherMods return LauncherMods
+1
View File
@@ -68,6 +68,7 @@ local devShim = { installed = false, permissions = {}, warned = {}, depth = 0 }
local SUPPORTED_REQUIRES = { local SUPPORTED_REQUIRES = {
["src.mods.Semver"] = true, ["src.mods.Semver"] = true,
["src.audio.ChipAsm"] = true, ["src.audio.ChipAsm"] = true,
["src.pokemon.Stats"] = true, -- Stats.isShiny / calc for indicator mods
} }
local function scanRequire(name) local function scanRequire(name)
+21 -20
View File
@@ -10,6 +10,7 @@ local Semver = require("src.mods.Semver")
local Version = require("src.core.Version") local Version = require("src.core.Version")
local Theme = require("src.ui.Theme") local Theme = require("src.ui.Theme")
local OptionRows = require("src.ui.OptionRows") local OptionRows = require("src.ui.OptionRows")
local Strings = require("src.core.Strings")
local ManagerState = {} local ManagerState = {}
ManagerState.__index = ManagerState ManagerState.__index = ManagerState
@@ -269,7 +270,7 @@ function ManagerState:modRows()
end end
end end
if #rows == 0 then if #rows == 0 then
rows[1] = { header = true, label = "NO MODS INSTALLED" } rows[1] = { header = true, label = Strings("NO MODS INSTALLED") }
end end
return rows return rows
end end
@@ -281,7 +282,7 @@ function ManagerState:profileRows()
rows[#rows + 1] = { profile = p, label = p.name, rows[#rows + 1] = { profile = p, label = p.name,
glyph = opts.activeProfile == p.name and GLYPH.errored or " " } glyph = opts.activeProfile == p.name and GLYPH.errored or " " }
end 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, rows[#rows + 1] = { adhoc = true,
label = opts.activeProfile and "[AD-HOC]" or "[AD-HOC] (LIVE)" } label = opts.activeProfile and "[AD-HOC]" or "[AD-HOC] (LIVE)" }
return rows return rows
@@ -316,30 +317,30 @@ function ManagerState:detailRows(m)
rows[#rows + 1] = { label = m.enabled and "DISABLE" or "ENABLE", rows[#rows + 1] = { label = m.enabled and "DISABLE" or "ENABLE",
action = function() self:beginToggle(m) end } action = function() self:beginToggle(m) end }
if self:schemaFor(m) then if self:schemaFor(m) then
rows[#rows + 1] = { label = "OPTIONS..", rows[#rows + 1] = { label = Strings("OPTIONS.."),
action = function() self:openOptions(m) end } action = function() self:openOptions(m) end }
end end
if m.permissions and #m.permissions > 0 then 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 } action = function() self:goTo("permissions") end }
end end
if m.error then if m.error then
rows[#rows + 1] = { label = "VIEW ERROR..", rows[#rows + 1] = { label = Strings("VIEW ERROR.."),
action = function() self:goTo("errors") end } action = function() self:goTo("errors") end }
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 return rows
end end
function ManagerState:applyRows() function ManagerState:applyRows()
local rows = {} 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) self:openConfirm({ "RESTART NOW?" }, function() self:restartGame() end)
end } end }
rows[#rows + 1] = { label = "DISCARD CHANGES", action = function() rows[#rows + 1] = { label = Strings("DISCARD CHANGES"), action = function()
self:discardChanges() self:discardChanges()
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 return rows
end end
@@ -352,7 +353,7 @@ function ManagerState:permissionRows(m)
label = info and info.text or name } label = info and info.text or name }
end end
if #rows == 0 then if #rows == 0 then
rows[1] = { inert = true, label = "DATA & API ONLY" } rows[1] = { inert = true, label = Strings("DATA & API ONLY") }
end end
return rows return rows
end end
@@ -564,7 +565,7 @@ function ManagerState:openCascade(r, m, want)
else else
local dep = self.byId[r.alsoDisable[1]] local dep = self.byId[r.alsoDisable[1]]
lines[#lines + 1] = (dep and dep.name or r.alsoDisable[1]) .. " NEEDS THIS." 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 end
self.overlay = { kind = "confirm", lines = lines, index = 1, self.overlay = { kind = "confirm", lines = lines, index = 1,
onYes = function() self:commitToggle(r.apply) end } onYes = function() self:commitToggle(r.apply) end }
@@ -700,7 +701,7 @@ end
function ManagerState:saveCurrentAs() function ManagerState:saveCurrentAs()
local NamingScreen = require("src.ui.NamingScreen") local NamingScreen = require("src.ui.NamingScreen")
self.game.stack:push(NamingScreen.new(self.game, { self.game.stack:push(NamingScreen.new(self.game, {
title = "PROFILE NAME?", title = Strings("PROFILE NAME?"),
maxLen = 10, maxLen = 10,
onDone = function(name) onDone = function(name)
local opts = self:optionsTable() local opts = self:optionsTable()
@@ -726,7 +727,7 @@ end
function ManagerState:renameProfile(p) function ManagerState:renameProfile(p)
local NamingScreen = require("src.ui.NamingScreen") local NamingScreen = require("src.ui.NamingScreen")
self.game.stack:push(NamingScreen.new(self.game, { self.game.stack:push(NamingScreen.new(self.game, {
title = "RENAME?", title = Strings("RENAME?"),
maxLen = 10, maxLen = 10,
default = p.name, default = p.name,
onDone = function(name) onDone = function(name)
@@ -886,7 +887,7 @@ function ManagerState:buildOptionRows(m, schema)
end } end }
end end
end end
rows[#rows + 1] = { id = "__reset", label = "RESET DEFAULTS", rows[#rows + 1] = { id = "__reset", label = Strings("RESET DEFAULTS"),
value = function() return "" end, value = function() return "" end,
activate = function() activate = function()
for _, row in ipairs(schema) do for _, row in ipairs(schema) do
@@ -1047,7 +1048,7 @@ function ManagerState:drawApply()
y = y + 1 y = y + 1
end end
if #staged == 0 then 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 y = y + 1
end end
local rows = self:rowsForScreen() local rows = self:rowsForScreen()
@@ -1076,12 +1077,12 @@ function ManagerState:drawOverlay()
end end
if overlay.kind == "confirm" then if overlay.kind == "confirm" then
local yesY = ty + #lines + 1 local yesY = ty + #lines + 1
Font.draw("YES", 5 * 8, yesY * 8) Font.draw(Strings("YES"), 5 * 8, yesY * 8)
Font.draw("NO", 5 * 8, (yesY + 1) * 8) Font.draw(Strings("NO"), 5 * 8, (yesY + 1) * 8)
Font.drawCode(Theme.cursor, 4 * 8, Font.drawCode(Theme.cursor, 4 * 8,
(overlay.index == 1 and yesY or yesY + 1) * 8) (overlay.index == 1 and yesY or yesY + 1) * 8)
else else
Font.draw("A:OK", 5 * 8, (ty + #lines + 1) * 8) Font.draw(Strings("A:OK"), 5 * 8, (ty + #lines + 1) * 8)
end end
end end
@@ -1090,7 +1091,7 @@ function ManagerState:draw()
OptionRows.draw(self.game, self.optionRows or {}, self.cursor, OptionRows.draw(self.game, self.optionRows or {}, self.cursor,
self.scroll or 0) self.scroll or 0)
love.graphics.setColor(0, 0, 0, 1) 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) love.graphics.setColor(1, 1, 1, 1)
if self.overlay then self:drawOverlay() end if self.overlay then self:drawOverlay() end
return return
@@ -1099,7 +1100,7 @@ function ManagerState:draw()
love.graphics.rectangle("fill", 0, 0, 160, 144) love.graphics.rectangle("fill", 0, 0, 160, 144)
love.graphics.setColor(1, 1, 1, 1) love.graphics.setColor(1, 1, 1, 1)
Font.drawBox(0, 0, 20, 18) 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 if self.screen == "list" then
self:drawList() self:drawList()
elseif self.screen == "detail" then elseif self.screen == "detail" then
+31 -2
View File
@@ -584,6 +584,17 @@ R.text = {
example = 'mod.content.text:override("_PalletTownText1", "HELLO!")', 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); -- the self-contained bytecode blob ChipAsm.song/sfx emit (13.1 shape 2);
-- shared by every namespace that plays a chip program -- shared by every namespace that plays a chip program
local chipProgram = f.rec{ 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 -- 1 = native pixels; the drawn size relative to the pic's own pixels
scale = f.numRange(0.25, 4.0), scale = f.numRange(0.25, 4.0),
}, },
example = 'mod.content.battle_sprite_scales:register("abra_back", ' .. example = [[
'{ path = "assets/generated/battle/back/abrab.png", scale = 1.5 })', 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 -- ------- progression
@@ -1146,6 +1169,12 @@ R.field = {
name = f.opt(f.str) })), name = f.opt(f.str) })),
nest = f.opt(f.any) }, nest = f.opt(f.any) },
flyOrder = f.list(f.str), 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 -- the new-game and boot config a total conversion replaces
boot = f.rec{ boot = f.rec{
startMap = f.opt(f.str), startX = f.opt(f.int(0)), startY = f.opt(f.int(0)), 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 Screens = require("src.ui.Screens")
local Stats = require("src.pokemon.Stats") local Stats = require("src.pokemon.Stats")
local TextBox = require("src.render.TextBox") local TextBox = require("src.render.TextBox")
local Strings = require("src.core.Strings")
local Evolution = {} local Evolution = {}
@@ -23,7 +24,7 @@ Evolution.METHODS = {
return trigger.kind == "levelup" and mon.level >= (evo.level or 0) return trigger.kind == "levelup" and mon.level >= (evo.level or 0)
end, end,
describe = function(evo) describe = function(evo)
return ("Level %d"):format(evo.level or 0) return Strings("Level %d", evo.level or 0)
end, end,
}, },
ITEM = { ITEM = {
@@ -139,7 +140,7 @@ function Evolution.learnEvolutionMoves(game, mon, onDone)
table.insert(mon.moves, { id = moveId, pp = mdef.pp }) table.insert(mon.moves, { id = moveId, pp = mdef.pp })
Runtime.emit("pokemon.move_learned", { mon = mon, moveId = moveId }) Runtime.emit("pokemon.move_learned", { mon = mon, moveId = moveId })
game.stack:push(TextBox.new(game, 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 else
-- LearnMoveFromLevelUp with a full moveset: the forget UI -- LearnMoveFromLevelUp with a full moveset: the forget UI
Screens.push(game, "MoveLearnMenu", mon, moveId, nextStep) 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")) Music.play(game.data, Music.special(game.data, "evolution"))
local oldName = mon.nickname or game.data.pokemon[mon.species].name local oldName = mon.nickname or game.data.pokemon[mon.species].name
Evolution.apply(game, mon, newSpecies, via) Evolution.apply(game, mon, newSpecies, via)
local msg = ("What?\n%s is\nevolving!\fCongratulations!\nYour %s\nevolved into\n%s!") local msg = Strings("What?\n%s is\nevolving!\fCongratulations!\nYour %s\nevolved into\n%s!",
:format(oldName, oldName, game.data.pokemon[newSpecies].name) oldName, oldName, game.data.pokemon[newSpecies].name)
game.stack:push(TextBox.new(game, msg, function() game.stack:push(TextBox.new(game, msg, function()
Music.restoreMap(game.data) Music.restoreMap(game.data)
-- re-run the evolved species' level-up learn check before onDone -- re-run the evolved species' level-up learn check before onDone
+97
View File
@@ -0,0 +1,97 @@
-- Runtime Pokémon art resolution. Content registries freeze after load,
-- so a mod that lets the player pick an alternate skin mid-session cannot
-- 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 = {}
local function samePath(path) return path end
-- Resolve a battle / menu front or back pic path for `species`.
-- side: "front" | "back"
-- opts.mon: the live mon when available (per-instance skins)
-- opts.kind: "battle" | "summary" | "dex" | "evolution" | "hof" | "trade"
-- | "title" | "oak" | "credits" (informational for wrappers)
-- Returns path, trueColor.
function Sprites.path(data, species, side, opts)
opts = opts or {}
local def = data and data.pokemon and data.pokemon[species]
if not def then return nil, false end
local path = side == "back" and def.spriteBack or def.spriteFront
local ctx = {
species = species,
side = side == "back" and "back" or "front",
kind = opts.kind or "battle",
mon = opts.mon,
trueColor = def.trueColor and true or false,
data = data,
}
if path and Runtime.wantsHook("pokemon.sprite") then
local hooked = Runtime.call("pokemon.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 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.
-- Returns path (possibly nil).
function Sprites.iconPath(data, mon, vanillaPath, opts)
opts = opts or {}
if not vanillaPath and not Runtime.wantsHook("pokemon.icon") then
return vanillaPath
end
local species = mon and mon.species
local ctx = {
species = species,
mon = mon,
name = opts.name,
data = data,
kind = "icon",
}
if not Runtime.wantsHook("pokemon.icon") then return vanillaPath end
local hooked = Runtime.call("pokemon.icon", samePath, vanillaPath, ctx)
if type(hooked) == "string" and hooked ~= "" then return hooked end
if hooked == nil or hooked == false then return nil end
return vanillaPath
end
return Sprites
+16
View File
@@ -57,4 +57,20 @@ function Stats.applyStage(value, stage)
return math.max(1, math.min(999, v)) return math.max(1, math.min(999, v))
end end
-- Gen 2 shiny formula applied to Gen 1 DVs (the RBY "virtual shiny"):
-- Defense/Speed/Special DV == 10 and Attack DV is even-high
-- (2, 3, 6, 7, 10, 11, 14, or 15). Used by shiny-indicator mods.
local SHINY_ATK = {
[2] = true, [3] = true, [6] = true, [7] = true,
[10] = true, [11] = true, [14] = true, [15] = true,
}
function Stats.isShiny(dvs)
if type(dvs) ~= "table" then return false end
return (dvs.defense or 0) == 10
and (dvs.speed or 0) == 10
and (dvs.special or 0) == 10
and SHINY_ATK[dvs.attack or 0] == true
end
return Stats return Stats
+69 -20
View File
@@ -102,35 +102,84 @@ local function pageFor(code)
return nil return nil
end 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 -- Convert a text string into a list of glyph codes. Unknown characters
-- render as space (and are reported once). -- render as space (and are reported once).
local reported = {} local reported = {}
function Font.encode(text) function Font.encode(text)
local codes = {} local codes = {}
local i = 1 for _, span in ipairs(Font.split(text)) do
while i <= #text do local code = span.code
local candidates = state.byFirstByte[text:byte(i)] if not code then
local matched = false local ch = text:sub(span.from, span.to)
if candidates then if not reported[ch] and text:byte(span.from) >= 32 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
reported[ch] = true reported[ch] = true
require("src.core.Logger").warn("font: no glyph for %q", ch) require("src.core.Logger").warn("font: no glyph for %q", ch)
end end
codes[#codes + 1] = 0x7F -- space code = SPACE
i = i + 1
end end
codes[#codes + 1] = code
end end
return codes return codes
end end
+45
View File
@@ -0,0 +1,45 @@
-- Render targets measured in real framebuffer pixels.
--
-- love.graphics.newCanvas defaults its `dpiscale` to
-- love.graphics.getDPIScale(), so on a highdpi surface the canvas texture is
-- NOT the size it was asked for: newCanvas(160, 144) on a device reporting a
-- DPI scale of 2.755 allocates a 441x397 texture, and the 160x144 GB scene
-- then renders into it at 2.755 texels per GB pixel. conf.lua sets
-- t.window.highdpi on Android/iOS (required for Retina), and Android's
-- DisplayMetrics.density is routinely non-integer (1.5, 2.75, ...), so this
-- is the normal mobile case, not an edge case.
--
-- That fractional render breaks the whole premise of Renderer's integer
-- scale pipeline. Renderer:fitScale() picks a whole number of physical
-- pixels per GB pixel (7 on 1080p) and the composite blit lands on it
-- exactly -- but if the *source* already holds 2.755 texels per GB pixel,
-- some GB pixels are 2 texels wide and some are 3, and the nearest-neighbour
-- upscale turns them into 5 / 7 / 8 physical pixels instead of a uniform 7.
-- Measured on a 1080p density-2.755 fixture, only 43 of 160 columns came out
-- the right width. Fonts show it worst: their strokes are single pixels.
-- That is issue #208 ("some are noticeably stretched, especially fonts").
--
-- Forcing dpiscale = 1 makes canvas texels the pixels the renderer already
-- believes it is drawing. getWidth()/getHeight() are unaffected (they always
-- reported the requested size), so no geometry anywhere changes -- only the
-- resolution of the target. Desktop is unchanged; dpiscale is already 1
-- there because conf.lua sets highdpi on mobile only.
--
-- Exception: a canvas that is deliberately sized in LOVE *units* and blitted
-- back at unit scale 1 (Renderer's presentCanvas) must keep the screen's DPI
-- scale so its texture still covers the framebuffer; it is not built with
-- this helper.
local PixelCanvas = {}
-- One framebuffer pixel per w/h unit, always. `filter` is applied only when
-- given, so callers that relied on LOVE's default ("linear") keep it.
function PixelCanvas.new(w, h, filter)
local canvas = love.graphics.newCanvas(w, h, { dpiscale = 1 })
if filter and canvas and canvas.setFilter then
canvas:setFilter(filter, filter)
end
return canvas
end
return PixelCanvas
+28 -8
View File
@@ -10,6 +10,8 @@ local Zoom = require("src.render.Zoom")
local Tilt = require("src.render.Tilt") local Tilt = require("src.render.Tilt")
local PaletteFX = require("src.render.PaletteFX") local PaletteFX = require("src.render.PaletteFX")
local Pipelines = require("src.render.Pipelines") local Pipelines = require("src.render.Pipelines")
local PixelCanvas = require("src.render.PixelCanvas")
local Runtime = require("src.mods.Runtime")
local Renderer = {} local Renderer = {}
@@ -78,8 +80,10 @@ local function displayMetrics()
end end
function Renderer:init() function Renderer:init()
self.canvas = love.graphics.newCanvas(self.WIDTH, self.HEIGHT) -- 160x144 real pixels, never DPI-scaled: see src/render/PixelCanvas.lua
self.canvas:setFilter("nearest", "nearest") -- (#208). Every canvas below is sized in framebuffer pixels for the same
-- reason -- worldViewSize() already works in drawable pixels.
self.canvas = PixelCanvas.new(self.WIDTH, self.HEIGHT, "nearest")
self.worldCanvas = nil self.worldCanvas = nil
self.worldActive = false self.worldActive = false
-- tilt mode only: a transparent overlay canvas the size of the world -- tilt mode only: a transparent overlay canvas the size of the world
@@ -230,8 +234,7 @@ function Renderer:beginWorldPass()
-- the view size every frame, so without this the superseded canvases -- the view size every frame, so without this the superseded canvases
-- pile up in VRAM until a GC finalizer happens to run -- pile up in VRAM until a GC finalizer happens to run
if self.worldCanvas and self.worldCanvas.release then self.worldCanvas:release() end if self.worldCanvas and self.worldCanvas.release then self.worldCanvas:release() end
self.worldCanvas = love.graphics.newCanvas(vw, vh) self.worldCanvas = PixelCanvas.new(vw, vh, "nearest")
self.worldCanvas:setFilter("nearest", "nearest")
end end
self.worldActive = true self.worldActive = true
PaletteFX.setPass("world") PaletteFX.setPass("world")
@@ -259,8 +262,7 @@ function Renderer:beginUprightPass()
if not self.uprightCanvas or self.uprightCanvas:getWidth() ~= cw if not self.uprightCanvas or self.uprightCanvas:getWidth() ~= cw
or self.uprightCanvas:getHeight() ~= ch then or self.uprightCanvas:getHeight() ~= ch then
if self.uprightCanvas and self.uprightCanvas.release then self.uprightCanvas:release() end if self.uprightCanvas and self.uprightCanvas.release then self.uprightCanvas:release() end
self.uprightCanvas = love.graphics.newCanvas(cw, ch) self.uprightCanvas = PixelCanvas.new(cw, ch, "nearest")
self.uprightCanvas:setFilter("nearest", "nearest")
end end
self.uprightActive = true self.uprightActive = true
PaletteFX.setPass(nil) PaletteFX.setPass(nil)
@@ -354,8 +356,7 @@ function Renderer:drawTiltedWorld(zoneList, sx, sy, wox, woy, target)
if not self.tiltCanvas or self.tiltCanvas:getWidth() ~= wvw if not self.tiltCanvas or self.tiltCanvas:getWidth() ~= wvw
or self.tiltCanvas:getHeight() ~= wvh then or self.tiltCanvas:getHeight() ~= wvh then
if self.tiltCanvas and self.tiltCanvas.release then self.tiltCanvas:release() end if self.tiltCanvas and self.tiltCanvas.release then self.tiltCanvas:release() end
self.tiltCanvas = love.graphics.newCanvas(wvw, wvh) self.tiltCanvas = PixelCanvas.new(wvw, wvh, "linear")
self.tiltCanvas:setFilter("linear", "linear")
end end
love.graphics.setCanvas(self.tiltCanvas) love.graphics.setCanvas(self.tiltCanvas)
@@ -464,6 +465,14 @@ function Renderer:endFrame(zones, worldZones)
if needPresent then if needPresent then
if not self.presentCanvas or self.presentCanvas:getWidth() ~= ww if not self.presentCanvas or self.presentCanvas:getWidth() ~= ww
or self.presentCanvas:getHeight() ~= wh then or self.presentCanvas:getHeight() ~= wh then
-- The one canvas NOT built through PixelCanvas: it is sized in LOVE
-- units and blitted back at unit scale 1 (and handed to mod present
-- passes as ww x wh), so it has to keep the screen's DPI scale for its
-- texture to cover the framebuffer. Everything composited into it is
-- already native-resolution now, so #208's fractional source is gone;
-- what remains here is the dpiX vs dpiY truncation gap (well under 1%,
-- one seam across the window) that a single scalar dpiscale cannot
-- express.
self.presentCanvas = love.graphics.newCanvas(ww, wh) self.presentCanvas = love.graphics.newCanvas(ww, wh)
self.presentCanvas:setFilter("linear", "linear") self.presentCanvas:setFilter("linear", "linear")
end end
@@ -486,6 +495,17 @@ function Renderer:endFrame(zones, worldZones)
love.graphics.setColor(clearR, clearG, clearB, 1) love.graphics.setColor(clearR, clearG, clearB, 1)
love.graphics.rectangle("fill", 0, 0, ww, wh) love.graphics.rectangle("fill", 0, 0, ww, wh)
love.graphics.setColor(1, 1, 1, 1) love.graphics.setColor(1, 1, 1, 1)
-- render.letterbox: SGB borders / custom void art in the bars around the
-- 160x144 (or world) blit. Drawn after the clear and before the game
-- canvas so the playfield sits on top of the border.
if Runtime.wantsHook("render.letterbox") then
Runtime.call("render.letterbox", function() end, {
ww = ww, wh = wh, pw = pw, ph = ph,
ox = ox, oy = oy, vpw = vpw, vph = vph,
scale = Sp, dpiX = dpiX, dpiY = dpiY,
worldActive = self.worldActive and true or false,
})
end
-- blit `canvas` at (sx, sy) LOVE-unit scales into origin (bx, by), -- blit `canvas` at (sx, sy) LOVE-unit scales into origin (bx, by),
-- scissored to the (boxX, boxY, boxW, boxH) screen rect. zoneSx/zoneSy -- scissored to the (boxX, boxY, boxW, boxH) screen rect. zoneSx/zoneSy
+31 -4
View File
@@ -92,13 +92,28 @@ end
-- pokered ContText waits for A/B + ▼ before scrolling that line in. -- pokered ContText waits for A/B + ▼ before scrolling that line in.
function TextBox.paginate(text, maxCols) function TextBox.paginate(text, maxCols)
maxCols = maxCols or (Theme.textBox and Theme.textBox.maxCols) or MAX_COLS 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 pages = {}
local contBefore = {} 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) local function pushLine(lines, conts, line, wait)
while #line > maxCols do while true do
local cut = maxCols local spans = Font.split(line)
for i = maxCols, 1, -1 do local fit = Font.spansFitting(spans, budget)
if line:sub(i, i) == " " then cut = i break end 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 end
table.insert(lines, line:sub(1, cut)) table.insert(lines, line:sub(1, cut))
table.insert(conts, wait) table.insert(conts, wait)
@@ -167,6 +182,18 @@ function TextBox:update(dt)
if self.autoSrc and self.autoSrc.isPlaying and self.autoSrc:isPlaying() then if self.autoSrc and self.autoSrc.isPlaying and self.autoSrc:isPlaying() then
return -- the cry is still sounding (WaitForSoundToFinish) return -- the cry is still sounding (WaitForSoundToFinish)
end 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 self.autoTimer = self.autoTimer + 1
local delay = self.auto.delay or 3 local delay = self.auto.delay or 3
-- auto.onOverlap: fired once when the delay elapses but before the -- auto.onOverlap: fired once when the delay elapses but before the
+4 -1
View File
@@ -542,7 +542,10 @@ end
local function bakeBorderFill(self, block) local function bakeBorderFill(self, block)
local border = self.map.tileset.blocks[block + 1] local border = self.map.tileset.blocks[block + 1]
if not border then return end if not border then return end
local canvas = love.graphics.newCanvas(32, 32) -- 32x32 real pixels: a DPI-scaled canvas would bake the border block at a
-- fractional texel size and the repeat-wrapped image would then tile at
-- non-square pixels (#208, see src/render/PixelCanvas.lua)
local canvas = require("src.render.PixelCanvas").new(32, 32)
love.graphics.push("all") love.graphics.push("all")
love.graphics.setCanvas(canvas) love.graphics.setCanvas(canvas)
love.graphics.clear(1, 1, 1, 1) love.graphics.clear(1, 1, 1, 1)
+26 -7
View File
@@ -4,19 +4,38 @@
-- zoom. Persisted as save.options.zoom (default 0 = FIT). -- zoom. Persisted as save.options.zoom (default 0 = FIT).
-- Spec: docs/new-features.md (survey zoom) -- Spec: docs/new-features.md (survey zoom)
local Runtime = require("src.mods.Runtime")
local Zoom = {} local Zoom = {}
Zoom.offset = 0 Zoom.offset = 0
-- effective integer scale s' in [1, 2*S] -- legal offset range for a given fit scale (vanilla: survey at 1 px/world
function Zoom.scale(S) -- through 2× fit). zoom.range may widen or shrink the window.
return math.max(1, math.min(2 * S, S + Zoom.offset))
end
-- legal offset range for a given fit scale
function Zoom.offsetRange(S) function Zoom.offsetRange(S)
S = math.max(1, math.floor(tonumber(S) or 1)) S = math.max(1, math.floor(tonumber(S) or 1))
return 1 - S, S local lo, hi = 1 - S, S
if Runtime.wantsHook("zoom.range") then
lo, hi = Runtime.call("zoom.range", function(a, b) return a, b end, lo, hi, S)
lo = math.floor(tonumber(lo) or (1 - S))
hi = math.floor(tonumber(hi) or S)
if lo > hi then lo, hi = hi, lo end
end
return lo, hi
end
-- effective scale s' = S + offset, clamped to the (possibly modded) range.
-- Vanilla stays in [1, 2*S]. A zoom.range wrapper that lowers `lo` below
-- 1-S permits sub-1 survey scales so the whole region can fit on screen.
function Zoom.scale(S)
local lo, hi = Zoom.offsetRange(S)
local s = S + Zoom.offset
local minScale = S + lo
local maxScale = math.max(minScale, S + hi)
if s < minScale then s = minScale end
if s > maxScale then s = maxScale end
if s < 0.25 then s = 0.25 end
return s
end end
function Zoom.clampOffset(offset, S) function Zoom.clampOffset(offset, S)
+29 -14
View File
@@ -10,6 +10,7 @@ local Flags = require("src.script.Flags")
local Logger = require("src.core.Logger") local Logger = require("src.core.Logger")
local Screens = require("src.ui.Screens") local Screens = require("src.ui.Screens")
local TextBox = require("src.render.TextBox") local TextBox = require("src.render.TextBox")
local Strings = require("src.core.Strings")
local Commands = {} local Commands = {}
@@ -74,6 +75,9 @@ end
-- <PROMPT> of its own; the box only closes once WaitForSoundToFinish's poll -- <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 -- 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. -- 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) function Commands.show_text(ctx, textId, subs)
local text = ctx.game.data.text[textId] local text = ctx.game.data.text[textId]
if not text and ctx.overworld then if not text and ctx.overworld then
@@ -100,10 +104,12 @@ function Commands.show_text(ctx, textId, subs)
local opts local opts
if ctx.pendingCry then if ctx.pendingCry then
local species = ctx.pendingCry 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() opts = { auto = { sound = function()
return require("src.core.Sound").playCry(ctx.game.data, species) 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 end
-- text_opts armed the next box: auto = true is the plain no-button-wait -- text_opts armed the next box: auto = true is the plain no-button-wait
-- form, overlap folds under auto, everything else passes through -- 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) -- skips the received text entirely when AddItemToInventory refuses)
if not require("src.inventory.Bag").add(ctx.save, itemId, count or 1) then if not require("src.inventory.Bag").add(ctx.save, itemId, count or 1) then
Commands.show_text(ctx, ctx.game.data.text 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 return math.huge
end end
local def = ctx.game.data.items[itemId] 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") (def and def.keyItem) and "Get_Key_Item" or "Get_Item1")
if gotText ~= false then if gotText ~= false then
Commands.show_text(ctx, gotText Commands.show_text(ctx, gotText
or "{PLAYER} got\n" .. ctx.game.stringBuffer .. "!") or Strings("{PLAYER} got\n%s!", ctx.game.stringBuffer))
end end
end end
@@ -469,18 +475,26 @@ function Commands.play_once(ctx, songId)
runner:yield() runner:yield()
end end
-- play_cry <species>: PlayCry (home/audio.asm). The text_asm bodies that -- play_cry <species> [waitForButton]: PlayCry (home/audio.asm). The
-- use it run text_far (a no-button-wait "...@" string) -> PlayCry -> -- text_asm bodies that use it run text_far (a no-button-wait "...@" string)
-- WaitForSoundToFinish, all within the same text ID -- the cry only starts -- -> PlayCry -> WaitForSoundToFinish, all within the same text ID -- the cry
-- once the box has finished typing, and the box then auto-closes (no A -- only starts once the box has finished typing, and the box then auto-closes
-- press) the instant the cry finishes, rather than firing immediately -- (no A press) the instant the cry finishes, rather than firing immediately
-- alongside the typewriter effect. Script rows run strictly in order, so -- alongside the typewriter effect. Script rows run strictly in order, so
-- this stashes the species on ctx for the show_text row that always -- this stashes the species on ctx for the show_text row that always
-- immediately follows it (Power Plant Zapdos, Seafoam Articuno, Victory -- immediately follows it (Power Plant Zapdos, Seafoam Articuno, Victory
-- Road Moltres, Cerulean Cave Mewtwo battle text) to play once its box is -- 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. -- 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.pendingCry = species
ctx.pendingCryWait = waitForButton or nil
end end
-- check_battle_result <r1> [r2 ...]: lastCheck = the last scripted -- 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 if ctx.game.data.text and ctx.game.data.text._DoYouWantToNicknameText then
Commands.show_text(ctx, "_DoYouWantToNicknameText", { RAM = name }) Commands.show_text(ctx, "_DoYouWantToNicknameText", { RAM = name })
else 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 end
local ChoiceBox = require("src.ui.ChoiceBox") local ChoiceBox = require("src.ui.ChoiceBox")
ctx.game.stack:push(ChoiceBox.new(ctx.game, function(yes) ctx.game.stack:push(ChoiceBox.new(ctx.game, function(yes)
@@ -527,7 +541,7 @@ local function askNickname(ctx, mon)
return return
end end
Screens.push(ctx.game, "NamingScreen", { Screens.push(ctx.game, "NamingScreen", {
title = "NICKNAME?", maxLen = 10, title = Strings("NICKNAME?"), maxLen = 10,
onDone = function(nick) onDone = function(nick)
if nick and #nick > 0 then mon.nickname = nick end if nick and #nick > 0 then mon.nickname = nick end
ctx.lastCheck = success ctx.lastCheck = success
@@ -598,8 +612,9 @@ function Commands.give_pokemon(ctx, species, level)
local TextBox = require("src.render.TextBox") local TextBox = require("src.render.TextBox")
local raw = ctx.game.data.text and ctx.game.data.text._SentToBoxText local raw = ctx.game.data.text and ctx.game.data.text._SentToBoxText
ctx.game.stack:push(TextBox.new(ctx.game, raw or ( ctx.game.stack:push(TextBox.new(ctx.game, raw or (
"There's no more\nroom for POKéMON!\v" .. name Strings(
.. " was\vsent to POKéMON\vBOX " .. boxNum .. " on PC!"), "There's no more\nroom for POKéMON!\v%s was\vsent to POKéMON\vBOX %s on PC!",
name, boxNum)),
function() end)) function() end))
end end
end end
+2 -1
View File
@@ -15,6 +15,7 @@
local Data = require("src.core.Data") local Data = require("src.core.Data")
local Logger = require("src.core.Logger") local Logger = require("src.core.Logger")
local Runtime = require("src.mods.Runtime") local Runtime = require("src.mods.Runtime")
local Strings = require("src.core.Strings")
local MapScripts = {} local MapScripts = {}
@@ -268,7 +269,7 @@ function MapScripts.validateContribution(contribution, lookup)
end end
end end
if type(contribution) ~= "table" then if type(contribution) ~= "table" then
return { "contribution is not a table" } return { Strings("contribution is not a table") }
end end
for textConst, script in pairs(contribution.talk or {}) do for textConst, script in pairs(contribution.talk or {}) do
if type(script) == "table" then collect("talk." .. textConst, script) end 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 Commands = require("src.script.Commands")
local Logger = require("src.core.Logger") local Logger = require("src.core.Logger")
local Runtime = require("src.mods.Runtime") local Runtime = require("src.mods.Runtime")
local Strings = require("src.core.Strings")
local unpack = table.unpack or unpack -- LuaJIT (LÖVE) compatibility local unpack = table.unpack or unpack -- LuaJIT (LÖVE) compatibility
@@ -195,7 +196,7 @@ function ScriptRunner:resume(...)
if not ok then if not ok then
local source = self.ctx and self.ctx.source local source = self.ctx and self.ctx.source
local where = 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 "?")) tostring(source.mapId or "?"), tostring(source.hook or "?"))
or "" or ""
Logger.error("script error%s: %s", where, tostring(err)) Logger.error("script error%s: %s", where, tostring(err))
+32 -20
View File
@@ -9,6 +9,7 @@ local TextBox = require("src.render.TextBox")
local BagMenu = {} local BagMenu = {}
local Bag = require("src.inventory.Bag") local Bag = require("src.inventory.Bag")
local Strings = require("src.core.Strings")
-- acquisition order like wBagItems (Bag.order), not alphabetical -- acquisition order like wBagItems (Bag.order), not alphabetical
local function buildItems(game) local function buildItems(game)
@@ -108,13 +109,13 @@ local function useOn(game, battle, id, target, list, moveIndex)
if game.save.onBike then if game.save.onBike then
game.save.onBike = false game.save.onBike = false
Music.playMap(game.data, ow and ow.map.id, 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 elseif bikeAllowed() then
game.save.onBike = true game.save.onBike = true
Music.playMap(game.data, ow.map.id, 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 else
showMessages(game, { "No cycling\nallowed here." }) showMessages(game, { Strings("No cycling\nallowed here.") })
end end
return return
end end
@@ -130,13 +131,14 @@ local function useOn(game, battle, id, target, list, moveIndex)
return return
end end
end end
showMessages(game, { "No good! It's not\neven near water." }) showMessages(game, { Strings("No good! It's not\neven near water.") })
return return
end end
if result == "ball" then if result == "ball" then
if not battle 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 return
end end
consume(game, id) consume(game, id)
@@ -151,7 +153,7 @@ local function useOn(game, battle, id, target, list, moveIndex)
local function teach() local function teach()
if #target.moves < 4 then if #target.moves < 4 then
table.insert(target.moves, { id = moveId, pp = mdef.pp }) 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) }) game.data.pokemon[target.species].name, mdef.name) })
if result == "learn" then consume(game, id) end if result == "learn" then consume(game, id) end
else else
@@ -172,7 +174,7 @@ local function useOn(game, battle, id, target, list, moveIndex)
require("src.ui.Screens").push(game, "TownMap") require("src.ui.Screens").push(game, "TownMap")
end) end)
if not ok then if not ok then
showMessages(game, { "The TOWN MAP is\nunreadable here." }) showMessages(game, { Strings("The TOWN MAP is\nunreadable here.") })
end end
return return
end end
@@ -184,10 +186,10 @@ local function useOn(game, battle, id, target, list, moveIndex)
local t = game.data.text local t = game.data.text
if ow and ow:hasHiddenItemLeft() then if ow and ow:hasHiddenItemLeft() then
showMessages(game, { t._ItemfinderFoundItemText showMessages(game, { t._ItemfinderFoundItemText
or "Yes! ITEMFINDER\nindicates there's\nan item nearby." }) or Strings("Yes! ITEMFINDER\nindicates there's\nan item nearby.") })
else else
showMessages(game, { t._ItemfinderFoundNothingText showMessages(game, { t._ItemfinderFoundNothingText
or "Nope! ITEMFINDER\nisn't responding." }) or Strings("Nope! ITEMFINDER\nisn't responding.") })
end end
return return
end 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 -- departure helper -- the same path Dig/Teleport take from the party menu
ow:beginTeleportOut() ow:beginTeleportOut()
else else
showMessages(game, { "OAK: " .. game.save.player.name showMessages(game, { Strings(
.. "!\nThis isn't the\ntime to use that!" }) "OAK: %s!\nThis isn't the\ntime to use that!",
game.save.player.name) })
end end
return return
end end
@@ -264,7 +267,7 @@ local function useOn(game, battle, id, target, list, moveIndex)
if #target.moves < 4 then if #target.moves < 4 then
table.insert(target.moves, { id = moveId, pp = mdef.pp }) table.insert(target.moves, { id = moveId, pp = mdef.pp })
local name = target.nickname or def.name 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) nextStep)
else else
require("src.ui.Screens").push(game, "MoveLearnMenu", 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 moveDef = game.data.moves[def.machine.move]
local moveName = moveDef and moveDef.name or def.machine.move local moveName = moveDef and moveDef.name or def.machine.move
local booted = def.machine.kind == "HM" local booted = def.machine.kind == "HM"
and "Booted up an HM!" or "Booted up a TM!" and "Booted up an HM!" or Strings("Booted up a TM!")
showMessages(game, { booted, ("It contained\n%s!"):format(moveName) }, showMessages(game, { booted, Strings("It contained\n%s!", moveName) },
function() pickTargetAndUse(game, battle, id, list) end) function() pickTargetAndUse(game, battle, id, list) end)
return return
end end
@@ -370,7 +373,10 @@ function BagMenu.new(game, opts)
local battle = opts.battle local battle = opts.battle
local list local list
list = ListMenu.new(game, "ITEMS", buildItems(game), { list = ListMenu.new(game, "ITEMS", buildItems(game), {
kind = "bag",
footer = ("¥%d"):format(game.save.money), 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) -- SELECT reorders items like the original bag (swap_items.asm)
onSelectKey = function(item, l) onSelectKey = function(item, l)
if not item then return end if not item then return end
@@ -399,17 +405,23 @@ function BagMenu.new(game, opts)
useItem(game, battle, id, list) useItem(game, battle, id, list)
return return
end 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") local Menu = require("src.ui.Menu")
game.stack:push(Menu.new(game, { game.stack:push(Menu.new(game, {
{ label = "USE", onSelect = function() { label = Strings("USE"), onSelect = function()
useItem(game, battle, id, list) useItem(game, battle, id, list)
end }, end },
{ label = "TOSS", onSelect = function() { label = Strings("TOSS"), onSelect = function()
-- KeyItemFlags + HMs decide tossability (not price: -- KeyItemFlags + HMs decide tossability (not price:
-- MOON STONE is price 0 but tossable) -- MOON STONE is price 0 but tossable)
if not def or def.keyItem or id:find("^HM_") then 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 return
end end
local QuantityBox = require("src.ui.QuantityBox") local QuantityBox = require("src.ui.QuantityBox")
@@ -423,12 +435,12 @@ function BagMenu.new(game, opts)
Bag.remove(game.save, id, qty) Bag.remove(game.save, id, qty)
list.items = buildItems(game) list.items = buildItems(game)
list.index = math.min(list.index, math.max(1, #list.items)) 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, end,
})) }))
end }, end },
}, { tx = 12, ty = 10, tw = 8, th = 6 })) }, { tx = 13, ty = 10, tw = 7, th = 5 }))
end, end,
}) })
return list return list
+5 -2
View File
@@ -7,6 +7,7 @@
local Font = require("src.render.Font") local Font = require("src.render.Font")
local ListMenu = require("src.ui.ListMenu") local ListMenu = require("src.ui.ListMenu")
local Input = require("src.core.Input") local Input = require("src.core.Input")
local Strings = require("src.core.Strings")
local BindingsMenu = setmetatable({}, { __index = ListMenu }) local BindingsMenu = setmetatable({}, { __index = ListMenu })
BindingsMenu.__index = BindingsMenu BindingsMenu.__index = BindingsMenu
@@ -55,7 +56,9 @@ function BindingsMenu.new(game)
and game.save.options.bindings and game.save.options.bindings
local items = {} local items = {}
for i, def in ipairs(BUTTONS) do 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 } right = boundRight(overlay, def), button = def }
end end
local self = setmetatable(ListMenu.new(game, "CONTROLS", items, {}), local self = setmetatable(ListMenu.new(game, "CONTROLS", items, {}),
@@ -111,7 +114,7 @@ function BindingsMenu:draw()
if self.capture then if self.capture then
Font.drawBox(1, 6, 18, 4) Font.drawBox(1, 6, 18, 4)
love.graphics.setColor(0, 0, 0, 1) 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) love.graphics.setColor(1, 1, 1, 1)
end end
end end
+26 -25
View File
@@ -8,12 +8,13 @@ local ListMenu = require("src.ui.ListMenu")
local Menu = require("src.ui.Menu") local Menu = require("src.ui.Menu")
local Party = require("src.pokemon.Party") local Party = require("src.pokemon.Party")
local TextBox = require("src.render.TextBox") local TextBox = require("src.render.TextBox")
local Strings = require("src.core.Strings")
local BoxMenu = {} local BoxMenu = {}
local function monLabel(game, mon) local function monLabel(game, mon)
local def = game.data.pokemon[mon.species] 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 end
local function monName(game, mon) local function monName(game, mon)
@@ -28,13 +29,13 @@ local function monSubmenu(game, action, mon, onAction)
game.stack:push(Menu.new(game, { game.stack:push(Menu.new(game, {
{ label = action, onSelect = onAction }, { label = action, onSelect = onAction },
{ {
label = "STATS", label = Strings("STATS"),
keepOpen = true, keepOpen = true,
onSelect = function() onSelect = function()
require("src.ui.Screens").push(game, "SummaryMenu", mon) require("src.ui.Screens").push(game, "SummaryMenu", mon)
end, end,
}, },
{ label = "CANCEL" }, { label = Strings("CANCEL") },
}, { tx = 9, ty = 10, tw = 11, th = 8, noSound = true })) }, { tx = 9, ty = 10, tw = 11, th = 8, noSound = true }))
end end
@@ -50,12 +51,12 @@ local function withdraw(game)
local t = game.data.text local t = game.data.text
if #box == 0 then if #box == 0 then
game.stack:push(TextBox.new(game, t._NoMonText 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 return
end end
if #game.save.party >= Party.MAX then if #game.save.party >= Party.MAX then
game.stack:push(TextBox.new(game, t._CantTakeMonText 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 return
end end
local items = {} local items = {}
@@ -63,7 +64,7 @@ local function withdraw(game)
table.insert(items, { label = monLabel(game, mon), value = i }) table.insert(items, { label = monLabel(game, mon), value = i })
end end
game.stack:push(ListMenu.new(game, 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) onChoose = function(item, list)
local mon = box[item.value] local mon = box[item.value]
if not mon then return end if not mon then return end
@@ -78,7 +79,7 @@ local function withdraw(game)
game.stringBuffer = name game.stringBuffer = name
require("src.core.Sound").playCry(game.data, mon.species) require("src.core.Sound").playCry(game.data, mon.species)
afterTransfer(game, list, t._MonIsTakenOutText afterTransfer(game, list, t._MonIsTakenOutText
or (name .. " is\ntaken out.\vGot " .. name .. ".")) or Strings("%s is\ntaken out.\vGot %s.", name, name))
end) end)
end, end,
})) }))
@@ -88,13 +89,13 @@ local function deposit(game)
local t = game.data.text local t = game.data.text
if #game.save.party <= 1 then if #game.save.party <= 1 then
game.stack:push(TextBox.new(game, t._CantDepositLastMonText 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 return
end end
local box = Boxes.active(game.save) local box = Boxes.active(game.save)
if #box >= Boxes.CAPACITY then if #box >= Boxes.CAPACITY then
game.stack:push(TextBox.new(game, t._BoxFullText 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 return
end end
local items = {} local items = {}
@@ -107,12 +108,12 @@ local function deposit(game)
if not mon then return end if not mon then return end
monSubmenu(game, "DEPOSIT", mon, function() monSubmenu(game, "DEPOSIT", mon, function()
if #game.save.party <= 1 then 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 return
end end
local active = Boxes.active(game.save) local active = Boxes.active(game.save)
if #active >= Boxes.CAPACITY then 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 return
end end
table.remove(game.save.party, item.value) table.remove(game.save.party, item.value)
@@ -122,7 +123,7 @@ local function deposit(game)
game.boxNumString = tostring(game.save.currentBox) game.boxNumString = tostring(game.save.currentBox)
require("src.core.Sound").playCry(game.data, mon.species) require("src.core.Sound").playCry(game.data, mon.species)
afterTransfer(game, list, t._MonWasStoredText 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)
end, end,
})) }))
@@ -135,7 +136,7 @@ local function release(game)
local t = game.data.text local t = game.data.text
if #box == 0 then if #box == 0 then
game.stack:push(TextBox.new(game, t._NoMonText 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 return
end end
local items = {} local items = {}
@@ -143,20 +144,20 @@ local function release(game)
table.insert(items, { label = monLabel(game, mon), value = i }) table.insert(items, { label = monLabel(game, mon), value = i })
end end
game.stack:push(ListMenu.new(game, 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) onChoose = function(_, list)
local mon = box[list.index] local mon = box[list.index]
if not mon then return end if not mon then return end
local name = monName(game, mon) local name = monName(game, mon)
local ChoiceBox = require("src.ui.ChoiceBox") local ChoiceBox = require("src.ui.ChoiceBox")
game.stack:push(TextBox.new(game, 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) game.stack:push(ChoiceBox.new(game, function(yes)
if not yes then return end if not yes then return end
table.remove(box, list.index) table.remove(box, list.index)
require("src.core.Sound").playCry(game.data, mon.species) require("src.core.Sound").playCry(game.data, mon.species)
game.stack:push(TextBox.new(game, 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() list:removeCurrent()
end, { defaultNo = true, noSound = true })) end, { defaultNo = true, noSound = true }))
end)) end))
@@ -170,7 +171,7 @@ local function changeBox(game)
for i = 1, Boxes.COUNT do for i = 1, Boxes.COUNT do
local mark = i == game.save.currentBox and "*" or " " local mark = i == game.save.currentBox and "*" or " "
table.insert(items, { table.insert(items, {
label = ("%sBOX %2d"):format(mark, i), label = Strings("%sBOX %2d", mark, i),
right = ("%d/%d"):format(#boxes[i], Boxes.CAPACITY), right = ("%d/%d"):format(#boxes[i], Boxes.CAPACITY),
value = i, value = i,
}) })
@@ -181,7 +182,7 @@ local function changeBox(game)
-- BOX, data will be saved. OK?"); declining aborts the change -- BOX, data will be saved. OK?"); declining aborts the change
local ChoiceBox = require("src.ui.ChoiceBox") local ChoiceBox = require("src.ui.ChoiceBox")
game.stack:push(TextBox.new(game, 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) game.stack:push(ChoiceBox.new(game, function(yes)
if not yes then return end if not yes then return end
game.save.currentBox = item.value game.save.currentBox = item.value
@@ -197,10 +198,10 @@ end
local function drawChrome(game) local function drawChrome(game)
Font.drawBox(0, 12, 20, 6) Font.drawBox(0, 12, 20, 6)
love.graphics.setColor(0, 0, 0, 1) love.graphics.setColor(0, 0, 0, 1)
Font.draw("What?", 8, 112) Font.draw(Strings("What?"), 8, 112)
Font.drawBox(9, 14, 11, 4) Font.drawBox(9, 14, 11, 4)
love.graphics.setColor(0, 0, 0, 1) 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 local n = game.save.currentBox or 1
if n >= 10 then if n >= 10 then
Font.draw(tostring(n), 136, 128) Font.draw(tostring(n), 136, 128)
@@ -217,15 +218,15 @@ function BoxMenu.new(game)
-- full interior (cursor col + label). keepOpen so WITHDRAW/DEPOSIT/ -- full interior (cursor col + label). keepOpen so WITHDRAW/DEPOSIT/
-- RELEASE/CHANGE BOX leave this menu underneath (jp BillsPCMenu). -- RELEASE/CHANGE BOX leave this menu underneath (jp BillsPCMenu).
local menu = Menu.new(game, { local menu = Menu.new(game, {
{ label = "WITHDRAW <PK><MN>", keepOpen = true, { label = Strings("WITHDRAW <PK><MN>"), keepOpen = true,
onSelect = function() withdraw(game) end }, onSelect = function() withdraw(game) end },
{ label = "DEPOSIT <PK><MN>", keepOpen = true, { label = Strings("DEPOSIT <PK><MN>"), keepOpen = true,
onSelect = function() deposit(game) end }, onSelect = function() deposit(game) end },
{ label = "RELEASE <PK><MN>", keepOpen = true, { label = Strings("RELEASE <PK><MN>"), keepOpen = true,
onSelect = function() release(game) end }, onSelect = function() release(game) end },
{ label = "CHANGE BOX", keepOpen = true, { label = Strings("CHANGE BOX"), keepOpen = true,
onSelect = function() changeBox(game) end }, 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, -- Bill's PC runs silent end to end (BIT_NO_MENU_BUTTON_SOUND,
-- engine/menus/pokemon_pc.asm) -- engine/menus/pokemon_pc.asm)
}, { tx = 0, ty = 0, tw = 14, th = 12, noSound = true }) }, { 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 Font = require("src.render.Font")
local Theme = require("src.ui.Theme") local Theme = require("src.ui.Theme")
local Strings = require("src.core.Strings")
local ChoiceBox = {} local ChoiceBox = {}
ChoiceBox.__index = ChoiceBox ChoiceBox.__index = ChoiceBox
@@ -46,8 +47,8 @@ function ChoiceBox:draw()
local tx, ty, tw, th = self.tx, self.ty, self.tw, self.th local tx, ty, tw, th = self.tx, self.ty, self.tw, self.th
Font.drawBox(tx, ty, tw, th) Font.drawBox(tx, ty, tw, th)
love.graphics.setColor(0, 0, 0, 1) love.graphics.setColor(0, 0, 0, 1)
Font.draw("YES", (tx + 2) * 8, (ty + 1) * 8) Font.draw(Strings("YES"), (tx + 2) * 8, (ty + 1) * 8)
Font.draw("NO", (tx + 2) * 8, (ty + 3) * 8) Font.draw(Strings("NO"), (tx + 2) * 8, (ty + 3) * 8)
Font.drawCode(Theme.cursor, (tx + 1) * 8, Font.drawCode(Theme.cursor, (tx + 1) * 8,
(ty + (self.index == 1 and 1 or 3)) * 8) (ty + (self.index == 1 and 1 or 3)) * 8)
love.graphics.setColor(1, 1, 1, 1) love.graphics.setColor(1, 1, 1, 1)
+9 -7
View File
@@ -31,6 +31,7 @@
local Font = require("src.render.Font") local Font = require("src.render.Font")
local Music = require("src.core.Music") local Music = require("src.core.Music")
local Strings = require("src.core.Strings")
local Credits = {} local Credits = {}
Credits.__index = Credits Credits.__index = Credits
@@ -140,8 +141,9 @@ function Credits:enter()
end end
function Credits:monSprite(species) function Credits:monSprite(species)
local def = self.game.data.pokemon and self.game.data.pokemon[species] local path = require("src.pokemon.Sprites").path(
return silhouette(def and def.spriteFront) self.game.data, species, "front", { kind = "credits" })
return silhouette(path)
end end
-- advance to the next CreditsOrder screen (Credits .nextCreditsScreen); -- advance to the next CreditsOrder screen (Credits .nextCreditsScreen);
@@ -260,14 +262,14 @@ function Credits:drawCopyright(xoff)
love.graphics.draw(self.gfImg, xoff + 80, 88) love.graphics.draw(self.gfImg, xoff + 80, 88)
else else
love.graphics.setColor(0, 0, 0, 1) 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) love.graphics.setColor(1, 1, 1, 1)
end end
else else
love.graphics.setColor(0, 0, 0, 1) love.graphics.setColor(0, 0, 0, 1)
Font.draw("Nintendo", xoff + 80, 56) Font.draw(Strings("Nintendo"), xoff + 80, 56)
Font.draw("Creatures inc.", xoff + 80, 72) Font.draw(Strings("Creatures inc."), xoff + 80, 72)
Font.draw("GAME FREAK inc.", xoff + 16, 88) Font.draw(Strings("GAME FREAK inc."), xoff + 16, 88)
love.graphics.setColor(1, 1, 1, 1) love.graphics.setColor(1, 1, 1, 1)
end end
end end
@@ -297,7 +299,7 @@ function Credits:drawTheEnd()
end end
else else
love.graphics.setColor(0, 0, 0, 1) 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) love.graphics.setColor(1, 1, 1, 1)
end end
end end
+7 -4
View File
@@ -8,6 +8,7 @@
-- without permanently marking the mon owned. -- without permanently marking the mon owned.
local Font = require("src.render.Font") local Font = require("src.render.Font")
local Strings = require("src.core.Strings")
local DexEntryMenu = {} local DexEntryMenu = {}
DexEntryMenu.__index = DexEntryMenu DexEntryMenu.__index = DexEntryMenu
@@ -34,7 +35,9 @@ function DexEntryMenu.new(game, speciesOrOpts)
local species, forceOwned = resolveArgs(speciesOrOpts) local species, forceOwned = resolveArgs(speciesOrOpts)
local self = setmetatable({ game = game, forceOwned = forceOwned }, DexEntryMenu) local self = setmetatable({ game = game, forceOwned = forceOwned }, DexEntryMenu)
self.def = game.data.pokemon[species] self.def = game.data.pokemon[species]
local ok, img = pcall(love.graphics.newImage, self.def.spriteFront) local path = require("src.pokemon.Sprites").path(game.data, species, "front",
{ kind = "dex" })
local ok, img = path and pcall(love.graphics.newImage, path)
self.sprite = ok and img or nil self.sprite = ok and img or nil
require("src.core.Sound").playCry(game.data, species) require("src.core.Sound").playCry(game.data, species)
return self return self
@@ -74,8 +77,8 @@ function DexEntryMenu:draw()
-- feet/inches use the dex screen's /″ glyphs ("HT ???″" in -- feet/inches use the dex screen's /″ glyphs ("HT ???″" in
-- pokedex.asm; the tiles come from gfx/pokedex/pokedex.png via -- pokedex.asm; the tiles come from gfx/pokedex/pokedex.png via
-- engine/gfx/load_pokedex_tiles.asm) -- engine/gfx/load_pokedex_tiles.asm)
Font.draw(("HT %d%02d″"):format(e.heightFt, e.heightIn or 0), 72, 44) Font.draw(Strings("HT %d%02d″", e.heightFt, e.heightIn or 0), 72, 44)
Font.draw(("WT %.1flb"):format((e.weight or 0) / 10), 72, 54) Font.draw(Strings("WT %.1flb", (e.weight or 0) / 10), 72, 54)
end end
local text = owned and e.text and self.game.data.text[e.text] or nil local text = owned and e.text and self.game.data.text[e.text] or nil
local y = 72 local y = 72
@@ -86,7 +89,7 @@ function DexEntryMenu:draw()
y = y + 10 y = y + 10
end end
else else
Font.draw("Data unknown.", 8, y) Font.draw(Strings("Data unknown."), 8, y)
end end
love.graphics.setColor(1, 1, 1, 1) love.graphics.setColor(1, 1, 1, 1)
end end
+26 -19
View File
@@ -1,14 +1,17 @@
-- The evolution movie (engine/movie/evolution.asm): the mon's pic -- The evolution movie (engine/movie/evolution.asm): the mon's pic
-- flashes back and forth with the evolved form, speeding up, then the -- flashes back and forth with the evolved form, speeding up, then the
-- new form appears with its cry and the congratulations text. -- new form appears with its cry and the congratulations text.
-- pokered engine/pokemon/evos_moves.asm (EvolveMon) polls hJoyHeld during -- pokered engine/movie/evolution.asm (Evolution_CheckForCancel) polls the
-- the flash: holding B aborts the evolution -- the mon keeps its species -- joypad during the flash: holding B aborts the evolution -- the mon keeps
-- and _StoppedEvolvingText ("Huh? MON stopped evolving!") prints. The -- its species and _StoppedEvolvingText ("Huh? MON stopped evolving!")
-- lone exception is trade evolutions (wLinkState == LINK_STATE_TRADING), -- prints. Two kinds are exempt: trade evolutions, which evos_moves.asm
-- which skip that poll and cannot be cancelled (#213). -- 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 Font = require("src.render.Font")
local Music = require("src.core.Music") local Music = require("src.core.Music")
local Strings = require("src.core.Strings")
local EvolutionState = {} local EvolutionState = {}
EvolutionState.__index = EvolutionState EvolutionState.__index = EvolutionState
@@ -28,10 +31,11 @@ end
local FLASH_FRAMES = 220 local FLASH_FRAMES = 220
local function frontSprite(game, species) local function frontSprite(game, species, mon)
local def = game.data.pokemon[species] local path = require("src.pokemon.Sprites").path(game.data, species, "front",
if not (def and def.spriteFront) then return nil end { mon = mon, kind = "evolution" })
local ok, img = pcall(love.graphics.newImage, def.spriteFront) if not path then return nil end
local ok, img = pcall(love.graphics.newImage, path)
return ok and img or nil return ok and img or nil
end end
@@ -42,12 +46,15 @@ function EvolutionState.new(game, mon, newSpecies, onDone, via)
self.newSpecies = newSpecies self.newSpecies = newSpecies
self.onDone = onDone self.onDone = onDone
self.via = via self.via = via
-- evos_moves.asm: only trade evolutions (LINK_STATE_TRADING) skip the -- evolution.asm Evolution_CheckForCancel: a B press is discarded when
-- B-cancel poll; level-up, stone and rare-candy evos are all cancelable. -- wForceEvolution is set, and ItemUseEvoStone sets it before calling
self.cancelable = (via ~= "TRADE") -- 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.oldName = mon.nickname or game.data.pokemon[mon.species].name
self.oldSprite = frontSprite(game, mon.species) self.oldSprite = frontSprite(game, mon.species, mon)
self.newSprite = frontSprite(game, newSpecies) self.newSprite = frontSprite(game, newSpecies, mon)
self.t = 0 self.t = 0
self.done = false self.done = false
self.canceled = false self.canceled = false
@@ -68,7 +75,7 @@ function EvolutionState:update(dt)
local TextBox = require("src.render.TextBox") local TextBox = require("src.render.TextBox")
-- mirrors data/generated/text.lua _StoppedEvolvingText -- mirrors data/generated/text.lua _StoppedEvolvingText
game.stack:push(TextBox.new(game, game.stack:push(TextBox.new(game,
("Huh? %s\nstopped evolving!"):format(self.oldName), Strings("Huh? %s\nstopped evolving!", self.oldName),
function() function()
Music.restoreMap(game.data) Music.restoreMap(game.data)
game.stack:pop() -- the evolution screen itself game.stack:pop() -- the evolution screen itself
@@ -84,8 +91,8 @@ function EvolutionState:update(dt)
local TextBox = require("src.render.TextBox") local TextBox = require("src.render.TextBox")
local newName = game.data.pokemon[self.newSpecies].name local newName = game.data.pokemon[self.newSpecies].name
game.stack:push(TextBox.new(game, game.stack:push(TextBox.new(game,
("Congratulations!\nYour %s\nevolved into\n%s!") Strings("Congratulations!\nYour %s\nevolved into\n%s!",
:format(self.oldName, newName), self.oldName, newName),
function() function()
Music.restoreMap(game.data) Music.restoreMap(game.data)
game.stack:pop() -- the evolution screen itself game.stack:pop() -- the evolution screen itself
@@ -120,9 +127,9 @@ function EvolutionState:draw()
love.graphics.setColor(0, 0, 0, 1) love.graphics.setColor(0, 0, 0, 1)
if not self.done then 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(self.oldName .. " is", 8, 114)
Font.draw("evolving!", 8, 124) Font.draw(Strings("evolving!"), 8, 124)
end end
love.graphics.setColor(1, 1, 1, 1) love.graphics.setColor(1, 1, 1, 1)
end end
+19 -13
View File
@@ -7,10 +7,12 @@
-- name/time/money boxes plus the dex rating. Plays Music_HallOfFame -- name/time/money boxes plus the dex rating. Plays Music_HallOfFame
-- when the audio data has it. Calls onDone() after popping itself. -- when the audio data has it. Calls onDone() after popping itself.
local Assets = require("src.render.Assets")
local Font = require("src.render.Font") local Font = require("src.render.Font")
local Music = require("src.core.Music") local Music = require("src.core.Music")
local Sound = require("src.core.Sound") local Sound = require("src.core.Sound")
local TypeChart = require("src.battle.TypeChart") local TypeChart = require("src.battle.TypeChart")
local Strings = require("src.core.Strings")
local HallOfFame = {} local HallOfFame = {}
HallOfFame.__index = HallOfFame HallOfFame.__index = HallOfFame
@@ -47,9 +49,11 @@ local FADE_FRAMES = 20
-- HoFPrintTextAndDelay after each dex line -- HoFPrintTextAndDelay after each dex line
local DEX_HOLD = 120 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) local function tryImage(path)
if not path then return nil end 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 return ok and img or nil
end end
@@ -79,7 +83,8 @@ function HallOfFame.new(game, onDone)
self.timer = 0 self.timer = 0
self.phase = "mons" self.phase = "mons"
self.sprites = {} -- species -> image or false 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.scrollX = PIC_X
self.showHofBanner = false self.showHofBanner = false
self.fade = 0 self.fade = 0
@@ -118,8 +123,9 @@ end
function HallOfFame:spriteFor(species) function HallOfFame:spriteFor(species)
local cached = self.sprites[species] local cached = self.sprites[species]
if cached == nil then if cached == nil then
local def = self.game.data.pokemon[species] local path = require("src.pokemon.Sprites").path(
cached = tryImage(def and def.spriteFront) or false self.game.data, species, "front", { kind = "hof" })
cached = tryImage(path) or false
self.sprites[species] = cached self.sprites[species] = cached
end end
return cached or nil return cached or nil
@@ -205,13 +211,13 @@ function HallOfFame:drawMonInfo(mon)
love.graphics.setColor(0, 0, 0, 1) love.graphics.setColor(0, 0, 0, 1)
local name = mon.nickname or (def and def.name) or mon.species local name = mon.nickname or (def and def.name) or mon.species
Font.draw(name, 1 * 8, 4 * 8) Font.draw(name, 1 * 8, 4 * 8)
Font.draw("LEVEL/", 2 * 8, 6 * 8) Font.draw(Strings("LEVEL/"), 2 * 8, 6 * 8)
Font.draw("TYPE1/", 2 * 8, 7 * 8) Font.draw(Strings("TYPE1/"), 2 * 8, 7 * 8)
local t1 = def and def.types and def.types[1] local t1 = def and def.types and def.types[1]
local t2 = def and def.types and def.types[2] local t2 = def and def.types and def.types[2]
local dual = t2 and t2 ~= t1 local dual = t2 and t2 ~= t1
if dual then if dual then
Font.draw("TYPE2/", 2 * 8, 8 * 8) Font.draw(Strings("TYPE2/"), 2 * 8, 8 * 8)
end end
-- PrintLevelCommon at (8,7): bare level digits (no <LV> tile here) -- PrintLevelCommon at (8,7): bare level digits (no <LV> tile here)
Font.draw(tostring(mon.level), 8 * 8, 7 * 8) Font.draw(tostring(mon.level), 8 * 8, 7 * 8)
@@ -228,7 +234,7 @@ end
function HallOfFame:drawHofBanner() function HallOfFame:drawHofBanner()
Font.drawBox(2, 13, 16, 5) Font.drawBox(2, 13, 16, 5)
love.graphics.setColor(0, 0, 0, 1) 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 end
function HallOfFame:drawPic(img) function HallOfFame:drawPic(img)
@@ -248,11 +254,11 @@ function HallOfFame:drawPlayerStats()
-- play time / money box: TextBoxBorder (0,4) b=6,c=10 → drawBox(0,4,12,8) -- play time / money box: TextBoxBorder (0,4) b=6,c=10 → drawBox(0,4,12,8)
Font.drawBox(0, 4, 12, 8) Font.drawBox(0, 4, 12, 8)
love.graphics.setColor(0, 0, 0, 1) 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) local t = math.floor(save.playTime or 0)
Font.draw(("%3d:%02d"):format(math.floor(t / 3600), math.floor(t / 60) % 60), Font.draw(("%3d:%02d"):format(math.floor(t / 3600), math.floor(t / 60) % 60),
5 * 8, 7 * 8) 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 -- PrintBCDNumber with MONEY_SIGN; port uses ¥ like TrainerCard
Font.draw(("¥%d"):format(save.money or 0), 4 * 8, 10 * 8) Font.draw(("¥%d"):format(save.money or 0), 4 * 8, 10 * 8)
end end
@@ -265,16 +271,16 @@ function HallOfFame:drawDexBox(kind)
if kind == "seen" then if kind == "seen" then
local seen, owned = self:dexSeenOwned() local seen, owned = self:dexSeenOwned()
local seenOwned = text._DexSeenOwnedText 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 seenOwned = seenOwned
:gsub("{NUM:wDexRatingNumMonsSeen[^}]*}", tostring(seen)) :gsub("{NUM:wDexRatingNumMonsSeen[^}]*}", tostring(seen))
:gsub("{NUM:wDexRatingNumMonsOwned[^}]*}", tostring(owned)) :gsub("{NUM:wDexRatingNumMonsOwned[^}]*}", tostring(owned))
drawTextBlock(seenOwned, 1 * 8, 14 * 8, 17 * 8) drawTextBlock(seenOwned, 1 * 8, 14 * 8, 17 * 8)
else else
local _, owned = self:dexSeenOwned() 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) 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) drawTextBlock(rating, 1 * 8, 15 * 8, 17 * 8)
end end
end end
+4 -3
View File
@@ -27,6 +27,7 @@
local Font = require("src.render.Font") local Font = require("src.render.Font")
local Music = require("src.core.Music") local Music = require("src.core.Music")
local Sound = require("src.core.Sound") local Sound = require("src.core.Sound")
local Strings = require("src.core.Strings")
local IntroMovie = {} local IntroMovie = {}
IntroMovie.__index = IntroMovie IntroMovie.__index = IntroMovie
@@ -287,7 +288,7 @@ function IntroMovie:drawSplash()
end end
-- custom studio name (replaces the GAME FREAK splash text) -- custom studio name (replaces the GAME FREAK splash text)
love.graphics.setColor(0, 0, 0, dim and 0.35 or 1) 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) Font.draw(card, (160 - #card * 8) / 2, TEXT_Y)
love.graphics.setColor(1, 1, 1, 1) love.graphics.setColor(1, 1, 1, 1)
end end
@@ -348,7 +349,7 @@ function IntroMovie:drawFight()
if not gengar and not nido then if not gengar and not nido then
love.graphics.setColor(0, 0, 0, 1) 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) love.graphics.setColor(1, 1, 1, 1)
end end
drawBars() drawBars()
@@ -366,7 +367,7 @@ function IntroMovie:draw()
-- custom boot card (replaces the Nintendo / GAME FREAK copyright -- custom boot card (replaces the Nintendo / GAME FREAK copyright
-- card; no (c) glyph in the charmap, keep it ASCII-safe) -- card; no (c) glyph in the charmap, keep it ASCII-safe)
love.graphics.setColor(0, 0, 0, 1) 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("2026", (160 - 4 * 8) / 2, 48)
Font.draw(credit, (160 - #credit * 8) / 2, 64) Font.draw(credit, (160 - #credit * 8) / 2, 64)
Font.draw("bryanthaboi", (160 - 11 * 8) / 2, 80) Font.draw("bryanthaboi", (160 - 11 * 8) / 2, 80)
+102 -9
View File
@@ -3,7 +3,9 @@
-- box and the Pokédex. -- box and the Pokédex.
local Font = require("src.render.Font") local Font = require("src.render.Font")
local Runtime = require("src.mods.Runtime")
local Theme = require("src.ui.Theme") local Theme = require("src.ui.Theme")
local Strings = require("src.core.Strings")
local ListMenu = {} local ListMenu = {}
ListMenu.__index = ListMenu ListMenu.__index = ListMenu
@@ -15,9 +17,37 @@ function ListMenu:sgbPalettes(game)
end end
local ROWS = 7 local ROWS = 7
-- frames to wait before key-repeat kicks in, then between repeats
local REPEAT_DELAY = 16
local REPEAT_RATE = 4
-- ui.list_menu identity: unhooked opts pass through unchanged
local function sameOpts(opts) return opts end
function ListMenu.new(game, title, items, opts) function ListMenu.new(game, title, items, opts)
opts = opts or {} opts = opts or {}
-- bag / shop / dex / generic: mods may enable wrap, pageJump, keyRepeat
if Runtime.wantsHook("ui.list_menu") then
local hooked = Runtime.call("ui.list_menu", sameOpts, {
wrap = opts.wrap,
pageJump = opts.pageJump,
keyRepeat = opts.keyRepeat,
repeatDelay = opts.repeatDelay,
repeatRate = opts.repeatRate,
}, {
game = game,
title = title,
kind = opts.kind or title,
itemCount = items and #items or 0,
})
if type(hooked) == "table" then
if hooked.wrap ~= nil then opts.wrap = hooked.wrap end
if hooked.pageJump ~= nil then opts.pageJump = hooked.pageJump end
if hooked.keyRepeat ~= nil then opts.keyRepeat = hooked.keyRepeat end
if hooked.repeatDelay ~= nil then opts.repeatDelay = hooked.repeatDelay end
if hooked.repeatRate ~= nil then opts.repeatRate = hooked.repeatRate end
end
end
local self = setmetatable({}, ListMenu) local self = setmetatable({}, ListMenu)
self.game = game self.game = game
self.title = title self.title = title
@@ -28,6 +58,12 @@ function ListMenu.new(game, title, items, opts)
self.onCancel = opts.onCancel self.onCancel = opts.onCancel
self.footer = opts.footer self.footer = opts.footer
self.pageJump = opts.pageJump -- Left/Right move a page at a time self.pageJump = opts.pageJump -- Left/Right move a page at a time
self.wrap = opts.wrap -- Up on first / Down on last wraps
self.keyRepeat = opts.keyRepeat -- hold Up/Down (and pageJump L/R) to scroll
self.repeatDelay = opts.repeatDelay or REPEAT_DELAY
self.repeatRate = opts.repeatRate or REPEAT_RATE
self.holdDir = nil
self.holdFrames = 0
self.onSelectKey = opts.onSelectKey -- SELECT pressed on an item self.onSelectKey = opts.onSelectKey -- SELECT pressed on an item
-- scripted mode (the old man tutorial): update() runs the script -- scripted mode (the old man tutorial): update() runs the script
-- every frame INSTEAD of reading input -- DisplayListMenuID's old-man -- every frame INSTEAD of reading input -- DisplayListMenuID's old-man
@@ -46,6 +82,42 @@ function ListMenu.new(game, title, items, opts)
return self return self
end end
local function moveIndex(self, delta)
local n = #self.items
if n == 0 then return end
local next = self.index + delta
if self.wrap then
next = ((next - 1) % n) + 1
else
next = math.max(1, math.min(n, next))
end
self.index = next
end
local function syncScroll(self)
if self.index - self.scroll > self.rows then
self.scroll = self.index - self.rows
end
if self.index - self.scroll < 1 then self.scroll = self.index - 1 end
end
-- edge press or key-repeat tick for a held direction
local function navPressed(self, dir)
if dir == "up" then
moveIndex(self, -1)
elseif dir == "down" then
moveIndex(self, 1)
elseif dir == "left" and self.pageJump then
moveIndex(self, -self.rows)
elseif dir == "right" and self.pageJump then
moveIndex(self, self.rows)
else
return false
end
syncScroll(self)
return true
end
function ListMenu:update(dt) function ListMenu:update(dt)
if self.script then if self.script then
self.script(self) self.script(self)
@@ -59,14 +131,20 @@ function ListMenu:update(dt)
end end
return return
end end
local moved = false
if input:wasPressed("up") then if input:wasPressed("up") then
self.index = math.max(1, self.index - 1) moved = navPressed(self, "up")
self.holdDir, self.holdFrames = "up", 0
elseif input:wasPressed("down") then elseif input:wasPressed("down") then
self.index = math.min(#self.items, self.index + 1) moved = navPressed(self, "down")
self.holdDir, self.holdFrames = "down", 0
elseif self.pageJump and input:wasPressed("left") then elseif self.pageJump and input:wasPressed("left") then
self.index = math.max(1, self.index - self.rows) moved = navPressed(self, "left")
self.holdDir, self.holdFrames = "left", 0
elseif self.pageJump and input:wasPressed("right") then elseif self.pageJump and input:wasPressed("right") then
self.index = math.min(#self.items, self.index + self.rows) moved = navPressed(self, "right")
self.holdDir, self.holdFrames = "right", 0
elseif self.onSelectKey and input:wasPressed("select") then elseif self.onSelectKey and input:wasPressed("select") then
self.onSelectKey(self.items[self.index], self) self.onSelectKey(self.items[self.index], self)
elseif input:wasPressed("b") then elseif input:wasPressed("b") then
@@ -80,10 +158,22 @@ function ListMenu:update(dt)
end end
return return
end end
if self.index - self.scroll > self.rows then
self.scroll = self.index - self.rows -- hold-to-scroll (opt-in via ui.list_menu keyRepeat)
if self.keyRepeat then
local dir = self.holdDir
if dir and input:isDown(dir) then
self.holdFrames = self.holdFrames + 1
local afterDelay = self.holdFrames - self.repeatDelay
if afterDelay >= 0 and afterDelay % self.repeatRate == 0 then
navPressed(self, dir)
end
else
self.holdDir, self.holdFrames = nil, 0
end
end end
if self.index - self.scroll < 1 then self.scroll = self.index - 1 end
if not moved then syncScroll(self) end
end end
-- remove current item (e.g. consumed); keeps cursor valid -- remove current item (e.g. consumed); keeps cursor valid
@@ -103,7 +193,7 @@ function ListMenu:draw()
love.graphics.setColor(0, 0, 0, 1) love.graphics.setColor(0, 0, 0, 1)
Font.draw(self.title, 8, 4) Font.draw(self.title, 8, 4)
if #self.items == 0 then if #self.items == 0 then
Font.draw("Nothing here.", 16, 64) Font.draw(Strings("Nothing here."), 16, 64)
end end
for row = 1, self.rows do for row = 1, self.rows do
local i = self.scroll + row local i = self.scroll + row
@@ -112,7 +202,10 @@ function ListMenu:draw()
local y = 8 + row * 16 local y = 8 + row * 16
Font.draw(item.label, 16, y) Font.draw(item.label, 16, y)
if item.ball then -- the Pokédex owned-ball marker tile 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 local by = y + 3
love.graphics.circle("fill", bx, by, 3.5) love.graphics.circle("fill", bx, by, 3.5)
love.graphics.setColor(1, 1, 1, 1) love.graphics.setColor(1, 1, 1, 1)
+28
View File
@@ -57,4 +57,32 @@ function ModUI.removeLabel(items, label)
return items return items
end end
-- Oak speech step-list helpers (intro.oak_speech.build). Anchored on
-- stable step.id values the same way insertBefore anchors on labels.
local function stepIndex(steps, id)
for i, step in ipairs(steps) do
if step.id == id then return i end
end
return nil
end
function ModUI.insertStepBefore(steps, anchorId, step)
local i = stepIndex(steps, anchorId)
table.insert(steps, i or (#steps + 1), step)
return steps
end
function ModUI.insertStepAfter(steps, anchorId, step)
local i = stepIndex(steps, anchorId)
table.insert(steps, i and (i + 1) or (#steps + 1), step)
return steps
end
function ModUI.removeStep(steps, id)
for i = #steps, 1, -1 do
if steps[i].id == id then table.remove(steps, i) end
end
return steps
end
return ModUI return ModUI
+12 -11
View File
@@ -4,6 +4,7 @@
-- can't be forgotten; B / CANCEL gives up on the new move. -- can't be forgotten; B / CANCEL gives up on the new move.
local Font = require("src.render.Font") local Font = require("src.render.Font")
local Strings = require("src.core.Strings")
local MoveLearnMenu = {} local MoveLearnMenu = {}
MoveLearnMenu.__index = MoveLearnMenu MoveLearnMenu.__index = MoveLearnMenu
@@ -43,9 +44,9 @@ function MoveLearnMenu:enter()
local name = self:monName() local name = self:monName()
self.selecting = false self.selecting = false
game.stack:push(TextBox.new(game, game.stack:push(TextBox.new(game,
("%s is\ntrying to learn\v%s!\fBut, %s\ncan't learn more\vthan 4 moves!\f") Strings("%s is\ntrying to learn\v%s!\fBut, %s\ncan't learn more\vthan 4 moves!\f",
:format(name, mdef.name, name) .. name, mdef.name, name) ..
("Delete an older\nmove to make room\vfor %s?"):format(mdef.name), Strings("Delete an older\nmove to make room\vfor %s?", mdef.name),
nil, { nil, {
choice = function(yes) choice = function(yes)
if yes then if yes then
@@ -76,7 +77,7 @@ function MoveLearnMenu:update(dt)
-- HMCantDeleteText, then back to the forget list -- HMCantDeleteText, then back to the forget list
local TextBox = require("src.render.TextBox") local TextBox = require("src.render.TextBox")
self.game.stack:push(TextBox.new(self.game, self.game.stack:push(TextBox.new(self.game,
"HM techniques\ncan't be deleted!")) Strings("HM techniques\ncan't be deleted!")))
return return
end end
local mdef = self.game.data.moves[self.newMoveId] local mdef = self.game.data.moves[self.newMoveId]
@@ -96,7 +97,7 @@ function MoveLearnMenu:confirmAbandon()
local mdef = game.data.moves[self.newMoveId] local mdef = game.data.moves[self.newMoveId]
self.selecting = false self.selecting = false
game.stack:push(TextBox.new(game, 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) choice = function(yes)
if yes then self:finish(false) else self:enter() end if yes then self:finish(false) else self:enter() end
end, end,
@@ -113,11 +114,11 @@ function MoveLearnMenu:finish(learned)
local msg local msg
if learned then if learned then
-- OneTwoAndText/PoofText/ForgotAndText -- OneTwoAndText/PoofText/ForgotAndText
msg = ("1, 2 and... Poof!\f%s forgot\n%s!\fAnd...\f%s learned\n%s!") msg = Strings("1, 2 and... Poof!\f%s forgot\n%s!\fAnd...\f%s learned\n%s!",
:format(name, self.forgot, name, mdef.name) name, self.forgot, name, mdef.name)
else else
-- DidNotLearnText -- DidNotLearnText
msg = ("%s\ndid not learn\v%s!"):format(name, mdef.name) msg = Strings("%s\ndid not learn\v%s!", name, mdef.name)
end end
game.stack:push(TextBox.new(game, msg, function() game.stack:push(TextBox.new(game, msg, function()
if self.onDone then self.onDone(learned) end if self.onDone then self.onDone(learned) end
@@ -133,12 +134,12 @@ function MoveLearnMenu:draw()
for i, mv in ipairs(self.mon.moves) do for i, mv in ipairs(self.mon.moves) do
Font.draw(self.game.data.moves[mv.id].name, 48, (5 + i) * 8) Font.draw(self.game.data.moves[mv.id].name, 48, (5 + i) * 8)
end 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) Font.drawCode(CURSOR, 40, (5 + self.index) * 8)
-- WhichMoveToForgetText in the bottom dialogue box -- WhichMoveToForgetText in the bottom dialogue box
Font.drawBox(0, 12, 20, 6) Font.drawBox(0, 12, 20, 6)
Font.draw("Which move should", 8, 14 * 8) Font.draw(Strings("Which move should"), 8, 14 * 8)
Font.draw("be forgotten?", 8, 16 * 8) Font.draw(Strings("be forgotten?"), 8, 16 * 8)
love.graphics.setColor(1, 1, 1, 1) love.graphics.setColor(1, 1, 1, 1)
end end
+43 -11
View File
@@ -5,10 +5,15 @@
-- given, a "NEW NAME" + presets menu is shown first -- given, a "NEW NAME" + presets menu is shown first
-- (engine/menus/main_menu.asm name lists). -- (engine/menus/main_menu.asm name lists).
-- Pops itself from the stack, then calls opts.onDone(name). -- Pops itself from the stack, then calls opts.onDone(name).
--
-- ui.naming.grid may replace either page; keep an "ED" cell and a
-- single-cell case-switch row so confirm / case-flip keep working.
local Font = require("src.render.Font") local Font = require("src.render.Font")
local Runtime = require("src.mods.Runtime")
local Sound = require("src.core.Sound") local Sound = require("src.core.Sound")
local Theme = require("src.ui.Theme") local Theme = require("src.ui.Theme")
local Strings = require("src.core.Strings")
local NamingScreen = {} local NamingScreen = {}
NamingScreen.__index = NamingScreen NamingScreen.__index = NamingScreen
@@ -37,14 +42,30 @@ local GRID_LOWER = {
{ "-", "?", "!", "", "", "/", ".", ",", "ED" }, { "-", "?", "!", "", "", "/", ".", ",", "ED" },
{ "UPPER CASE" }, { "UPPER CASE" },
} }
local CASE_ROW = 6
local ED_ROW, ED_COL = 5, 9 -- locate the ED confirm cell and the case-switch row on a (possibly
-- modded) grid; falls back to vanilla coordinates
local function findMeta(grid)
local caseRow, edRow, edCol = #grid, 5, 9
for r, row in ipairs(grid) do
if #row == 1 and (row[1] == "lower case" or row[1] == "UPPER CASE"
or row[1] == "lower" or row[1] == "UPPER") then
caseRow = r
end
for c, cell in ipairs(row) do
if cell == "ED" then edRow, edCol = r, c end
end
end
return caseRow, edRow, edCol
end
local function sameGrid(grid) return grid end
function NamingScreen.new(game, opts) function NamingScreen.new(game, opts)
opts = opts or {} opts = opts or {}
local self = setmetatable({}, NamingScreen) local self = setmetatable({}, NamingScreen)
self.game = game self.game = game
self.title = opts.title or "YOUR NAME?" self.title = opts.title or Strings("YOUR NAME?")
self.presets = opts.presets self.presets = opts.presets
self.maxLen = opts.maxLen or 7 self.maxLen = opts.maxLen or 7
self.default = opts.default self.default = opts.default
@@ -58,7 +79,7 @@ end
function NamingScreen:enter() function NamingScreen:enter()
if self.presets and #self.presets > 0 then if self.presets and #self.presets > 0 then
local Menu = require("src.ui.Menu") local Menu = require("src.ui.Menu")
local items = { { label = "NEW NAME" } } local items = { { label = Strings("NEW NAME") } }
for _, preset in ipairs(self.presets) do for _, preset in ipairs(self.presets) do
table.insert(items, { table.insert(items, {
label = preset, label = preset,
@@ -86,16 +107,27 @@ function NamingScreen:confirm()
end end
function NamingScreen:grid() function NamingScreen:grid()
return self.lower and GRID_LOWER or GRID_UPPER local base = self.lower and GRID_LOWER or GRID_UPPER
if not Runtime.wantsHook("ui.naming.grid") then return base end
local hooked = Runtime.call("ui.naming.grid", sameGrid, base, {
lower = self.lower and true or false,
title = self.title,
maxLen = self.maxLen,
game = self.game,
})
if type(hooked) ~= "table" or #hooked == 0 then return base end
return hooked
end end
-- Gen 1 jumps the cursor to ED once the name is full. -- Gen 1 jumps the cursor to ED once the name is full.
function NamingScreen:jumpToEnd() function NamingScreen:jumpToEnd()
self.row, self.col = ED_ROW, ED_COL local _, edRow, edCol = findMeta(self:grid())
self.row, self.col = edRow, edCol
end end
function NamingScreen:update(dt) function NamingScreen:update(dt)
local GRID = self:grid() local GRID = self:grid()
local caseRow, edRow, edCol = findMeta(GRID)
local input = self.game.input local input = self.game.input
if input:wasPressed("start") then if input:wasPressed("start") then
self:confirm() self:confirm()
@@ -107,28 +139,28 @@ function NamingScreen:update(dt)
end end
if input:wasPressed("up") then if input:wasPressed("up") then
-- wrapping up from the top row lands on the case-switch cell -- wrapping up from the top row lands on the case-switch cell
self.row = self.row > 1 and self.row - 1 or CASE_ROW self.row = self.row > 1 and self.row - 1 or caseRow
self.col = math.min(self.col, #GRID[self.row]) self.col = math.min(self.col, #GRID[self.row])
elseif input:wasPressed("down") then elseif input:wasPressed("down") then
self.row = self.row < #GRID and self.row + 1 or 1 self.row = self.row < #GRID and self.row + 1 or 1
self.col = math.min(self.col, #GRID[self.row]) self.col = math.min(self.col, #GRID[self.row])
elseif input:wasPressed("left") then elseif input:wasPressed("left") then
-- no horizontal movement on the case-switch row -- no horizontal movement on the case-switch row
if self.row ~= CASE_ROW then if self.row ~= caseRow then
self.col = self.col > 1 and self.col - 1 or #GRID[self.row] self.col = self.col > 1 and self.col - 1 or #GRID[self.row]
end end
elseif input:wasPressed("right") then elseif input:wasPressed("right") then
if self.row ~= CASE_ROW then if self.row ~= caseRow then
self.col = self.col < #GRID[self.row] and self.col + 1 or 1 self.col = self.col < #GRID[self.row] and self.col + 1 or 1
end end
elseif input:wasPressed("b") then elseif input:wasPressed("b") then
table.remove(self.glyphs) table.remove(self.glyphs)
elseif input:wasPressed("a") then elseif input:wasPressed("a") then
if self.row == ED_ROW and self.col == ED_COL then if self.row == edRow and self.col == edCol then
self:confirm() self:confirm()
return return
end end
if self.row == CASE_ROW then if self.row == caseRow then
self.lower = not self.lower self.lower = not self.lower
return return
end end
+371 -75
View File
@@ -1,16 +1,20 @@
-- The intro sequence (engine/movie/oak_speech/oak_speech.asm): Oak's -- The intro sequence (engine/movie/oak_speech/oak_speech.asm): Oak's
-- welcome, the NIDORINO show-off, player and rival naming, and the -- welcome, the NIDORINO show-off, player and rival naming, and the
-- closing "legend is about to unfold" text followed by the shrink-away: -- closing "legend is about to unfold" text followed by the shrink-away.
-- the player pic collapses through ShrinkPic1/ShrinkPic2 into the --
-- overworld walking sprite before the fade to white. Uses the real -- Steps are a data table so mods can reshape the whole speech through
-- extracted texts (_OakSpeechText1/2A/2B/3, _IntroducePlayerText, -- hooks:wrap("intro.oak_speech.build"). Vanilla ids stay stable so a
-- _IntroduceRivalText) with literal fallbacks. -- mod can insertBefore("name_player", ...) without counting indices.
-- Calls onDone() after popping itself. -- Calls onDone() after popping itself.
local Assets = require("src.render.Assets")
local Sound = require("src.core.Sound") local Sound = require("src.core.Sound")
local Music = require("src.core.Music") 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 TextBox = require("src.render.TextBox")
local Font = require("src.render.Font") local Font = require("src.render.Font")
local Strings = require("src.core.Strings")
local OakSpeech = {} local OakSpeech = {}
OakSpeech.__index = OakSpeech OakSpeech.__index = OakSpeech
@@ -49,18 +53,164 @@ local function textOr(game, key)
return (t and t[key]) or FALLBACKS[key] return (t and t[key]) or FALLBACKS[key]
end 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 function tryImage(path)
if not path then return nil end 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 return ok and img or nil
end end
-- Resolve a pic descriptor to (image, flip).
-- Descriptors:
-- "oak" | "rival" | "player" shorthand
-- { type = "trainer", id = "OPP_PROF_OAK" }
-- { type = "pokemon", id = "PIKACHU", flip = true }
-- { type = "player", path = "..." } optional override path
-- { type = "image", path = "..." }
-- { type = "sprite", id = "SPRITE_RED" }
function OakSpeech.resolvePic(game, desc, speech)
if desc == nil then return nil, false end
if type(desc) == "string" then
if desc == "oak" then
desc = { type = "trainer", id = "OPP_PROF_OAK" }
elseif desc == "rival" then
desc = { type = "trainer", id = "OPP_RIVAL1" }
elseif desc == "player" then
desc = { type = "player" }
else
-- bare species id
desc = { type = "pokemon", id = desc }
end
end
local t = desc.type
if t == "trainer" then
if speech and desc.id == "OPP_PROF_OAK" and speech.oakPic then
return speech.oakPic, false
end
if speech and desc.id == "OPP_RIVAL1" and speech.rivalPic then
return speech.rivalPic, false
end
local trainers = game.data.trainers or {}
local tr = trainers[desc.id]
return tryImage(tr and tr.pic), false
elseif t == "pokemon" then
if speech and desc.id == speech.demoSpecies and speech.demoPic then
return speech.demoPic, desc.flip and true or false
end
local path = require("src.pokemon.Sprites").path(
game.data, desc.id, "front", { kind = "oak" })
return tryImage(path), desc.flip and true or false
elseif t == "player" then
if speech and speech.playerPic and not desc.path then
return speech.playerPic, false
end
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
local sp = game.data.sprites and game.data.sprites[desc.id]
return tryImage(sp and sp.image), desc.flip and true or false
end
return nil, false
end
-- Vanilla step list. Ids are the stable anchors mods insert around.
function OakSpeech.defaultSteps(speech)
return {
{
id = "oak_welcome",
kind = "say",
textKey = "_OakSpeechText1",
pic = "oak",
reveal = "fade",
},
{
id = "demo_mon",
kind = "demo",
},
{
id = "world_spiel",
kind = "say",
textKey = "_OakSpeechText2B",
},
{
id = "ask_player_name",
kind = "say",
textKey = "_IntroducePlayerText",
pic = "player",
},
{
id = "name_player",
kind = "name",
who = "player",
title = Strings("YOUR NAME?"),
presetsWho = "player",
presetsFallback = { "RED", "ASH", "JACK" },
},
{
id = "ask_rival_name",
kind = "say",
textKey = "_IntroduceRivalText",
pic = "rival",
},
{
id = "name_rival",
kind = "name",
who = "rival",
title = Strings("HIS NAME?"),
presetsWho = "rival",
presetsFallback = { "BLUE", "GARY", "JOHN" },
},
{
id = "legend",
kind = "say",
textKey = "_OakSpeechText3",
pic = "player",
},
{
id = "shrink",
kind = "shrink",
},
}
end
-- list helpers for intro.oak_speech.build wrappers (also on ModUI)
local function indexOfId(steps, id)
for i, step in ipairs(steps) do
if step.id == id then return i end
end
return nil
end
function OakSpeech.insertBefore(steps, anchorId, step)
local i = indexOfId(steps, anchorId)
table.insert(steps, i or (#steps + 1), step)
return steps
end
function OakSpeech.insertAfter(steps, anchorId, step)
local i = indexOfId(steps, anchorId)
table.insert(steps, i and (i + 1) or (#steps + 1), step)
return steps
end
function OakSpeech.removeId(steps, id)
for i = #steps, 1, -1 do
if steps[i].id == id then table.remove(steps, i) end
end
return steps
end
function OakSpeech.new(game, onDone) function OakSpeech.new(game, onDone)
local self = setmetatable({}, OakSpeech) local self = setmetatable({}, OakSpeech)
self.game = game self.game = game
self.onDone = onDone self.onDone = onDone
self.step = 0 self.step = 0
self.pic = nil self.pic = nil
self.answers = {}
local trainers = game.data.trainers or {} local trainers = game.data.trainers or {}
self.oakPic = tryImage(trainers.OPP_PROF_OAK and trainers.OPP_PROF_OAK.pic) self.oakPic = tryImage(trainers.OPP_PROF_OAK and trainers.OPP_PROF_OAK.pic)
self.rivalPic = tryImage(trainers.OPP_RIVAL1 and trainers.OPP_RIVAL1.pic) self.rivalPic = tryImage(trainers.OPP_RIVAL1 and trainers.OPP_RIVAL1.pic)
@@ -69,13 +219,15 @@ function OakSpeech.new(game, onDone)
-- the show-off mon and the name length cap come from data; the vanilla -- the show-off mon and the name length cap come from data; the vanilla
-- literals stay as the fallbacks -- literals stay as the fallbacks
self.demoSpecies = oakGfx.demoSpecies or "NIDORINO" self.demoSpecies = oakGfx.demoSpecies or "NIDORINO"
local demo = game.data.pokemon and game.data.pokemon[self.demoSpecies] local demoPath = require("src.pokemon.Sprites").path(
self.demoPic = tryImage(demo and demo.spriteFront) game.data, self.demoSpecies, "front", { kind = "oak" })
self.demoPic = tryImage(demoPath)
local constants = game.data.constants or {} local constants = game.data.constants or {}
self.nameLen = constants.playerNameLength or 7 self.nameLen = constants.playerNameLength or 7
-- RedPicFront (gfx/player/red.png, shared with the trainer card) and -- RedPicFront (gfx/player/red.png, shared with the trainer card) and
-- the ShrinkPic1/ShrinkPic2 frames (gfx/player/shrink{1,2}.png) -- 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 self.shrinkPic1 = tryImage(oakGfx.shrink1
or "assets/generated/intro/shrink1.png") or "assets/generated/intro/shrink1.png")
self.shrinkPic2 = tryImage(oakGfx.shrink2 self.shrinkPic2 = tryImage(oakGfx.shrink2
@@ -87,9 +239,26 @@ function OakSpeech.new(game, onDone)
return self return self
end end
function OakSpeech:buildSteps()
local steps = OakSpeech.defaultSteps(self)
local hooked = Runtime.call("intro.oak_speech.build",
function(s) return s end, steps, self)
if type(hooked) ~= "table" then
Logger.error("intro.oak_speech.build returned %s; keeping vanilla steps",
type(hooked))
return steps
end
return hooked
end
function OakSpeech:enter() function OakSpeech:enter()
-- MUSIC_ROUTES2 plays under the whole speech (oak_speech.asm:43-48) -- MUSIC_ROUTES2 plays under the whole speech (oak_speech.asm:43-48)
Music.play(self.game.data, self.cfg.music or "Music_Routes2") Music.play(self.game.data, self.cfg.music or "Music_Routes2")
self.answers = {}
self.steps = self:buildSteps()
if Runtime.wants("intro.oak_speech.started") then
Runtime.emit("intro.oak_speech.started", { speech = self, steps = self.steps })
end
self:advance() self:advance()
end end
@@ -97,80 +266,187 @@ function OakSpeech:say(key, next)
self.game.stack:push(TextBox.new(self.game, textOr(self.game, key), next)) self.game.stack:push(TextBox.new(self.game, textOr(self.game, key), next))
end end
local STEPS = { function OakSpeech:sayText(text, next, opts)
-- 1. Oak's welcome (Oak's pic fades in first, FadeInIntroPic) self.game.stack:push(TextBox.new(self.game, text, next, opts))
function(self) end
self.pic = self.oakPic
self:revealPic("fade", function() function OakSpeech:stepText(step)
self:say("_OakSpeechText1", function() self:advance() end) if step.text then return step.text end
if step.textKey then return textOr(self.game, step.textKey) end
return ""
end
function OakSpeech:applyPic(step)
if step.pic == nil then return end
local img, flip = OakSpeech.resolvePic(self.game, step.pic, self)
if img then
self.pic = img
self.picFlip = flip or false
elseif step.pic == "player" or (type(step.pic) == "table" and step.pic.type == "player") then
-- mirror the old fallback: player pic missing → oak
self.pic = self.playerPic or self.oakPic
self.picFlip = false
end
end
function OakSpeech:recordAnswer(step, index, label, value)
if value == nil then value = label end
if step.saveKey then
self.answers[step.saveKey] = value
end
if Runtime.wants("intro.oak_speech.answered") then
Runtime.emit("intro.oak_speech.answered", {
speech = self,
step = step,
index = index,
label = label,
value = value,
saveKey = step.saveKey,
})
end
end
function OakSpeech:afterReveal(step, fn)
if step.reveal then
self:revealPic(step.reveal, fn)
else
fn()
end
end
function OakSpeech:runCry(step)
local cry = step.cry
if not cry then return end
if cry == true then
if type(step.pic) == "string" and step.pic ~= "oak"
and step.pic ~= "rival" and step.pic ~= "player" then
cry = step.pic
elseif type(step.pic) == "table" and step.pic.type == "pokemon" then
cry = step.pic.id
else
return
end
end
Sound.playCry(self.game.data, cry)
end
function OakSpeech:runStep(step)
local kind = step.kind or "say"
if kind == "say" then
self:applyPic(step)
self:afterReveal(step, function()
self:runCry(step)
self:sayText(self:stepText(step), function() self:advance() end)
end) end)
end, elseif kind == "demo" then
-- 2. NIDORINO show-off: the front sprite is mirrored horizontally -- NIDORINO show-off: mirrored front sprite + wipe + cry + text 2A
-- (LoadFlippedFrontSpriteByMonIndex) and wipes in from the right
-- (MovePicLeft), then its cry sounds and the text prints
function(self)
self.pic = self.demoPic self.pic = self.demoPic
self.picFlip = true self.picFlip = true
self:revealPic("wipe", function() self:revealPic("wipe", function()
Sound.playCry(self.game.data, self.demoSpecies) Sound.playCry(self.game.data, self.demoSpecies)
self:say("_OakSpeechText2A", function() self:advance() end) self:say(Strings("_OakSpeechText2A"), function() self:advance() end)
end) end)
end, elseif kind == "name" then
-- 3. the rest of the world-of-POKéMON spiel local who = step.who or "player"
function(self) local presets = step.presets
self:say("_OakSpeechText2B", function() self:advance() end) or namePresets(self.game, step.presetsWho or who,
end, step.presetsFallback or { "RED" })
-- 4. "First, what is your name?" over the player's own pic
-- (RedPicFront, oak_speech.asm:86-91) then the naming screen
function(self)
self.pic = self.playerPic or self.oakPic
self:say("_IntroducePlayerText", function() self:advance() end)
end,
function(self)
require("src.ui.Screens").push(self.game, "NamingScreen", { require("src.ui.Screens").push(self.game, "NamingScreen", {
title = "YOUR NAME?", title = step.title or (who == "rival" and "HIS NAME?" or Strings("YOUR NAME?")),
presets = namePresets(self.game, "player", { "RED", "ASH", "JACK" }), presets = presets,
maxLen = self.nameLen, maxLen = step.maxLen or self.nameLen,
onDone = function(name) onDone = function(name)
self.game.save.player.name = name if who == "rival" then
self.game.save.player.rival = name
else
self.game.save.player.name = name
end
self:recordAnswer(step, 1, name, name)
self:advance() self:advance()
end, end,
}) })
end, elseif kind == "choice" then
-- 6. the rival introduction and naming self:applyPic(step)
function(self) self:afterReveal(step, function()
self.pic = self.rivalPic self:runCry(step)
self:say("_IntroduceRivalText", function() self:advance() end) local function openMenu()
end, local Menu = require("src.ui.Menu")
function(self) local items = {}
require("src.ui.Screens").push(self.game, "NamingScreen", { for i, label in ipairs(step.choices or {}) do
title = "HIS NAME?", items[i] = {
presets = namePresets(self.game, "rival", { "BLUE", "GARY", "JOHN" }), label = label,
maxLen = self.nameLen, onSelect = function()
onDone = function(name) local value = label
self.game.save.player.rival = name if step.values and step.values[i] ~= nil then
self:advance() value = step.values[i]
end, end
}) self:recordAnswer(step, i, label, value)
end, self:advance()
-- 8. "your very own POKéMON legend is about to unfold!" over the end,
-- player pic again (oak_speech.asm:105-113) }
function(self) end
self.pic = self.playerPic or self.oakPic self.game.stack:push(Menu.new(self.game, items, {
self:say("_OakSpeechText3", function() self:advance() end) cancelable = step.cancelable == true,
end, tx = step.tx or 4,
-- 9. SFX_SHRINK: the pic collapses through the two shrink frames ty = step.ty or 0,
-- into the walking sprite, then fades to white (oak_speech.asm tw = step.tw or 12,
-- .next, lines 115-166). Not skippable, like the DelayFrames th = step.th,
-- chain it ports. }))
function(self) end
local text = self:stepText(step)
if text ~= "" then
self:sayText(text, openMenu)
else
openMenu()
end
end)
elseif kind == "yesno" then
self:applyPic(step)
self:afterReveal(step, function()
self:runCry(step)
self:sayText(self:stepText(step), nil, {
defaultNo = step.defaultNo,
choice = function(yes)
local label = yes and "YES" or "NO"
local value = yes
if step.values then
if yes then
value = step.values[1]
else
value = step.values[2]
end
end
self:recordAnswer(step, yes and 1 or 2, label, value)
self:advance()
end,
})
end)
elseif kind == "pic" then
-- show a sprite with an optional reveal, no text
self:applyPic(step)
self:afterReveal(step, function()
self:runCry(step)
self:advance()
end)
elseif kind == "shrink" then
Sound.play(self.game.data, "Shrink") Sound.play(self.game.data, "Shrink")
-- the OakSpeechText3 box holds its last page on screen through the -- prefer an explicit shrink text key; fall back to the legend beat
-- shrink (pokered text boxes persist until overwritten) local key = step.textKey or "_OakSpeechText3"
self.shrinkText = self:lastPageLines("_OakSpeechText3") self.shrinkText = self:lastPageLines(key)
self.shrink = { frame = 0 } self.shrink = { frame = 0 }
end, elseif kind == "fn" then
} -- full escape hatch: step.run(speech, done)
if type(step.run) == "function" then
step.run(self, function() self:advance() end)
else
self:advance()
end
else
Logger.warn("oak speech unknown step kind %s (id=%s); skipping",
tostring(kind), tostring(step.id))
self:advance()
end
end
-- the last two visible lines of a text's final page, pre-encoded -- the last two visible lines of a text's final page, pre-encoded
function OakSpeech:lastPageLines(key) function OakSpeech:lastPageLines(key)
@@ -202,15 +478,31 @@ end
function OakSpeech:advance() function OakSpeech:advance()
self.step = self.step + 1 self.step = self.step + 1
self.picFlip = false self.picFlip = false
local fn = STEPS[self.step] local steps = self.steps
if fn then if not steps then
fn(self) -- enter() builds steps; keep a path for callers that advance early
steps = self:buildSteps()
self.steps = steps
end
local step = steps[self.step]
if step then
if Runtime.wants("intro.oak_speech.step") then
Runtime.emit("intro.oak_speech.step", {
speech = self, step = step, index = self.step,
})
end
self:runStep(step)
else else
self:finish() self:finish()
end end
end end
function OakSpeech:finish() function OakSpeech:finish()
if Runtime.wants("intro.oak_speech.finished") then
Runtime.emit("intro.oak_speech.finished", {
speech = self, answers = self.answers,
})
end
-- the map theme starts with the overworld beneath (the original's -- the map theme starts with the overworld beneath (the original's
-- special warp into Pallet Town) -- special warp into Pallet Town)
local ow = self.game.overworld local ow = self.game.overworld
@@ -257,6 +549,10 @@ function OakSpeech:update(dt)
elseif s.frame >= 79 and s.frame <= 102 then elseif s.frame >= 79 and s.frame <= 102 then
self.fadeLevel = math.floor((s.frame - 79) / 8) + 1 self.fadeLevel = math.floor((s.frame - 79) / 8) + 1
elseif s.frame > 102 then 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() self:finish()
end end
end end
@@ -267,8 +563,8 @@ function OakSpeech:draw()
if self.pic then if self.pic then
-- IntroDisplayPicCenteredOrUpperRight centered: the 7x7-tile pic -- IntroDisplayPicCenteredOrUpperRight centered: the 7x7-tile pic
-- area sits at hlcoord 6,4 = (48,32); smaller mon pics pad inside -- area sits at hlcoord 6,4 = (48,32); smaller mon pics pad inside
-- it like the sprite buffer does ((8 - w) >> 1 tiles across, -- it like the sprite buffer does ((8 - w) >> 1) tiles across,
-- bottom-aligned) -- bottom-aligned
local w, h = self.pic:getDimensions() local w, h = self.pic:getDimensions()
local x = 48 + math.floor((8 - w / 8) / 2) * 8 local x = 48 + math.floor((8 - w / 8) / 2) * 8
local y = 32 + (7 - h / 8) * 8 local y = 32 + (7 - h / 8) * 8
+25 -21
View File
@@ -22,6 +22,7 @@ local Logger = require("src.core.Logger")
local Runtime = require("src.mods.Runtime") local Runtime = require("src.mods.Runtime")
local OptionRows = require("src.ui.OptionRows") local OptionRows = require("src.ui.OptionRows")
local Renderer = require("src.render.Renderer") local Renderer = require("src.render.Renderer")
local Strings = require("src.core.Strings")
local OptionsMenu = {} local OptionsMenu = {}
OptionsMenu.__index = OptionsMenu OptionsMenu.__index = OptionsMenu
@@ -116,14 +117,14 @@ local function sameRows(_, rows) return rows end
-- ladder's, so the save.options mutations are unchanged -- ladder's, so the save.options mutations are unchanged
local function buildRows(game) local function buildRows(game)
local rows = { local rows = {
{ id = "textSpeed", label = "TEXT SPEED", { id = "textSpeed", label = Strings("TEXT SPEED"),
value = function(g) return SPEEDS[speedIndex(g)][2] end, value = function(g) return SPEEDS[speedIndex(g)][2] end,
step = function(g) step = function(g)
local i = speedIndex(g) % #SPEEDS + 1 local i = speedIndex(g) % #SPEEDS + 1
g.save.options.textSpeed = SPEEDS[i][1] g.save.options.textSpeed = SPEEDS[i][1]
return true return true
end }, end },
{ id = "animations", label = "BATTLE ANIMATION", { id = "animations", label = Strings("BATTLE ANIMATION"),
value = function(g) value = function(g)
return g.save.options.animations == false and "OFF" or "ON" return g.save.options.animations == false and "OFF" or "ON"
end, end,
@@ -132,7 +133,7 @@ local function buildRows(game)
o.animations = o.animations == false and true or false o.animations = o.animations == false and true or false
return true return true
end }, end },
{ id = "battleStyle", label = "BATTLE STYLE", { id = "battleStyle", label = Strings("BATTLE STYLE"),
value = function(g) value = function(g)
return g.save.options.battleStyle == "set" and "SET" or "SHIFT" return g.save.options.battleStyle == "set" and "SET" or "SHIFT"
end, end,
@@ -141,7 +142,7 @@ local function buildRows(game)
o.battleStyle = o.battleStyle == "set" and "shift" or "set" o.battleStyle = o.battleStyle == "set" and "shift" or "set"
return true return true
end }, end },
{ id = "ruleset", label = "RULESET", { id = "ruleset", label = Strings("RULESET"),
value = function(g) return rulesetName(g) end, value = function(g) return rulesetName(g) end,
step = function(g, dir) step = function(g, dir)
local ids = rulesetIds(g) local ids = rulesetIds(g)
@@ -150,7 +151,7 @@ local function buildRows(game)
g.save.options.ruleset = ids[wrapIndex(i - 1 + dir, #ids) + 1] g.save.options.ruleset = ids[wrapIndex(i - 1 + dir, #ids) + 1]
return true return true
end }, end },
{ id = "musicVol", label = "MUSIC VOL", { id = "musicVol", label = Strings("MUSIC VOL"),
value = function(g) return volLabel(g.save.options.musicVol) end, value = function(g) return volLabel(g.save.options.musicVol) end,
step = function(g, dir) step = function(g, dir)
local o = g.save.options local o = g.save.options
@@ -158,7 +159,7 @@ local function buildRows(game)
require("src.core.Music").setVolumeLevel(o.musicVol) require("src.core.Music").setVolumeLevel(o.musicVol)
return true return true
end }, end },
{ id = "sfxVol", label = "SFX VOL", { id = "sfxVol", label = Strings("SFX VOL"),
value = function(g) return volLabel(g.save.options.sfxVol) end, value = function(g) return volLabel(g.save.options.sfxVol) end,
step = function(g, dir) step = function(g, dir)
local o = g.save.options local o = g.save.options
@@ -166,7 +167,7 @@ local function buildRows(game)
require("src.core.Sound").setVolumeLevel(o.sfxVol) require("src.core.Sound").setVolumeLevel(o.sfxVol)
return true return true
end }, end },
{ id = "musicFilter", label = "MUSIC FILTER", { id = "musicFilter", label = Strings("MUSIC FILTER"),
value = function(g) value = function(g)
return FILTERS[(g.save.options.musicFilter or 0) + 1] return FILTERS[(g.save.options.musicFilter or 0) + 1]
end, end,
@@ -176,7 +177,7 @@ local function buildRows(game)
require("src.core.Music").setFilterLevel(o.musicFilter) require("src.core.Music").setFilterLevel(o.musicFilter)
return true return true
end }, end },
{ id = "colors", label = "COLORS", { id = "colors", label = Strings("COLORS"),
value = function(g) value = function(g)
return PaletteFX.modeLabel(g.save.options.colors or "gbc") return PaletteFX.modeLabel(g.save.options.colors or "gbc")
end, end,
@@ -188,7 +189,7 @@ local function buildRows(game)
PaletteFX.setMode(o.colors) PaletteFX.setMode(o.colors)
return true return true
end }, end },
{ id = "tilt", label = "TILT", { id = "tilt", label = Strings("TILT"),
value = function(g) return Tilt.levelLabel(g.save.options.tilt or 0) end, value = function(g) return Tilt.levelLabel(g.save.options.tilt or 0) end,
step = function(g, dir) step = function(g, dir)
local o = g.save.options local o = g.save.options
@@ -205,7 +206,7 @@ local function buildRows(game)
end end
return true return true
end }, end },
{ id = "gbcfx", label = "GBC FX", { id = "gbcfx", label = Strings("GBC FX"),
value = function(g) value = function(g)
return GBCFX.levelLabel(g.save.options.gbcfx or 0) return GBCFX.levelLabel(g.save.options.gbcfx or 0)
end, end,
@@ -215,7 +216,7 @@ local function buildRows(game)
GBCFX.setLevel(o.gbcfx) GBCFX.setLevel(o.gbcfx)
return true return true
end }, end },
{ id = "zoom", label = "ZOOM", { id = "zoom", label = Strings("ZOOM"),
value = function(g) value = function(g)
return Zoom.offsetLabel(g.save.options.zoom or 0) return Zoom.offsetLabel(g.save.options.zoom or 0)
end, end,
@@ -230,7 +231,7 @@ local function buildRows(game)
Zoom.offset = off Zoom.offset = off
return true return true
end }, end },
{ id = "voidFill", label = "VOID FILL", { id = "voidFill", label = Strings("VOID FILL"),
value = function(g) value = function(g)
return TileRenderer.voidFillLabel(g.save.options.voidFill) return TileRenderer.voidFillLabel(g.save.options.voidFill)
end, end,
@@ -246,7 +247,7 @@ local function buildRows(game)
TileRenderer.setVoidFill(o.voidFill) TileRenderer.setVoidFill(o.voidFill)
return true return true
end }, end },
{ id = "videoMode", label = "VIDEO MODE", { id = "videoMode", label = Strings("VIDEO MODE"),
value = function(g) value = function(g)
return VideoMode.modeLabel(g.save.options.videoMode) return VideoMode.modeLabel(g.save.options.videoMode)
end, end,
@@ -259,7 +260,7 @@ local function buildRows(game)
-- hard render cap (issue #88): bounds the present rate so a -- hard render cap (issue #88): bounds the present rate so a
-- driver-forced vsync-off run cannot spin at thousands of FPS. Logic -- driver-forced vsync-off run cannot spin at thousands of FPS. Logic
-- is fixed-step off dt, so this touches presentation only. -- is fixed-step off dt, so this touches presentation only.
{ id = "fpsCap", label = "MAX FPS", { id = "fpsCap", label = Strings("MAX FPS"),
value = function(g) value = function(g)
return FrameCap.label(g.save.options.fpsCap) return FrameCap.label(g.save.options.fpsCap)
end, end,
@@ -271,7 +272,7 @@ local function buildRows(game)
end }, end },
-- fast-forward the logic clock only; music and sfx keep their tempo -- fast-forward the logic clock only; music and sfx keep their tempo
-- (src/core/GameSpeed.lua), so this is safe to leave on -- (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) value = function(g)
return GameSpeed.levelLabel(g.save.options.speed) return GameSpeed.levelLabel(g.save.options.speed)
end, end,
@@ -282,17 +283,17 @@ local function buildRows(game)
end }, end },
-- the manager's discoverable home (18-mod-manager-ux); inert until -- the manager's discoverable home (18-mod-manager-ux); inert until
-- opened, so the row costs a vanilla install nothing -- opened, so the row costs a vanilla install nothing
{ id = "mods", label = "MODS", { id = "mods", label = Strings("MODS"),
value = function(g) value = function(g)
local status = g.modStatus or {} local status = g.modStatus or {}
return ("%d INSTALLED"):format(#(status.available or {})) return Strings("%d INSTALLED", #(status.available or {}))
end, end,
activate = function(g) activate = function(g)
require("src.ui.Screens").push(g, "ManagerState") require("src.ui.Screens").push(g, "ManagerState")
end }, end },
-- rebinding UI (gap C2, 12-ui-extensibility 4.4); captured inputs -- rebinding UI (gap C2, 12-ui-extensibility 4.4); captured inputs
-- live in options.bindings, so the row costs a vanilla install nothing -- live in options.bindings, so the row costs a vanilla install nothing
{ id = "controls", label = "CONTROLS", { id = "controls", label = Strings("CONTROLS"),
activate = function(g) activate = function(g)
require("src.ui.Screens").push(g, "BindingsMenu") require("src.ui.Screens").push(g, "BindingsMenu")
end }, end },
@@ -328,7 +329,8 @@ local function buildRows(game)
return rows return rows
end end
function OptionsMenu.new(game) function OptionsMenu.new(game, opts)
opts = opts or {}
local rows = buildRows(game) local rows = buildRows(game)
local hooked = Runtime.call("ui.options.rows", sameRows, game, rows) local hooked = Runtime.call("ui.options.rows", sameRows, game, rows)
if type(hooked) == "table" then if type(hooked) == "table" then
@@ -337,8 +339,8 @@ function OptionsMenu.new(game)
Logger.error("ui.options.rows returned %s; keeping the vanilla rows", Logger.error("ui.options.rows returned %s; keeping the vanilla rows",
type(hooked)) type(hooked))
end end
return setmetatable({ game = game, rows = rows, index = 1, scroll = 0 }, return setmetatable({ game = game, rows = rows, index = 1, scroll = 0,
OptionsMenu) onCancel = opts.onCancel }, OptionsMenu)
end end
function OptionsMenu:update(dt) function OptionsMenu:update(dt)
@@ -361,9 +363,11 @@ function OptionsMenu:update(dt)
changed = row.step(self.game, dir) and true or false changed = row.step(self.game, dir) and true or false
elseif input:wasPressed("a") then -- CANCEL elseif input:wasPressed("a") then -- CANCEL
self.game.stack:pop() self.game.stack:pop()
if self.onCancel then self.onCancel() end
end end
elseif input:wasPressed("b") or input:wasPressed("start") then elseif input:wasPressed("b") or input:wasPressed("start") then
self.game.stack:pop() self.game.stack:pop()
if self.onCancel then self.onCancel() end
end end
if changed and self.game.writeOptions then if changed and self.game.writeOptions then
self.game:writeOptions() self.game:writeOptions()
+40 -33
View File
@@ -17,6 +17,7 @@ local Screens = require("src.ui.Screens")
local Theme = require("src.ui.Theme") local Theme = require("src.ui.Theme")
local FieldDefaults = require("src.world.FieldDefaults") local FieldDefaults = require("src.world.FieldDefaults")
local Map = require("src.world.Map") local Map = require("src.world.Map")
local Strings = require("src.core.Strings")
local PartyMenu = {} local PartyMenu = {}
PartyMenu.__index = PartyMenu PartyMenu.__index = PartyMenu
@@ -95,6 +96,7 @@ local function drawIcon(game, mon, x, y, selected, counter)
name = def and def.dex and icons.byDex and icons.byDex[def.dex] name = def and def.dex and icons.byDex and icons.byDex[def.dex]
path = name and icons.icons and icons.icons[name] path = name and icons.icons and icons.icons[name]
end end
path = require("src.pokemon.Sprites").iconPath(game.data, mon, path, { name = name })
if not path then return end if not path then return end
if iconImages[path] == nil then if iconImages[path] == nil then
-- resolve through Assets so an overrides/ or transform-derived icon -- resolve through Assets so an overrides/ or transform-derived icon
@@ -203,7 +205,7 @@ function PartyMenu:update(dt)
self.game.save.flashLit = true self.game.save.flashLit = true
self.game.stack:push(TextBox.new(self.game, self.game.stack:push(TextBox.new(self.game,
self.game.data.text._FlashLightsAreaText 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)) self.game.stack:push(Transition.whiteFlash(self.game))
end)) end))
return return
@@ -246,7 +248,7 @@ function PartyMenu:update(dt)
current = "_CurrentTooFastText", current = "_CurrentTooFastText",
no_place = "_SurfingNoPlaceToGetOffText" })[reason] no_place = "_SurfingNoPlaceToGetOffText" })[reason]
or "_NoSurfingHereText" 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) :gsub("{RAM:wNameBuffer}", mon.nickname or def.name)
if reason == "no_place" then if reason == "no_place" then
-- .cannotStopSurfing prints _SurfingNoPlaceToGetOffText but -- .cannotStopSurfing prints _SurfingNoPlaceToGetOffText but
@@ -277,7 +279,7 @@ function PartyMenu:update(dt)
local def = self.game.data.pokemon[mon.species] local def = self.game.data.pokemon[mon.species]
local key = (reason == "no_badge") and "_NewBadgeRequiredText" local key = (reason == "no_badge") and "_NewBadgeRequiredText"
or "_NothingToCutText" 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) :gsub("{RAM:wNameBuffer}", mon.nickname or def.name)
self.game.stack:push(TextBox.new(self.game, txt)) self.game.stack:push(TextBox.new(self.game, txt))
return -- .loop: submenu stays open behind the message return -- .loop: submenu stays open behind the message
@@ -299,9 +301,9 @@ function PartyMenu:update(dt)
self.game.stack:pop() -- close the party menu (jp .goBackToMap) self.game.stack:pop() -- close the party menu (jp .goBackToMap)
ow.strengthActive = true ow.strengthActive = true
local t1 = (self.game.data.text._UsedStrengthText 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 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, t1, function()
self.game.stack:push(TextBox.new(self.game, t2, function() self.game.stack:push(TextBox.new(self.game, t2, function()
self.game.stack:push(Transition.whiteFlash(self.game)) self.game.stack:push(Transition.whiteFlash(self.game))
@@ -347,7 +349,7 @@ function PartyMenu:update(dt)
or user.hp <= heal then or user.hp <= heal then
self.softboiledFrom = nil self.softboiledFrom = nil
local TextBox = require("src.render.TextBox") 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 else
user.hp = user.hp - heal user.hp = user.hp - heal
mon.hp = math.min(mon.stats.hp, mon.hp + heal) mon.hp = math.min(mon.stats.hp, mon.hp + heal)
@@ -356,7 +358,7 @@ function PartyMenu:update(dt)
local def = self.game.data.pokemon[mon.species] local def = self.game.data.pokemon[mon.species]
local TextBox = require("src.render.TextBox") local TextBox = require("src.render.TextBox")
self.game.stack:push(TextBox.new(self.game, 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 end
elseif self.swapFrom then elseif self.swapFrom then
if self.swapFrom ~= self.index then if self.swapFrom ~= self.index then
@@ -374,14 +376,14 @@ function PartyMenu:update(dt)
local ow = self.game.overworld local ow = self.game.overworld
if self.battle and self.onSwitch then if self.battle and self.onSwitch then
-- SwitchStatsCancelText (core.asm PartyMenuOrRockOrRun) -- SwitchStatsCancelText (core.asm PartyMenuOrRockOrRun)
items = { { label = "SWITCH", action = "battle_switch" }, items = { { label = Strings("SWITCH"), action = "battle_switch" },
{ label = "STATS", action = "stats" }, { label = Strings("STATS"), action = "stats" },
{ label = "CANCEL", action = "cancel" } } { label = Strings("CANCEL"), action = "cancel" } }
else else
-- STATS/SWITCH plus this mon's field moves (start_sub_menus.asm -- STATS/SWITCH plus this mon's field moves (start_sub_menus.asm
-- builds the same dynamic list) -- builds the same dynamic list)
items = { { label = "STATS", action = "stats" }, items = { { label = Strings("STATS"), action = "stats" },
{ label = "SWITCH", action = "switch" } } { label = Strings("SWITCH"), action = "switch" } }
-- Field moves (HMs/TMs) are usable out of battle even when the mon -- 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. -- is fainted -- Gen 1 does not require HP for Cut/Fly/Surf/etc.
-- Battle still excludes this list via `not self.battle`. Softboiled -- Battle still excludes this list via `not self.battle`. Softboiled
@@ -394,35 +396,35 @@ function PartyMenu:update(dt)
for _, mv in ipairs(mon.moves) do for _, mv in ipairs(mon.moves) do
if mv.id == "FLY" and outside if mv.id == "FLY" and outside
and self.game.save.inventory.THUNDERBADGE then 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 elseif mv.id == "FLASH" and ow.dark
and self.game.save.inventory.BOULDERBADGE then 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 elseif mv.id == "CUT" and self.game.save.inventory.CASCADEBADGE then
-- CUT/SURF/STRENGTH are party-menu field moves too -- CUT/SURF/STRENGTH are party-menu field moves too
-- (start_sub_menus.asm .outOfBattleMovePointers); listed here -- (start_sub_menus.asm .outOfBattleMovePointers); listed here
-- with the same list-time badge filter this file already uses -- with the same list-time badge filter this file already uses
-- for FLY/FLASH. The facing-tile/activation check happens on -- for FLY/FLASH. The facing-tile/activation check happens on
-- selection (useCutFieldMove/useSurfFieldMove). -- 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 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 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 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 elseif mv.id == "TELEPORT" and outside then
-- TELEPORT works only OUTDOORS (start_sub_menus.asm -- TELEPORT works only OUTDOORS (start_sub_menus.asm
-- .teleport -> CheckIfInOutsideMap); dark maps don't -- .teleport -> CheckIfInOutsideMap); dark maps don't
-- block it -- 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] elseif mv.id == "DIG" and DIG_TILESETS[ow.map.def.tileset]
and ow.map.id ~= "AGATHAS_ROOM" then and ow.map.id ~= "AGATHAS_ROOM" then
-- DIG runs ItemUseEscapeRope (.dig sets wCurItem = -- DIG runs ItemUseEscapeRope (.dig sets wCurItem =
-- ESCAPE_ROPE): usable in the dungeon tilesets of -- ESCAPE_ROPE): usable in the dungeon tilesets of
-- escape_rope_tilesets.asm minus Agatha's room, even in -- escape_rope_tilesets.asm minus Agatha's room, even in
-- the dark (Rock Tunnel) -- the dark (Rock Tunnel)
table.insert(items, { label = "DIG", action = "escape" }) table.insert(items, { label = Strings("DIG"), action = "escape" })
end end
end end
end end
@@ -455,13 +457,13 @@ function PartyMenu:bottomMessage()
return "Use on which one?" return "Use on which one?"
elseif self.tmhm then elseif self.tmhm then
return self.game.data.text._PartyMenuUseTMText 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 elseif self.battle then
return self.game.data.text._PartyMenuBattleText return self.game.data.text._PartyMenuBattleText
or "Bring out which\nPOKéMON?" or Strings("Bring out which\nPOKéMON?")
else else
return self.game.data.text._PartyMenuNormalText return self.game.data.text._PartyMenuNormalText
or "Choose a POKéMON." or Strings("Choose a POKéMON.")
end end
end end
@@ -479,7 +481,7 @@ function PartyMenu:draw()
love.graphics.setColor(0, 0, 0, 1) love.graphics.setColor(0, 0, 0, 1)
local party = self.party or self.game.save.party local party = self.party or self.game.save.party
if #party == 0 then if #party == 0 then
Font.draw("No POKéMON!", 16, 64) Font.draw(Strings("No POKéMON!"), 16, 64)
end end
local HudTiles = require("src.render.HudTiles") local HudTiles = require("src.render.HudTiles")
for i, mon in ipairs(party) do for i, mon in ipairs(party) do
@@ -511,13 +513,13 @@ function PartyMenu:draw()
end end
-- right-aligned so the shorter "ABLE" shares "NOT ABLE"'s right edge -- right-aligned so the shorter "ABLE" shares "NOT ABLE"'s right edge
if can then if can then
Font.draw("ABLE", 120, y + 8) Font.draw(Strings("ABLE"), 120, y + 8)
else else
Font.draw("NOT ABLE", 88, y + 8) Font.draw(Strings("NOT ABLE"), 88, y + 8)
end end
else else
if mon.hp <= 0 then if mon.hp <= 0 then
Font.draw("FNT", 136, y) Font.draw(Strings("FNT"), 136, y)
elseif mon.status then elseif mon.status then
Font.draw(mon.status, 136, y) Font.draw(mon.status, 136, y)
end end
@@ -527,17 +529,22 @@ function PartyMenu:draw()
love.graphics.setColor(0, 0, 0, 1) love.graphics.setColor(0, 0, 0, 1)
Font.draw(("%3d/%3d"):format(mon.hp, mon.stats.hp), 104, y + 8) Font.draw(("%3d/%3d"):format(mon.hp, mon.stats.hp), 104, y + 8)
end 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 if i == self.index then
Font.drawCode(Theme.cursor, 0, y) Font.drawCode(Theme.cursor, 0, cursorY)
end end
if i == self.swapFrom or i == self.softboiledFrom then 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
end end
if self.swapFrom then if self.swapFrom then
Font.draw("Move to where?", 8, 136) Font.draw(Strings("Move to where?"), 8, 136)
elseif self.softboiledFrom then 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 elseif self.tmhm then
-- "Use TM on which\nPOKeMON?" in the standard bottom text box -- "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 -- (party_menu.asm keeps the message box for the TM/HM menu); box + line
@@ -545,14 +552,14 @@ function PartyMenu:draw()
Font.drawBox(0, 12, 20, 6) Font.drawBox(0, 12, 20, 6)
love.graphics.setColor(0, 0, 0, 1) love.graphics.setColor(0, 0, 0, 1)
local prompt = self.game.data.text._PartyMenuUseTMText 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 local ly = 112
for line in (prompt .. "\n"):gmatch("([^\n]*)\n") do for line in (prompt .. "\n"):gmatch("([^\n]*)\n") do
Font.draw(line, 8, ly) Font.draw(line, 8, ly)
ly = ly + 16 ly = ly + 16
end end
elseif self.pickOnly then elseif self.pickOnly then
Font.draw("Use on which one?", 8, 136) Font.draw(Strings("Use on which one?"), 8, 136)
else else
-- default field party menu (StartMenu) and the battle voluntary-switch -- default field party menu (StartMenu) and the battle voluntary-switch
-- (BattleState:openParty): Gen1 prints PartyMenuNormalText / PartyMenuBattleText -- (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 ListMenu = require("src.ui.ListMenu")
local Menu = require("src.ui.Menu") local Menu = require("src.ui.Menu")
local Sound = require("src.core.Sound") local Sound = require("src.core.Sound")
local Strings = require("src.core.Strings")
local PlayerPC = {} local PlayerPC = {}
@@ -76,14 +77,14 @@ local function withdraw(game)
askQuantity(game, list, pc[item.value] or 1, item.value, function(qty) askQuantity(game, list, pc[item.value] or 1, item.value, function(qty)
local Bag = require("src.inventory.Bag") local Bag = require("src.inventory.Bag")
if not Bag.add(game.save, item.value, qty) then 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 return
end end
pc[item.value] = pc[item.value] - qty pc[item.value] = pc[item.value] - qty
if pc[item.value] <= 0 then pc[item.value] = nil end if pc[item.value] <= 0 then pc[item.value] = nil end
refreshRow(list, pc, item.value) refreshRow(list, pc, item.value)
Sound.play(game.data, "Withdraw_Deposit") 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)
end, end,
})) }))
@@ -112,14 +113,14 @@ local function deposit(game)
onChoose = function(item, list) onChoose = function(item, list)
askQuantity(game, list, inv[item.value] or 1, item.value, function(qty) askQuantity(game, list, inv[item.value] or 1, item.value, function(qty)
if pcFull(game, pc, item.value) then 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 return
end end
require("src.inventory.Bag").remove(game.save, item.value, qty) require("src.inventory.Bag").remove(game.save, item.value, qty)
pc[item.value] = (pc[item.value] or 0) + qty pc[item.value] = (pc[item.value] or 0) + qty
refreshRow(list, inv, item.value) refreshRow(list, inv, item.value)
Sound.play(game.data, "Withdraw_Deposit") 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)
end, end,
})) }))
@@ -132,7 +133,7 @@ local function toss(game)
onChoose = function(item, list) onChoose = function(item, list)
local def = game.data.items[item.value] local def = game.data.items[item.value]
if (def and def.keyItem) or item.value:find("^HM_") then 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 return
end end
local QuantityBox = require("src.ui.QuantityBox") local QuantityBox = require("src.ui.QuantityBox")
@@ -140,13 +141,13 @@ local function toss(game)
max = pc[item.value] or 1, max = pc[item.value] or 1,
onDone = function(qty) onDone = function(qty)
if not qty then return end 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) game.stack:push(ChoiceBox.new(game, function(yes)
if yes then if yes then
pc[item.value] = pc[item.value] - qty pc[item.value] = pc[item.value] - qty
if pc[item.value] <= 0 then pc[item.value] = nil end if pc[item.value] <= 0 then pc[item.value] = nil end
refreshRow(list, pc, item.value) 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 else
list.footer = nil list.footer = nil
end end
@@ -160,10 +161,13 @@ end
function PlayerPC.new(game) function PlayerPC.new(game)
game.save.pcItems = game.save.pcItems or {} game.save.pcItems = game.save.pcItems or {}
return Menu.new(game, { return Menu.new(game, {
{ label = "WITHDRAW ITEM", onSelect = function() withdraw(game) end }, -- keepOpen so B in the item lists returns here instead of dropping the
{ label = "DEPOSIT ITEM", onSelect = function() deposit(game) end }, -- whole PC session (players_pc.asm re-shows the PC menu); same pattern
{ label = "TOSS ITEM", onSelect = function() toss(game) end }, -- as BoxMenu's rows
{ label = "LOG OFF" }, { label = Strings("WITHDRAW ITEM"), keepOpen = true, onSelect = function() withdraw(game) end },
{ label = Strings("DEPOSIT ITEM"), keepOpen = true, onSelect = function() deposit(game) end },
{ label = Strings("TOSS ITEM"), keepOpen = true, onSelect = function() toss(game) end },
{ label = Strings("LOG OFF") },
-- silent PC session (BIT_NO_MENU_BUTTON_SOUND); players_pc.asm -- silent PC session (BIT_NO_MENU_BUTTON_SOUND); players_pc.asm
-- PlayersPCMenu TextBoxBorder (0,0) b=8 c=14 → 16x10 -- PlayersPCMenu TextBoxBorder (0,0) b=8 c=14 → 16x10
}, { tx = 0, ty = 0, tw = 16, th = 10, noSound = true }) }, { 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. -- Minimal Pokédex: dex-ordered list with seen/owned markers.
local ListMenu = require("src.ui.ListMenu") local ListMenu = require("src.ui.ListMenu")
local Strings = require("src.core.Strings")
local PokedexMenu = {} local PokedexMenu = {}
@@ -9,7 +10,8 @@ function PokedexMenu:sgbPalettes(game)
return require("src.render.PaletteFX").wholeNamed(game.data, "BROWNMON") return require("src.render.PaletteFX").wholeNamed(game.data, "BROWNMON")
end end
function PokedexMenu.new(game) function PokedexMenu.new(game, opts)
opts = opts or {}
local dex = game.save.pokedex or { seen = {}, owned = {} } local dex = game.save.pokedex or { seen = {}, owned = {} }
local byDex = {} local byDex = {}
for species, def in pairs(game.data.pokemon) do for species, def in pairs(game.data.pokemon) do
@@ -45,8 +47,9 @@ function PokedexMenu.new(game)
end end
end end
local list = ListMenu.new(game, "POKéDEX", items, { 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 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) onChoose = function(item)
if not item.value then return end if not item.value then return end
-- the DATA / CRY / AREA / QUIT choice (engine/menus/pokedex.asm -- 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 Menu = require("src.ui.Menu")
local Screens = require("src.ui.Screens") local Screens = require("src.ui.Screens")
game.stack:push(Menu.new(game, { game.stack:push(Menu.new(game, {
{ label = "DATA", onSelect = function() { label = Strings("DATA"), onSelect = function()
Screens.push(game, "DexEntryMenu", item.value) Screens.push(game, "DexEntryMenu", item.value)
end }, end },
{ label = "CRY", keepOpen = true, onSelect = function() { label = Strings("CRY"), keepOpen = true, onSelect = function()
require("src.core.Sound").playCry(game.data, item.value) require("src.core.Sound").playCry(game.data, item.value)
end }, end },
{ label = "AREA", onSelect = function() { label = Strings("AREA"), onSelect = function()
Screens.push(game, "TownMap", { nestSpecies = item.value }) Screens.push(game, "TownMap", { nestSpecies = item.value })
end }, end },
{ label = "QUIT" }, { label = Strings("QUIT") },
}, { tx = 12, ty = 8, tw = 8, th = 10 })) }, { tx = 12, ty = 8, tw = 8, th = 10 }))
end, end,
}) })
+8 -7
View File
@@ -6,6 +6,7 @@
local Font = require("src.render.Font") local Font = require("src.render.Font")
local SaveData = require("src.core.SaveData") local SaveData = require("src.core.SaveData")
local Strings = require("src.core.Strings")
local QuarantineReport = {} local QuarantineReport = {}
QuarantineReport.__index = QuarantineReport QuarantineReport.__index = QuarantineReport
@@ -38,12 +39,12 @@ local function buildLines(report, meta)
end end
local rows = {} local rows = {}
for _, mon in ipairs(report.lostMons or {}) do 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 end
section(lines, "Moved to LOST box:", rows) section(lines, "Moved to LOST box:", rows)
rows = {} rows = {}
for _, item in ipairs(report.lostItems or {}) do 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 end
section(lines, "Items removed:", rows) section(lines, "Items removed:", rows)
rows = {} rows = {}
@@ -51,16 +52,16 @@ local function buildLines(report, meta)
if map.to then if map.to then
rows[#rows + 1] = ("%s>%s"):format(map.id or "?", map.to) rows[#rows + 1] = ("%s>%s"):format(map.id or "?", map.to)
else 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
end end
section(lines, "Location reset:", rows) section(lines, "Location reset:", rows)
rows = {} rows = {}
for _, mon in ipairs(report.restoredMons or {}) do 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 end
for _, item in ipairs(report.restoredItems or {}) do 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 end
section(lines, "Restored:", rows) section(lines, "Restored:", rows)
local notice = SaveData.modsDiffNotice(report.modsDiff, meta) local notice = SaveData.modsDiffNotice(report.modsDiff, meta)
@@ -113,7 +114,7 @@ function QuarantineReport:draw()
love.graphics.rectangle("fill", 0, 0, 160, 144) love.graphics.rectangle("fill", 0, 0, 160, 144)
Font.drawBox(0, 0, 20, 18) Font.drawBox(0, 0, 20, 18)
love.graphics.setColor(0, 0, 0, 1) 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 for row = 1, VISIBLE do
local line = self.lines[self.offset + row] local line = self.lines[self.offset + row]
if line then Font.draw(line, 8, 12 + row * 8) end if line then Font.draw(line, 8, 12 + row * 8) end
@@ -121,7 +122,7 @@ function QuarantineReport:draw()
if self.offset < self:maxOffset() then if self.offset < self:maxOffset() then
Font.drawCode(require("src.ui.Theme").moreArrow, 144, 124) Font.drawCode(require("src.ui.Theme").moreArrow, 144, 124)
end end
Font.draw("A:CONTINUE", 8, 130) Font.draw(Strings("A:CONTINUE"), 8, 130)
love.graphics.setColor(1, 1, 1, 1) love.graphics.setColor(1, 1, 1, 1)
end end
+10 -9
View File
@@ -12,6 +12,7 @@ local ChoiceBox = require("src.ui.ChoiceBox")
local ListMenu = require("src.ui.ListMenu") local ListMenu = require("src.ui.ListMenu")
local Menu = require("src.ui.Menu") local Menu = require("src.ui.Menu")
local QuantityBox = require("src.ui.QuantityBox") local QuantityBox = require("src.ui.QuantityBox")
local Strings = require("src.core.Strings")
local ShopMenu = {} local ShopMenu = {}
@@ -33,7 +34,7 @@ local function buy(game, stock)
end end
local greet = txt(game, "_PokemartBuyingGreetingText", "Take your time.") local greet = txt(game, "_PokemartBuyingGreetingText", "Take your time.")
local notEnough = txt(game, "_PokemartNotEnoughMoneyText", local notEnough = txt(game, "_PokemartNotEnoughMoneyText",
"You don't have\nenough money.") Strings("You don't have\nenough money."))
local list local list
list = ListMenu.new(game, "BUY", items, { list = ListMenu.new(game, "BUY", items, {
dialogue = true, dialogue = true,
@@ -56,7 +57,7 @@ local function buy(game, stock)
end end
local cost = qty * def.price local cost = qty * def.price
-- _PokemartTellBuyPriceText + yes/no confirm -- _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) game.stack:push(ChoiceBox.new(game, function(yes)
if not yes then if not yes then
list.footer = greet list.footer = greet
@@ -68,13 +69,13 @@ local function buy(game, stock)
end end
if not Bag.add(game.save, item.value, qty) then if not Bag.add(game.save, item.value, qty) then
list.footer = txt(game, "_PokemartItemBagFullText", list.footer = txt(game, "_PokemartItemBagFullText",
"You can't carry\nany more items.") Strings("You can't carry\nany more items."))
return return
end end
require("src.core.Sound").play(game.data, "Purchase") require("src.core.Sound").play(game.data, "Purchase")
game.save.money = game.save.money - cost game.save.money = game.save.money - cost
list.footer = txt(game, "_PokemartBoughtItemText", list.footer = txt(game, "_PokemartBoughtItemText",
"Here you are!\nThank you!") Strings("Here you are!\nThank you!"))
end)) end))
end, end,
})) }))
@@ -112,7 +113,7 @@ local function sell(game)
-- ITEM_NONE "0" from Blue's House before that pickup was fixed (#11). -- 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 if not def or def.keyItem or item.value:find("^HM_") then
list.footer = txt(game, "_PokemartUnsellableItemText", list.footer = txt(game, "_PokemartUnsellableItemText",
"I can't put a\nprice on that.") Strings("I can't put a\nprice on that."))
return return
end end
local unit = math.floor(def.price / 2) local unit = math.floor(def.price / 2)
@@ -125,7 +126,7 @@ local function sell(game)
return return
end end
-- _PokemartTellSellPriceText + yes/no confirm -- _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) game.stack:push(ChoiceBox.new(game, function(yes)
if not yes then if not yes then
list.footer = greet list.footer = greet
@@ -152,9 +153,9 @@ function ShopMenu.new(game, stock, onQuit)
-- keepOpen: the mart menu stays underneath its list so closing the -- keepOpen: the mart menu stays underneath its list so closing the
-- list lands back here; only QUIT (or B) leaves and fires onQuit -- list lands back here; only QUIT (or B) leaves and fires onQuit
local menu = Menu.new(game, { local menu = Menu.new(game, {
{ label = "BUY", keepOpen = true, onSelect = function() buy(game, stock) end }, { label = Strings("BUY"), keepOpen = true, onSelect = function() buy(game, stock) end },
{ label = "SELL", keepOpen = true, onSelect = function() sell(game) end }, { label = Strings("SELL"), keepOpen = true, onSelect = function() sell(game) end },
{ label = "QUIT", onSelect = onQuit }, { label = Strings("QUIT"), onSelect = onQuit },
}, { tx = 0, ty = 0, tw = 8, th = 8 }) }, { tx = 0, ty = 0, tw = 8, th = 8 })
menu.onCancel = onQuit menu.onCancel = onQuit
return menu return menu
+8 -7
View File
@@ -45,6 +45,7 @@
local Font = require("src.render.Font") local Font = require("src.render.Font")
local Sound = require("src.core.Sound") local Sound = require("src.core.Sound")
local Strings = require("src.core.Strings")
local SlotMachine = {} local SlotMachine = {}
SlotMachine.__index = SlotMachine SlotMachine.__index = SlotMachine
@@ -356,7 +357,7 @@ function SlotMachine:resolveWin(win)
-- SlotReward300Func prints "Yeah!" (text_pause) before the flash; the port -- SlotReward300Func prints "Yeah!" (text_pause) before the flash; the port
-- shows it in the box while the screen flashes. LinedUpText follows. -- shows it in the box while the screen flashes. LinedUpText follows.
self.yeah = (sym == "7") 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 -- .flashScreenLoop: flip rBGP, wait 5 frames, b times. The coins are not
-- credited until the player dismisses the "lined up" text (see startPayout). -- credited until the player dismisses the "lined up" text (see startPayout).
self.stage = "flash" self.stage = "flash"
@@ -394,7 +395,7 @@ end
-- delay then CloseTextDisplay), otherwise ask "One more go?". -- delay then CloseTextDisplay), otherwise ask "One more go?".
function SlotMachine:afterSpin() function SlotMachine:afterSpin()
if coins(self) == 0 then if coins(self) == 0 then
self.message = "Darn!\nRan out of coins!" self.message = Strings("Darn!\nRan out of coins!")
self.stage = "message" self.stage = "message"
self.afterMessage = nil self.afterMessage = nil
self.exitTimer = 60 self.exitTimer = 60
@@ -515,7 +516,7 @@ function SlotMachine:update(dt)
self.bet = 3 - self.betIndex self.bet = 3 - self.betIndex
if input:wasPressed("a") then if input:wasPressed("a") then
if coins(self) < self.bet then if coins(self) < self.bet then
self.message = "Not enough\ncoins!" self.message = Strings("Not enough\ncoins!")
self.afterMessage = "bet" self.afterMessage = "bet"
self.stage = "message" self.stage = "message"
return return
@@ -671,8 +672,8 @@ function SlotMachine:drawBottom()
local by = self.stage == "intro" and 6 or 11 local by = self.stage == "intro" and 6 or 11
Font.drawBox(13, by, 6, 5) Font.drawBox(13, by, 6, 5)
love.graphics.setColor(0, 0, 0, 1) love.graphics.setColor(0, 0, 0, 1)
Font.draw("YES", 15 * 8, (by + 1) * 8) Font.draw(Strings("YES"), 15 * 8, (by + 1) * 8)
Font.draw("NO", 15 * 8, (by + 2) * 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) Font.drawCode(0xED, 14 * 8, (by + 1 + (self.yesno == 1 and 0 or 1)) * 8)
end end
love.graphics.setColor(1, 1, 1, 1) love.graphics.setColor(1, 1, 1, 1)
@@ -723,8 +724,8 @@ function SlotMachine:drawPlain(art)
love.graphics.setColor(1, 1, 1, 1) love.graphics.setColor(1, 1, 1, 1)
love.graphics.rectangle("fill", 0, 0, 160, 144) love.graphics.rectangle("fill", 0, 0, 160, 144)
love.graphics.setColor(0, 0, 0, 1) love.graphics.setColor(0, 0, 0, 1)
Font.draw("SLOT MACHINE", 32, 4) Font.draw(Strings("SLOT MACHINE"), 32, 4)
Font.draw(("COINS %4d"):format(coins(self)), 8, 16) Font.draw(Strings("COINS %4d", coins(self)), 8, 16)
if art then self:drawReels(art) end if art then self:drawReels(art) end
love.graphics.setColor(0, 0, 0, 1) love.graphics.setColor(0, 0, 0, 1)
Font.draw(">", 12, 56) Font.draw(">", 12, 56)
+27 -21
View File
@@ -10,6 +10,7 @@ local Menu = require("src.ui.Menu")
local Renderer = require("src.render.Renderer") local Renderer = require("src.render.Renderer")
local Runtime = require("src.mods.Runtime") local Runtime = require("src.mods.Runtime")
local Screens = require("src.ui.Screens") local Screens = require("src.ui.Screens")
local Strings = require("src.core.Strings")
local StartMenu = {} local StartMenu = {}
@@ -19,33 +20,38 @@ function StartMenu.new(game)
local flags = game.save.flags or {} local flags = game.save.flags or {}
local items = {} 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 -- POKéDEX: only after Oak hands it over
if flags.EVENT_GOT_POKEDEX then if flags.EVENT_GOT_POKEDEX then
table.insert(items, { label = "POKéDEX", onSelect = function() table.insert(items, { label = Strings("POKéDEX"), onSelect = function()
Screens.push(game, "PokedexMenu") Screens.push(game, "PokedexMenu", { onCancel = reopen })
end }) end })
end end
-- POKéMON is always listed (draw_start_menu.asm prints it even with -- POKéMON is always listed (draw_start_menu.asm prints it even with
-- an empty party; selecting it then just no-ops) -- 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 if #game.save.party == 0 then return end
Screens.push(game, "PartyMenu") Screens.push(game, "PartyMenu", { onCancel = reopen })
end }) end })
table.insert(items, { label = "ITEM", onSelect = function() table.insert(items, { label = Strings("ITEM"), onSelect = function()
Screens.push(game, "BagMenu") Screens.push(game, "BagMenu", { onCancel = reopen })
end }) end })
-- the player's name opens the trainer card (StartMenu_TrainerInfo) -- the player's name opens the trainer card (StartMenu_TrainerInfo)
table.insert(items, { label = game.save.player.name or "RED", table.insert(items, { label = game.save.player.name or "RED",
onSelect = function() onSelect = function()
Screens.push(game, "TrainerCard") Screens.push(game, "TrainerCard", { onCancel = reopen })
end }) end })
-- SAVE shows the player/badges/dex/time panel then asks to confirm -- SAVE shows the player/badges/dex/time panel then asks to confirm
-- (PrintSaveScreenText) -- (PrintSaveScreenText)
table.insert(items, { label = "SAVE", onSelect = function() table.insert(items, { label = Strings("SAVE"), onSelect = function()
local TextBox = require("src.render.TextBox") local TextBox = require("src.render.TextBox")
local ChoiceBox = require("src.ui.ChoiceBox") local ChoiceBox = require("src.ui.ChoiceBox")
local badges = require("src.inventory.Badges").count(game.data, game.save) local badges = require("src.inventory.Badges").count(game.data, game.save)
@@ -54,32 +60,32 @@ function StartMenu.new(game)
owned = owned + 1 owned = owned + 1
end end
local t = math.floor(game.save.playTime or 0) local t = math.floor(game.save.playTime or 0)
local panel = ("PLAYER %s\nBADGES %d\nPOKéDEX %3d\nTIME %6d:%02d") local panel = Strings("PLAYER %s\nBADGES %d\nPOKéDEX %3d\nTIME %6d:%02d",
:format(game.save.player.name or "RED", badges, owned, game.save.player.name or "RED", badges, owned,
math.floor(t / 3600), math.floor(t / 60) % 60) math.floor(t / 3600), math.floor(t / 60) % 60)
game.stack:push(TextBox.new(game, 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) game.stack:push(ChoiceBox.new(game, function(yes)
if not yes then return end if not yes then return end
-- "Now saving..." beat before the write (save.asm -- "Now saving..." beat before the write (save.asm
-- NowSavingString), then GameSavedText + SFX_SAVE -- 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() game:writeSave()
require("src.core.Sound").play(game.data, "Save") require("src.core.Sound").play(game.data, "Save")
game.stack:push(TextBox.new(game, 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))
end)) end))
end }) end })
table.insert(items, { label = "OPTION", onSelect = function() table.insert(items, { label = Strings("OPTION"), onSelect = function()
Screens.push(game, "OptionsMenu") Screens.push(game, "OptionsMenu", { onCancel = reopen })
end }) end })
-- LINK needs a party -- LINK needs a party
if #game.save.party > 0 then 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") local LinkState = require("src.link.LinkState")
game.stack:push(LinkState.new(game)) game.stack:push(LinkState.new(game))
end }) end })
@@ -89,7 +95,7 @@ function StartMenu.new(game)
-- one discovered mod so a vanilla install's menu is unchanged -- one discovered mod so a vanilla install's menu is unchanged
local status = game.modStatus local status = game.modStatus
if status and #(status.available or {}) > 0 then 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") Screens.push(game, "ManagerState")
end }) end })
end end
@@ -97,10 +103,10 @@ function StartMenu.new(game)
-- the original's EXIT just closed the menu (CloseStartMenu); with a -- the original's EXIT just closed the menu (CloseStartMenu); with a
-- window close button covering that, QUIT instead power-cycles back -- window close button covering that, QUIT instead power-cycles back
-- to the title after a confirm (defaultNo guards accidental quits) -- 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 TextBox = require("src.render.TextBox")
local ChoiceBox = require("src.ui.ChoiceBox") 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) game.stack:push(ChoiceBox.new(game, function(yes)
if yes then game:returnToTitle() end if yes then game:returnToTitle() end
end, { defaultNo = true })) end, { defaultNo = true }))
@@ -150,7 +156,7 @@ function StartMenu.new(game)
love.graphics.setColor(0, 0, 0, 1) love.graphics.setColor(0, 0, 0, 1)
Font.draw(("%3d"):format(math.floor(safari.steps or 0)), 8, 8) Font.draw(("%3d"):format(math.floor(safari.steps or 0)), 8, 8)
Font.draw("/500", 32, 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) Font.draw(("%2d"):format(math.floor(safari.balls or 0)), 48, 24)
love.graphics.setColor(1, 1, 1, 1) love.graphics.setColor(1, 1, 1, 1)
end end
+14 -11
View File
@@ -12,6 +12,7 @@ local Font = require("src.render.Font")
-- TypeChart.displayName maps it back to "PSYCHIC", like HallOfFame and the -- TypeChart.displayName maps it back to "PSYCHIC", like HallOfFame and the
-- battle move-type box already do (#214). -- battle move-type box already do (#214).
local TypeChart = require("src.battle.TypeChart") local TypeChart = require("src.battle.TypeChart")
local Strings = require("src.core.Strings")
local SummaryMenu = {} local SummaryMenu = {}
SummaryMenu.__index = SummaryMenu SummaryMenu.__index = SummaryMenu
@@ -30,9 +31,11 @@ end
function SummaryMenu.new(game, mon) function SummaryMenu.new(game, mon)
local self = setmetatable({ game = game, mon = mon, page = 1 }, SummaryMenu) local self = setmetatable({ game = game, mon = mon, page = 1 }, SummaryMenu)
local def = game.data.pokemon[mon.species] local Sprites = require("src.pokemon.Sprites")
if def and def.spriteFront then local path = Sprites.path(game.data, mon.species, "front",
local ok, img = pcall(love.graphics.newImage, def.spriteFront) { mon = mon, kind = "summary" })
if path then
local ok, img = pcall(love.graphics.newImage, path)
self.sprite = ok and img or nil self.sprite = ok and img or nil
end end
require("src.core.Sound").playCry(game.data, mon.species) require("src.core.Sound").playCry(game.data, mon.species)
@@ -88,7 +91,7 @@ function SummaryMenu:draw()
drawLineBox(19, 1, 6, 10) drawLineBox(19, 1, 6, 10)
HudTiles.drawHPBar(data, 11, 3, mon, 1) -- wHPBarType 1 HudTiles.drawHPBar(data, 11, 3, mon, 1) -- wHPBarType 1
Font.draw(("%3d/%3d"):format(mon.hp, mon.stats.hp), 96, 32) 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) Font.draw(mon.status or "OK", 128, 48)
-- stats box (0,8) 10x10: names rows 9/11/13/15, values indented -- stats box (0,8) 10x10: names rows 9/11/13/15, values indented
@@ -105,27 +108,27 @@ function SummaryMenu:draw()
-- TYPE1/TYPE2/IDNo/OT column (10,9) with values indented (11,10) -- TYPE1/TYPE2/IDNo/OT column (10,9) with values indented (11,10)
drawLineBox(19, 9, 8, 6) 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) Font.draw(def.types[1] and TypeChart.displayName(def.types[1]) or "", 88, 80)
if def.types[2] then 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) Font.draw(TypeChart.displayName(def.types[2]), 88, 96)
end end
Font.draw("IDNo/", 80, 104) Font.draw(Strings("IDNo/"), 80, 104)
-- the trainer ID is rolled at new game (SaveData.newGame) and -- the trainer ID is rolled at new game (SaveData.newGame) and
-- backfilled on load for old saves -- backfilled on load for old saves
Font.draw(("%05d"):format(mon.otId or game.save.player.id or 0), 96, 112) 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) Font.draw(mon.ot or game.save.player.name or "RED", 96, 128)
else else
-- page 2: EXP + the moves with PP (StatusScreen2) -- page 2: EXP + the moves with PP (StatusScreen2)
drawLineBox(19, 1, 6, 10) 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) Font.draw(("%d"):format(mon.exp), 96, 32)
-- StatusScreen2: "LEVEL UP" at (9,5); next-exp PrintNumber 7 cols -- StatusScreen2: "LEVEL UP" at (9,5); next-exp PrintNumber 7 cols
-- at (7,6); space at (14,6); PrintLevel at (16,6). The old -- 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. -- "%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 Growth = require("src.pokemon.Growth")
local nextExp = mon.level < 100 local nextExp = mon.level < 100
and (Growth.expForLevel(def.growthRate, mon.level + 1) - mon.exp) or 0 and (Growth.expForLevel(def.growthRate, mon.level + 1) - mon.exp) or 0
@@ -139,7 +142,7 @@ function SummaryMenu:draw()
if mv then if mv then
local mdef = data.moves[mv.id] local mdef = data.moves[mv.id]
Font.draw(mdef.name, 16, y) 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) Font.draw(("%2d/%2d"):format(mv.pp, mdef.pp), 112, y + 8)
else else
Font.draw("-", 16, y) Font.draw("-", 16, y)
+14 -12
View File
@@ -6,6 +6,7 @@
local Font = require("src.render.Font") local Font = require("src.render.Font")
local Music = require("src.core.Music") local Music = require("src.core.Music")
local GameVersion = require("src.core.GameVersion") local GameVersion = require("src.core.GameVersion")
local Strings = require("src.core.Strings")
local TitleState = {} local TitleState = {}
TitleState.__index = TitleState TitleState.__index = TitleState
@@ -117,8 +118,9 @@ function TitleState:currentSprite()
local species = self.cycleSpecies[self.cycleIndex] local species = self.cycleSpecies[self.cycleIndex]
local cached = self.sprites[species] local cached = self.sprites[species]
if cached == nil then if cached == nil then
local def = self.game.data.pokemon[species] local path = require("src.pokemon.Sprites").path(
cached = tryImage(def and def.spriteFront) or false self.game.data, species, "front", { kind = "title" })
cached = tryImage(path) or false
self.sprites[species] = cached self.sprites[species] = cached
end end
return cached or nil return cached or nil
@@ -164,19 +166,19 @@ function ContinueInfo:draw()
-- box at (4,7), 8x14 content; labels double-spaced from (5,9) -- box at (4,7), 8x14 content; labels double-spaced from (5,9)
Font.drawBox(4, 7, 16, 10) Font.drawBox(4, 7, 16, 10)
love.graphics.setColor(0, 0, 0, 1) 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) Font.draw((save.player and save.player.name) or "RED", 96, 72)
local badges = require("src.inventory.Badges").count(self.game.data, save) 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) Font.draw(("%2d"):format(badges), 128, 88)
local owned = 0 local owned = 0
for _ in pairs(save.pokedex and save.pokedex.owned or {}) do for _ in pairs(save.pokedex and save.pokedex.owned or {}) do
owned = owned + 1 owned = owned + 1
end end
Font.draw("POKéDEX", 40, 104) Font.draw(Strings("POKéDEX"), 40, 104)
Font.draw(("%3d"):format(owned), 120, 104) Font.draw(("%3d"):format(owned), 120, 104)
local t = math.floor(save.playTime or 0) 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), Font.draw(("%3d:%02d"):format(math.floor(t / 3600),
math.floor(t / 60) % 60), 104, 120) math.floor(t / 60) % 60), 104, 120)
love.graphics.setColor(1, 1, 1, 1) love.graphics.setColor(1, 1, 1, 1)
@@ -187,7 +189,7 @@ function TitleState:openMenu()
local game = self.game local game = self.game
local items = {} local items = {}
if hasSave() then 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 -- peek at the save for the info window; fall through if the
-- file can't be read -- file can't be read
local ok, loaded = pcall(require("src.core.SaveData").load) local ok, loaded = pcall(require("src.core.SaveData").load)
@@ -198,13 +200,13 @@ function TitleState:openMenu()
end end
end }) 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 if self.onNewGame then self.onNewGame() end
end }) end })
table.insert(items, { label = "OPTION", onSelect = function() table.insert(items, { label = Strings("OPTION"), onSelect = function()
require("src.ui.Screens").push(game, "OptionsMenu") require("src.ui.Screens").push(game, "OptionsMenu")
end }) 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 if love.event and love.event.quit then
love.event.quit() love.event.quit()
end end
@@ -253,7 +255,7 @@ function TitleState:draw()
love.graphics.draw(self.logo, 16, 8) love.graphics.draw(self.logo, 16, 8)
else else
love.graphics.setColor(0, 0, 0, 1) 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) (160 - 12 * 8) / 2, 24)
love.graphics.setColor(1, 1, 1, 1) love.graphics.setColor(1, 1, 1, 1)
end end
@@ -289,7 +291,7 @@ function TitleState:draw()
love.graphics.setColor(0, 0, 0, 1) love.graphics.setColor(0, 0, 0, 1)
-- the copyright row (tile 2,17); copyrightText because field.title's -- the copyright row (tile 2,17); copyrightText because field.title's
-- copyright key already names the extracted image strip -- 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) love.graphics.setColor(1, 1, 1, 1)
end end
+5 -3
View File
@@ -4,6 +4,7 @@
local Font = require("src.render.Font") local Font = require("src.render.Font")
local Sound = require("src.core.Sound") local Sound = require("src.core.Sound")
local TextBox = require("src.render.TextBox") local TextBox = require("src.render.TextBox")
local Strings = require("src.core.Strings")
local TradeAnim = {} local TradeAnim = {}
TradeAnim.__index = TradeAnim TradeAnim.__index = TradeAnim
@@ -48,8 +49,9 @@ local function dexOf(game, mon)
end end
local function spriteOf(game, mon) local function spriteOf(game, mon)
local def = game.data.pokemon[mon.species] local path = require("src.pokemon.Sprites").path(game.data, mon.species, "front",
return tryImage(def and def.spriteFront) { mon = mon, kind = "trade" })
return tryImage(path)
end end
local function expand(game, key, subs) local function expand(game, key, subs)
@@ -342,7 +344,7 @@ function TradeAnim:drawMonInfo(mon, ot, otId, boxTy)
love.graphics.setColor(0, 0, 0, 1) love.graphics.setColor(0, 0, 0, 1)
Font.draw(no, 56, y0) Font.draw(no, 56, y0)
Font.draw(speciesName(self.game, mon), 40, y0 + 16) 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) Font.draw(("IDNo.%05d"):format(otId or 0), 40, y0 + 48)
love.graphics.setColor(1, 1, 1, 1) love.graphics.setColor(1, 1, 1, 1)
end end
+18 -6
View File
@@ -4,8 +4,10 @@
-- are built from the real trainer_info.png frame tiles (the patterned -- are built from the real trainer_info.png frame tiles (the patterned
-- band + line style). -- band + line style).
local Assets = require("src.render.Assets")
local Badges = require("src.inventory.Badges") local Badges = require("src.inventory.Badges")
local Font = require("src.render.Font") local Font = require("src.render.Font")
local Strings = require("src.core.Strings")
local TrainerCard = {} local TrainerCard = {}
TrainerCard.__index = TrainerCard TrainerCard.__index = TrainerCard
@@ -16,8 +18,11 @@ function TrainerCard:sgbPalettes(game)
return require("src.render.PaletteFX").wholeNamed(game.data, "MEWMON") return require("src.render.PaletteFX").wholeNamed(game.data, "MEWMON")
end 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 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 return ok and img or nil
end end
@@ -30,8 +35,10 @@ local function quads16(img, count, stride, x0, y0)
return q return q
end end
function TrainerCard.new(game) function TrainerCard.new(game, opts)
local self = setmetatable({ game = game }, TrainerCard) opts = opts or {}
local self = setmetatable({ game = game, onCancel = opts.onCancel },
TrainerCard)
local img = tryImage("assets/generated/trainer_card/badges.png") local img = tryImage("assets/generated/trainer_card/badges.png")
if img then if img then
-- badges.2bpp is 8 stacked [face, badge] pairs (DrawBadges FaceBadgeTiles) -- badges.2bpp is 8 stacked [face, badge] pairs (DrawBadges FaceBadgeTiles)
@@ -60,14 +67,19 @@ function TrainerCard.new(game)
end end
end end
self.circle = tryImage("assets/generated/trainer_card/circle_tile.png") 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 return self
end end
function TrainerCard:update(dt) function TrainerCard:update(dt)
local input = self.game.input 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 if input:wasPressed("a") or input:wasPressed("b") then
self.game.stack:pop() self.game.stack:pop()
if self.onCancel then self.onCancel() end
end end
end end
@@ -108,7 +120,7 @@ function TrainerCard:draw()
love.graphics.draw(self.pic, 104, 4) love.graphics.draw(self.pic, 104, 4)
end end
love.graphics.setColor(0, 0, 0, 1) 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) Font.draw(("MONEY/¥%d"):format(save.money or 0), 16, 32)
local t = math.floor(save.playTime or 0) local t = math.floor(save.playTime or 0)
Font.draw(("TIME/%3d:%02d"):format(math.floor(t / 3600), Font.draw(("TIME/%3d:%02d"):format(math.floor(t / 3600),
@@ -117,7 +129,7 @@ function TrainerCard:draw()
-- the circle-dotted BADGES banner (TrainerInfo_BadgesText) -- the circle-dotted BADGES banner (TrainerInfo_BadgesText)
self:frameBox(0, 8, 20, 3) self:frameBox(0, 8, 20, 3)
love.graphics.setColor(0, 0, 0, 1) love.graphics.setColor(0, 0, 0, 1)
Font.draw("BADGES", 56, 72) Font.draw(Strings("BADGES"), 56, 72)
if self.circle then if self.circle then
love.graphics.setColor(1, 1, 1, 1) love.graphics.setColor(1, 1, 1, 1)
love.graphics.draw(self.circle, 48, 72) love.graphics.draw(self.circle, 48, 72)
+14
View File
@@ -79,6 +79,19 @@ local PLAYER_SPRITES = {
bike = "SPRITE_RED_BIKE", fly = "SPRITE_BIRD", 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 -- 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 -- 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. -- are ordered, first match wins, the last row is the default.
@@ -106,6 +119,7 @@ FieldDefaults.FIELD = {
fishing = FISHING, fishing = FISHING,
safari = SAFARI, safari = SAFARI,
playerSprites = PLAYER_SPRITES, playerSprites = PLAYER_SPRITES,
playerPics = PLAYER_PICS,
lastMapRewrites = LAST_MAP_REWRITES, lastMapRewrites = LAST_MAP_REWRITES,
-- CheckIfInOutsideMap: what counts as "outside" for the wLastMap memory -- CheckIfInOutsideMap: what counts as "outside" for the wLastMap memory
outsideTilesets = { "OVERWORLD", "PLATEAU" }, outsideTilesets = { "OVERWORLD", "PLATEAU" },
+154 -68
View File
@@ -22,6 +22,7 @@ local TextBox = require("src.render.TextBox")
local Transition = require("src.render.Transition") local Transition = require("src.render.Transition")
local Warp = require("src.world.Warp") local Warp = require("src.world.Warp")
local Zoom = require("src.render.Zoom") local Zoom = require("src.render.Zoom")
local Strings = require("src.core.Strings")
-- isOverworld marks the live world state for WorldAPI's stack scan -- isOverworld marks the live world state for WorldAPI's stack scan
local OverworldState = { isOpaque = true, isOverworld = true } local OverworldState = { isOpaque = true, isOverworld = true }
@@ -452,6 +453,35 @@ end
-- name -> name so the map.palette chain has a vanilla link to wrap -- name -> name so the map.palette chain has a vanilla link to wrap
local function samePalette(name) return name end local function samePalette(name) return name end
local function sameTod(tod) return tod end
-- world.tod default: always DAY. A day/night mod returns "NIGHT",
-- "MORNING", etc.; the result is cached on the overworld and handed to
-- map.palette as ctx.tod so palette swaps can key off the period.
function OverworldState:timeOfDay()
local tod = self.tod or "DAY"
if not Runtime.wantsHook("world.tod") then return tod end
local map = self.map
local nextTod = Runtime.call("world.tod", sameTod, tod, {
map = map,
mapId = map and map.id,
x = self.player and self.player.cellX,
y = self.player and self.player.cellY,
steps = self.todSteps or 0,
})
if type(nextTod) ~= "string" or nextTod == "" then nextTod = tod end
if nextTod ~= tod then
self.tod = nextTod
if Runtime.wants("world.tod_changed") then
Runtime.emit("world.tod_changed", {
tod = nextTod, previous = tod, mapId = map and map.id,
})
end
else
self.tod = nextTod
end
return self.tod
end
function OverworldState:paletteNameFor(map) function OverworldState:paletteNameFor(map)
local palettes = FieldDefaults.field(Game.data, "palettes") local palettes = FieldDefaults.field(Game.data, "palettes")
@@ -470,8 +500,9 @@ function OverworldState:paletteNameFor(map)
name = (last and paletteLookup(palettes, last, lastDef and lastDef.tileset)) name = (last and paletteLookup(palettes, last, lastDef and lastDef.tileset))
or palettes.default or palettes.default
end end
local tod = self:timeOfDay()
if not Runtime.wantsHook("map.palette") then return name end if not Runtime.wantsHook("map.palette") then return name end
return Runtime.call("map.palette", samePalette, name, map) return Runtime.call("map.palette", samePalette, name, map, { tod = tod })
end end
-- UI-pass palette (text boxes and menus tint with the current map). OG RED -- UI-pass palette (text boxes and menus tint with the current map). OG RED
@@ -1210,7 +1241,7 @@ end
-- (constants.hmBadges; distinct from constants.hmMoves, the forget gate). -- (constants.hmBadges; distinct from constants.hmMoves, the forget gate).
-- Gen 1 allows field use from fainted party members (party menu + name -- Gen 1 allows field use from fainted party members (party menu + name
-- lookup for Cut/Surf messages); do not require mon.hp > 0 here. -- 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 gate = (FieldDefaults.constant(Game.data, "hmBadges") or {})[moveId]
local badge = gate and gate.badge local badge = gate and gate.badge
if badge and not Game.save.inventory[badge] then if badge and not Game.save.inventory[badge] then
@@ -1224,6 +1255,17 @@ function OverworldState:partyKnows(moveId)
return nil return nil
end 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 -- The rejection loop shared by the Good and Super Rods
-- (item_effects.asm ItemUseGoodRod .RandomLoop / ReadSuperRodData): an -- (item_effects.asm ItemUseGoodRod .RandomLoop / ReadSuperRodData): an
-- odd random byte is no bite; otherwise a 2-bit pick rerolls until it -- odd random byte is no bite; otherwise a 2-bit pick rerolls until it
@@ -1282,10 +1324,10 @@ function OverworldState:goFishing(rod)
Game.stack:push(TextBox.new(Game, ". . .", function() Game.stack:push(TextBox.new(Game, ". . .", function()
self.fishing = nil self.fishing = nil
if not enc then 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 return
end 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 BattleState = require("src.battle.BattleState")
local battle = BattleState.newWild(Game, enc.species, enc.level, { hooked = true }) 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 if Game.save.safari and Map.inRegion(self.map.def, "SAFARI", "SAFARI_ZONE") then
@@ -1452,29 +1494,66 @@ function OverworldState:tryBookshelf(fx, fy)
if self.map.def.tileset == "MANSION" if self.map.def.tileset == "MANSION"
and self.map:tileAt(fx * 2, fy * 2) == 0x38 then and self.map:tileAt(fx * 2, fy * 2) == 0x38 then
Game.stack:push(TextBox.new(Game, t._DiglettSculptureText 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 return true
end end
Game.stack:push(TextBox.new(Game, t._PokemonBooksText 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 elseif kind == "stuff" then
Game.stack:push(TextBox.new(Game, t._PokemonStuffText 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 elseif kind == "elevator" then
Game.stack:push(TextBox.new(Game, t._ElevatorText Game.stack:push(TextBox.new(Game, t._ElevatorText
or "An elevator!")) or Strings("An elevator!")))
elseif kind == "statues" then elseif kind == "statues" then
-- IndigoPlateauStatues: the plaque, then one of the two lines -- IndigoPlateauStatues: the plaque, then one of the two lines
-- keyed by the statue's column (XCoord bit 0) -- keyed by the statue's column (XCoord bit 0)
local line = (self.player.cellX % 2 == 0) and t._IndigoPlateauStatuesText2 local line = (self.player.cellX % 2 == 0) and t._IndigoPlateauStatuesText2
or t._IndigoPlateauStatuesText3 or t._IndigoPlateauStatuesText3
Game.stack:push(TextBox.new(Game, Game.stack:push(TextBox.new(Game,
(t._IndigoPlateauStatuesText1 or "INDIGO PLATEAU") .. "\f" (t._IndigoPlateauStatuesText1 or Strings("INDIGO PLATEAU")) .. "\f"
.. (line or "POKéMON LEAGUE HQ"))) .. (line or Strings("POKéMON LEAGUE HQ"))))
end end
return true return true
end 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): -- Hidden events at the faced cell (data/events/hidden_events.asm):
-- HiddenItems give their item once, HiddenCoins fill the COIN CASE, -- HiddenItems give their item once, HiddenCoins fill the COIN CASE,
-- StartSlotMachine seats open the minigame. Taken spots persist in -- StartSlotMachine seats open the minigame. Taken spots persist in
@@ -1489,7 +1568,7 @@ function OverworldState:tryHiddenObject(fx, fy)
save.hiddenTaken = save.hiddenTaken or {} save.hiddenTaken = save.hiddenTaken or {}
if save.hiddenTaken[key] then return false end if save.hiddenTaken[key] then return false end
if not require("src.inventory.Bag").add(save, h.item, 1) then 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 return true
end end
save.hiddenTaken[key] = true save.hiddenTaken[key] = true
@@ -1497,7 +1576,7 @@ function OverworldState:tryHiddenObject(fx, fy)
-- hidden items always play SFX_GET_ITEM_2 (hidden_items.asm) -- hidden items always play SFX_GET_ITEM_2 (hidden_items.asm)
require("src.core.Sound").play(Game.data, "Get_Item2") require("src.core.Sound").play(Game.data, "Get_Item2")
Game.stack:push(TextBox.new(Game, 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 return true
end end
end end
@@ -1511,7 +1590,7 @@ function OverworldState:tryHiddenObject(fx, fy)
save.coins = math.min(9999, (save.coins or 0) + h.coins) save.coins = math.min(9999, (save.coins or 0) + h.coins)
require("src.core.Sound").play(Game.data, "Get_Item2") require("src.core.Sound").play(Game.data, "Get_Item2")
Game.stack:push(TextBox.new(Game, 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 return true
end end
end end
@@ -1523,20 +1602,20 @@ function OverworldState:tryHiddenObject(fx, fy)
if h.x == fx and h.y == fy then if h.x == fx and h.y == fy then
if h.state == "out_of_order" then if h.state == "out_of_order" then
Game.stack:push(TextBox.new(Game, txt._GameCornerOutOfOrderText 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 elseif h.state == "out_to_lunch" then
Game.stack:push(TextBox.new(Game, txt._GameCornerOutToLunchText 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 elseif h.state == "keys" then
Game.stack:push(TextBox.new(Game, txt._GameCornerSomeonesKeysText 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 elseif not save.inventory.COIN_CASE then
Game.stack:push(TextBox.new(Game, txt._GameCornerCoinCaseText 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 elseif (save.coins or 0) == 0 then
-- AbleToPlaySlotsCheck: a COIN CASE with no coins can't play -- AbleToPlaySlotsCheck: a COIN CASE with no coins can't play
Game.stack:push(TextBox.new(Game, txt._GameCornerNoCoinsText Game.stack:push(TextBox.new(Game, txt._GameCornerNoCoinsText
or "You don't have\nany coins!")) or Strings("You don't have\nany coins!")))
else else
-- one machine per visit is secretly lucky -- one machine per visit is secretly lucky
-- (wLuckySlotHiddenEventIndex, engine/slots/game_corner_slots.asm) -- (wLuckySlotHiddenEventIndex, engine/slots/game_corner_slots.asm)
@@ -1579,7 +1658,7 @@ function OverworldState:tryHiddenObject(fx, fy)
-- bench guys (data/events/bench_guys.asm) -- bench guys (data/events/bench_guys.asm)
for _, h in ipairs(extras.benchGuys[self.map.id] or {}) do 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 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 if text then
Game.stack:push(TextBox.new(Game, text)) Game.stack:push(TextBox.new(Game, text))
return true return true
@@ -1596,7 +1675,7 @@ function OverworldState:tryHiddenObject(fx, fy)
if gym then if gym then
local key = save.inventory[gym.badge] and "_GymStatueText2" or "_GymStatueText1" local key = save.inventory[gym.badge] and "_GymStatueText2" or "_GymStatueText1"
local text = Game.data.text[key] 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) text = text:gsub("{RAM:wGymCityName}", gym.city)
:gsub("{RAM:wGymLeaderName}", gym.leader) :gsub("{RAM:wGymLeaderName}", gym.leader)
Game.stack:push(TextBox.new(Game, text)) Game.stack:push(TextBox.new(Game, text))
@@ -1609,7 +1688,7 @@ function OverworldState:tryHiddenObject(fx, fy)
for _, h in ipairs(extras.printTrash and extras.printTrash[self.map.id] or {}) do for _, h in ipairs(extras.printTrash and extras.printTrash[self.map.id] or {}) do
if h.x == fx and h.y == fy then if h.x == fx and h.y == fy then
Game.stack:push(TextBox.new(Game, txt._VermilionGymTrashText 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 return true
end end
end end
@@ -1651,7 +1730,7 @@ function OverworldState:tryCardKeyDoor(fx, fy)
local t = Game.data.text local t = Game.data.text
if not Game.save.inventory.CARD_KEY then if not Game.save.inventory.CARD_KEY then
Game.stack:push(TextBox.new(Game, 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 return true
end end
require("src.core.Sound").play(Game.data, "Go_Inside") require("src.core.Sound").play(Game.data, "Go_Inside")
@@ -1668,8 +1747,8 @@ function OverworldState:tryCardKeyDoor(fx, fy)
end end
end end
Game.stack:push(TextBox.new(Game, Game.stack:push(TextBox.new(Game,
(t._CardKeySuccessText1 or "Bingo!") (t._CardKeySuccessText1 or Strings("Bingo!"))
.. (t._CardKeySuccessText2 or "\nThe CARD KEY\nopened the door!"))) .. (t._CardKeySuccessText2 or Strings("\nThe CARD KEY\nopened the door!"))))
return true return true
end end
@@ -1686,7 +1765,7 @@ function OverworldState:trashCanSwitch(canIndex)
local t = Game.data.text local t = Game.data.text
local save = Game.save local save = Game.save
local tc = Game.data.field.hiddenExtras.trashCans 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." -- "Don't do the trash can puzzle if it's already been done."
if save.flags.EVENT_2ND_LOCK_OPENED then if save.flags.EVENT_2ND_LOCK_OPENED then
Game.stack:push(TextBox.new(Game, trashText)) Game.stack:push(TextBox.new(Game, trashText))
@@ -1734,7 +1813,7 @@ function OverworldState:trashCanSwitch(canIndex)
-- open. -- open.
Game.stack:push(TextBox.new(Game, Game.stack:push(TextBox.new(Game,
t._VermilionGymTrashSuccessText1 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)) function() require("src.core.Sound").play(Game.data, "Switch") end))
return return
end end
@@ -1751,7 +1830,7 @@ function OverworldState:trashCanSwitch(canIndex)
-- prints, so the beep fires as the box closes, not as it opens. -- prints, so the beep fires as the box closes, not as it opens.
Game.stack:push(TextBox.new(Game, Game.stack:push(TextBox.new(Game,
t._VermilionGymTrashSuccessText3 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)) function() require("src.core.Sound").play(Game.data, "Go_Inside") end))
else else
-- wrong can: ResetEvent EVENT_1ST_LOCK_OPENED and immediately -- wrong can: ResetEvent EVENT_1ST_LOCK_OPENED and immediately
@@ -1763,7 +1842,7 @@ function OverworldState:trashCanSwitch(canIndex)
-- text prints, so the beep fires as the box closes, not as it opens. -- text prints, so the beep fires as the box closes, not as it opens.
Game.stack:push(TextBox.new(Game, Game.stack:push(TextBox.new(Game,
t._VermilionGymTrashFailText 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)) function() require("src.core.Sound").play(Game.data, "Denied") end))
end end
end end
@@ -1785,12 +1864,12 @@ function OverworldState:billsHousePC()
if flags.EVENT_USED_CELL_SEPARATOR_ON_BILL if flags.EVENT_USED_CELL_SEPARATOR_ON_BILL
or not flags.EVENT_BILL_SAID_USE_CELL_SEPARATOR then or not flags.EVENT_BILL_SAID_USE_CELL_SEPARATOR then
Game.stack:push(TextBox.new(Game, t._BillsHouseMonitorText 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 return
end end
require("src.core.Music").stop() require("src.core.Music").stop()
Game.stack:push(TextBox.new(Game, t._BillsHouseInitiatedText 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 flags.EVENT_USED_CELL_SEPARATOR_ON_BILL = true
require("src.core.Sound").play(Game.data, "Switch") require("src.core.Sound").play(Game.data, "Switch")
self:queueScript({ self:queueScript({
@@ -1827,13 +1906,13 @@ function OverworldState:billsHousePokemonList()
end, end,
}) })
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 -- TextBoxBorder b=10,c=9 at (0,0) -> total tw=11, th=12
Game.stack:push(Menu.new(Game, items, Game.stack:push(Menu.new(Game, items,
{ tx = 0, ty = 0, tw = 11, th = 12 })) { tx = 0, ty = 0, tw = 11, th = 12 }))
end end
Game.stack:push(TextBox.new(Game, t._BillsHousePokemonListText1 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 end
-- BillsHouseBillExitsMachineScript: human Bill appears inside the machine -- BillsHouseBillExitsMachineScript: human Bill appears inside the machine
@@ -1927,7 +2006,7 @@ function OverworldState:trySurf(fx, fy)
local p = self.player local p = self.player
p.surfing = true p.surfing = true
require("src.core.Music").setSurfing(Game.data, 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) :gsub("{RAM:wNameBuffer}", name)
Game.stack:push(TextBox.new(Game, text, function() Game.stack:push(TextBox.new(Game, text, function()
-- start_sub_menus.asm .surf: UseItem returns (mount + text done), -- start_sub_menus.asm .surf: UseItem returns (mount + text done),
@@ -1975,7 +2054,7 @@ function OverworldState:tryCut(fx, fy)
-- gen 1 confirms nothing (engine/overworld/cut.asm UsedCut): the -- gen 1 confirms nothing (engine/overworld/cut.asm UsedCut): the
-- _UsedCutText message, then the tree vanishes with dust + SFX_CUT -- _UsedCutText message, then the tree vanishes with dust + SFX_CUT
local name = mon.nickname or Game.data.pokemon[mon.species].name 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) :gsub("{RAM:wNameBuffer}", name)
Game.stack:push(TextBox.new(Game, text, function() Game.stack:push(TextBox.new(Game, text, function()
self.cutBlocks = self.cutBlocks or {} self.cutBlocks = self.cutBlocks or {}
@@ -2111,7 +2190,7 @@ function OverworldState:talkTo(npc)
-- the string "0" as truthy, so screen it out and fall through to text. -- 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 d.item and d.item ~= "0" and d.item ~= 0 then
if not require("src.inventory.Bag").add(Game.save, d.item, 1) 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 return
end end
Game.save.itemsTaken = Game.save.itemsTaken or {} Game.save.itemsTaken = Game.save.itemsTaken or {}
@@ -2127,7 +2206,7 @@ function OverworldState:talkTo(npc)
require("src.core.Sound").play(Game.data, require("src.core.Sound").play(Game.data,
(ddef and ddef.keyItem) and "Get_Key_Item" or "Get_Item1") (ddef and ddef.keyItem) and "Get_Key_Item" or "Get_Item1")
Game.stack:push(TextBox.new(Game, 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 return
end end
@@ -2136,7 +2215,7 @@ function OverworldState:talkTo(npc)
if d.pokemon then if d.pokemon then
npc:facePlayer(self.player) npc:facePlayer(self.player)
local text = select(1, Game.data:resolveText(self.map.def.label, d.text)) local text = select(1, Game.data:resolveText(self.map.def.label, d.text))
or "Gyaoo!" or Strings("Gyaoo!")
local BattleState = require("src.battle.BattleState") local BattleState = require("src.battle.BattleState")
Game.stack:push(TextBox.new(Game, text, function() Game.stack:push(TextBox.new(Game, text, function()
local battle = BattleState.newWild(Game, d.pokemon, d.level) local battle = BattleState.newWild(Game, d.pokemon, d.level)
@@ -2179,7 +2258,7 @@ function OverworldState:talkTo(npc)
if entry then if entry then
if entry.mart then if entry.mart then
npc:facePlayer(self.player) 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) Screens.push(Game, "ShopMenu", entry.mart)
unfreeze() unfreeze()
end)) end))
@@ -2223,7 +2302,7 @@ function OverworldState:openPC(onDone)
-- when Bill hands over the SS Ticket) -- when Bill hands over the SS Ticket)
local metBill = flags.EVENT_MET_BILL or flags.EVENT_GOT_SS_TICKET local metBill = flags.EVENT_MET_BILL or flags.EVENT_GOT_SS_TICKET
table.insert(items, { 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() onSelect = function()
require("src.core.Sound").play(Game.data, "Enter_PC") require("src.core.Sound").play(Game.data, "Enter_PC")
Screens.push(Game, "BoxMenu") Screens.push(Game, "BoxMenu")
@@ -2243,7 +2322,7 @@ function OverworldState:openPC(onDone)
-- Prof. Oak's dex rating only appears once you have the Pokédex -- Prof. Oak's dex rating only appears once you have the Pokédex
if flags.EVENT_GOT_POKEDEX then if flags.EVENT_GOT_POKEDEX then
table.insert(items, { table.insert(items, {
label = "PROF.OAK's PC", label = Strings("PROF.OAK's PC"),
onSelect = function() onSelect = function()
self:dexRating() self:dexRating()
done() done()
@@ -2263,7 +2342,7 @@ function OverworldState:openPC(onDone)
require("src.core.Sound").play(Game.data, "Turn_Off_PC") require("src.core.Sound").play(Game.data, "Turn_Off_PC")
done() done()
end 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 -- pokered sets BIT_NO_MENU_BUTTON_SOUND for the whole PC session
-- (engine/overworld/pokecenter_pc.asm / player_pc.asm); DisplayPCMainMenu -- (engine/overworld/pokecenter_pc.asm / player_pc.asm); DisplayPCMainMenu
-- calls TextBoxBorder with c=14 (interior width, +2 for the border), so -- calls TextBoxBorder with c=14 (interior width, +2 for the border), so
@@ -2288,9 +2367,9 @@ function OverworldState:dexRating()
local lo = math.floor(owned / 10) * 10 local lo = math.floor(owned / 10) * 10
key = ("_DexRatingText_Own%dTo%d"):format(lo, lo + 9) key = ("_DexRatingText_Own%dTo%d"):format(lo, lo + 9)
end 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 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 completion = completion
:gsub("{NUM:hDexRatingNumMonsSeen[^}]*}", tostring(seen)) :gsub("{NUM:hDexRatingNumMonsSeen[^}]*}", tostring(seen))
:gsub("{NUM:hDexRatingNumMonsOwned[^}]*}", tostring(owned)) :gsub("{NUM:hDexRatingNumMonsOwned[^}]*}", tostring(owned))
@@ -2337,20 +2416,20 @@ end
-- POKéMON" and "fighting fit". -- POKéMON" and "fighting fit".
function OverworldState:nurseHeal(onDone, npc) function OverworldState:nurseHeal(onDone, npc)
local t = Game.data.text 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 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 if not Game.save.usedPokecenter then
Game.save.usedPokecenter = true -- BIT_USED_POKECENTER Game.save.usedPokecenter = true -- BIT_USED_POKECENTER
hello = hello .. "\f" hello = hello .. "\f"
.. (t._ShallWeHealYourPokemonText or "Shall we heal your\nPOKéMON?") .. (t._ShallWeHealYourPokemonText or Strings("Shall we heal your\nPOKéMON?"))
end end
Game.stack:push(TextBox.new(Game, hello, nil, { choice = function(yes) Game.stack:push(TextBox.new(Game, hello, nil, { choice = function(yes)
if not yes then if not yes then
Game.stack:push(TextBox.new(Game, bye, onDone)) Game.stack:push(TextBox.new(Game, bye, onDone))
return return
end 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() Game.stack:push(TextBox.new(Game, need, function()
-- the nurse turns to the machine, the map music stops, and the -- the nurse turns to the machine, the map music stops, and the
-- party heals before the machine runs (predef HealParty) -- party heals before the machine runs (predef HealParty)
@@ -2385,7 +2464,7 @@ end
function OverworldState:finishNurseHeal(bye, onDone) function OverworldState:finishNurseHeal(bye, onDone)
local t = Game.data.text 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)) Game.stack:push(TextBox.new(Game, fit .. "\f" .. bye, onDone))
end end
@@ -2397,21 +2476,21 @@ end
-- for the original serial handshake; declining prints "Please come again!" -- for the original serial handshake; declining prints "Please come again!"
function OverworldState:cableClubReceptionist(onDone) function OverworldState:cableClubReceptionist(onDone)
local t = Game.data.text 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 if not Game.save.flags.EVENT_GOT_POKEDEX then
-- CableClubNPC .didNotConnect path before the pokedex -- CableClubNPC .didNotConnect path before the pokedex
Game.stack:push(TextBox.new(Game, welcome .. "\f" Game.stack:push(TextBox.new(Game, welcome .. "\f"
.. (t._CableClubNPCMakingPreparationsText .. (t._CableClubNPCMakingPreparationsText
or "We're making\npreparations.\vPlease wait."), onDone)) or Strings("We're making\npreparations.\vPlease wait.")), onDone))
return return
end end
local apply = t._CableClubNPCPleaseApplyHereHaveToSaveText 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, Game.stack:push(TextBox.new(Game, welcome .. "\f" .. apply, nil,
{ choice = function(yes) { choice = function(yes)
if not yes then if not yes then
Game.stack:push(TextBox.new(Game, Game.stack:push(TextBox.new(Game,
t._CableClubNPCPleaseComeAgainText or "Please come\nagain!", onDone)) t._CableClubNPCPleaseComeAgainText or Strings("Please come\nagain!"), onDone))
return return
end end
Game:writeSave() Game:writeSave()
@@ -2446,7 +2525,7 @@ function OverworldState:engageTrainer(npc, onDone)
local battleText = header and header.battle and Game.data.text[header.battle] local battleText = header and header.battle and Game.data.text[header.battle]
if not battleText then if not battleText then
battleText = select(1, Game.data:resolveText(self.map.def.label, d.text)) 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 end
local wonText = header and header.won and Game.data.text[header.won] local wonText = header and header.won and Game.data.text[header.won]
@@ -2525,11 +2604,11 @@ function OverworldState:checkVictoryRewards(trainerClass, partyIndex)
if reward.badge then if reward.badge then
local name = Game.data.items[reward.badge] and Game.data.items[reward.badge].name local name = Game.data.items[reward.badge] and Game.data.items[reward.badge].name
or reward.badge 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 end
if reward.item then if reward.item then
local name = Game.stringBuffer or reward.item 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
end end
if #lines > 0 then if #lines > 0 then
@@ -2723,7 +2802,7 @@ function OverworldState:applyFieldPoison()
local queue = {} local queue = {}
for _, mon in ipairs(fainted) do for _, mon in ipairs(fainted) do
local name = mon.nickname or Game.data.pokemon[mon.species].name 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 end
local alive = false local alive = false
for _, mon in ipairs(save.party) do for _, mon in ipairs(save.party) do
@@ -2737,7 +2816,7 @@ function OverworldState:applyFieldPoison()
end end
if not alive then if not alive then
Game.stack:push(TextBox.new(Game, 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") local Pokemon = require("src.pokemon.Pokemon")
for _, mon in ipairs(save.party) do Pokemon.heal(mon) end for _, mon in ipairs(save.party) do Pokemon.heal(mon) end
save.money = math.floor(save.money save.money = math.floor(save.money
@@ -2781,11 +2860,18 @@ end
function OverworldState:onStepComplete() function OverworldState:onStepComplete()
local p = self.player local p = self.player
self.todSteps = (self.todSteps or 0) + 1
-- re-evaluate day/night so a step-based clock can fire world.tod_changed;
-- paletteNameFor reads self.tod on the next paint
if Runtime.wantsHook("world.tod") then
self:timeOfDay()
end
-- hot path: the payload is only built when something is listening -- hot path: the payload is only built when something is listening
if Runtime.wants("world.stepped") then if Runtime.wants("world.stepped") then
Runtime.emit("world.stepped", { mapId = self.map.id, x = p.cellX, y = p.cellY, Runtime.emit("world.stepped", { mapId = self.map.id, x = p.cellX, y = p.cellY,
tile = self.map:cellTile(p.cellX, p.cellY) }) tile = self.map:cellTile(p.cellX, p.cellY),
tod = self.tod })
end end
-- dismounting a surf: landing on a walkable cell ends it -- dismounting a surf: landing on a walkable cell ends it
@@ -2866,7 +2952,7 @@ function OverworldState:onStepComplete()
if Game.save.repelSteps == 0 then if Game.save.repelSteps == 0 then
-- no encounter on the exact wear-off step (wild_encounters.asm -- no encounter on the exact wear-off step (wild_encounters.asm
-- .lastRepelStep returns CantEncounter) -- .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 return
end end
end end
@@ -3000,14 +3086,14 @@ function OverworldState:checkBadgeGate()
-- Route22GateGuardGoRightAheadText plays sound_get_item_1 -- Route22GateGuardGoRightAheadText plays sound_get_item_1
require("src.core.Sound").play(Game.data, "Get_Item1") require("src.core.Sound").play(Game.data, "Get_Item1")
Game.stack:push(TextBox.new(Game, Game.stack:push(TextBox.new(Game,
t["_" .. g.passText] or "Go right ahead!")) t["_" .. g.passText] or Strings("Go right ahead!")))
end end
return false return false
end end
-- Route22GateGuardNoBoulderbadgeText plays SFX_DENIED -- Route22GateGuardNoBoulderbadgeText plays SFX_DENIED
require("src.core.Sound").play(Game.data, "Denied") require("src.core.Sound").play(Game.data, "Denied")
Game.stack:push(TextBox.new(Game, 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() .. (t._Route22GateGuardICantLetYouPassText or ""), function()
self:scriptMove(p, "down", 1) self:scriptMove(p, "down", 1)
end)) end))
@@ -3028,14 +3114,14 @@ function OverworldState:checkBadgeGate()
-- Route23OhThatIsTheBadgeText plays sound_get_item_1 -- Route23OhThatIsTheBadgeText plays sound_get_item_1
require("src.core.Sound").play(Game.data, "Get_Item1") require("src.core.Sound").play(Game.data, "Get_Item1")
local text = (t["_" .. g.passText] or 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)) Game.stack:push(TextBox.new(Game, text))
return false return false
end end
-- Route23YouDontHaveTheBadgeYetText plays SFX_DENIED -- Route23YouDontHaveTheBadgeYetText plays SFX_DENIED
require("src.core.Sound").play(Game.data, "Denied") require("src.core.Sound").play(Game.data, "Denied")
local text = (t["_" .. g.failText] or 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() Game.stack:push(TextBox.new(Game, text, function()
self:scriptMove(p, "down", 1) self:scriptMove(p, "down", 1)
end)) end))
@@ -3073,7 +3159,7 @@ function OverworldState:checkForcedMovement()
Game.save.forcedBike = true Game.save.forcedBike = true
require("src.core.Music").playMap(Game.data, self.map.id, true) require("src.core.Music").playMap(Game.data, self.map.id, true)
else 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() function()
local back = ({ up = "down", down = "up", local back = ({ up = "down", down = "up",
left = "right", right = "left" })[p.facing] left = "right", right = "left" })[p.facing]
@@ -3201,7 +3287,7 @@ function OverworldState:boulderIntoHole(npc)
for i = #self.entities, 1, -1 do for i = #self.entities, 1, -1 do
if self.entities[i] == npc then table.remove(self.entities, i) end if self.entities[i] == npc then table.remove(self.entities, i) end
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 return true
end end
end end
@@ -3231,7 +3317,7 @@ function OverworldState:safariStep()
if not st or not self:inSafariStepZone() then return false end if not st or not self:inSafariStepZone() then return false end
st.steps = st.steps - 1 st.steps = st.steps - 1
if st.steps > 0 then return false end 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 return true
end end
@@ -3240,7 +3326,7 @@ function OverworldState:safariGameOver(text)
Game.save.safari = nil Game.save.safari = nil
local t = Game.data.text local t = Game.data.text
Game.stack:push(TextBox.new(Game, 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() function()
local exit_ = FieldDefaults.fieldValue(Game.data, "safari", "exitWarp") local exit_ = FieldDefaults.fieldValue(Game.data, "safari", "exitWarp")
self:startWarpTo(exit_.map, exit_.x, exit_.y, exit_.facing or "down") self:startWarpTo(exit_.map, exit_.x, exit_.y, exit_.facing or "down")
@@ -3283,7 +3369,7 @@ function OverworldState:afterBattle(result, battle)
else else
-- throwing the last SAFARI BALL ends the game -- throwing the last SAFARI BALL ends the game
if Game.save.safari and Game.save.safari.balls <= 0 then 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 end
evolutions() evolutions()
end end
+17 -4
View File
@@ -4,6 +4,7 @@
local Collision = require("src.world.Collision") local Collision = require("src.world.Collision")
local FieldDefaults = require("src.world.FieldDefaults") local FieldDefaults = require("src.world.FieldDefaults")
local Runtime = require("src.mods.Runtime")
local SpriteRenderer = require("src.render.SpriteRenderer") local SpriteRenderer = require("src.render.SpriteRenderer")
local Player = {} local Player = {}
@@ -82,10 +83,22 @@ function Player:tryMove(dir, map, entities)
self.moving = true self.moving = true
self.bumpFrames = nil -- a real step supersedes any in-place bonk self.bumpFrames = nil -- a real step supersedes any in-place bonk
self.progress = 0 self.progress = 0
-- the bicycle doubles walking speed (8 frames per step) -- the bicycle doubles walking speed (8 frames per step); movement.speed
local save = require("src.core.Game").save -- lets a mod multiply or replace that (running shoes, dash, etc.)
self.stepFramesCur = (save and save.onBike) and self.bikeStepFrames local Game = require("src.core.Game")
or self.stepFrames or STEP_FRAMES local save = Game.save
local frames = (save and save.onBike) and self.bikeStepFrames
or self.stepFrames or STEP_FRAMES
if Runtime.wantsHook("movement.speed") then
frames = Runtime.call("movement.speed", function(f) return f end, frames, {
onBike = save and save.onBike or false,
surfing = self.surfing and true or false,
player = self,
input = Game.input,
save = save,
})
end
self.stepFramesCur = math.max(1, math.floor(tonumber(frames) or STEP_FRAMES))
return "moved" return "moved"
end 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
+23
View File
@@ -0,0 +1,23 @@
-- Driver: drops a fake 3-mon party into the overworld and then gets out
-- of the way -- yields forever without ever touching input itself, so
-- real keyboard/controller play works normally from here on. This is
-- just a quick way to pop up a playable window with Pokemon already in
-- the party, not a scripted playthrough.
return function(game)
local U = dofile("tests/drivers/util.lua")
local Pokemon = require("src.pokemon.Pokemon")
game.save.party = {
Pokemon.new(game.data, "CHARIZARD", 50),
Pokemon.new(game.data, "PIKACHU", 30),
Pokemon.new(game.data, "SNORLAX", 77),
}
game.save.player.name = "bryan"
U.teleport(game, "PALLET_TOWN", 10, 8, "down")
U.log("popup_fake_save: party ready, handing off to real input")
while true do
coroutine.yield()
end
end
+243
View File
@@ -0,0 +1,243 @@
-- Driver: NEW GAME through the silly Oak intro (example_silly_oak must be
-- installed under mods/). Mashes text, picks every menu option in a fixed
-- pattern, screenshots key beats, then asserts mod.save answers.
--
-- Setup:
-- cp -r mods/examples/example_silly_oak mods/
-- SHOT_DIR=/tmp/silly_oak POKEPORT_DRIVER=tests/drivers/silly_oak_intro_test.lua love .
return function(game)
local U = dofile("tests/drivers/util.lua")
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
local function top() return game.stack:top() end
local function speechState()
for _, s in ipairs(game.stack.states or {}) do
if s.oakPic ~= nil or (s.steps and s.answers) then return s end
end
end
local function topName()
local s = top()
if not s then return "?" end
return s.screenId or (s.__index and tostring(s.__index)) or tostring(s)
end
local function isMenu(s)
return s and s.items and s.index and s.tx ~= nil
end
local function isNaming(s)
return s and s.glyphs ~= nil and s.title ~= nil
end
local function isChoiceBox(s)
return s and s.onChoose and s.index ~= nil and not s.items
end
-- confirm the mod actually loaded
local loaded = false
if game.modStatus and game.modStatus.loaded then
for _, m in ipairs(game.modStatus.loaded) do
if m.id == "example_silly_oak" then loaded = true break end
end
end
if not loaded and game.mods and game.mods.mods then
local m = game.mods.mods.example_silly_oak
loaded = m and m.enabled and not m.failed
end
U.log("example_silly_oak loaded:", tostring(loaded))
if not loaded then
U.log("FAIL: copy the mod up first:")
U.log(" cp -r mods/examples/example_silly_oak mods/")
return
end
U.wait(5)
U.tap(game, "start") -- skip intro movie
U.wait(15)
U.tap(game, "a") -- title -> menu
U.wait(8)
-- with an existing save the menu is CONTINUE / NEW GAME / OPTION;
-- always land on NEW GAME (row 2 when a save exists, else row 1)
local ok, saved = pcall(function()
return require("src.core.SaveData").load() ~= nil
end)
if ok and saved then
U.tap(game, "down")
U.wait(5)
end
U.tap(game, "a") -- NEW GAME
U.wait(20)
-- confirm we actually entered OakSpeech, not CONTINUE into the world
local entered = false
for _ = 1, 60 do
if speechState() then entered = true break end
if game.overworld and top() == game.overworld then break end
U.wait(1)
end
if not entered then
U.log("FAIL: never entered OakSpeech (did we hit CONTINUE?)")
U.log("top:", topName())
return
end
U.shot(game, DIR .. "/silly_01_start.png")
-- walk the speech: A advances text; menus get a deliberate pick pattern
local picks = {
-- silly_toast YES/NO: pick YES (index 1)
toast = 1,
-- naming player: take preset 1 (RED) via NEW NAME menu -> first preset
-- snack: OLD ROD (index 3)
snack = 3,
-- trusts rival: NO (index 2)
trust = 2,
-- pineapple YES/NO: pick NO
pineapple = 2,
}
local saw = {
toast_kid = false, mew = false, snack = false, trust = false,
pineapple = false, shrink = false,
}
local snackPicked, trustPicked = false, false
local toastAnswered, pineappleAnswered = false, false
local shot = {}
for _ = 1, 900 do
local speech = speechState()
local s = top()
if speech and speech.shrink then
saw.shrink = true
if not shot.shrink then
shot.shrink = true
U.shot(game, DIR .. "/silly_05_shrink.png")
end
break
end
-- track which injected beats we hit + screenshot once each
if speech and speech.steps and speech.step then
local cur = speech.steps[speech.step]
if cur then
if cur.id == "silly_toast_kid" then
saw.toast_kid = true
if not shot.toast_kid then
shot.toast_kid = true
U.shot(game, DIR .. "/silly_04_toast_kid.png")
end
end
if cur.id == "silly_mew" then
saw.mew = true
if not shot.mew then
shot.mew = true
U.shot(game, DIR .. "/silly_04b_mew.png")
end
end
if cur.id == "silly_snack" then saw.snack = true end
if cur.id == "silly_trust" then saw.trust = true end
if cur.id == "silly_pineapple" then saw.pineapple = true end
end
end
if isChoiceBox(s) then
-- YES/NO: move to desired row then A
local want
local cur = speech and speech.steps and speech.steps[speech.step]
if cur and cur.id == "silly_toast" then
want = picks.toast
toastAnswered = true
elseif cur and cur.id == "silly_pineapple" then
want = picks.pineapple
pineappleAnswered = true
else
want = 1
end
if s.index ~= want then
U.tap(game, "down")
U.wait(2)
end
U.tap(game, "a")
U.wait(4)
elseif isMenu(s) and not isNaming(s) then
-- multi choice or naming presets
local cur = speech and speech.steps and speech.steps[speech.step]
local want = 1
if cur and cur.id == "silly_snack" then
want = picks.snack
snackPicked = true
if not shot.snack then
shot.snack = true
U.shot(game, DIR .. "/silly_02_snack.png")
end
elseif cur and cur.id == "silly_trust" then
want = picks.trust
trustPicked = true
if not shot.trust then
shot.trust = true
U.shot(game, DIR .. "/silly_03_trust.png")
end
elseif s.items and s.items[1] and s.items[1].label == "NEW NAME" then
-- naming preset menu: pick first preset (row 2)
want = 2
end
while s.index < want do
U.tap(game, "down")
U.wait(2)
s = top()
if not isMenu(s) then break end
end
U.tap(game, "a")
U.wait(4)
else
U.tap(game, "a")
U.wait(2)
end
if game.overworld and top() == game.overworld then break end
end
-- finish shrink + land in overworld
for _ = 1, 150 do
if game.overworld and top() == game.overworld then break end
U.wait(1)
end
U.wait(10)
U.shot(game, DIR .. "/silly_06_overworld.png")
local bucket = (game.save and game.save.modData
and game.save.modData.example_silly_oak)
or (game.mods and game.mods.modSave
and game.mods.modSave.example_silly_oak)
or {}
U.log("saw toast_kid:", tostring(saw.toast_kid),
"mew:", tostring(saw.mew),
"snack:", tostring(saw.snack),
"trust:", tostring(saw.trust),
"pineapple:", tostring(saw.pineapple),
"shrink:", tostring(saw.shrink))
U.log("answers:",
"likes_toast=", tostring(bucket.likes_toast),
"snack=", tostring(bucket.snack),
"trusts_rival=", tostring(bucket.trusts_rival),
"pineapple=", tostring(bucket.pineapple_on_pizza),
"quiz_done=", tostring(bucket.quiz_done))
U.log("player=", game.save and game.save.player and game.save.player.name,
"rival=", game.save and game.save.player and game.save.player.rival)
U.log("top:", topName(),
"map:", game.overworld and game.overworld.map
and game.overworld.map.id or "?")
local function need(cond, msg)
if not cond then U.log("FAIL:", msg) else U.log("ok:", msg) end
end
need(saw.toast_kid, "custom Toast Kid sprite beat ran")
need(saw.mew, "MEW sprite beat ran")
need(saw.snack or snackPicked, "snack choice ran")
need(saw.trust or trustPicked, "rival trust choice ran")
need(saw.pineapple or pineappleAnswered, "pineapple yes/no ran")
need(bucket.likes_toast == true, "likes_toast saved true")
need(bucket.snack == "OLD ROD", "snack saved OLD ROD")
need(bucket.trusts_rival == false, "trusts_rival saved false")
need(bucket.pineapple_on_pizza == false, "pineapple saved false")
need(bucket.quiz_done == true, "quiz_done set")
need(game.overworld and top() == game.overworld, "landed in overworld")
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

Some files were not shown because too many files have changed in this diff Show More