Compare commits

..

18 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
bryanthaboi 043f6b2426 Bugsquashingjuly26 (#264)
* big bug squash

* ok
2026-07-26 14:11:07 -04:00
bryanthaboi f7695308b7 big bug squash (#261) 2026-07-26 13:38:52 -04:00
bryanthaboi a8936e79d6 Add on-screen touch controls and assets 2026-07-25 23:16:15 -04:00
207 changed files with 14859 additions and 1871 deletions
+14 -1
View File
@@ -25,7 +25,9 @@ body:
options:
- macOS
- Windows
- Both
- Linux
- Android
- Multiple platforms
validations:
required: true
@@ -38,6 +40,17 @@ body:
validations:
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
id: location
attributes:
+34 -19
View File
@@ -1,35 +1,53 @@
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"]
body:
- type: markdown
attributes:
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,
and how you picture it working. A request with real detail is one someone can
actually build without guessing at what you meant.
and how you picture it working.
- type: input
id: summary
attributes:
label: One line summary
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:
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
id: what
attributes:
label: What do you want
description: >
Describe it properly. What is it, where does it live in the game, what does the
player see or do. If it changes something that already exists, say what it does today
and what it should do instead.
Describe it properly. What is it, where does it live (launcher, options,
engine), what does the player see or do. If it changes something that already
exists, say what it does today and what it should do instead.
placeholder: |
A hold-to-run button, the way later Pokemon games do it. Hold B while moving on
foot and the player moves at bike speed. Release and you go back to walking.
Doesn't work indoors where the bike doesn't work, doesn't work in battles or menus.
Ship a Linux AppImage on each release, same version as the macOS/Windows builds,
with the same save folder layout and mod discovery path.
validations:
required: true
@@ -41,9 +59,8 @@ body:
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.
placeholder: |
Backtracking across routes you've already cleared is slow, and the bike is a menu
dive away and doesn't work in a lot of places. Running would cut a lot of dead time
without making the game easier.
LÖVE already runs on Linux; without a packaged build, players have to assemble
it themselves and miss release notes / update checks.
validations:
required: true
@@ -52,14 +69,12 @@ body:
attributes:
label: How should it work
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.
placeholder: |
- Hold B on the overworld to run
- Same speed as the bike
- Disabled anywhere the bike is disabled
- Sprite should probably need a running animation, not sure what that takes
- No effect on encounter rates
- GitHub Releases asset next to the .dmg / .exe
- Same options.lua / mods/ layout as desktop
- Documented in the README install section
validations:
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:
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:
# 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:
# * run this workflow manually (Actions tab) and type it into "version", or
# * 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:
push:
+9
View File
@@ -0,0 +1,9 @@
# On-screen touch control art
From Xelu's "Free Controller & Keyboard Prompts" pack (Nicolae Berbece /
Those Awesome Guys), public domain under CC0:
https://thoseawesomeguys.com/prompts/
256px Nintendo Switch set, renamed for src/core/TouchControls.lua:
d-pad (neutral + per-direction highlight), A, B, plus (START) and
minus (SELECT).
Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 751 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

+3 -2
View File
@@ -47,8 +47,9 @@ function love.conf(t)
-- freely to portrait or landscape), otherwise locked to the window's w/h
-- aspect. So a non-resizable tall window forced portrait; resizable lets
-- the game follow the device. The renderer letterboxes the 160x144
-- viewport into whatever size results, and touch input is gesture-based,
-- so both orientations just work. iOS follows the Info.plist orientations
-- viewport into whatever size results, and the on-screen touch controls
-- re-lay themselves out from the new window size, so both orientations
-- just work. iOS follows the Info.plist orientations
-- (see mobile/ios/overlays/love-ios.plist, now portrait + landscape).
t.window.resizable = true
-- Starting size is a tall portrait hint; the OS resizes to the real
+10 -2
View File
@@ -1,13 +1,21 @@
-- pokered/scripts/PewterNidoranHouse.asm
-- PewterNidoranHouseNidoranText: text_far _PewterNidoranHouseNidoranText, then
-- text_asm plays the NIDORAN_M cry (PlayCry/WaitForSoundToFinish) before ending.
-- No sound-effect command exists in Commands.lua, so we port the dialogue line
-- only; the cry SFX is cosmetic and has no gameplay effect.
-- play_cry only stashes ctx.pendingCry for the very next show_text row, so it
-- has to sit immediately before that row (src/script/Commands.lua); the cry
-- then sounds once the box has typed out, matching the asm's print-then-cry
-- order. Its waitForButton form keeps DisplayTextID's trailing
-- WaitForTextScrollButtonPress -- PewterNidoranHouse_Script is
-- `jp EnableAutoTextBoxDrawing`, which zeroes
-- wDoNotWaitForButtonPressAfterDisplayingText -- so the box still holds until
-- A/B. The cry row was missing, leaving the NIDORAN silent (#247).
return {
PEWTER_NIDORAN_HOUSE = {
talk = {
TEXT_PEWTERNIDORANHOUSE_NIDORAN = {
{ "face_player" },
-- text_asm: ld a, NIDORAN_M / call PlayCry
{ "play_cry", "NIDORAN_M", true },
{ "show_text", "_PewterNidoranHouseNidoranText" },
},
},
+7 -3
View File
@@ -59,13 +59,17 @@ M.VIRIDIAN_CITY = {
-- ViridianCityYoungster2CaterpieAndWeedleDescriptionText are
-- defined without a leading underscore in pokered/text/ViridianCity.asm
-- and aren't present in data/generated/text.lua, so we fall back to
-- the literal strings from pokered.
-- the literal strings from pokered. Those fallbacks have to carry the
-- extractor's markers, not plain newlines: line -> \n, cont -> \v,
-- para -> \f. Spelling cont/para as \n and \n\n put all six lines on
-- one page with nothing to wait on, so the whole speech scrolled past
-- without a button press (#250).
TEXT_VIRIDIANCITY_YOUNGSTER2 = function(game, ow, npc, done)
local t = text(game)
ask(game, t._ViridianCityYoungster2YouWantToKnowAboutText
or "You want to know\nabout the 2 kinds\nof caterpillar\nPOKéMON?", function(yes)
or "You want to know\nabout the 2 kinds\vof caterpillar\vPOKéMON?", function(yes)
if yes then
push(game, "CATERPIE has no\npoison, but\nWEEDLE does.\n\nWatch out for its\nPOISON STING!", done)
push(game, "CATERPIE has no\npoison, but\vWEEDLE does.\fWatch out for its\nPOISON STING!", done)
else
push(game, "Oh, OK then!", done)
end
@@ -3,12 +3,16 @@
return {
VIRIDIAN_NICKNAME_HOUSE = {
talk = {
-- ViridianNicknameHouseSpearowText: text_asm that shows the text
-- then plays a SPEAROW cry (PlayCry/WaitForSoundToFinish). The
-- cry playback isn't in the talk-script command vocabulary, so
-- only the flavor line is ported here.
-- ViridianNicknameHouseSpearowText: text_asm that PrintTexts the
-- line, then plays the SPEAROW cry (PlayCry/WaitForSoundToFinish)
-- before TextScriptEnd. play_cry only arms the show_text row that
-- follows it, so the cry sounds once the box has typed out; its
-- waitForButton form keeps DisplayTextID's trailing
-- WaitForTextScrollButtonPress, so the box then holds until A/B like
-- any other NPC line rather than popping itself (#251).
TEXT_VIRIDIANNICKNAMEHOUSE_SPEAROW = {
{ "show_text", "_ViridianNicknameHouseSpearowText" },
{ "play_cry", "SPEAROW", true }, -- 1 PlayCry
{ "show_text", "_ViridianNicknameHouseSpearowText" }, -- 2 PrintText
},
},
},
+59 -34
View File
@@ -6,7 +6,8 @@
-- takes it ("I'll take this one, then!") and both balls disappear.
-- Source: scripts/OaksLab.asm OaksLabCharmanderPokeBallText /
-- OaksLabRivalTakePokeBallScript.
-- * Rival (object 1): before starter -> "gramps isn't around"; with
-- * Rival (object 1): before starter -> "go ahead and choose" once Oak
-- has walked you in, else "gramps isn't around" (#218); with
-- starter -> taunt + battle OPP_RIVAL1 with the counter-pick party
-- (player Bulbasaur -> rival Charmander etc., parties 1/2/3 =
-- Squirtle/Bulbasaur/Charmander in data/trainers/parties.asm);
@@ -162,36 +163,45 @@ return {
starterBall("_OaksLabYouWantBulbasaurText", "BULBASAUR", "EVENT_CHOSE_BULBASAUR",
"OAKSLAB_BULBASAUR_POKE_BALL", 6, "OAKSLAB_CHARMANDER_POKE_BALL"),
-- Talking to the rival only ever prints a line: the lab battle is a
-- coordinate trigger (OaksLabRivalChallengesPlayerScript, wYCoord == 6;
-- see onStep below), never a talk action. The handler used to fall
-- through from the "looks stronger" taunt straight into start_battle,
-- so talking to Blue at the table launched the rival fight before the
-- player ever stepped onto the trigger (#219). scripts/OaksLab.asm
-- OaksLabText8 branches text only:
-- * got starter, not yet battled -> _OaksLabRivalMyPokemonLooksStronger
-- * already battled -> _OaksLabRivalFedUpWithWaitingText (a Route 22
-- line; normally unreachable since the rival is hidden after the
-- lab battle in OaksLabRivalEndBattleScript)
-- * no starter yet -> the FOLLOWED_OAK pre-starter fork (#218)
TEXT_OAKSLAB_RIVAL = {
{ "face_player" }, -- 1
{ "check_flag", "EVENT_GOT_STARTER" }, -- 2
{ "jump_if_false", 21 }, -- 3
{ "check_flag", "EVENT_BATTLED_RIVAL_IN_OAKS_LAB" }, -- 4
{ "jump_if_true", 19 }, -- 5
{ "show_text", "_OaksLabRivalMyPokemonLooksStrongerText" }, -- 6
{ "check_flag", "EVENT_CHOSE_BULBASAUR" }, -- 7
{ "jump_if_false", 11 }, -- 8
{ "start_battle", "trainer", "OPP_RIVAL1", 3 }, -- 9 Charmander
{ "jump", 16 }, -- 10
{ "check_flag", "EVENT_CHOSE_SQUIRTLE" }, -- 11
{ "jump_if_false", 15 }, -- 12
{ "start_battle", "trainer", "OPP_RIVAL1", 2 }, -- 13 Bulbasaur
{ "jump", 16 }, -- 14
{ "start_battle", "trainer", "OPP_RIVAL1", 1 }, -- 15 Squirtle
-- OaksLabRivalEndBattleScript: HealParty + flag, then exit either way
{ "heal_party" }, -- 16
{ "set_flag", "EVENT_BATTLED_RIVAL_IN_OAKS_LAB" }, -- 17
{ "jump", 23 }, -- 18
{ "show_text", "_OaksLabRivalFedUpWithWaitingText" }, -- 19
{ "jump", "end" }, -- 20
{ "show_text", "_OaksLabRivalGrampsIsntAroundText" }, -- 21
{ "jump", "end" }, -- 22
-- win: sulk text then exit; loss: Rival1WinText already played in
-- battle (HandlePlayerBlackOut), so skip straight to the walk-out
{ "jump_if_false", 25 }, -- 23
{ "show_text", "_OaksLabRivalIPickedTheWrongPokemonText" }, -- 24
{ "move_npc_to", 1, 4, 11 }, -- 25
{ "hide_object", "OAKS_LAB", "OAKSLAB_RIVAL" }, -- 26
{ "face_player" },
{ "check_flag", "EVENT_GOT_STARTER" },
{ "jump_if_false", "pre_starter" },
{ "check_flag", "EVENT_BATTLED_RIVAL_IN_OAKS_LAB" },
{ "jump_if_true", "after_battle" },
-- has a starter, has not fought yet: just the taunt. The battle is
-- the coordinate trigger in onStep, not this talk (#219)
{ "show_text", "_OaksLabRivalMyPokemonLooksStrongerText" },
{ "jump", "end" },
{ "label", "after_battle" },
{ "show_text", "_OaksLabRivalFedUpWithWaitingText" },
{ "jump", "end" },
-- pre-starter fork (scripts/OaksLab.asm OaksLabText8 rival handler):
-- Oak has already escorted you in (three balls on the table) -> he
-- waves you on to choose; not yet escorted (very early game) -> the
-- "Gramps isn't around" line. #218
{ "label", "pre_starter" },
{ "check_flag", "EVENT_FOLLOWED_OAK_INTO_LAB" },
{ "jump_if_false", "gramps_gone" },
{ "show_text", "_OaksLabRivalGoAheadAndChooseText" },
{ "jump", "end" },
{ "label", "gramps_gone" },
{ "show_text", "_OaksLabRivalGrampsIsntAroundText" },
},
},
@@ -213,9 +223,16 @@ return {
-- OaksLabScript8 / OaksLabRivalChallenge)
onStep = function(game, ow, x, y)
local flags = game.save.flags
-- Oak blocks the exit mats (4,11)/(5,11) until you take a starter
-- Oak stops you leaving without a starter at the bookshelf row (cell
-- y == 6): this is the same wYCoord == 6 coordinate script the rival
-- challenge just below uses (scripts/OaksLab.asm, both gated on
-- EVENT_GOT_STARTER), so Oak halts you level with the shelves, not one
-- corridor length later on the exit mat. At y>=6 only the x=4,5
-- corridor is walkable and the up-push keeps the player above y=7, so
-- this only ever fires at y=6 in the corridor -- matching the rival
-- trigger's shape. (#232)
if flags.EVENT_FOLLOWED_OAK_INTO_LAB and not flags.EVENT_GOT_STARTER
and y == 11 and (x == 4 or x == 5) then
and y >= 6 then
ow.runner:run({
{ "show_text", "_OaksLabOakDontGoAwayYetText" },
{ "move_player", "up", 1 },
@@ -254,10 +271,18 @@ return {
-- OaksLabRivalEndBattleScript: heal + flag on win or loss; no blackout
table.insert(rows, { "heal_party" })
table.insert(rows, { "set_flag", "EVENT_BATTLED_RIVAL_IN_OAKS_LAB" })
-- win: sulk text then exit; loss jumps to the walk-out (taunt was
-- already shown in-battle via Rival1WinText)
-- OaksLabRivalEndBattleScript: on WIN, print the "picked the wrong
-- POKéMON!" gloat, then BOTH win and loss print the shared exit line
-- _OaksLabRivalSmellYouLaterText ("OK! I'll make my POKéMON fight to
-- toughen it up!\012<PLAYER>! Gramps! Smell you later!") before Blue
-- marches out. A loss skips only the gloat (that taunt was already
-- shown in-battle via Rival1WinText), never the exit line (#231). The
-- jump_if_false convergence point is the exit line: base+6 indexes the
-- SmellYouLater row below, so WIN falls IPicked -> SmellYouLater and
-- LOSS jumps straight to SmellYouLater (both then walk-out + hide).
table.insert(rows, { "jump_if_false", base + 6 })
table.insert(rows, { "show_text", "_OaksLabRivalIPickedTheWrongPokemonText" })
table.insert(rows, { "show_text", "_OaksLabRivalSmellYouLaterText" })
table.insert(rows, { "move_npc_to", 1, 4, 11 })
table.insert(rows, { "hide_object", "OAKS_LAB", "OAKSLAB_RIVAL" })
ow.runner:run(rows, { npc = rival })
+95 -1
View File
@@ -16,6 +16,14 @@ M.VIRIDIAN_MART = {
-- StartSimulatingJoypadStates walks the player to the counter
-- (.PlayerMovement: PAD_LEFT 1, PAD_UP 2, door (3,7) -> counter (2,5))
-- and ViridianMartOaksParcelScript hands the parcel over. The player
-- never presses A.
--
-- The RLE list is consumed back to front: DecodeRLEList fills forward
-- from wSimulatedJoypadStatesEnd, the script seeds wSimulatedJoypadStates-
-- Index with the count, and home/overworld.asm decrements that index
-- before each read. So PAD_LEFT 1, PAD_UP 2 actually plays up, up, left,
-- and the walk ends facing the clerk. Replaying the list front to back
-- put the player at the counter facing up instead (#235). The player
-- never presses A. This matters beyond convenience: the parcel gates
-- Oak's Pokedex and the old man clearing Route 2, so vanilla guarantees
-- it on entry rather than letting you walk out without it.
@@ -28,8 +36,8 @@ M.VIRIDIAN_MART = {
if not f.EVENT_GOT_STARTER then return end
ow:queueScript({
{ "show_text", "_ViridianMartClerkYouCameFromPalletTownText" },
{ "move_player", "left", 1 },
{ "move_player", "up", 2 },
{ "move_player", "left", 1 },
-- the quest text's last page is "{PLAYER} got\nOAK's PARCEL!"
{ "give_item", "OAKS_PARCEL", 1, "_ViridianMartClerkParcelQuestText" },
{ "set_flag", "EVENT_GOT_OAKS_PARCEL" },
@@ -395,7 +403,93 @@ M.SS_ANNE_CAPTAINS_ROOM = {
-- MrFujisHouse.asm; the teleport back to his house is a warp)
-- -------------------------------------------------------------------
-- scripts/PokemonTower7F.asm: after each Rocket loses,
-- PokemonTower7FEndBattleScript shows its EndBattle text (EndTrainerBattle),
-- prints its AfterBattle text (DisplayTextID), then
-- PokemonTower7FRocketLeaveMovementScript walks the grunt off toward the
-- (9,16) stairs (MoveSprite) and PokemonTower7FHideNPCScript despawns it
-- (HideObject). Without this the beaten grunts stood in the corridor
-- forever (#200).
local POKEMON_TOWER_7F_ROCKETS = {
{ index = 1, name = "POKEMONTOWER7F_ROCKET1",
beat = "EVENT_BEAT_POKEMONTOWER_7_TRAINER_0",
after = "_PokemonTower7FRocket1AfterBattleText" },
{ index = 2, name = "POKEMONTOWER7F_ROCKET2",
beat = "EVENT_BEAT_POKEMONTOWER_7_TRAINER_1",
after = "_PokemonTower7FRocket2AfterBattleText" },
{ index = 3, name = "POKEMONTOWER7F_ROCKET3",
beat = "EVENT_BEAT_POKEMONTOWER_7_TRAINER_2",
after = "_PokemonTower7FRocket3AfterBattleText" },
}
-- PokemonTower7FNPCCoordMovementTable, keyed by the PLAYER's tile ("x,y")
-- at the engagement (both the talk and the sight walk-up leave the player on
-- the tile the vanilla table indexes). Each list is that entry's
-- NPC_MOVEMENT_* exit, applied from the grunt's current tile. The exact
-- tables are one per grunt (dbmapcoord bytes decoded back to x,y).
local POKEMON_TOWER_7F_EXITS = {
[1] = { -- ROCKET1 @ (9,11), faces RIGHT
["9,12"] = { "right", "down", "down", "down", "down", "down", "left" },
["10,11"] = { "down", "right", "down", "down", "down", "down" },
["11,11"] = { "down", "down", "down", "down", "down" },
["12,11"] = { "down", "down", "down", "down", "down" },
},
[2] = { -- ROCKET2 @ (12,9), faces LEFT
["12,10"] = { "left", "down", "down", "down", "down", "down", "down" },
["11,9"] = { "down", "down", "down", "left", "down", "down" },
["10,9"] = { "down", "down", "down", "down", "down" },
["9,9"] = { "down", "down", "down", "down", "down" },
},
[3] = { -- ROCKET3 @ (9,7), faces RIGHT
["9,8"] = { "right", "down", "down", "down", "down", "down", "down" },
["10,7"] = { "down", "down", "down", "down", "down" },
["11,7"] = { "down", "down", "down", "down", "down" },
["12,7"] = { "down", "down", "down", "down", "down" },
},
}
-- The vanilla table only lists the sight/talk tiles the map geometry allows;
-- a plain down-walk keeps any other engagement tile (e.g. talking from the
-- side) safe -- it still clears the corridor and despawns the grunt.
local POKEMON_TOWER_7F_EXIT_FALLBACK =
{ "down", "down", "down", "down", "down" }
M.POKEMON_TOWER_7F = {
-- Repair saves that beat a grunt before the exit walk was ported: in
-- vanilla HideObject persists (wMissableObjectFlags), so a beaten grunt is
-- still gone on re-entry. hide_object below writes objectToggles for new
-- wins; this hides any grunt whose BEAT flag is already set.
onEnter = function(game, ow)
local Commands = require("src.script.Commands")
local ctx = { game = game, save = game.save, overworld = ow }
for _, r in ipairs(POKEMON_TOWER_7F_ROCKETS) do
if game.save.flags[r.beat] then
Commands.hide_object(ctx, "POKEMON_TOWER_7F", r.name)
end
end
end,
-- runVictoryHook fires after every trainer win on the map (both the sight
-- and talk paths route through engageTrainer -> checkVictoryRewards), so
-- this walks off whichever grunt was just beaten but is still standing.
-- The AfterBattle text + walk queue UNDER the EndBattle box engageTrainer
-- pushes right after, giving the vanilla order: EndBattle, AfterBattle,
-- walk, despawn.
onVictory = function(game, ow)
if ow.runner:isRunning() then return end
for _, r in ipairs(POKEMON_TOWER_7F_ROCKETS) do
local npc = ow:npcByIndex(r.index)
if npc and game.save.flags[r.beat] then
local key = ow.player.cellX .. "," .. ow.player.cellY
local dirs = (POKEMON_TOWER_7F_EXITS[r.index] or {})[key]
or POKEMON_TOWER_7F_EXIT_FALLBACK
ow.runner:run({
{ "show_text", r.after }, -- DisplayTextID
{ "walk_npc", r.index, dirs }, -- MoveSprite
{ "hide_object", "POKEMON_TOWER_7F", r.name }, -- HideObject
}, { npc = npc })
return
end
end
end,
talk = {
TEXT_POKEMONTOWER7F_MR_FUJI = {
{ "face_player" }, -- 1
+4 -2
View File
@@ -528,7 +528,9 @@ M.MT_MOON_B2F = {
}
-- The ticket clerk (scripts/Museum1F.asm Museum1FScientist1Text):
-- Y50, once; declining at the rope walks you back out.
-- Y50, once. Declining at the rope shoves the player one tile SOUTH back off
-- the exhibit rope they crossed heading north (#151); the museum floor has no
-- ledges, so a plain scriptMove("down",1) is the correct primitive.
local function museumClerk(game, ow, done, onDecline)
local TextBox = require("src.render.TextBox")
local ChoiceBox = require("src.ui.ChoiceBox")
@@ -563,7 +565,7 @@ M.MUSEUM_1F = {
if y == 4 and (x == 9 or x == 10)
and not game.save.flags.EVENT_BOUGHT_MUSEUM_TICKET then
museumClerk(game, ow, nil, function()
ow:scriptMove(ow.player, "right", 1)
ow:scriptMove(ow.player, "down", 1)
end)
return true
end
+62 -31
View File
@@ -524,8 +524,17 @@ M.GAME_CORNER = {
game.data.text._GameCornerRocketAfterBattleText
or "Our hideout might\nbe discovered! I\nbetter tell BOSS!",
function()
hideRocket()
done()
-- #198: GameCornerRocketExitScript (scripts/GameCorner.asm)
-- ApplyMovementData walks the grunt one tile UP into the poster
-- (the hideout's secret entrance at 9,4) before HideObject, so
-- he leaves the floor rather than popping out of existence on
-- (9,5). scriptMove locks player input (#scriptMoves>0) and
-- ignores collision, so we despawn + unfreeze (done) only once
-- the step lands.
ow:scriptMove(npc, "up", 1, function()
hideRocket()
done()
end)
end))
end)
end,
@@ -606,40 +615,62 @@ local function activePrizes()
return require("src.core.GameVersion").isBlue() and BLUE_PRIZES or RED_PRIZES
end
-- Prize counters (engine/menus/prize_menu.asm CeladonPrizeMenu; the prize
-- list itself is data/events/prizes.asm, prize_mon_levels.asm). Gen1 gates
-- the prize window on the COIN CASE: it does IsItemInBag COIN_CASE first, and
-- with no case prints RequireCoinCaseText and returns without ever opening a
-- window; only with the case does it print ExchangeCoinsForPrizesText and then
-- show the prizes. #194: the port used to open the window unconditionally and
-- skip both text boxes.
local function prizeCounter(game, ow, npc, done)
local ListMenu = require("src.ui.ListMenu")
local Commands = require("src.script.Commands")
local items = {}
for _, p in ipairs(activePrizes()) do
local label
if p.kind == "mon" then
label = ("%s L%d"):format(game.data.pokemon[p.species].name, p.level)
else
label = game.data.items[p.item].name
end
table.insert(items, { label = label, right = tostring(p.cost), value = p })
local TextBox = require("src.render.TextBox")
local t = game.data.text
-- IsItemInBag COIN_CASE: without the case, deny and open no window
-- (COIN_CASE is a numeric count in save.inventory, nil when absent).
if not game.save.inventory.COIN_CASE then
game.stack:push(TextBox.new(game,
t._RequireCoinCaseText or "A COIN CASE is\nrequired!", done))
return
end
local list
list = ListMenu.new(game, "PRIZES (COINS)", items, {
footer = ("COINS %d"):format(game.save.coins or 0),
onChoose = function(item)
local p = item.value
if (game.save.coins or 0) < p.cost then
list.footer = "Not enough coins!"
return
-- ExchangeCoinsForPrizesText plays before the prize window opens.
game.stack:push(TextBox.new(game,
t._ExchangeCoinsForPrizesText or "We exchange your\ncoins for prizes.",
function()
local items = {}
for _, p in ipairs(activePrizes()) do
local label
if p.kind == "mon" then
label = ("%s L%d"):format(game.data.pokemon[p.species].name, p.level)
else
label = game.data.items[p.item].name
end
table.insert(items,
{ label = label, right = tostring(p.cost), value = p })
end
game.save.coins = game.save.coins - p.cost
if p.kind == "mon" then
Commands.give_pokemon({ save = game.save, game = game },
p.species, p.level)
else
game.save.inventory[p.item] = (game.save.inventory[p.item] or 0) + 1
end
list.footer = ("Got it! COINS %d"):format(game.save.coins)
end,
onCancel = done,
})
game.stack:push(list)
local list
list = ListMenu.new(game, "PRIZES (COINS)", items, {
footer = ("COINS %d"):format(game.save.coins or 0),
onChoose = function(item)
local p = item.value
if (game.save.coins or 0) < p.cost then
list.footer = "Not enough coins!"
return
end
game.save.coins = game.save.coins - p.cost
if p.kind == "mon" then
Commands.give_pokemon({ save = game.save, game = game },
p.species, p.level)
else
game.save.inventory[p.item] = (game.save.inventory[p.item] or 0) + 1
end
list.footer = ("Got it! COINS %d"):format(game.save.coins)
end,
onCancel = done,
})
game.stack:push(list)
end))
end
M.GAME_CORNER_PRIZE_ROOM = {
+26 -2
View File
@@ -134,12 +134,19 @@ M.MT_MOON_POKECENTER = {
-- Karate Master, take HITMONLEE or HITMONCHAN (the other disappears)
-- -------------------------------------------------------------------
-- ownBall/otherBall keep their spots for signature symmetry; only ownBall
-- is ever hidden (Gen1 removes just the chosen ball).
local function dojoBall(species, ownBall, otherBall, askKey)
return function(game, ow, npc, done)
local t = text(game)
local flags = game.save.flags
-- Already took one prize: the OTHER ball is still on the mat, so
-- talking to it now gives the "Better not get greedy..." refusal
-- (FightingDojo.asm .gotThePokemon / _FightingDojoBetterNotGetGreedyText),
-- not a silent no-op (#197).
if flags.EVENT_GOT_HITMONLEE or flags.EVENT_GOT_HITMONCHAN then
done()
push(game, t._FightingDojoBetterNotGetGreedyText
or "Better not get\ngreedy...", done)
return
end
if not flags.EVENT_BEAT_KARATE_MASTER then
@@ -153,8 +160,10 @@ local function dojoBall(species, ownBall, otherBall, askKey)
local Commands = require("src.script.Commands")
local ctx = { save = game.save, game = game, overworld = ow }
Commands.give_pokemon(ctx, species, 30)
-- Hide ONLY the chosen ball; the other stays (FightingDojo.asm hides
-- just the picked object's index) and routes to the greedy line above
-- when talked to (#197).
Commands.hide_object(ctx, "FIGHTING_DOJO", ownBall)
Commands.hide_object(ctx, "FIGHTING_DOJO", otherBall)
push(game, ("%s got\n%s!"):format(game.save.player.name, species), done)
end)
end
@@ -171,6 +180,21 @@ M.FIGHTING_DOJO = {
"FIGHTINGDOJO_HITMONLEE_POKE_BALL",
"_FightingDojoHitmonchanPokeBallText"),
},
-- The two dojo posters on the north wall (FightingDojo.asm bg_events at
-- the top of the room, cells (4,0)/(5,0) directly above the prize balls)
-- print _EnemiesOnEverySideText. The map extractor dropped the dojo
-- bg_events (signs = {}), so wire the poster read here, the same
-- facing-up + coord shape the mansion switches use. Reachable only once
-- a claimed prize frees the ball cell below the poster (#197).
onInteract = function(game, ow, fx, fy)
if ow.player.facing ~= "up" then return false end
if fy == 0 and (fx == 4 or fx == 5) then
push(game, text(game)._EnemiesOnEverySideText
or "Enemies on every\nside!")
return true
end
return false
end,
}
-- -------------------------------------------------------------------
+13 -3
View File
@@ -196,14 +196,24 @@ local function inCoords(coords, x, y)
return false
end
-- coordinate block: show a line and push the player back one step
-- coordinate block: show a line and shove the player back one step. pokered
-- shoves with a SIMULATED JOYPAD press (scripts/ViridianCity.asm ViridianCity-
-- CheckGymOpenScript), which runs the normal overworld step pipeline including
-- HandleLedges (engine/overworld/ledges.asm), so the shove must HOP a ledge
-- when one sits in front: the tile below the Viridian Gym door is a down-ledge
-- (#151). Gates with no ledge in front (Cinnabar gym lock at (18,4)) get
-- checkLedgeHop == false and fall back to the plain forced step, unchanged.
local function stepGate(opts)
return function(game, ow, x, y)
if not inCoords(opts.coords, x, y) then return false end
if not opts.blocked(game) then return false end
require("src.core.Sound").play(game.data, "Denied")
push(game, text(game)[opts.text] or opts.fallback,
function() ow:scriptMove(ow.player, opts.push, 1) end)
push(game, text(game)[opts.text] or opts.fallback, function()
ow.player.facing = opts.push
if not ow:checkLedgeHop(opts.push) then
ow:scriptMove(ow.player, opts.push, 1)
end
end)
return true
end
end
+5 -2
View File
@@ -114,9 +114,12 @@ return {
-- Fighting Dojo Karate Master (scripts/FightingDojo.asm
-- FightingDojoKarateMasterPostBattleScript sets EVENT_BEAT_KARATE_MASTER,
-- which gates the HITMONLEE/HITMONCHAN gift). OPP_BLACKBELT party 1 is
-- only him (data/maps/objects/FightingDojo.asm).
-- only him (data/maps/objects/FightingDojo.asm). `dialogue` is the
-- concede + prize offer he speaks after the win (his header supplies the
-- "Hwa! Arrgh! Beaten!" won line first; #197).
["OPP_BLACKBELT#1"] = { flag = "EVENT_BEAT_KARATE_MASTER",
deactivate = range("EVENT_BEAT_FIGHTING_DOJO_TRAINER_", 0, 3) },
deactivate = range("EVENT_BEAT_FIGHTING_DOJO_TRAINER_", 0, 3),
dialogue = { "_FightingDojoKarateMasterIWillGiveYouAPokemonText" } },
-- Elite Four progress flags (their rooms' door logic isn't ported, but
-- the flags make the Hall of Fame checkable)
+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
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
picker (`GameActivity.showRomFilePicker`); the chosen file is copied to
`picked_rom.gb` in the app save directory. `RomImporter` then imports on
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
`picked_rom.gb` from Red therefore cannot block Blue's Choose (issue #167).
After a successful import the consumed save-dir `.gb` is removed.
On Android, `love.system.pickFile([kind])` opens the Storage Access Framework
picker (`GameActivity.showFilePicker`); the chosen file is copied into the app
save directory as:
| `kind` | Destination |
| --- | --- |
| 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 →
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
@@ -78,6 +98,10 @@ The launcher-facing API:
- `SaveData.createSlot(version)` -> new slot id, registered but with **no
save file written**. An empty slot means the title screen offers NEW GAME
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
@@ -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
`RomImporter` handles a dropped ROM. A failed copy rolls its partial tree
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
@@ -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`.
- **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`;
Android has no picker and shows a drop hint). `SaveFileIO.importToSlot`
reads the bytes (an absolute path, a dropped LOVE file, or raw bytes),
a native `.sav` picker (`chooseSav` on desktop; on Android,
`love.system.pickFile("sav")``picked_save.sav`, same SAF path as ROMs).
`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
a bad main-data checksum), then registers a fresh slot (`SaveData.createSlot`),
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
`rawImport`, so this is a zero-filled template export, which is valid), and
writes `exports/gen1recomp-<version>-<slotId>.sav` in the save directory
(`love.filesystem.createDirectory("exports")`). It returns the absolute path
(`love.filesystem.getSaveDirectory()`), which the notice line shows with a
desktop "Open folder" affordance (`love.system.openURL("file://" .. dir)`).
(`love.filesystem.createDirectory("exports")`). On desktop it returns the
absolute path (`love.filesystem.getSaveDirectory()`), which the notice line
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
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
+49 -1
View File
@@ -179,4 +179,52 @@ Into-battle wipes still run the original eight styles inside the classic
blocks cascade outward from that square into the surrounding world so the
void outside the OG wipe fills in lockstep. Once the battle state is up,
letterbox voids around the battle canvas fill **white** instead of black
so the whole window reads as one continuous battle screen.
so the whole window reads as one continuous battle screen.
## On-screen touch controls (mobile)
On Android/iOS the game draws a translucent d-pad (bottom-left), A/B
buttons (bottom-right, Game Boy diagonal), and +/- START/SELECT (bottom
center) over the frame, using Xelu's CC0 controller prompts
(`assets/touch/`). Real buttons, not gestures: press lands the frame the
finger does, sliding on the d-pad changes direction without lifting, and
multi-touch chords (e.g. hold a direction + tap B) work. The overlay only
appears while no controller is being used: the first gamepad button or
stick push hides it, the next screen touch brings it back, and unplugging
the last controller restores it immediately. Layout re-derives from the
window size on rotation. Desktop testing: `POKEPORT_TOUCH=1 love .` forces
the overlay on and lets the mouse act as a finger (`=0` forces it off).
## 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.
+18
View File
@@ -18,6 +18,11 @@ local driverCo -- optional frame-driver (POKEPORT_DRIVER=file.lua): a
-- bot or screenshot run is not at the mercy of the player's last choice.
local speedOverride = tonumber(os.getenv("POKEPORT_SPEED"))
-- POKEPORT_TOUCH=1 forces the mobile on-screen controls on and lets the
-- mouse stand in for a finger, so the overlay can be exercised on desktop
-- (see src/core/TouchControls.lua).
local mouseTouch = os.getenv("POKEPORT_TOUCH") == "1"
-- How many times to run a scripted act+step loop per rendered frame. Only
-- scripted runs use this; interactive play fast-forwards through
-- Game.speedOverride / the GAME SPEED option instead.
@@ -274,6 +279,9 @@ function love.mousepressed(x, y, button)
if editorMode and EditorApp.mousepressed then
return EditorApp.mousepressed(x, y, button)
end
if mouseTouch and Game and button == 1 then
Game:touchpressed("mouse", x, y)
end
end
function love.mousereleased(x, y, button)
@@ -281,6 +289,16 @@ function love.mousereleased(x, y, button)
if editorMode and EditorApp.mousereleased then
return EditorApp.mousereleased(x, y, button)
end
if mouseTouch and Game and button == 1 then
Game:touchreleased("mouse", x, y)
end
end
function love.mousemoved(x, y)
if editorMode or Importer then return end
if mouseTouch and Game and love.mouse.isDown(1) then
Game:touchmoved("mouse", x, y)
end
end
function love.textinput(text)
+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
`tools/rom_manifest.json`.
ROM import on Android uses `love.system.pickFile()`
`GameActivity.showRomFilePicker` (Storage Access Framework), which copies the
chosen file to `picked_rom.gb` under the app save directory. `RomImporter`
imports pending (not-yet-ready) `.gb` files from that folder on Choose /
refocus; see `docs/launcher.md` (Android multi-ROM import). The APK payload
ROM / mod / save import on Android uses `love.system.pickFile([kind])`
`GameActivity.showFilePicker` (Storage Access Framework), which copies the
chosen file under the app save directory as `picked_rom.gb`,
`picked_mod.zip`, or `picked_save.sav`. `RomImporter` imports pending files
from that folder on Choose / refocus; see `docs/launcher.md`. The APK payload
itself remains data-free (no embedded ROM or generated cache).
### SDK / NDK
+1 -1
View File
@@ -18,4 +18,4 @@ android.useAndroidX=true
android.defaults.buildfeatures.buildconfig=true
android.nonTransitiveRClass=true
android.nonFinalResIds=true
app.name=Pokemon Red
app.name=gen1recomp
@@ -183,13 +183,37 @@ void vibrate(double seconds)
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();
jclass activity = env->FindClass("org/love2d/android/GameActivity");
jmethodID method = env->GetStaticMethodID(activity, "showRomFilePicker", "()Z");
jboolean result = env->CallStaticBooleanMethod(activity, method);
jmethodID method = env->GetStaticMethodID(activity, "showFilePicker",
"(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);
return result;
@@ -63,9 +63,16 @@ void vibrate(double seconds);
* Shows the system's "pick a document" UI (Storage Access Framework).
* Returns true if the picker was launched; the picked file (if any) is
* 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
@@ -22,6 +22,8 @@
#include "common/config.h"
#include "System.h"
#include <cstring>
#if defined(LOVE_MACOSX)
#include <CoreServices/CoreServices.h>
#elif defined(LOVE_IOS)
@@ -180,11 +182,32 @@ void System::vibrate(double seconds) const
#endif
}
bool System::pickFile() const
bool System::pickFile(const char *kind) const
{
#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
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;
#endif
}
@@ -111,9 +111,21 @@ public:
* Android only for now; the result (if any) is not returned here -- see
* 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.
**/
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.
@@ -97,7 +97,15 @@ int w_vibrate(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;
}
@@ -117,6 +125,7 @@ static const luaL_Reg functions[] =
{ "openURL", w_openURL },
{ "vibrate", w_vibrate },
{ "pickFile", w_pickFile },
{ "createFile", w_createFile },
{ "hasBackgroundMusic", w_hasBackgroundMusic },
{ 0, 0 }
};
@@ -22,12 +22,15 @@ package org.love2d.android;
import org.libsdl.app.SDLActivity;
import java.io.BufferedInputStream;
import java.io.BufferedOutputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
@@ -61,13 +64,24 @@ public class GameActivity extends SDLActivity {
protected final int[] recordAudioRequestDummy = new int[1];
public static final int EXTERNAL_STORAGE_REQUEST_CODE = 2;
public static final int RECORD_AUDIO_REQUEST_CODE = 3;
public static final int ROM_PICKER_REQUEST_CODE = 4;
// Mirrors conf.lua's t.identity ("pokemon-love2d"): where the picked ROM
public static final int FILE_PICKER_REQUEST_CODE = 4;
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
// src/import/RomImporter.lua and Filesystem::setIdentity (sets Android's
// save directory to getExternalFilesDir()/save/<identity>).
private static final String ROM_SAVE_IDENTITY = "pokemon-love2d";
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 needToCopyGameInArchive = false;
private boolean storagePermissionUnnecessary = false;
@@ -341,59 +355,185 @@ public class GameActivity extends SDLActivity {
/**
* Shows the system document picker (Storage Access Framework) so the
* player can pick their ROM from anywhere (Downloads, Drive, etc.)
* without needing to know where the app's external files folder is.
* Requires API 19+ (ACTION_OPEN_DOCUMENT); the picked file (if any)
* player can pick a ROM / mod / save from anywhere (Downloads, Drive,
* etc.) without needing to know where the app's external files folder
* is. Requires API 19+ (ACTION_OPEN_DOCUMENT); the picked file (if any)
* 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
public static boolean showRomFilePicker() {
public static boolean showFilePicker(String destFilename) {
if (android.os.Build.VERSION.SDK_INT < 19) return false;
GameActivity self = (GameActivity) mSingleton;
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.addCategory(Intent.CATEGORY_OPENABLE);
intent.setType("*/*");
try {
self.startActivityForResult(intent, ROM_PICKER_REQUEST_CODE);
self.startActivityForResult(intent, FILE_PICKER_REQUEST_CODE);
return true;
} 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;
}
}
/** 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
protected void onActivityResult(int requestCode, int resultCode, Intent 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) {
Log.d("GameActivity", "ROM picker returned no file (cancelled?)");
Log.d("GameActivity", "file picker returned no file (cancelled?)");
return;
}
Uri uri = data.getData();
File destDir = new File(new File(getExternalFilesDir(null), "save"), ROM_SAVE_IDENTITY);
File destDir = saveIdentityDir();
if (!destDir.exists() && !destDir.mkdirs()) {
Log.d("GameActivity", "could not create " + destDir);
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;
try {
source = getContentResolver().openInputStream(uri);
} catch (FileNotFoundException e) {
Log.d("GameActivity", "could not open picked ROM: " + e.getMessage());
Log.d("GameActivity", "could not open picked file: " + e.getMessage());
return;
}
if (source == null) {
Log.d("GameActivity", "ContentResolver returned no stream for picked ROM");
Log.d("GameActivity", "ContentResolver returned no stream for picked file");
return;
}
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
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,
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 |
| 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 |
| 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
@@ -46,7 +47,8 @@ and `field`, and exercises:
- both buses — `events:on` / `events:emit` and `hooks:wrap` — across
`music.select`, `battle.damage`, `ui.start_menu.items`, `ui.options.rows`,
`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:read`, `mod.log` and `mod.path`
- 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
```
`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.
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")
+308 -145
View File
File diff suppressed because it is too large Load Diff
+13 -12
View File
@@ -8,6 +8,7 @@
local MoveEffects = require("src.battle.MoveEffects")
local Runtime = require("src.mods.Runtime")
local StatusRegistry = require("src.battle.StatusRegistry")
local Strings = require("src.core.Strings")
local EffectRegistry = {}
@@ -91,7 +92,7 @@ function EffectRegistry.runDamaging(battle, ctx, record)
if target.invulnerable and not neverMiss then
-- Explosion/Selfdestruct still animate on a miss (HandleIfPlayerMoveMissed)
if not (record and record.explode) then battle:cancelMoveAnim() end
battle:sayNext(("%s's\nattack missed!"):format(displayName(user)))
battle:sayNext(Strings("%s's\nattack missed!", displayName(user)))
return
end
@@ -113,7 +114,7 @@ function EffectRegistry.runDamaging(battle, ctx, record)
if not battle:accuracyRoll(move, user, target) then
-- Explosion/Selfdestruct still animate on a miss (HandleIfPlayerMoveMissed)
if not (record and record.explode) then battle:cancelMoveAnim() end
battle:sayNext(("%s's\nattack missed!"):format(displayName(user)))
battle:sayNext(Strings("%s's\nattack missed!", displayName(user)))
-- Jump Kick crash, Explode self-destruct
if record and record.onMiss then record.onMiss(ctx, "accuracy") end
user.trappingTurns = nil
@@ -140,7 +141,7 @@ function EffectRegistry.runDamaging(battle, ctx, record)
end
if not counterable or (battle.lastDamage or 0) == 0 then
battle:cancelMoveAnim()
battle:sayNext(("%s's\nattack missed!"):format(displayName(user)))
battle:sayNext(Strings("%s's\nattack missed!", displayName(user)))
return
end
dmg = math.min(65535, battle.lastDamage * 2)
@@ -163,14 +164,14 @@ function EffectRegistry.runDamaging(battle, ctx, record)
if info.typeMult == 0 then
-- type immunity zeros damage and sets wMoveMissed in Gen 1, so no anim
if not (record and record.explode) then battle:cancelMoveAnim() end
battle:sayNext(("It doesn't affect\n%s!"):format(displayName(target)))
battle:sayNext(Strings("It doesn't affect\n%s!", displayName(target)))
if record and record.onMiss then record.onMiss(ctx, "immune") end
return
end
if info.missed then
-- 0.25x floored the damage to zero: the original registers a miss
if not (record and record.explode) then battle:cancelMoveAnim() end
battle:sayNext(("%s's\nattack missed!"):format(displayName(user)))
battle:sayNext(Strings("%s's\nattack missed!", displayName(user)))
if record and record.onMiss then record.onMiss(ctx, "floored") end
return
end
@@ -214,12 +215,12 @@ function EffectRegistry.runDamaging(battle, ctx, record)
-- multi-hit loop (core.asm .moveDidNotMiss before the jump back
-- to GetPlayerAnimationType), so crit/effectiveness reprint on
-- every strike -- damage was only rolled once
if info.crit then battle:sayNext("Critical hit!") end
if info.ohko then battle:sayNext("One-hit KO!") end
if info.crit then battle:sayNext(Strings("Critical hit!")) end
if info.ohko then battle:sayNext(Strings("One-hit KO!")) end
if info.typeMult > 10 then
battle:sayNext("It's super\neffective!")
battle:sayNext(Strings("It's super\neffective!"))
elseif info.typeMult < 10 then
battle:sayNext("It's not very\neffective...")
battle:sayNext(Strings("It's not very\neffective..."))
end
if Runtime.wants("battle.damage_dealt") then
Runtime.emit("battle.damage_dealt", {
@@ -237,9 +238,9 @@ function EffectRegistry.runDamaging(battle, ctx, record)
if hits > 1 then
-- player: _MultiHitText; enemy: _HitXTimesText (always plural)
if user.isPlayer then
battle:sayNext(("Hit the enemy\n%d times!"):format(hits))
battle:sayNext(Strings("Hit the enemy\n%d times!", hits))
else
battle:sayNext(("Hit %d times!"):format(hits))
battle:sayNext(Strings("Hit %d times!", hits))
end
end
@@ -251,7 +252,7 @@ function EffectRegistry.runDamaging(battle, ctx, record)
elseif moveInst.struggle then
-- struggle recoils even when its effect id resolves to no record
local recoil = math.max(1, math.floor(dmg / 2))
battle:sayNext(("%s's\nhit with recoil!"):format(displayName(user)))
battle:sayNext(Strings("%s's\nhit with recoil!", displayName(user)))
battle:applyDamage(user, recoil)
end
+65 -58
View File
@@ -15,6 +15,7 @@ local Logger = require("src.core.Logger")
local StatusRegistry = require("src.battle.StatusRegistry")
local TurnOrder = require("src.battle.TurnOrder")
local TypeChart = require("src.battle.TypeChart")
local Strings = require("src.core.Strings")
local MoveEffects = {}
@@ -36,14 +37,14 @@ local STAT_LABEL = {
local function changeStage(battle, who, stat, delta, fromEnemy)
if fromEnemy and (who.substituteHP or who.mist) then
if who.mist then
return { displayName(who) .. " is\nprotected by MIST!" }
return { Strings("%s is\nprotected by MIST!", displayName(who)) }
end
return { "But, it failed!" }
return { Strings("But, it failed!") }
end
local cur = who.stages[stat] or 0
local new = math.max(-6, math.min(6, cur + delta))
if new == cur then
return { ("Nothing happened!") }
return { Strings("Nothing happened!") }
end
who.stages[stat] = new
-- effects.asm:505-506: after any stat-stage change, modified stats are
@@ -53,13 +54,13 @@ local function changeStage(battle, who, stat, delta, fromEnemy)
-- _MonsStatsRoseText/_MonsStatsFellText: "X's / STAT rose!"; the
-- two-stage variants scroll "greatly" onto a third line
if delta >= 2 then
return { ("%s's\n%s\ngreatly rose!"):format(displayName(who), STAT_LABEL[stat]) }
return { Strings("%s's\n%s\ngreatly rose!", displayName(who), STAT_LABEL[stat]) }
elseif delta == 1 then
return { ("%s's\n%s rose!"):format(displayName(who), STAT_LABEL[stat]) }
return { Strings("%s's\n%s rose!", displayName(who), STAT_LABEL[stat]) }
elseif delta == -1 then
return { ("%s's\n%s fell!"):format(displayName(who), STAT_LABEL[stat]) }
return { Strings("%s's\n%s fell!", displayName(who), STAT_LABEL[stat]) }
end
return { ("%s's\n%s\ngreatly fell!"):format(displayName(who), STAT_LABEL[stat]) }
return { Strings("%s's\n%s\ngreatly fell!", displayName(who), STAT_LABEL[stat]) }
end
MoveEffects.changeStage = changeStage
@@ -88,10 +89,10 @@ end
local function statusMove(status)
return function(battle, user, target, move)
if target.mon.status then
return { "But, it failed!" }
return { Strings("But, it failed!") }
end
if status == "PSN" and target.substituteHP then
return { "But, it failed!" }
return { Strings("But, it failed!") }
end
local msgs = inflictStatus(battle, target, status, {
toxic = move and move.id == "TOXIC",
@@ -99,7 +100,7 @@ local function statusMove(status)
source = move and move.id,
})
if #msgs == 0 then
return { "But, it failed!" }
return { Strings("But, it failed!") }
end
return msgs
end
@@ -111,7 +112,7 @@ local function statusSide(status, chance)
-- target (regardless of the burn roll)
if move and move.type == "FIRE" and target.mon.status == "FRZ" then
target.mon.status = nil
return { ("Fire defrosted\n%s!"):format(displayName(target)) }
return { Strings("Fire defrosted\n%s!", displayName(target)) }
end
if battle.rng(0, 255) >= chance then return {} end
return inflictStatus(battle, target, status, {
@@ -144,10 +145,10 @@ end
local function confuse(battle, target, pierceSub)
if target.confusedTurns or (target.substituteHP and not pierceSub) then
return { "But, it failed!" }
return { Strings("But, it failed!") }
end
target.confusedTurns = battle.rng(2, 5)
return { ("%s\nbecame confused!"):format(displayName(target)) }
return { Strings("%s\nbecame confused!", displayName(target)) }
end
-- ---------------------------------------------------------------------
@@ -181,53 +182,53 @@ MoveEffects.primary = {
LEECH_SEED_EFFECT = function(battle, user, target)
-- leech_seed.asm has no substitute check: seeding lands through one
if target.leechSeeded then
return { "But, it failed!" }
return { Strings("But, it failed!") }
end
for _, t in ipairs(target.curTypes) do
if t == "GRASS" then return { "But, it failed!" } end
if t == "GRASS" then return { Strings("But, it failed!") } end
end
target.leechSeeded = true
return { ("%s\nwas seeded!"):format(displayName(target)) }
return { Strings("%s\nwas seeded!", displayName(target)) }
end,
HEAL_EFFECT = function(battle, user, target, move)
local mon = user.mon
if move.id == "REST" then
if mon.hp == mon.stats.hp then return { "But, it failed!" } end
if mon.hp == mon.stats.hp then return { Strings("But, it failed!") } end
mon.hp = mon.stats.hp
mon.status = "SLP"
user.sleepTurns = 2
user.toxicCounter = nil
return { ("%s\nstarted sleeping!"):format(displayName(user)) }
return { Strings("%s\nstarted sleeping!", displayName(user)) }
end
if mon.hp == mon.stats.hp then return { "But, it failed!" } end
if mon.hp == mon.stats.hp then return { Strings("But, it failed!") } end
mon.hp = math.min(mon.stats.hp, mon.hp + math.floor(mon.stats.hp / 2))
return { ("%s\nregained health!"):format(displayName(user)) }
return { Strings("%s\nregained health!", displayName(user)) }
end,
LIGHT_SCREEN_EFFECT = function(battle, user)
if user.lightScreen then return { "But, it failed!" } end
if user.lightScreen then return { Strings("But, it failed!") } end
user.lightScreen = true
return { ("%s's\nprotected against\nspecial attacks!"):format(displayName(user)) }
return { Strings("%s's\nprotected against\nspecial attacks!", displayName(user)) }
end,
REFLECT_EFFECT = function(battle, user)
if user.reflect then return { "But, it failed!" } end
if user.reflect then return { Strings("But, it failed!") } end
user.reflect = true
return { ("%s\ngained armor!"):format(displayName(user)) }
return { Strings("%s\ngained armor!", displayName(user)) }
end,
MIST_EFFECT = function(battle, user)
if user.mist then return { "But, it failed!" } end
if user.mist then return { Strings("But, it failed!") } end
user.mist = true
-- _ShroudedInMistText (lowercase "mist")
return { ("%s's\nshrouded in mist!"):format(displayName(user)) }
return { Strings("%s's\nshrouded in mist!", displayName(user)) }
end,
FOCUS_ENERGY_EFFECT = function(battle, user)
if user.focusEnergy then return { "But, it failed!" } end
if user.focusEnergy then return { Strings("But, it failed!") } end
user.focusEnergy = true
return { ("%s's\ngetting pumped!"):format(displayName(user)) }
return { Strings("%s's\ngetting pumped!", displayName(user)) }
end,
HAZE_EFFECT = function(battle, user, target)
@@ -254,33 +255,33 @@ MoveEffects.primary = {
target.skipMove = true
end
target.mon.status = nil
return { "All STATUS changes\nare eliminated!" }
return { Strings("All STATUS changes\nare eliminated!") }
end,
SUBSTITUTE_EFFECT = function(battle, user)
if user.substituteHP then return { ("%s\nhas a SUBSTITUTE!"):format(displayName(user)) } end
if user.substituteHP then return { Strings("%s\nhas a SUBSTITUTE!", displayName(user)) } end
local cost = math.floor(user.mon.stats.hp / 4)
-- substitute.asm only fails on subtraction underflow (current HP
-- strictly below maxHP/4); at equality the substitute is built and
-- the user is left standing on exactly 0 HP (it faints only when
-- the engine next checks HP, not here)
if user.mon.hp < cost then
return { "Too weak to make\na SUBSTITUTE!" }
return { Strings("Too weak to make\na SUBSTITUTE!") }
end
user.mon.hp = user.mon.hp - cost
user.substituteHP = cost + 1
-- _SubstituteText
return { "It created a\nSUBSTITUTE!" }
return { Strings("It created a\nSUBSTITUTE!") }
end,
CONVERSION_EFFECT = function(battle, user, target)
-- conversion.asm fails against a mid-Fly/Dig target (INVULNERABLE)
if target.invulnerable then
return { "But, it failed!" }
return { Strings("But, it failed!") }
end
user.curTypes = { target.curTypes[1], target.curTypes[2] }
-- _ConvertedTypeText
return { ("Converted type to\n%s's!"):format(displayName(target)) }
return { Strings("Converted type to\n%s's!", displayName(target)) }
end,
-- MIMIC_EFFECT lives in BattleState:resolveMimic: MimicEffect
@@ -311,27 +312,27 @@ MoveEffects.primary = {
table.insert(user.curMoves, { id = mv.id, pp = 5, mimic = true })
end
-- _TransformedText: the copied name prints bare (wNameBuffer)
return { ("%s\ntransformed into\n%s!"):format(displayName(user), target.name) }
return { Strings("%s\ntransformed into\n%s!", displayName(user), target.name) }
end,
DISABLE_EFFECT = function(battle, user, target)
if target.disabledSlot then return { "But, it failed!" } end
if target.disabledSlot then return { Strings("But, it failed!") } end
local usable = {}
for i, mv in ipairs(target.curMoves) do
if mv.pp > 0 then table.insert(usable, i) end
end
if #usable == 0 then return { "But, it failed!" } end
if #usable == 0 then return { Strings("But, it failed!") } end
local slot = usable[battle.rng(1, #usable)]
target.disabledSlot = slot
target.disabledTurns = battle.rng(1, 8)
local id = target.curMoves[slot].id
-- _MoveWasDisabledText: "X's / MOVE was / disabled!"
return { ("%s's\n%s was\ndisabled!"):format(displayName(target),
return { Strings("%s's\n%s was\ndisabled!", displayName(target),
battle.data.moves[id].name) }
end,
SPLASH_EFFECT = function()
return { "No effect!" }
return { Strings("No effect!") }
end,
}
@@ -428,7 +429,9 @@ local function drainHalf(text)
local mon = ctx.user.mon
mon.hp = math.min(mon.stats.hp, mon.hp + heal)
ctx.drain()
ctx.say(text:format(displayName(ctx.target)))
-- `text` arrives as a source string (Strings.source at the call
-- site keeps it in the catalog); look it up here, at use time
ctx.say(Strings(text, displayName(ctx.target)))
end
end
@@ -436,7 +439,7 @@ end
-- flags the miss before the special-damage override)
local function immuneMsg(ctx)
if TypeChart.effectiveness(ctx.move.type, ctx.target.curTypes) == 0 then
return ("It doesn't affect\n%s!"):format(displayName(ctx.target))
return Strings("It doesn't affect\n%s!", displayName(ctx.target))
end
return nil
end
@@ -498,12 +501,12 @@ MoveEffects.full = {
-- removed): overkill and substitute hits recoil at full strength
local recoil = math.max(1, math.floor(ctx.rawDamage
/ (ctx.moveInst.struggle and 2 or 4)))
ctx.say(("%s's\nhit with recoil!"):format(displayName(ctx.user)))
ctx.say(Strings("%s's\nhit with recoil!", displayName(ctx.user)))
ctx.battle:applyDamage(ctx.user, recoil)
end,
},
DRAIN_HP_EFFECT = {
afterDamage = drainHalf("Sucked health from\n%s!"),
afterDamage = drainHalf(Strings.source("Sucked health from\n%s!")),
},
DREAM_EATER_EFFECT = {
-- only works on sleeping targets (checked before damage)
@@ -511,7 +514,7 @@ MoveEffects.full = {
if ctx.target.mon.status ~= "SLP" then return false, "But, it failed!" end
return true
end,
afterDamage = drainHalf("%s's\ndream was eaten!"),
afterDamage = drainHalf(Strings.source("%s's\ndream was eaten!")),
},
-- charge moves: first turn just charges; Fly AND Dig go
@@ -557,7 +560,7 @@ MoveEffects.full = {
user.thrashTurns, user.thrashMove, user.thrashAnnounced = nil, nil, nil
if not user.confusedTurns then
user.confusedTurns = ctx.rng(2, 5)
ctx.say(("%s\nbecame confused!"):format(displayName(user)))
ctx.say(Strings("%s\nbecame confused!", displayName(user)))
end
end
end
@@ -566,7 +569,7 @@ MoveEffects.full = {
JUMP_KICK_EFFECT = {
onMiss = function(ctx, reason)
if reason ~= "accuracy" then return end
ctx.say(("%s\nkept going and\ncrashed!"):format(displayName(ctx.user)))
ctx.say(Strings("%s\nkept going and\ncrashed!", displayName(ctx.user)))
ctx.damage(ctx.user, 1)
end,
},
@@ -581,8 +584,12 @@ MoveEffects.full = {
},
HYPER_BEAM_EFFECT = {
afterDamage = function(ctx)
-- no recharge when the target faints OR its substitute breaks
if ctx.target.mon.hp > 0 and not ctx.brokeSub then
-- Gen 1: no recharge when the target faints OR its substitute breaks.
-- 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
end
end,
@@ -591,7 +598,7 @@ MoveEffects.full = {
afterDamage = function(ctx)
local battle = ctx.battle
battle.payDay = (battle.payDay or 0) + 2 * ctx.user.mon.level
ctx.say("Coins scattered\neverywhere!")
ctx.say(Strings("Coins scattered\neverywhere!"))
end,
},
SWIFT_EFFECT = { neverMiss = true },
@@ -606,7 +613,7 @@ MoveEffects.full = {
local user = ctx.user
user.bideTurns = ctx.rng(2, 3)
user.bideDamage = 0
ctx.say(("%s\nis storing energy!"):format(displayName(user)))
ctx.say(Strings("%s\nis storing energy!", displayName(user)))
end,
},
SWITCH_AND_TELEPORT_EFFECT = {
@@ -627,27 +634,27 @@ MoveEffects.full = {
end
if ok then
if move.id == "ROAR" then
ctx.say(("%s\nran away scared!"):format(displayName(target)))
ctx.say(Strings("%s\nran away scared!", displayName(target)))
elseif move.id == "WHIRLWIND" then
ctx.say(("%s\nwas blown away!"):format(displayName(target)))
ctx.say(Strings("%s\nwas blown away!", displayName(target)))
else
ctx.say(("%s\nran from battle!"):format(displayName(user)))
ctx.say(Strings("%s\nran from battle!", displayName(user)))
end
battle.result = "run"
battle.afterQueue = "finish"
elseif move.id == "TELEPORT" then
battle:cancelMoveAnim()
ctx.say("But, it failed!")
ctx.say(Strings("But, it failed!"))
else
battle:cancelMoveAnim()
ctx.say(("It didn't affect\n%s!"):format(displayName(target)))
ctx.say(Strings("It didn't affect\n%s!", displayName(target)))
end
elseif move.id == "TELEPORT" then
battle:cancelMoveAnim()
ctx.say("But, it failed!")
ctx.say(Strings("But, it failed!"))
else
battle:cancelMoveAnim()
ctx.say(("%s\nis unaffected!"):format(displayName(target)))
ctx.say(Strings("%s\nis unaffected!", displayName(target)))
end
end,
},
@@ -665,7 +672,7 @@ MoveEffects.full = {
callsMove = function(ctx)
local last = ctx.target.lastMove
if not last then
ctx.say("The MIRROR MOVE\nfailed!")
ctx.say(Strings("The MIRROR MOVE\nfailed!"))
return nil
end
return last
+31 -21
View File
@@ -5,6 +5,8 @@
-- and residual sweep. Callers without a battle (pure-module tests) fall
-- back to the vanilla records, which is bit-identical behavior.
local Strings = require("src.core.Strings")
local Status = {}
-- pokered's <USER>/<TARGET> text macros print "Enemy " before the enemy
@@ -28,8 +30,11 @@ local function hasType(battler, wanted)
end
-- shared PSN/BRN residual: 1/16 max HP, multiplied (and advanced) by the
-- Toxic counter (HandlePoisonBurnLeechSeed)
local function damageOverTime(what)
-- Toxic counter (HandlePoisonBurnLeechSeed). The caller passes the whole
-- sentence rather than the noun: "hurt by poison" and "hurt by the burn"
-- decline differently once translated, so a shared fragment cannot be the
-- translatable unit.
local function damageOverTime(template)
return function(battler)
local mon = battler.mon
local base = math.max(1, math.floor(mon.stats.hp / 16))
@@ -39,10 +44,15 @@ local function damageOverTime(what)
battler.toxicCounter = battler.toxicCounter + 1
end
mon.hp = math.max(0, mon.hp - dmg)
return { ("%s's\nhurt by %s!"):format(name(battler), what) }
return { Strings(template, name(battler)) }
end
end
-- Labels stay plain literals on purpose: this table is built at require
-- time, before Strings.load has a catalog, so a Strings() here would
-- freeze the English. They are already translatable through the
-- statuses registry (mod.content.statuses:patch(id, { label = ... })).
--
-- The five persistent conditions as records: the beforeMove gauntlet, the
-- residual sweep, the inflict text/immunities (StatusRegistry.inflict),
-- the catch/wobble bonuses (Catching.attempt), the HUD label, and the
@@ -57,13 +67,13 @@ Status.RECORDS = {
battler.sleepTurns = (battler.sleepTurns or 1) - 1
if battler.sleepTurns <= 0 then
battler.mon.status = nil
return false, { name(battler) .. "\nwoke up!" } -- wakes but loses the turn
return false, { Strings("%s\nwoke up!", name(battler)) } -- wakes, loses the turn
end
return false, { name(battler) .. "\nis fast asleep!" }
return false, { Strings("%s\nis fast asleep!", name(battler)) }
end,
onInflict = function(battle, target, opts, display)
target.sleepTurns = battle.rng(1, 7)
return { ("%s\nfell asleep!"):format(display) }
return { Strings("%s\nfell asleep!", display) }
end,
},
FRZ = {
@@ -71,35 +81,35 @@ Status.RECORDS = {
catchBonus = 25, shakeBonus = 10,
beforeMovePriority = 30,
beforeMove = function(battler)
return false, { name(battler) .. "\nis frozen solid!" }
return false, { Strings("%s\nis frozen solid!", name(battler)) }
end,
canInflict = function(target) return not hasType(target, "ICE") end,
onInflict = function(_, _, _, display)
return { ("%s\nwas frozen solid!"):format(display) }
return { Strings("%s\nwas frozen solid!", display) }
end,
},
PSN = {
id = "PSN", label = "PSN", hudLabel = "PSN",
catchBonus = 12, shakeBonus = 5,
residual = damageOverTime("poison"),
residual = damageOverTime(Strings.source("%s's\nhurt by poison!")),
canInflict = function(target) return not hasType(target, "POISON") end,
onInflict = function(_, target, opts, display)
if opts.toxic then
target.toxicCounter = 1
-- _BadlyPoisonedText
return { ("%s's\nbadly poisoned!"):format(display) }
return { Strings("%s's\nbadly poisoned!", display) }
end
return { ("%s\nwas poisoned!"):format(display) }
return { Strings("%s\nwas poisoned!", display) }
end,
},
BRN = {
id = "BRN", label = "BRN", hudLabel = "BRN",
catchBonus = 12, shakeBonus = 5,
statPenalty = { stat = "attack", div = 2 },
residual = damageOverTime("the burn"),
residual = damageOverTime(Strings.source("%s's\nhurt by the burn!")),
canInflict = function(target) return not hasType(target, "FIRE") end,
onInflict = function(_, _, _, display)
return { ("%s\nwas burned!"):format(display) }
return { Strings("%s\nwas burned!", display) }
end,
},
PAR = {
@@ -110,7 +120,7 @@ Status.RECORDS = {
beforeMove = function(battler, rng)
-- cp 25 percent / jr nc: fully paralyzed on rand < 63 (63/256)
if rng(0, 255) < 63 then
return false, { name(battler) .. "'s\nfully paralyzed!" }
return false, { Strings("%s's\nfully paralyzed!", name(battler)) }
end
return true, {}
end,
@@ -120,7 +130,7 @@ Status.RECORDS = {
end,
onInflict = function(_, _, _, display)
-- _ParalyzedMayNotAttackText (primary and secondary paralysis)
return { ("%s's\nparalyzed! It may\nnot attack!"):format(display) }
return { Strings("%s's\nparalyzed! It may\nnot attack!", display) }
end,
},
}
@@ -156,7 +166,7 @@ function Status.beforeMove(battler, rng, battle)
end
if battler.flinched then
battler.flinched = false
return false, { name(battler) .. "\nflinched!" }
return false, { Strings("%s\nflinched!", name(battler)) }
end
local record = Status.recordFor(battleStatuses(battle), mon.status)
local handler = record and record.beforeMove
@@ -174,23 +184,23 @@ function Status.beforeMove(battler, rng, battle)
end
if battler.boundTurns and battler.boundTurns > 0 then
battler.boundTurns = battler.boundTurns - 1
msgs[#msgs + 1] = name(battler) .. "\ncan't move!"
msgs[#msgs + 1] = Strings("%s\ncan't move!", name(battler))
return false, msgs
end
if battler.disabledTurns then
battler.disabledTurns = battler.disabledTurns - 1
if battler.disabledTurns <= 0 then
battler.disabledTurns, battler.disabledSlot = nil, nil
table.insert(msgs, name(battler) .. "'s\ndisabled no more!")
table.insert(msgs, Strings("%s's\ndisabled no more!", name(battler)))
end
end
if battler.confusedTurns then
battler.confusedTurns = battler.confusedTurns - 1
if battler.confusedTurns <= 0 then
battler.confusedTurns = nil
table.insert(msgs, name(battler) .. "\nsnapped out of\nconfusion!")
table.insert(msgs, Strings("%s\nsnapped out of\nconfusion!", name(battler)))
else
table.insert(msgs, name(battler) .. "\nis confused!")
table.insert(msgs, Strings("%s\nis confused!", name(battler)))
-- cp 50 percent + 1 / jr c: hurt itself on rand >= 128 (128/256)
if rng(0, 255) < 128 then
return false, msgs, true -- hurt itself
@@ -231,7 +241,7 @@ function Status.residual(battler, opponent, battle)
dmg = math.min(dmg, mon.hp)
mon.hp = mon.hp - dmg
opponent.mon.hp = math.min(opponent.mon.stats.hp, opponent.mon.hp + dmg)
table.insert(msgs, ("LEECH SEED saps\n%s!"):format(name(battler)))
table.insert(msgs, Strings("LEECH SEED saps\n%s!", name(battler)))
end
return msgs
end
+2 -1
View File
@@ -5,6 +5,7 @@
local Runtime = require("src.mods.Runtime")
local Status = require("src.battle.Status")
local Strings = require("src.core.Strings")
local StatusRegistry = {}
@@ -45,7 +46,7 @@ function StatusRegistry.inflict(battle, target, status, opts)
if record and record.onInflict then
msgs = record.onInflict(battle, target, opts, display)
else
msgs = { ("%s\nwas afflicted\nby %s!"):format(display,
msgs = { Strings("%s\nwas afflicted\nby %s!", display,
record and record.label or tostring(status)) }
end
Runtime.emit("battle.status_inflicted", {
+4 -3
View File
@@ -19,6 +19,7 @@
-- enemy PP — Gen 1 AI never reads wEnemyMonPP).
local TypeChart = require("src.battle.TypeChart")
local Strings = require("src.core.Strings")
local TrainerAI = {}
@@ -99,7 +100,7 @@ function TrainerAI.useItem(battle, item)
local enemy = battle.enemy
local trainerName = battle.trainer.name
local itemName = battle.data.items[item] and battle.data.items[item].name or item
local msgs = { ("%s\nused %s!"):format(trainerName, itemName) }
local msgs = { Strings("%s\nused %s!", trainerName, itemName) }
if item == "FULL_HEAL" then
enemy.mon.status = nil
enemy.toxicCounter = nil
@@ -112,10 +113,10 @@ function TrainerAI.useItem(battle, item)
elseif X_STAT[item] then
local stat = X_STAT[item]
enemy.stages[stat] = math.min(6, (enemy.stages[stat] or 0) + 1)
table.insert(msgs, ("%s's\n%s rose!"):format(enemy.name, stat:upper()))
table.insert(msgs, Strings("%s's\n%s rose!", enemy.name, stat:upper()))
elseif item == "GUARD_SPEC" then
enemy.mist = true
table.insert(msgs, ("%s's\nprotected against\nstat changes!"):format(enemy.name))
table.insert(msgs, Strings("%s's\nprotected against\nstat changes!", enemy.name))
end
return msgs
end
+3
View File
@@ -16,4 +16,7 @@ return {
-- Wild/trainer enemies never spend PP (DecrementPP only touches the
-- player side in pokered). They therefore never Struggle from empty PP.
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,
-- Gen 2+ style: AI opponents deplete PP and can Struggle when empty.
enemyUnlimitedPP = false,
-- Gen 2+: Hyper Beam always forces a recharge turn, even on a KO.
hyperBeamSkipRechargeOnKO = false,
}
+31
View File
@@ -102,10 +102,41 @@ function Data:seedDefaults()
-- after-battle rows so Blaine's SetEventRange deactivation and talk
-- after-text work like the other gyms (scripts/CinnabarGym.asm).
self:seedCinnabarGymTrainerHeaders()
-- #197: the Fighting Dojo Karate Master is text_asm, so the extractor
-- writes no header for him -- seed one so he engages on sight and has
-- his defeat / re-talk lines (same idea as the Cinnabar seed above).
self:seedFightingDojoKarateMaster()
-- #189: 1F cabin door order vs rooms map (survey zoom)
require("src.world.SsAnneLayout").apply(self.maps)
end
-- The Karate Master (FightingDojo.asm) is a text_asm object: his object has
-- no def_trainers row (DisplayTextID routes to his ASM script), so the
-- extractor emits headers only for the four blackbelts ([2]..[5]). Seed
-- object index [1] so he behaves like the real leader:
-- * range 4 (matches the strongest blackbelt) -> CheckFightingMapTrainers
-- spots the player in his DOWN line and he challenges on sight (#197),
-- * battle = his pre-battle challenge, won = "Hwa! Arrgh! Beaten!",
-- * after = the "Stay and train at Karate with us!" re-talk line.
-- Deliberately NO `event`: EVENT_BEAT_KARATE_MASTER is owned by
-- victories.lua (OPP_BLACKBELT#1) exactly like the gym leaders, and
-- engageTrainer sets header.event *before* checkVictoryRewards runs -- if
-- this header also set it, the reward's flag guard would early-return and
-- swallow the prize dialogue. trainerDefeated tracks him via
-- defeatedTrainers[npc.id] like the leaders.
function Data:seedFightingDojoKarateMaster()
local headers = self.trainer_headers
if not headers then return end
headers.FightingDojo = headers.FightingDojo or {}
if headers.FightingDojo[1] then return end
headers.FightingDojo[1] = {
range = 4,
battle = "_FightingDojoKarateMasterText",
won = "_FightingDojoKarateMasterDefeatedText",
after = "_FightingDojoKarateMasterStayAndTrainWithUsText",
}
end
function Data:seedCinnabarGymTrainerHeaders()
local headers = self.trainer_headers
if not headers or headers.CinnabarGym then return end
+7 -6
View File
@@ -9,6 +9,7 @@
local Json = require("src.link.Json")
local Logger = require("src.core.Logger")
local Runtime = require("src.mods.Runtime")
local Strings = require("src.core.Strings")
local DiscordPresence = {
APP_ID = "1529183141267374262",
@@ -436,7 +437,7 @@ end
local function buildActivity()
local details, activityState
if state.activity == "battle" then
details = state.battleLabel or "In battle"
details = state.battleLabel or Strings("In battle")
activityState = state.location and (state.location) or "Kanto"
elseif state.activity == "exploring" and state.location then
details = state.location
@@ -550,20 +551,20 @@ local function subscribe(game)
track("battle.started", function(ev)
local battle = ev and ev.battle
local label = "In battle"
local label = Strings("In battle")
if ev and ev.kind == "wild" then
local name = speciesName(game, ev.species)
if name then
label = "Battling wild " .. name
if ev.level then label = label .. " Lv" .. tostring(ev.level) end
else
label = "Wild battle"
label = Strings("Wild battle")
end
elseif ev and ev.kind == "trainer" then
local tname = battle and battle.trainer and battle.trainer.name
label = tname and ("Battling " .. tname) or "Trainer battle"
label = tname and ("Battling " .. tname) or Strings("Trainer battle")
elseif ev and ev.kind == "link" then
label = "Link battle"
label = Strings("Link battle")
end
local mapId = state.mapId
or (game.save and game.save.player and game.save.player.map)
@@ -594,7 +595,7 @@ local function subscribe(game)
state.mapId = nil
setPresence({
activity = "menu",
location = id == "IntroMovie" and "Watching the intro" or "Title screen",
location = id == "IntroMovie" and "Watching the intro" or Strings("Title screen"),
clearBattle = true,
})
end
+33 -12
View File
@@ -8,7 +8,7 @@ local Logger = require("src.core.Logger")
local Renderer = require("src.render.Renderer")
local SaveData = require("src.core.SaveData")
local StateStack = require("src.core.StateStack")
local TouchInput = require("src.core.TouchInput")
local TouchControls = require("src.core.TouchControls")
local ModLoader = require("src.mods.Loader")
local ModRuntime = require("src.mods.Runtime")
local Screens = require("src.ui.Screens")
@@ -44,8 +44,8 @@ function Game:load()
self.input = Input
Input:init()
self.touchInput = TouchInput
TouchInput:init()
self.touchControls = TouchControls
TouchControls:init()
self.renderer = Renderer
Renderer:init()
@@ -53,6 +53,9 @@ function Game:load()
require("src.render.Font").load(Data)
-- menu cursor/border/geometry constants; field.theme restyles them
require("src.ui.Theme").load(Data)
-- the engine's own text, after the merge so a translation mod's catalog
-- is already in Data.strings; empty on a mod-free boot and skipped
require("src.core.Strings").load(Data)
self.stack = StateStack
StateStack:init()
@@ -96,6 +99,20 @@ function Game:load()
end
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
-- the merged field.boot: spawn, names, money and the naming presets a
@@ -180,9 +197,6 @@ function Game:logicSpeed()
end
function Game:update(dt)
-- Touch timers / prior-frame auto-releases before the fixed step so
-- deferred A and edge pulses land in Input's press queue for this step.
TouchInput:update(dt)
-- Fast-forward scales only the logic clock (see src/core/GameSpeed.lua).
-- Give the accumulator room for one full frame at the current speed,
-- or the anti-spiral clamp quietly caps every level above ~15X.
@@ -246,6 +260,8 @@ function Game:draw()
worldZones = self.overworld:sgbWorldZones()
end
Renderer:endFrame(zones, worldZones)
-- on-screen mobile controls: pure screen-space, over the finished frame
TouchControls:draw()
end
-- overworld survey zoom: wheel up / '=' zooms in, wheel down / '-' out
@@ -375,6 +391,9 @@ function Game:keyreleased(key)
end
function Game:gamepadpressed(joystick, button)
-- a controller is being used: the touch overlay steps aside until the
-- next screen touch (mobile only; a no-op elsewhere)
TouchControls:noteGamepad()
-- BindingsMenu's pad capture rides the same top-state routing as keys
local top = self.stack and self.stack:top()
if top and top.onGamepadPressed then
@@ -389,6 +408,8 @@ function Game:gamepadreleased(joystick, button)
end
function Game:gamepadaxis(joystick, axis, value)
-- past-deadzone only, so resting-stick drift can't hide the overlay
if math.abs(value) > 0.5 then TouchControls:noteGamepad() end
Input:gamepadaxis(joystick, axis, value)
end
@@ -398,12 +419,12 @@ end
-- state is worse than asking the player to re-press.
function Game:focus(f)
Input:reset()
TouchInput:reset()
TouchControls:reset()
end
function Game:visible(v)
Input:reset()
TouchInput:reset()
TouchControls:reset()
end
-- A disconnected/dropped controller can't send the button-up for whatever
@@ -411,19 +432,19 @@ end
-- flags it owned.
function Game:joystickremoved(joystick)
Input:reset()
TouchInput:reset()
TouchControls:joystickremoved()
end
function Game:touchpressed(id, x, y)
TouchInput:touchpressed(id, x, y)
TouchControls:touchpressed(id, x, y)
end
function Game:touchmoved(id, x, y)
TouchInput:touchmoved(id, x, y)
TouchControls:touchmoved(id, x, y)
end
function Game:touchreleased(id, x, y)
TouchInput:touchreleased(id, x, y)
TouchControls:touchreleased(id, x, y)
end
-- Point the loader's mod.save backing at this save's modData so per-mod
+11
View File
@@ -154,6 +154,17 @@ function Input:step()
self.pressQueue = {}
end
-- The on-screen touch overlay (src/core/TouchControls.lua) presses GB
-- buttons directly by name -- not through a keyboard alias -- so a player
-- rebind can never detach or shadow the overlay.
function Input:overlayPressed(btn)
press(self, btn, "touch:" .. btn)
end
function Input:overlayReleased(btn)
release(self, btn, "touch:" .. btn)
end
function Input:gamepadpressed(joystick, button)
local btn = self.padBindings[button]
if btn then
+31 -1
View File
@@ -24,7 +24,31 @@ local FILTER_HIGHGAIN = { 0.4, 0.16, 0.064 }
local filterLevel = 0
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
-- Source:setFilter needs OpenAL EFX; the pcall degrades to unfiltered
@@ -408,6 +432,12 @@ end
-- restores the map theme after a one-shot jingle
function Music.update(data)
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`
-- frames, then drop one level (FadeOutAudio decrements both rAUDVOL
-- 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
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
-- migration/resolution against a freshly injected filesystem. Unused by
-- the game, which resolves each version exactly once per boot.
@@ -1117,9 +1155,16 @@ function SaveData.defaultHeal(boot)
end
-- Post-credits home (issue #103). pokered left the player in HALL_OF_FAME
-- after jp Init; this port places CONTINUE at the NewGameWarp bedroom and
-- retargets LAST_MAP exits (Red's house mats) at the heal-point town so
-- leaving the house does not dump the player back at Indigo Plateau.
-- after jp Init; this port relocates CONTINUE instead, and retargets
-- LAST_MAP exits (Red's house mats) at the heal-point town so leaving the
-- house does not dump the player back at Indigo Plateau.
--
-- The landing spot is the blackout point, not the NewGameWarp bedroom:
-- HallOfFameResetEventsAndSaveScript sets wLastBlackoutMap := PALLET_TOWN,
-- so resuming puts the player outside the front door. Spawning them back
-- in the upstairs bedroom was a port-only detour (#253). Non-vanilla
-- spawns are unaffected -- defaultHeal returns boot's own start map for
-- those, which is what the bedroom branch resolved to anyway.
-- Marks postGameHomeOk so a later intentional HoF save is not relocated.
function SaveData.applyPostGameHome(save, boot)
boot = type(boot) == "table" and boot or {}
@@ -1127,9 +1172,9 @@ function SaveData.applyPostGameHome(save, boot)
save.lastHeal = { map = heal.map, x = heal.x, y = heal.y }
save.lastOutdoor = { id = heal.map, x = heal.x, y = heal.y }
save.player = save.player or {}
save.player.map = boot.startMap or "REDS_HOUSE_2F"
save.player.x = boot.startX or 3
save.player.y = boot.startY or 6
save.player.map = heal.map
save.player.x = heal.x
save.player.y = heal.y
save.player.facing = boot.startFacing or "down"
save.postGameHomeOk = true
return heal
+134
View File
@@ -0,0 +1,134 @@
-- The engine's own player-facing text, made overridable.
--
-- Extracted dialogue already had a home: `Data.text`, keyed by pokered's
-- labels, which a mod reaches through `mod.content.text`. The strings the
-- engine *authors* had none. Battle messages, item results, menu labels
-- and the link-play screens were literals in Lua, so a translator could
-- reach two thirds of the game and no more (#186, #245).
--
-- Those literals stay where they are, wrapped in `S(...)`, and the English
-- source doubles as the catalog key:
--
-- self:say(S("But, it failed!"))
-- self:say(S("Wild %s\nappeared!", self.enemy.name))
--
-- Keying on the source rather than on an invented id is deliberate. It
-- keeps the English readable at the point it is used (the alternative
-- scatters a thousand `battle.it_failed` ids that have to be looked up to
-- review a diff), it needs no id registry to stay in sync, and an entry a
-- translation has not covered yet falls through to English instead of
-- rendering a raw id at the player. The cost is that editing an English
-- string orphans its translations; `tools/modkit.py translation --refresh`
-- reports those as changed keys rather than silently dropping them.
--
-- Same-source-different-meaning is the one case source keys cannot hold on
-- their own ("OFF" as a filter setting vs "OFF" as a toggle, which some
-- languages render differently). Those sites pass a context:
--
-- S("OFF", "options.musicFilter") -- key: "options.musicFilter|OFF"
--
-- A mod supplies the catalog through the `strings` registry:
--
-- mod.content.strings:override("But, it failed!", "Echec !")
--
-- With no mod loaded the catalog is empty and `S` is an identity function
-- guarded by one boolean, so a vanilla boot draws byte-identical text.
local Strings = {}
local catalog = nil -- Data.strings once a mod has put something in it
local missing = {} -- format-arity complaints, reported once each
-- Called from Game after the mod merge, and again on dev-mode hot reload.
-- Holding the table (not a copy) means a mod that registers late still
-- takes effect without a second load.
function Strings.load(data)
local t = data and data.strings
catalog = nil
if type(t) ~= "table" then return end
for _ in pairs(t) do
catalog = t
return
end
end
function Strings.active()
return catalog ~= nil
end
-- The lookup itself. `context` is optional and only disambiguates sources
-- that collide; the plain key is tried after it, so a translation that does
-- not care about the distinction can supply one entry for both.
function Strings.lookup(source, context)
if not catalog then return source end
if context then
local hit = catalog[context .. "|" .. source]
if type(hit) == "string" then return hit end
end
local hit = catalog[source]
if type(hit) == "string" then return hit end
return source
end
-- Count `%`-directives so a translation that drops or adds one is caught
-- here rather than as a mid-battle `string.format` error.
local function specifiers(s)
local n = 0
for spec in s:gmatch("%%(.)") do
if spec ~= "%" then n = n + 1 end
end
return n
end
-- S(source) -> translated source
-- S(source, ...) -> translated source, string.format'ed
-- S(source, context) -> context-disambiguated lookup, no formatting
--
-- The two-argument forms are told apart by whether the source carries any
-- format directives: a source with no `%s` cannot be formatting, so a lone
-- string second argument is a context.
function Strings.get(source, ...)
local argc = select("#", ...)
if argc == 0 then return Strings.lookup(source) end
local wants = specifiers(source)
if wants == 0 and argc == 1 and type((...)) == "string" then
return Strings.lookup(source, (...))
end
local text = Strings.lookup(source)
-- A translation with the wrong arity would raise inside string.format,
-- which in a battle means a crash the player cannot escape. Fall back to
-- the English source, which is known to match, and say so once.
if specifiers(text) ~= wants then
if not missing[source] then
missing[source] = true
require("src.core.Logger").warn(
"strings: translation of %q has %d format directives, source has %d"
.. " -- using the source", source, specifiers(text), wants)
end
text = source
end
local ok, out = pcall(string.format, text, ...)
if not ok then return source end
return out
end
-- A marker, not a lookup: returns its argument untouched.
--
-- Some templates are declared in a module-level table and formatted much
-- later (BattleState's charge-move lines, for one). Translating at the
-- declaration would freeze the English, because those tables are built at
-- require time and Strings.load has no catalog yet; the use site therefore
-- calls Strings(template, ...) and looks the source up then. That works at
-- runtime but leaves the literal invisible to the catalog generator, which
-- only sees what is spelled out at a call site. Wrapping the declaration in
-- Strings.source puts it back in the harvest while changing nothing at all
-- about when the lookup happens.
function Strings.source(text)
return text
end
setmetatable(Strings, { __call = function(_, ...) return Strings.get(...) end })
return Strings
+287
View File
@@ -0,0 +1,287 @@
-- On-screen touch controls: a visible d-pad, A, B, START and SELECT drawn
-- over the finished frame (art: Xelu's CC0 controller prompts, see
-- assets/touch/README.md). Replaces the old touch gesture recognizer:
-- every control is a real button under the thumb, so there is no
-- tap-vs-swipe classification, no deferred-A double-tap window, and no
-- added latency.
--
-- Mobile only, and only while no controller is being used: the overlay
-- shows on Android/iOS, disappears the moment a gamepad button or stick
-- is used, and comes back on the next screen touch (Game routes both
-- events here). POKEPORT_TOUCH=1 forces it on for desktop testing
-- (main.lua then drives it with the mouse); POKEPORT_TOUCH=0 forces it
-- off everywhere.
--
-- Controls press GB buttons through Input:overlayPressed/Released -- their
-- own input source, not a keyboard alias -- so a held overlay direction
-- merges cleanly with a keyboard key or stick holding the same button,
-- and a player rebind can never detach the overlay.
local Input = require("src.core.Input")
local TouchControls = {}
-- idle vs pressed overlay opacity
local ALPHA = 0.65
local ALPHA_PRESSED = 0.95
-- translucent backing disc behind each control: the prompt art is dark
-- gray, so without it the controls melt into dark map areas
local BACK = 0.24
local BACK_PRESSED = 0.38
-- neutral zone at the d-pad center, as a fraction of the d-pad width;
-- inside it no direction is held (keeps a resting thumb from jittering)
local DPAD_DEAD = 0.16
-- hit slop: how far past the visible edge a press still counts, as a
-- multiplier on the control's half-width. START/SELECT get more because
-- the glyphs are small.
local SLOP = { a = 1.3, b = 1.3, start = 1.4, select = 1.4 }
local BUTTONS = { "a", "b", "start", "select" }
local IMAGES = {
dpad = "assets/touch/dpad.png",
dpad_up = "assets/touch/dpad_up.png",
dpad_down = "assets/touch/dpad_down.png",
dpad_left = "assets/touch/dpad_left.png",
dpad_right = "assets/touch/dpad_right.png",
a = "assets/touch/a.png",
b = "assets/touch/b.png",
start = "assets/touch/start.png",
select = "assets/touch/select.png",
}
local function wantsOverlay()
local env = os.getenv("POKEPORT_TOUCH")
if env == "1" then return true end
if env == "0" then return false end
local osName = love.system and love.system.getOS and love.system.getOS()
return osName == "Android" or osName == "iOS"
end
function TouchControls:init()
self.active = wantsOverlay()
self.controllerHidden = false
self.touches = {}
-- per-GB-button owner count: two fingers on A must not double-press it,
-- and lifting one of them must not release the other's hold
self.held = {}
self.dpadTouch = nil
self.layoutW, self.layoutH = nil, nil
self.img = nil
if not self.active then return end
-- soft-fail: a missing/corrupt PNG must never block boot; the overlay
-- stays off and keyboard/controller play still works
local img = {}
for name, path in pairs(IMAGES) do
local ok, im = pcall(love.graphics.newImage, path)
if not ok then
img = nil
break
end
-- smooth UI icons; the global default filter is nearest for GB pixels
im:setFilter("linear", "linear")
img[name] = im
end
self.img = img
end
function TouchControls:visible()
return self.active and self.img ~= nil and not self.controllerHidden
end
-- Layout in LOVE units (density-independent on mobile), recomputed when
-- the window size changes (rotation, resize). D-pad bottom-left, B/A
-- bottom-right with A above B (the Game Boy diagonal), START/SELECT
-- flanking the bottom center.
function TouchControls:layout()
local ww, wh = love.graphics.getDimensions()
if self.layoutW == ww and self.layoutH == wh then return self.L end
self.layoutW, self.layoutH = ww, wh
local short = math.min(ww, wh)
-- ~a third of the short edge, capped so tablets don't get a dinner plate
local dpadW = math.min(180, short * 0.34)
local abW = dpadW * 0.46
local ssW = dpadW * 0.30
local margin = dpadW * 0.12
-- START/SELECT hug the bottom center: on a narrow portrait phone the
-- d-pad and B leave little room, so a tight pair is what keeps them off
-- the neighboring controls
self.L = {
dpad = { cx = margin + dpadW / 2, cy = wh - margin - dpadW / 2, w = dpadW },
a = { cx = ww - margin - abW * 0.55, cy = wh - margin - abW * 1.75, w = abW },
b = { cx = ww - margin - abW * 1.60, cy = wh - margin - abW * 0.55, w = abW },
start = { cx = ww / 2 + ssW * 0.60, cy = wh - margin - ssW * 0.95, w = ssW },
select = { cx = ww / 2 - ssW * 0.60, cy = wh - margin - ssW * 0.95, w = ssW },
}
local fontSize = math.max(8, math.floor(ssW * 0.26))
if not self.labelFont or self.fontSize ~= fontSize then
self.fontSize = fontSize
self.labelFont = love.graphics.newFont(fontSize)
end
return self.L
end
local function inCircle(zone, x, y, slop)
local r = zone.w * 0.5 * slop
local dx, dy = x - zone.cx, y - zone.cy
return dx * dx + dy * dy <= r * r
end
local function dpadDir(zone, x, y)
local dx, dy = x - zone.cx, y - zone.cy
local dead = zone.w * DPAD_DEAD
if math.abs(dx) < dead and math.abs(dy) < dead then return nil end
if math.abs(dx) >= math.abs(dy) then
return dx > 0 and "right" or "left"
end
return dy > 0 and "down" or "up"
end
local function pressBtn(self, btn)
local n = (self.held[btn] or 0) + 1
self.held[btn] = n
if n == 1 then Input:overlayPressed(btn) end
end
local function releaseBtn(self, btn)
local n = self.held[btn]
if not n then return end
if n > 1 then
self.held[btn] = n - 1
else
self.held[btn] = nil
Input:overlayReleased(btn)
end
end
-- the d-pad touch's held direction changed (or ended): swap the GB hold
local function setDpad(self, touch, dir)
if touch.dir == dir then return end
if touch.dir then releaseBtn(self, touch.dir) end
touch.dir = dir
if dir then pressBtn(self, dir) end
end
function TouchControls:touchpressed(id, x, y)
if not (self.active and self.img) then return end
-- a controller hid the overlay; the first touch only brings it back
if self.controllerHidden then
self.controllerHidden = false
return
end
local L = self:layout()
for _, btn in ipairs(BUTTONS) do
if inCircle(L[btn], x, y, SLOP[btn]) then
self.touches[id] = { control = btn }
pressBtn(self, btn)
return
end
end
-- square hit zone a bit past the cross art; one owning finger at a time
local dz = L.dpad
local half = dz.w * 0.65
if not self.dpadTouch
and math.abs(x - dz.cx) <= half and math.abs(y - dz.cy) <= half then
self.dpadTouch = id
local touch = { control = "dpad", dir = nil }
self.touches[id] = touch
setDpad(self, touch, dpadDir(dz, x, y))
end
end
function TouchControls:touchmoved(id, x, y)
local touch = self.touches[id]
-- only the d-pad tracks movement (slide between directions without
-- lifting); buttons hold until release wherever the finger wanders
if not touch or touch.control ~= "dpad" then return end
setDpad(self, touch, dpadDir(self:layout().dpad, x, y))
end
function TouchControls:touchreleased(id, x, y)
local touch = self.touches[id]
if not touch then return end
self.touches[id] = nil
if touch.control == "dpad" then
setDpad(self, touch, nil)
self.dpadTouch = nil
else
releaseBtn(self, touch.control)
end
end
-- LÖVE has no touchcancelled: a touch interrupted by the OS (app
-- backgrounded, a system gesture stealing the finger) never fires
-- touchreleased and would strand its button held forever. Called from
-- Game alongside Input:reset() on focus/visibility loss.
function TouchControls:reset()
for btn in pairs(self.held) do
Input:overlayReleased(btn)
end
self.held = {}
self.touches = {}
self.dpadTouch = nil
end
-- a gamepad is being used: hide the overlay (dropping anything it held)
-- until the next screen touch asks for it back
function TouchControls:noteGamepad()
if not self.active or self.controllerHidden then return end
self.controllerHidden = true
self:reset()
end
-- last controller unplugged: show the overlay again immediately instead
-- of requiring a blind first tap
function TouchControls:joystickremoved()
self:reset()
if love.joystick and love.joystick.getJoystickCount
and love.joystick.getJoystickCount() == 0 then
self.controllerHidden = false
end
end
local function drawIcon(img, zone, pressed)
love.graphics.setColor(1, 1, 1, pressed and BACK_PRESSED or BACK)
love.graphics.circle("fill", zone.cx, zone.cy, zone.w * 0.58)
local scale = zone.w / img:getWidth()
love.graphics.setColor(1, 1, 1, pressed and ALPHA_PRESSED or ALPHA)
love.graphics.draw(img, zone.cx - zone.w / 2,
zone.cy - img:getHeight() * scale / 2, 0, scale, scale)
end
-- Screen-space, called by Game:draw after Renderer:endFrame so the
-- overlay rides on top of everything (world, UI, CRT/GBC FX included).
function TouchControls:draw()
if not self:visible() then return end
local L = self:layout()
love.graphics.push("all")
love.graphics.origin()
local dpadTouch = self.dpadTouch and self.touches[self.dpadTouch]
local dir = dpadTouch and dpadTouch.dir
drawIcon(dir and self.img["dpad_" .. dir] or self.img.dpad, L.dpad,
dir ~= nil)
for _, btn in ipairs(BUTTONS) do
drawIcon(self.img[btn], L[btn], self.held[btn] ~= nil)
end
-- the +/- glyphs alone don't say which is which; shadowed so the text
-- reads on both the black letterbox and battle's white one
love.graphics.setFont(self.labelFont)
local ly = L.start.cy + L.start.w * 0.66
local function label(text, zone)
local w = self.labelFont:getWidth(text)
love.graphics.setColor(0, 0, 0, 0.6)
love.graphics.print(text, zone.cx - w / 2 + 1, ly + 1)
love.graphics.setColor(1, 1, 1, ALPHA + 0.2)
love.graphics.print(text, zone.cx - w / 2, ly)
end
label("START", L.start)
label("SELECT", L.select)
love.graphics.pop()
end
return TouchControls
-297
View File
@@ -1,297 +0,0 @@
-- Touch gesture recognizer → virtual keyboard keys for Input.lua.
--
-- Deferred-tap tradeoff: A fires only after DOUBLE_TAP_MS with no second
-- tap. That adds ~280ms latency to every A press so a double-tap can be
-- remapped to START instead of A-then-START. Gen 1 has no frame-perfect
-- input needs, so the latency is acceptable.
--
-- Select = two-finger tap (open Q1 in docs/mobile-plan.md): when a second distinct
-- touch ID lands while another short, low-movement touch is active, fire
-- SELECT (press + one-frame auto-release).
local Input = require("src.core.Input")
local TouchInput = {}
local function dpiScale()
if love and love.window then
if love.window.getDPIScale then
return love.window.getDPIScale()
end
if love.window.toPixels then
return love.window.toPixels(1)
end
end
return 1
end
-- Tunables (device-DPI-scaled where noted). Adjust after on-device testing.
local SWIPE_THRESHOLD_PX = 14
local EDGE_PX = 24
local EDGE_SWIPE_PX = 24
local DOUBLE_TAP_MS = 280
local TAP_MAX_MS = 320
local TAP_MAX_MOVE_PX = 12
local function scaled(px)
return px * dpiScale()
end
local DIRS = { up = true, down = true, left = true, right = true }
-- Virtual keys Input:keypressed looks up in KEYBOARD bindings (not button names).
local KEY = {
up = "up",
down = "down",
left = "left",
right = "right",
a = "z",
b = "x",
start = "escape",
select = "tab",
}
local function nowMs()
return love.timer.getTime() * 1000
end
local function dominantDir(dx, dy)
if math.abs(dx) >= math.abs(dy) then
return dx > 0 and "right" or "left"
end
return dy > 0 and "down" or "up"
end
function TouchInput:init()
self.touches = {}
self.pendingA = nil -- { deadlineMs = number }
-- Edge pulses (B / START / SELECT / deferred-A): press now, release on a
-- later update so FixedStep can consume wasPressed first.
-- `armed` = pressed during events since last update; promoted to
-- `autoRelease` at the start of update (released on the *following* update).
-- Deferred-A fired inside update goes straight into `autoRelease`.
self.armed = {}
self.autoRelease = {}
self.selectFired = false -- one SELECT per two-finger gesture cluster
end
-- LÖVE has no touchcancelled event, so a touch interrupted by the OS (app
-- backgrounded mid-touch, a system gesture stealing the finger) never fires
-- touchreleased and would otherwise strand its direction held forever.
-- Called from Game alongside Input:reset() on focus/visibility loss.
function TouchInput:reset()
for _, touch in pairs(self.touches) do
releaseDir(self, touch)
end
self.touches = {}
self.pendingA = nil
self.armed = {}
self.autoRelease = {}
self.selectFired = false
end
local function pulse(self, key)
Input:keypressed(key)
self.armed[#self.armed + 1] = key
end
local function pulseInUpdate(self, key)
Input:keypressed(key)
self.autoRelease[#self.autoRelease + 1] = key
end
local function releaseDir(self, touch)
if touch.dir and DIRS[touch.dir] then
Input:keyreleased(KEY[touch.dir])
touch.dir = nil
end
end
local function pressDir(self, touch, dir)
if touch.dir == dir then return end
releaseDir(self, touch)
touch.dir = dir
Input:keypressed(KEY[dir])
end
local function totalMove(touch, x, y)
local dx = x - touch.x0
local dy = y - touch.y0
return math.abs(dx), math.abs(dy), dx, dy
end
local function isTapLike(touch, x, y, tMs)
local ax, ay = totalMove(touch, x, y)
local elapsed = tMs - touch.t0
return elapsed <= TAP_MAX_MS
and ax <= scaled(TAP_MAX_MOVE_PX)
and ay <= scaled(TAP_MAX_MOVE_PX)
and not touch.classified
end
local function countActive(self)
local n = 0
for _ in pairs(self.touches) do n = n + 1 end
return n
end
local function tryTwoFingerSelect(self, tMs)
if self.selectFired then return false end
local ids = {}
for id, touch in pairs(self.touches) do
if isTapLike(touch, touch.x, touch.y, tMs) then
ids[#ids + 1] = id
end
end
if #ids < 2 then return false end
self.selectFired = true
self.pendingA = nil
for _, id in ipairs(ids) do
local touch = self.touches[id]
touch.classified = true
touch.consumed = true
releaseDir(self, touch)
end
pulse(self, KEY.select)
return true
end
function TouchInput:update(dt)
-- Releases armed on a prior update (FixedStep already saw wasPressed).
for i = 1, #self.autoRelease do
Input:keyreleased(self.autoRelease[i])
end
-- Promote event-phase pulses from since the last update; they release next time.
self.autoRelease = self.armed
self.armed = {}
local tMs = nowMs()
-- Deferred A: fire once the double-tap window closes with no second tap.
-- Queued into autoRelease so the next update clears hold after this FixedStep.
if self.pendingA and tMs >= self.pendingA.deadlineMs then
self.pendingA = nil
pulseInUpdate(self, KEY.a)
end
-- Keep two-finger SELECT detection live while both fingers stay down.
if countActive(self) >= 2 then
tryTwoFingerSelect(self, tMs)
elseif countActive(self) == 0 then
self.selectFired = false
end
end
function TouchInput:touchpressed(id, x, y)
local tMs = nowMs()
-- Second tap inside the deferred-A window → START instead of A.
if self.pendingA and tMs < self.pendingA.deadlineMs then
self.pendingA = nil
pulse(self, KEY.start)
-- Still record this touch so a lingering finger doesn't become a stray swipe.
self.touches[id] = {
x0 = x, y0 = y, x = x, y = y, t0 = tMs,
edge = x < scaled(EDGE_PX),
classified = true,
consumed = true,
dir = nil,
}
return
end
self.touches[id] = {
x0 = x, y0 = y, x = x, y = y, t0 = tMs,
edge = x < scaled(EDGE_PX),
classified = false,
consumed = false,
dir = nil,
}
if countActive(self) >= 2 then
tryTwoFingerSelect(self, tMs)
end
end
function TouchInput:touchmoved(id, x, y)
local touch = self.touches[id]
if not touch or touch.consumed then return end
touch.x, touch.y = x, y
local ax, ay, dx, dy = totalMove(touch, x, y)
local swipeTh = scaled(SWIPE_THRESHOLD_PX)
-- Edge-origin swipes become B on release; never promote to d-pad.
if touch.edge then
if ax >= scaled(EDGE_SWIPE_PX) or ay >= scaled(EDGE_SWIPE_PX) then
touch.classified = true
end
return
end
if not touch.classified then
if ax < swipeTh and ay < swipeTh then return end
touch.classified = true
pressDir(self, touch, dominantDir(dx, dy))
return
end
-- Mid-hold direction change: release old, press new (dominant axis).
if touch.dir then
local fromLastX = x - touch.x0
local fromLastY = y - touch.y0
-- Re-evaluate from origin so small jitter doesn't flip; require threshold
-- distance from origin along the new dominant axis.
if math.abs(fromLastX) >= swipeTh or math.abs(fromLastY) >= swipeTh then
local newDir = dominantDir(fromLastX, fromLastY)
if newDir ~= touch.dir then
pressDir(self, touch, newDir)
end
end
end
end
function TouchInput:touchreleased(id, x, y)
local touch = self.touches[id]
if not touch then return end
local tMs = nowMs()
touch.x, touch.y = x, y
local ax, ay = totalMove(touch, x, y)
if touch.dir then
releaseDir(self, touch)
self.touches[id] = nil
if countActive(self) == 0 then self.selectFired = false end
return
end
if touch.consumed then
self.touches[id] = nil
if countActive(self) == 0 then self.selectFired = false end
return
end
-- Left-edge B: origin in EDGE_PX strip and movement past EDGE_SWIPE_PX
-- (or already marked classified while moving). Prefer over d-pad / tap.
if touch.edge then
local edgeTh = scaled(EDGE_SWIPE_PX)
if touch.classified or ax >= edgeTh or ay >= edgeTh then
pulse(self, KEY.b)
self.touches[id] = nil
if countActive(self) == 0 then self.selectFired = false end
return
end
end
-- Plain tap → defer A (or it was already classified as swipe without dir, ignore).
if isTapLike(touch, x, y, tMs) then
self.pendingA = { deadlineMs = tMs + DOUBLE_TAP_MS }
end
self.touches[id] = nil
if countActive(self) == 0 then self.selectFired = false end
end
return TouchInput
+269 -60
View File
@@ -1,4 +1,5 @@
local GameVersion = require("src.core.GameVersion")
local Strings = require("src.core.Strings")
local RomImporter = {}
RomImporter.__index = RomImporter
@@ -290,6 +291,39 @@ local function findPendingRom(ready)
return nil
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)
promptName = promptName or "Pokemon"
local prompt = "Choose your " .. promptName .. " ROM"
@@ -320,10 +354,10 @@ local function chooseRom(promptName)
end
-- 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
-- panel steers that case to the drag-drop hint instead.
-- Returns the chosen absolute path or nil. Android uses love.system.pickFile
-- ("mod") instead -- see RomImporter:chooseMod.
local function chooseZip()
local prompt = "Choose a mod .zip"
local prompt = Strings("Choose a mod .zip")
local platform = love.system.getOS()
if platform == "OS X" then
return commandOutput(
@@ -351,10 +385,10 @@ local function chooseZip()
end
-- 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;
-- the SAVE FILES card steers that case to the drag-drop hint instead.
-- dialogs). Returns the chosen absolute path or nil. Android uses
-- love.system.pickFile("sav") instead -- see RomImporter:chooseSaveImport.
local function chooseSav()
local prompt = "Choose a .sav save file"
local prompt = Strings("Choose a .sav save file")
local platform = love.system.getOS()
if platform == "OS X" then
return commandOutput(
@@ -429,10 +463,16 @@ function RomImporter.new(onComplete, opts)
-- affordance (desktop love.system.openURL).
saveNotice = {},
-- MODS panel state (pass 3): mods is the cached LauncherMods.list() array
-- (refreshed lazily on first draw and after any toggle/install); modScroll
-- is the list scroll offset (px, clamped in draw); modNotice is the last
-- install result { ok, text } shown as a line above the list.
-- (refreshed lazily on first draw and after any toggle/install/delete);
-- modScroll is the list scroll offset (px, clamped in draw); modNotice is
-- the last install/delete result { ok, text } shown as a line above the list.
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)
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
-- 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
-- it into the save directory, so a pending-ROM rescan on refocus picks it
-- up without the player needing to tap the button again.
-- it into the save directory, so a pending-file rescan on refocus picks it
-- 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)
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
local name, data = findPendingRom(self.ready)
if name then self:startData(data, name) end
@@ -672,13 +742,38 @@ function RomImporter:_installMod(source)
self.tab = "mods"
end
-- "Import mod .zip" button: open a native picker (desktop) and install the
-- pick. Android has no picker, so it points the player at the drop path.
-- Remove an installed mod from the save-dir mods/ tree and refresh the panel.
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()
if self.workState == "working" then return end
if self.android then
self.modNotice =
{ ok = false, text = "Drop a mod .zip onto the window to install it." }
local name = findPendingMod(true)
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
end
local path = chooseZip()
@@ -722,13 +817,26 @@ function RomImporter:_importSave(version, source)
end
end
-- "Import save" button: open a native .sav picker (desktop) and import the pick.
-- Android has no picker, so it points the player at the drop path.
-- "Import save" button: open a native .sav picker and import the pick.
-- Android mirrors ROM / mod import via love.system.pickFile("sav").
function RomImporter:chooseSaveImport(version)
if self.workState == "working" then return end
if self.android then
self.saveNotice[version] =
{ ok = false, text = "Drop a .sav onto the window to import it." }
local name = findPendingSav(true)
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
end
local path = chooseSav()
@@ -736,16 +844,58 @@ function RomImporter:chooseSaveImport(version)
end
-- "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)
-- on the SAVE FILES card.
-- directory's exports/ folder. On desktop, show the path with an open-folder
-- 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)
if self.workState == "working" then return end
local ok, res = require("src.import.SaveFileIO").exportActiveSlot(version)
if ok then
local dir = res:match("^(.*)[/\\][^/\\]+$")
self.saveNotice[version] = { ok = true, text = "Exported to " .. res, dir = dir }
else
if not ok then
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
@@ -1277,7 +1427,7 @@ function RomImporter:draw()
self.updateButton = { x = rect.x, y = rect.y, width = rect.width,
height = rect.height, action = "download" }
message(upLatest and ("Update v" .. upLatest .. " available")
or "An update is available", rect.width)
or Strings("An update is available"), rect.width)
elseif upStatus == "needs_full" then
local rect = actionButton("Open releases")
self.updateButton = { x = rect.x, y = rect.y, width = rect.width,
@@ -1427,9 +1577,17 @@ function RomImporter:mousepressed(x, y, button)
end
return
end
-- SAVE SLOT rows. On desktop a press only ARMS a click: _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.
-- SAVE SLOT rows / Delete. Delete is checked first so a tap on the Delete
-- label never also selects the row. On desktop a press only ARMS a click:
-- _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
if inside(r, x, y) then
if self.android then
@@ -1445,12 +1603,18 @@ function RomImporter:mousepressed(x, y, button)
self:_newSlot(self.panelVersion); return
end
-- 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
-- press so _updateSlotDrag can tell a click from a drag-scroll (Android, with
-- no pointer polling, toggles on press).
-- conflict); Delete fires immediately; a toggle switch, which lives in the
-- scrollable list, only ARMS a press so _updateSlotDrag can tell a click from
-- a drag-scroll (Android, with no pointer polling, toggles on press).
if inside(self.modImportRect, x, y) then
self:chooseMod(); return
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
if inside(r, x, y) then
if self.android then
@@ -1555,7 +1719,7 @@ function RomImporter:_playButton(x, y, w, h, gameName, ready, locked)
col(PAL.disabledInk, 0.3)
love.graphics.rectangle("line", x, y, w, h, r, r)
col(PAL.disabledInk)
local label = locked and "Coming soon" or "Import a ROM to play"
local label = locked and "Coming soon" or Strings("Import a ROM to play")
printfB(label, x, y + (h - self.playFont:getHeight()) / 2, w, "center")
self.playButtonRect = nil
end
@@ -1567,13 +1731,13 @@ function RomImporter:_drawTabBar(x, y, w, h, chip)
local s, pulse = self._s, self.pulse
local tabs = {
{ id = "red", letter = "R", top = PAL.chipRedTop, bot = PAL.chipRedBot,
under = PAL.red, label = "RED", ink = PAL.white },
under = PAL.red, label = Strings("RED"), ink = PAL.white },
{ id = "blue", letter = "B", top = PAL.chipBlueTop, bot = PAL.chipBlueBot,
under = PAL.blue, label = "BLUE", ink = PAL.white },
under = PAL.blue, label = Strings("BLUE"), ink = PAL.white },
{ id = "yellow", letter = "Y", top = PAL.chipGoldTop, bot = PAL.chipGoldBot,
under = PAL.gold, label = "YELLOW", ink = PAL.chipInkGold },
under = PAL.gold, label = Strings("YELLOW"), ink = PAL.chipInkGold },
{ id = "mods", mods = true, top = PAL.chipModTop, bot = PAL.chipModBot,
under = PAL.modDot, label = "MODS" },
under = PAL.modDot, label = Strings("MODS") },
}
local gap = 10 * s
local r = 12 * s
@@ -1632,7 +1796,7 @@ function RomImporter:_drawTabBar(x, y, w, h, chip)
local ready = 0
for _, v in ipairs(GameVersion.ORDER) do if self.ready[v] then ready = ready + 1 end end
love.graphics.setFont(self.readyFont)
local label = ("%d of 3 ready"):format(ready)
local label = Strings("%d of 3 ready", ready)
local lw = self.readyFont:getWidth(label)
if x + w - lw > cursorX + 8 * s then
col(PAL.labelGray)
@@ -1688,7 +1852,7 @@ function RomImporter:_drawGamePanel(version, x, y, w, h)
-- ROM card contents by state (rehomes the existing import flow)
local dropHint = self.android and "Copy the .gb via USB."
or "Or drop the .gb file here."
or Strings("Or drop the .gb file here.")
local accent = locked and PAL.gold or (version == "red" and PAL.red or PAL.blue)
local romState, romDetail, romBtnLabel, romBtnEnabled, romProgress
if locked then
@@ -1703,12 +1867,12 @@ function RomImporter:_drawGamePanel(version, x, y, w, h)
romDetail = self.detail or ""
romProgress = self.progress or 0
elseif ready then
romState = self.romName[version] or "ROM imported"
romState = self.romName[version] or Strings("ROM imported")
romDetail = "Verified."
romBtnLabel, romBtnEnabled = "Re-import ROM", true
elseif erroring then
romState = "Import failed"
romDetail = self.detail or "That ROM could not be imported."
romDetail = self.detail or Strings("That ROM could not be imported.")
romBtnLabel, romBtnEnabled = "Import ROM", true
elseif notice then
romState = "No ROM imported"
@@ -1759,7 +1923,8 @@ function RomImporter:_drawGamePanel(version, x, y, w, h)
elseif locked then
sfHintText, sfHintCol = "Not available yet.", PAL.warning
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
sfHintText, sfHintCol =
"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
iy = iy + 4 * s
love.graphics.setFont(self.hintFont)
local label = "Open folder"
local label = Strings("Open folder")
local lw = self.hintFont:getWidth(label)
local frect = { x = ix, y = iy, width = lw, height = self.hintFont:getHeight(),
dir = sfNotice.dir }
@@ -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.",
rx + 12 * s, listTop + listH / 2 - self.hintFont:getHeight() / 2,
rw - 24 * s, "center")
self.slotRects = {}
self.slotDeleteRects = {}
elseif listH > 0 then
local nameH = self.slotNameFont:getHeight()
local metaH = self.labelFont:getHeight()
@@ -2017,6 +2184,7 @@ function RomImporter:_drawSaveSlotPanel(version, x, y, w, h)
self.slotScroll[version] = scroll
self.slotRects = {}
self.slotDeleteRects = {}
love.graphics.setScissor(math.floor(rx), math.floor(listTop),
math.ceil(rw), math.ceil(listH))
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)
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
local pillW = 0
if selected then
@@ -2047,21 +2229,20 @@ function RomImporter:_drawSaveSlotPanel(version, x, y, w, h)
love.graphics.setFont(self.slotNameFont)
col(PAL.white)
local name = slot.name or "NEW GAME"
printB(ellipsize(self.slotNameFont, name, rw - 24 * s - pillW),
local name = slot.name or Strings("NEW GAME")
printB(ellipsize(self.slotNameFont, name, rw - 24 * s - math.max(pillW, rightReserve)),
rx + 12 * s, ry + rowPadV)
local metaTxt
if slot.exists and slot.meta then
metaTxt = ("%d badges - %s - %d caught"):format(
slot.meta.badges or 0, slot.meta.timeText or "0:00",
metaTxt = Strings("%d badges - %s - %d caught", slot.meta.badges or 0, slot.meta.timeText or "0:00",
slot.meta.dexCount or 0)
else
metaTxt = "empty slot"
end
love.graphics.setFont(self.labelFont)
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)
-- 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] =
{ x = rx, y = vy, width = rw, height = vy2 - vy, id = slot.id }
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
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
love.graphics.setFont(self.hintFont)
col(PAL.warning)
love.graphics.print(("%d of %d enabled"):format(enabledCount, #mods),
love.graphics.print(Strings("%d of %d enabled", enabledCount, #mods),
x + nameW + 14 * s, y + (headerH - self.hintFont:getHeight()) / 2)
local btnLabel = "Import mod .zip"
@@ -2165,15 +2352,15 @@ function RomImporter:_drawModsPanel(x, y, w, h)
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)
if self.modNotice then
col(self.modNotice.ok and PAL.green or PAL.red)
love.graphics.printf(self.modNotice.text, x, top, w, "left")
else
col(PAL.warning)
love.graphics.printf(self.android and "Copy a mod .zip via USB."
or "Or drop a mod .zip onto the window.", x, top, w, "left")
love.graphics.printf(self.android and "Or copy a mod .zip via USB."
or Strings("Or drop a mod .zip onto the window."), x, top, w, "left")
end
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)
love.graphics.setFont(self.hintFont)
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")
self.modRects = {}
self.modDeleteRects = {}
self._modMax = 0
return
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 cardGap, cardR = 10 * s, 14 * s
local tw, th = 52 * s, 28 * s
local innerW = w - 2 * padH
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)
local nameH = self.stateFont:getHeight()
@@ -2210,7 +2402,8 @@ function RomImporter:_drawModsPanel(x, y, w, h)
for i, m in ipairs(mods) do
local chipText = modStatusChip(m.status)
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 descH = 0
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 cardH = math.max(contentH, padV * 2 + clusterH)
layout[i] = { h = cardH, leftW = leftW, clusterW = clusterW,
chipText = chipText, chipW = chipW }
chipText = chipText, chipW = chipW, delW = delW }
total = total + cardH
end
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)
self.modScroll = scroll
self.modRects = {}
self.modDeleteRects = {}
love.graphics.setScissor(math.floor(x), math.floor(top),
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")
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 clusterY = cy + (cardH - clusterH) / 2
local _, chipColor = modStatusChip(m.status)
@@ -2303,14 +2497,29 @@ function RomImporter:_drawModsPanel(x, y, w, h)
col(PAL.white)
love.graphics.circle("fill", kcx, ty + th / 2, kd / 2)
-- hit rect clipped to the visible list band (a partly-scrolled toggle is
-- only clickable where it actually shows)
-- Delete under the toggle
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 vy2 = math.min(trect.y + trect.height, top + listH)
if vy2 > vy then
self.modRects[#self.modRects + 1] =
{ x = trect.x, y = vy, width = trect.width, height = vy2 - vy, id = m.id }
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
cy = cy + cardH + cardGap
end
+13 -5
View File
@@ -47,11 +47,19 @@ local function readSource(source)
return source
end
local f, openErr = io.open(source, "rb")
if not f then return nil, "could not read the save file: " .. tostring(openErr) end
local data = f:read("*a")
f:close()
if type(data) ~= "string" then return nil, "the save file was empty" end
return data
if f then
local data = f:read("*a")
f:close()
if type(data) ~= "string" then return nil, "the save file was empty" end
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
-- importToSlot(source, version) -> ok, slotIdOrErr
+48 -46
View File
@@ -12,6 +12,7 @@
local Pokemon = require("src.pokemon.Pokemon")
local Flags = require("src.script.Flags")
local Strings = require("src.core.Strings")
local ItemEffects = {}
@@ -119,8 +120,8 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
if battle and (VITAMINS[itemId] or STONES[itemId] or itemId == "PP_UP"
or itemId == "RARE_CANDY" or itemId == "COIN_CASE"
or (itemDef and itemDef.machine)) then
return "failed", { "OAK: " .. save.player.name
.. "!\nThis isn't the\ntime to use that!" }
return "failed", { Strings("OAK: %s!\nThis isn't the\ntime to use that!",
save.player.name) }
end
if BALLS[itemId] then
@@ -138,11 +139,12 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
local mapId, npc = adjacentSleepingSnorlax(save, ow)
if npc then
return "flute_wake", { data.text._PlayedFluteHadEffectText
or "{PLAYER} played the\nPOKé FLUTE." }, { mapId = mapId, npc = npc }
or Strings("{PLAYER} played the\nPOKé FLUTE.") },
{ mapId = mapId, npc = npc }
end
-- otherwise: play the tune, nothing happens (ItemUsePokeFlute's
-- PlayedFluteNoEffectText branch)
return "flute_field", { "Played the POKé\nFLUTE.\fNow, that's a\ncatchy tune!" }
return "flute_field", { Strings("Played the POKé\nFLUTE.\fNow, that's a\ncatchy tune!") }
end
local woke = false
local function wake(mon)
@@ -157,24 +159,24 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
-- WakeUpEntireParty runs on the enemy's bench too
for _, mon in ipairs(battle.enemyParty or {}) do wake(mon) end
if not woke then
return "failed", { "Played the POKé\nFLUTE.\fNow, that's a\ncatchy tune!" }
return "failed", { Strings("Played the POKé\nFLUTE.\fNow, that's a\ncatchy tune!") }
end
return "flute", { ("%s played the\nPOKé FLUTE."):format(save.player.name),
"All sleeping\nPOKéMON woke up!" }
return "flute", { Strings("%s played the\nPOKé FLUTE.", save.player.name),
Strings("All sleeping\nPOKéMON woke up!") }
end
-- battle-only items
if X_ITEMS[itemId] or itemId == "DIRE_HIT" or itemId == "GUARD_SPEC"
or itemId == "POKE_DOLL" then
if not battle then
return "failed", { "OAK: " .. save.player.name .. "!\nThis isn't the\ntime to use that!" }
return "failed", { Strings("OAK: %s!\nThis isn't the\ntime to use that!", save.player.name) }
end
local b = battle.player
if itemId == "X_ACCURACY" then
-- ItemUseXAccuracy sets USING_X_ACCURACY: moves never miss
-- (not an accuracy stage)
b.xAccuracy = true
return "consumed", { ("%s's\nhits will never\nmiss!"):format(b.name) }
return "consumed", { Strings("%s's\nhits will never\nmiss!", b.name) }
end
if X_ITEMS[itemId] then
local stat = X_ITEMS[itemId]
@@ -183,28 +185,28 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
-- effect, so at +6 it is still consumed and StatModifierUpEffect
-- just prints "Nothing happened!"
if cur >= 6 then
return "consumed", { "Nothing happened!" }
return "consumed", { Strings("Nothing happened!") }
end
b.stages[stat] = cur + 1
return "consumed", { ("%s's\n%s rose!"):format(b.name, stat:upper()) }
return "consumed", { Strings("%s's\n%s rose!", b.name, stat:upper()) }
end
-- ItemUseDireHit/ItemUseGuardSpec always set the bit and consume
-- the item, even when it is already active
if itemId == "DIRE_HIT" then
b.focusEnergy = true
return "consumed", { ("%s's\ngetting pumped!"):format(b.name) }
return "consumed", { Strings("%s's\ngetting pumped!", b.name) }
end
if itemId == "GUARD_SPEC" then
b.mist = true
return "consumed", { ("%s's\nprotected against\nstat changes!"):format(b.name) }
return "consumed", { Strings("%s's\nprotected against\nstat changes!", b.name) }
end
if itemId == "POKE_DOLL" then
if battle.kind ~= "wild" then
-- ItemUsePokeDoll jumps to ItemUseNotTime in trainer battles
return "failed", { "OAK: " .. save.player.name
.. "!\nThis isn't the\ntime to use that!" }
return "failed", { Strings(
"OAK: %s!\nThis isn't the\ntime to use that!", save.player.name) }
end
return "consumed_escape", { "The wild POKéMON\nran away!" }
return "consumed_escape", { Strings("The wild POKéMON\nran away!") }
end
end
@@ -213,7 +215,7 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
-- restore every move with no menu.
if itemId == "ETHER" or itemId == "MAX_ETHER"
or itemId == "ELIXER" or itemId == "MAX_ELIXER" then
if not target then return "failed", { "It won't have\nany effect." } end
if not target then return "failed", { Strings("It won't have\nany effect.") } end
local restored = false
local full = itemId == "MAX_ETHER" or itemId == "MAX_ELIXER"
local allMoves = itemId == "ELIXER" or itemId == "MAX_ELIXER"
@@ -235,9 +237,9 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
restored = mv and restore(mv) or false
end
if not restored then
return "failed", { "It won't have\nany effect." }
return "failed", { Strings("It won't have\nany effect.") }
end
return "consumed", { ("%s's PP\nwas restored!"):format(monName(data, target)) }
return "consumed", { Strings("%s's PP\nwas restored!", monName(data, target)) }
end
local heal = HEAL_AMOUNT[itemId]
@@ -250,17 +252,17 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
target.status = nil
cureActiveToxic(battle, target)
require("src.core.Sound").play(data, "Heal_Ailment")
return "consumed", { ("%s's\nstatus returned\nto normal!"):format(monName(data, target)) }
return "consumed", { Strings("%s's\nstatus returned\nto normal!", monName(data, target)) }
end
if not target or target.hp <= 0 or target.hp >= target.stats.hp then
return "failed", { "It won't have\nany effect." }
return "failed", { Strings("It won't have\nany effect.") }
end
if itemId == "MAX_POTION" or itemId == "FULL_RESTORE" then
target.hp = target.stats.hp
else
target.hp = math.min(target.stats.hp, target.hp + heal)
end
local msgs = { ("%s's HP\nwas restored!"):format(monName(data, target)) }
local msgs = { Strings("%s's HP\nwas restored!", monName(data, target)) }
if itemId == "FULL_RESTORE" then
target.status = nil
cureActiveToxic(battle, target)
@@ -272,27 +274,27 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
local cures = STATUS_HEAL[itemId]
if cures then
if not target or not target.status or not cures[target.status] then
return "failed", { "It won't have\nany effect." }
return "failed", { Strings("It won't have\nany effect.") }
end
target.status = nil
cureActiveToxic(battle, target)
require("src.core.Sound").play(data, "Heal_Ailment")
return "consumed", { ("%s's\nstatus returned\nto normal!"):format(monName(data, target)) }
return "consumed", { Strings("%s's\nstatus returned\nto normal!", monName(data, target)) }
end
if itemId == "REVIVE" or itemId == "MAX_REVIVE" then
if not target or target.hp > 0 then
return "failed", { "It won't have\nany effect." }
return "failed", { Strings("It won't have\nany effect.") }
end
target.status = nil
target.hp = itemId == "REVIVE" and math.floor(target.stats.hp / 2) or target.stats.hp
require("src.core.Sound").play(data, "Heal_HP")
return "consumed", { ("%s\nis revitalized!"):format(monName(data, target)) }
return "consumed", { Strings("%s\nis revitalized!", monName(data, target)) }
end
if itemId == "RARE_CANDY" then
if not target or target.level >= 100 then
return "failed", { "It won't have\nany effect." }
return "failed", { Strings("It won't have\nany effect.") }
end
local Growth = require("src.pokemon.Growth")
local Stats = require("src.pokemon.Stats")
@@ -302,56 +304,56 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
local old = target.stats
target.stats = Stats.calc(speciesDef, target.level, target.dvs, target.statExp)
target.hp = math.min(target.stats.hp, target.hp + (target.stats.hp - old.hp))
return "consumed", { ("%s grew\nto level %d!"):format(monName(data, target), target.level) },
return "consumed", { Strings("%s grew\nto level %d!", monName(data, target), target.level) },
{ leveledTo = target.level }
end
if STONES[itemId] then
if not target then return "failed", { "It won't have\nany effect." } end
if not target then return "failed", { Strings("It won't have\nany effect.") } end
local speciesDef = data.pokemon[target.species]
for _, evo in ipairs(speciesDef.evolutions) do
if evo.method == "ITEM" and evo.item == itemId then
return "consumed", nil, { evolveTo = evo.species }
end
end
return "failed", { "It won't have\nany effect." }
return "failed", { Strings("It won't have\nany effect.") }
end
-- vitamins: +2560 stat exp, refused at 25600+ (ItemUseVitamin,
-- engine/items/item_effects.asm)
local vitaminStat = VITAMINS[itemId]
if vitaminStat then
if not target then return "failed", { "It won't have\nany effect." } end
if not target then return "failed", { Strings("It won't have\nany effect.") } end
target.statExp = target.statExp or {}
local cur = target.statExp[vitaminStat] or 0
if cur >= 25600 then
return "failed", { "It won't have\nany effect." }
return "failed", { Strings("It won't have\nany effect.") }
end
target.statExp[vitaminStat] = math.min(65535, cur + 2560)
local Stats = require("src.pokemon.Stats")
target.stats = Stats.calc(data.pokemon[target.species], target.level,
target.dvs, target.statExp)
target.hp = math.min(target.hp, target.stats.hp)
return "consumed", { ("%s's %s\nrose!"):format(monName(data, target),
return "consumed", { Strings("%s's %s\nrose!", monName(data, target),
vitaminStat == "hp" and "HP" or vitaminStat:upper()) }
end
-- PP UP boosts the move the player picked (ItemUsePPUp's move menu)
if itemId == "PP_UP" then
if not target then return "failed", { "It won't have\nany effect." } end
if not target then return "failed", { Strings("It won't have\nany effect.") } end
local mv = target.moves[moveIndex or 1]
local mdef = mv and data.moves[mv.id]
if mdef and (mv.ppUps or 0) < 3 then
mv.ppUps = (mv.ppUps or 0) + 1
-- each PP UP adds maxPP/5 uses on top of the base maximum
mv.pp = mv.pp + math.floor(mdef.pp / 5)
return "consumed", { ("%s's PP\nincreased!"):format(mdef.name) }
return "consumed", { Strings("%s's PP\nincreased!", mdef.name) }
end
return "failed", { "It won't have\nany effect." }
return "failed", { Strings("It won't have\nany effect.") }
end
if itemDef and itemDef.machine then
if not target then return "failed", { "It won't have\nany effect." } end
if not target then return "failed", { Strings("It won't have\nany effect.") } end
local speciesDef = data.pokemon[target.species]
local ok = false
for _, m in ipairs(speciesDef.tmhm) do
@@ -362,11 +364,11 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
-- plays SFX_DENIED before MonCannotLearnMachineMoveText (the generic
-- ItemUseNotTime/NoCyclingAllowedHere paths are silent)
require("src.core.Sound").play(data, "Denied")
return "failed", { ("%s can't\nlearn that move!"):format(monName(data, target)) }
return "failed", { Strings("%s can't\nlearn that move!", monName(data, target)) }
end
for _, mv in ipairs(target.moves) do
if mv.id == itemDef.machine.move then
return "failed", { "It knows that\nmove already!" }
return "failed", { Strings("It knows that\nmove already!") }
end
end
-- HMs are never consumed; TMs are single-use
@@ -375,14 +377,14 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
if itemId == "OLD_ROD" or itemId == "GOOD_ROD" or itemId == "SUPER_ROD" then
if battle then
return "failed", { "OAK: " .. save.player.name .. "!\nThis isn't the\ntime to use that!" }
return "failed", { Strings("OAK: %s!\nThis isn't the\ntime to use that!", save.player.name) }
end
return "fish", itemId
end
if itemId == "BICYCLE" then
if battle then
return "failed", { "OAK: " .. save.player.name .. "!\nThis isn't the\ntime to use that!" }
return "failed", { Strings("OAK: %s!\nThis isn't the\ntime to use that!", save.player.name) }
end
return "bicycle"
end
@@ -392,26 +394,26 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
end
if itemId == "TOWN_MAP" then
if battle then
return "failed", { "OAK: " .. save.player.name .. "!\nThis isn't the\ntime to use that!" }
return "failed", { Strings("OAK: %s!\nThis isn't the\ntime to use that!", save.player.name) }
end
return "townmap"
end
if itemId == "ITEMFINDER" then
if battle then
return "failed", { "OAK: " .. save.player.name .. "!\nThis isn't the\ntime to use that!" }
return "failed", { Strings("OAK: %s!\nThis isn't the\ntime to use that!", save.player.name) }
end
return "itemfinder"
end
if itemId == "COIN_CASE" then
return "failed", { ("Coin count:\n%d"):format(save.coins or 0) }
return "failed", { Strings("Coin count:\n%d", save.coins or 0) }
end
if itemId == "REPEL" or itemId == "SUPER_REPEL" or itemId == "MAX_REPEL" then
local steps = itemId == "REPEL" and 100 or itemId == "SUPER_REPEL" and 200 or 250
save.repelSteps = steps
return "consumed", { ("%s used\n%s!"):format(save.player.name, name) }
return "consumed", { Strings("%s used\n%s!", save.player.name, name) }
end
return "failed", { "OAK: " .. save.player.name .. "!\nThis isn't the\ntime to use that!" }
return "failed", { Strings("OAK: %s!\nThis isn't the\ntime to use that!", save.player.name) }
end
return ItemEffects
+25 -24
View File
@@ -21,6 +21,7 @@ local Logger = require("src.core.Logger")
local Protocol = require("src.link.Protocol")
local Runtime = require("src.mods.Runtime")
local TurnOrder = require("src.battle.TurnOrder")
local Strings = require("src.core.Strings")
local LinkBattle = {}
@@ -177,18 +178,18 @@ function LinkBattle.new(game, net, opts)
local theirName = opts.theirName or "FOE"
if not Handshake.battleAllowed(opts.verdict) then
return nil, "Link battle needs\nthe same mods on\nboth games."
return nil, Strings("Link battle needs\nthe same mods on\nboth games.")
end
-- both parties pass through the same pack->unpack clamp on both
-- machines, so the copies are identical everywhere
local unpackOpts = { strict = opts.strict or false, forceLevel = opts.forceLevel }
local myParty, myErr = unpackParty(game, opts.myParty, unpackOpts, function(p)
return ("Your %s can't\nbattle on the\nother game."):format(tostring(p.species))
return Strings("Your %s can't\nbattle on the\nother game.", tostring(p.species))
end)
if not myParty then return nil, myErr end
local theirParty, theirErr = unpackParty(game, opts.theirParty, unpackOpts, function(p, why)
return ("Their %s isn't\nin this game.\n(%s)"):format(tostring(p.species), tostring(why))
return Strings("Their %s isn't\nin this game.\n(%s)", tostring(p.species), tostring(why))
end)
if not theirParty then return nil, theirErr end
if #myParty == 0 or #theirParty == 0 then
@@ -232,7 +233,7 @@ function LinkBattle.new(game, net, opts)
self.enemyParty = theirParty
self.playerParty = myParty -- intro ball row uses the clamped copies
self.opponentName = theirName
self.introText = ("%s wants\nto battle!"):format(theirName)
self.introText = Strings("%s wants\nto battle!", theirName)
self.remoteHashes = {}
self.localHashes = {}
self.remoteParts = {}
@@ -292,7 +293,7 @@ function LinkBattle.new(game, net, opts)
battle = s, side = s.sides[2], battler = s.enemy, previous = previous,
})
s.enemySendingOut = true
s:sayNext(("%s sent\nout %s!"):format(theirName, s.enemy.name))
s:sayNext(Strings("%s sent\nout %s!", theirName, s.enemy.name))
s:actNext(function()
s.enemySendingOut = false
s:startGrowIn(s.enemy)
@@ -315,8 +316,9 @@ function LinkBattle.new(game, net, opts)
tostring(turn), component, tostring(localH), tostring(remoteH))
Runtime.emit("link.desync", { turn = turn, component = component,
localHash = localH, remoteHash = remoteH })
endAsDraw(s, ("Link desync!\n%s differs.\fAre both games\nrunning the same\nmods?")
:format(component))
endAsDraw(s, Strings(
"Link desync!\n%s differs.\fAre both games\nrunning the same\nmods?",
component))
end
-- a verified turn stays recorded: consuming it here left a finished
@@ -349,7 +351,7 @@ function LinkBattle.new(game, net, opts)
local function resolveLockstep(s, myMsg, theirMsg)
if myMsg.kind == "run" or theirMsg.kind == "run" then
local who = myMsg.kind == "run" and game.save.player.name or theirName
endAsDraw(s, ("%s ran from\nthe battle!"):format(who))
endAsDraw(s, Strings("%s ran from\nthe battle!", who))
return
end
s.phase = "messages"
@@ -479,9 +481,9 @@ function LinkBattle.new(game, net, opts)
party = myParty,
onSwitch = function(mon)
if mon == s.player.mon then
s:say(("%s is\nalready out!"):format(s.player.name))
s:say(Strings("%s is\nalready out!", s.player.name))
elseif mon.hp <= 0 then
s:say("There's no will\nto fight!")
s:say(Strings("There's no will\nto fight!"))
else
s:resolveSwitch(mon)
end
@@ -491,7 +493,7 @@ function LinkBattle.new(game, net, opts)
end
self.openItems = function(s)
s:say("Items can't be\nused in a link\nbattle!")
s:say(Strings("Items can't be\nused in a link\nbattle!"))
s.phase = "messages"
s.afterQueue = "menu"
end
@@ -509,7 +511,7 @@ function LinkBattle.new(game, net, opts)
return
end
end
s:sayNext(("%s is out of\nPOKéMON!\f%s wins!"):format(game.save.player.name,
s:sayNext(Strings("%s is out of\nPOKéMON!\f%s wins!", game.save.player.name,
theirName))
s.result = "lose"
s.afterQueue = "finish"
@@ -522,7 +524,7 @@ function LinkBattle.new(game, net, opts)
return
end
end
s:sayNext(("%s is out of\nPOKéMON!\f%s wins!"):format(theirName,
s:sayNext(Strings("%s is out of\nPOKéMON!\f%s wins!", theirName,
game.save.player.name))
s.result = "win"
s.afterQueue = "finish"
@@ -543,13 +545,13 @@ function LinkBattle.new(game, net, opts)
-- only a draw if our own simulation hasn't already decided
-- (the winner's bye can arrive while we're still animating)
if not s.result then
endAsDraw(s, ("%s left the\nbattle."):format(theirName))
endAsDraw(s, Strings("%s left the\nbattle.", theirName))
end
elseif msg.type == "forfeit" then
-- the peer's own shot clock ran out; unlike a mutual RUN/desync
-- draw, this has a definite winner (us)
if not s.result then
endWithResult(s, "win", ("%s ran out of\ntime!"):format(theirName))
endWithResult(s, "win", Strings("%s ran out of\ntime!", theirName))
end
else
-- a tournament control message (bracket_update, the next
@@ -582,7 +584,7 @@ function LinkBattle.new(game, net, opts)
if s.turnClock <= 0 then
s.turnClockActive = false
send({ type = "forfeit" })
endWithResult(s, "lose", "Time's up! You\nforfeit the match.")
endWithResult(s, "lose", Strings("Time's up! You\nforfeit the match."))
end
elseif opts.turnLimit then
s.turnClockActive = false
@@ -643,17 +645,16 @@ function LinkBattle.newSpectator(game, net, opts)
local guestName = opts.guestName or "GUEST"
if not Handshake.battleAllowed(opts.verdict) then
return nil, "Link battle needs\nthe same mods on\nboth games."
return nil, Strings("Link battle needs\nthe same mods on\nboth games.")
end
local unpackOpts = { strict = opts.strict or false, forceLevel = opts.forceLevel }
local hostParty, hostErr = unpackParty(game, opts.hostParty, unpackOpts, function(p)
return ("%s's %s can't\nbattle on this\ngame."):format(hostName, tostring(p.species))
return Strings("%s's %s can't\nbattle on this\ngame.", hostName, tostring(p.species))
end)
if not hostParty then return nil, hostErr end
local guestParty, guestErr = unpackParty(game, opts.guestParty, unpackOpts, function(p, why)
return ("%s's %s can't\nbattle on this\ngame.\n(%s)"):format(
guestName, tostring(p.species), tostring(why))
return Strings("%s's %s can't\nbattle on this\ngame.\n(%s)", guestName, tostring(p.species), tostring(why))
end)
if not guestParty then return nil, guestErr end
if #hostParty == 0 or #guestParty == 0 then
@@ -672,7 +673,7 @@ function LinkBattle.newSpectator(game, net, opts)
self.enemyParty = guestParty
self.playerParty = hostParty
self.opponentName = guestName
self.introText = ("%s vs %s!"):format(hostName, guestName)
self.introText = Strings("%s vs %s!", hostName, guestName)
local function orderMove(action)
if action and action.id then return game.data.moves[action.id] end
@@ -712,7 +713,7 @@ function LinkBattle.newSpectator(game, net, opts)
battle = s, side = s.sides[2], battler = s.enemy, previous = previous,
})
s.enemySendingOut = true
s:sayNext(("%s sent\nout %s!"):format(guestName, s.enemy.name))
s:sayNext(Strings("%s sent\nout %s!", guestName, s.enemy.name))
s:actNext(function()
s.enemySendingOut = false
s:startGrowIn(s.enemy)
@@ -789,7 +790,7 @@ function LinkBattle.newSpectator(game, net, opts)
return
end
end
s:sayNext(("%s is out of\nPOKéMON!\f%s wins!"):format(hostName, guestName))
s:sayNext(Strings("%s is out of\nPOKéMON!\f%s wins!", hostName, guestName))
s.result = "guestWin"
s.afterQueue = "finish"
end
@@ -801,7 +802,7 @@ function LinkBattle.newSpectator(game, net, opts)
return
end
end
s:sayNext(("%s is out of\nPOKéMON!\f%s wins!"):format(guestName, hostName))
s:sayNext(Strings("%s is out of\nPOKéMON!\f%s wins!", guestName, hostName))
s.result = "hostWin"
s.afterQueue = "finish"
end
+67 -47
View File
@@ -11,6 +11,7 @@ local Protocol = require("src.link.Protocol")
local Runtime = require("src.mods.Runtime")
local Screens = require("src.ui.Screens")
local TextBox = require("src.render.TextBox")
local Strings = require("src.core.Strings")
local LinkState = {}
LinkState.__index = LinkState
@@ -31,7 +32,12 @@ local function indexOf(list, value)
end
local function levelForWire(v)
return v == ANY and nil or v
-- ANY ("use each mon's real level") goes on the wire as nil (no forced
-- level). An explicit guard, not `v == ANY and nil or v`: that idiom's
-- true branch is nil, so it falls through to `or v` and returned the
-- literal "ANY" string, which then crashed math.floor in unpackMon (#204).
if v == ANY then return nil end
return v
end
local function forceLevelLabel(v)
@@ -85,7 +91,7 @@ function LinkState.newJoinOnline(game, code)
self.stage = "onlineJoining"
else
self.stage = "menu" -- exitWith below needs a real stage to unwind from
self:exitWith("Link error:\n" .. (self.net.error or "?"))
self:exitWith(Strings("Link error:\n%s", self.net.error or "?"))
end
return self
end
@@ -169,7 +175,7 @@ function LinkState:update(dt)
if self.net then
self.net:update()
if self.net.error and not PRE_CONNECT_STAGES[self.stage] then
self:exitWith("Link error:\n" .. self.net.error:sub(1, 60))
self:exitWith(Strings("Link error:\n%s", self.net.error:sub(1, 60)))
return
end
-- the peer vanished without a bye (only once the inbox is drained,
@@ -178,7 +184,7 @@ function LinkState:update(dt)
and not PRE_CONNECT_STAGES[self.stage] and self.stage ~= "addrEntry"
and self.stage ~= "codeEntry" and self.stage ~= "notice"
and self.stage ~= "battleRunning" then
self:exitWith("The link was\nbroken.")
self:exitWith(Strings("The link was\nbroken."))
return
end
end
@@ -196,7 +202,7 @@ function LinkState:update(dt)
self.index = 1
elseif self.index == 2 or self.index == 3 then
if not Handshake.onlineAllowed(self.game) then
self:exitWith("Online play needs\nno mods enabled.\fDisable them in\nSTART > MODS.")
self:exitWith(Strings("Online play needs\nno mods enabled.\fDisable them in\nSTART > MODS."))
return
end
if self.index == 2 then
@@ -222,7 +228,7 @@ function LinkState:update(dt)
if self.net:host() then
self.stage = "hosting"
else
self:exitWith("Link error:\n" .. (self.net.error or "?"))
self:exitWith(Strings("Link error:\n%s", self.net.error or "?"))
end
else
self.stage = "addrEntry"
@@ -241,7 +247,7 @@ function LinkState:update(dt)
if self.net:hostOnline() then
self.stage = "onlineHosting"
else
self:exitWith("Link error:\n" .. (self.net.error or "?"))
self:exitWith(Strings("Link error:\n%s", self.net.error or "?"))
end
else
self.stage = "codeEntry"
@@ -279,7 +285,7 @@ function LinkState:update(dt)
if self.net:joinOnline(nil, code) then
self.stage = "onlineJoining"
else
self:exitWith("Link error:\n" .. (self.net.error or "?"))
self:exitWith(Strings("Link error:\n%s", self.net.error or "?"))
end
end
@@ -318,7 +324,7 @@ function LinkState:update(dt)
if self.net:join(table.concat(octets, ".")) then
self.stage = "joining"
else
self:exitWith("Link error:\n" .. (self.net.error or "?"))
self:exitWith(Strings("Link error:\n%s", self.net.error or "?"))
end
end
@@ -428,7 +434,7 @@ function LinkState:update(dt)
end
if not battle then
self.net:send({ type = "bye" })
self:exitWith(why or "Link battle\ncan't start.", "error")
self:exitWith(why or Strings("Link battle\ncan't start."), "error")
return
end
self.game.stack:push(battle)
@@ -486,13 +492,21 @@ function LinkState:updateTrade(input)
local t = self.trade
if t.stage == "cancelled" then
self:exitWith(t.error and ("The trade stopped:\n%s."):format(t.error)
or "The trade was\ncancelled.")
self:exitWith(t.error and Strings("The trade stopped:\n%s.", t.error)
or Strings("The trade was\ncancelled."))
return
end
if t.stage == "done" then
local sent = t.party[t.myPick]
local received, evoTo = t:apply(self.game)
-- Autosave the instant the swap commits into game.save.party, matching the
-- Cable Club: pokered engine/link/cable_club.asm calls SaveSAVtoSRAM
-- (engine/menus/save.asm) right after every trade so the trade is on the
-- cartridge before the animation runs. Without this the received mon
-- lives only in memory until a manual START-menu save, so a force-quit
-- would lose it and a reset would clone the sent mon (#222). Guarded so
-- headless LinkBattle-style fake games with no writeSave are unaffected.
if self.game.writeSave then self.game:writeSave() end
local name = received.nickname or self.game.data.pokemon[received.species].name
Runtime.emit("link.ended", { reason = "done" })
self.net:close()
@@ -507,10 +521,16 @@ function LinkState:updateTrade(input)
enemyOtId = received.otId,
onDone = function()
game.stack:push(TextBox.new(game,
("Trade completed!\f%s received\n%s!"):format(game.save.player.name, name),
Strings("Trade completed!\f%s received\n%s!", game.save.player.name, name),
function()
if evoTo then
require("src.pokemon.Evolution").evolve(game, received, evoTo)
-- via="TRADE": a trade evolution cannot be B-cancelled
-- (pokered LINK_STATE_TRADING skips the flash B-poll) (#213).
-- Re-save once the evolution movie finishes so the evolved
-- species (not the pre-evo landed by t:apply) is what persists,
-- keeping disk in step with the autosave above (#222).
require("src.pokemon.Evolution").evolve(game, received, evoTo,
function() if game.writeSave then game:writeSave() end end, "TRADE")
end
end))
end,
@@ -528,7 +548,7 @@ function LinkState:updateTrade(input)
-- the trade) -- B is dead after that, matching the A branch's own
-- self.confirmed == nil guard
self.net:send({ type = "bye" })
self:exitWith("The trade was\ncancelled.")
self:exitWith(Strings("The trade was\ncancelled."))
elseif t.stage == "picking" and input:wasPressed("a") then
if t:canPick(self.index) then
self.net:send(t:pick(self.index))
@@ -555,30 +575,30 @@ end
function LinkState:draw()
if self.stage == "menu" then
drawTitle("BOIS CLUB LIVE")
Font.draw("LINK CABLE (LAN)", 32, 44)
Font.draw("ONLINE MATCH", 32, 60)
Font.draw("TOURNAMENT", 32, 76)
Font.draw(Strings("LINK CABLE (LAN)"), 32, 44)
Font.draw(Strings("ONLINE MATCH"), 32, 60)
Font.draw(Strings("TOURNAMENT"), 32, 76)
Font.drawCode(CURSOR, 24, 44 + (self.index - 1) * 16)
elseif self.stage == "lanMenu" then
drawTitle("LINK CABLE (LAN)")
Font.draw("HOST A GAME", 32, 48)
Font.draw("JOIN A GAME", 32, 68)
Font.draw(Strings("HOST A GAME"), 32, 48)
Font.draw(Strings("JOIN A GAME"), 32, 68)
Font.drawCode(CURSOR, 24, self.index == 1 and 48 or 68)
Font.draw("UDP port " .. Net.defaultPort(), 8, 128)
Font.draw(Strings("UDP port %s", Net.defaultPort()), 8, 128)
elseif self.stage == "onlineMenu" then
drawTitle("ONLINE MATCH")
Font.draw("HOST ONLINE", 32, 48)
Font.draw("JOIN ONLINE", 32, 68)
Font.draw(Strings("HOST ONLINE"), 32, 48)
Font.draw(Strings("JOIN ONLINE"), 32, 68)
Font.drawCode(CURSOR, 24, self.index == 1 and 48 or 68)
elseif self.stage == "onlineHosting" then
drawTitle("HOSTING ONLINE")
Font.draw("Tell your friend", 16, 40)
Font.draw("the code:", 16, 52)
Font.draw(Strings("Tell your friend"), 16, 40)
Font.draw(Strings("the code:"), 16, 52)
Font.draw(self.net.code or "??????", 32, 68)
Font.draw("Waiting for join...", 8, 96)
Font.draw(Strings("Waiting for join..."), 8, 96)
elseif self.stage == "codeEntry" then
drawTitle("ENTER CODE")
@@ -590,18 +610,18 @@ function LinkState:draw()
Font.drawCode(0xEE, x, 76) -- ▼ under the active slot
end
end
Font.draw("A: connect B: back", 8, 128)
Font.draw(Strings("A: connect B: back"), 8, 128)
elseif self.stage == "onlineJoining" then
drawTitle("CONNECTING...")
Font.draw("Calling...", 8, 56)
Font.draw(Strings("Calling..."), 8, 56)
Font.draw(self.net.target or "", 8, 72)
elseif self.stage == "hosting" then
drawTitle("HOSTING")
Font.draw("Friend joins at:", 16, 48)
Font.draw(Strings("Friend joins at:"), 16, 48)
Font.draw(self.net.address or "?", 16, 64)
Font.draw("Waiting for join...", 8, 96)
Font.draw(Strings("Waiting for join..."), 8, 96)
elseif self.stage == "addrEntry" then
drawTitle("ENTER HOST ADDRESS")
@@ -616,34 +636,34 @@ function LinkState:draw()
for octet = 1, 3 do
Font.draw(".", 16 + octet * 32 - 8, 64)
end
Font.draw("Port: " .. Net.defaultPort(), 16, 96)
Font.draw("A: connect B: back", 8, 128)
Font.draw(Strings("Port: %s", Net.defaultPort()), 16, 96)
Font.draw(Strings("A: connect B: back"), 8, 128)
elseif self.stage == "joining" then
drawTitle("JOINING...")
Font.draw("Calling...", 8, 56)
Font.draw(Strings("Calling..."), 8, 56)
Font.draw(self.net.target or "", 8, 72)
elseif self.stage == "modeSelect" then
drawTitle("CONNECTED!")
Font.draw("TRADE", 32, 48)
Font.draw("BATTLE", 32, 68)
Font.draw(Strings("TRADE"), 32, 48)
Font.draw(Strings("BATTLE"), 32, 68)
Font.drawCode(CURSOR, 24, self.index == 1 and 48 or 68)
elseif self.stage == "battleOptions" then
drawTitle("BATTLE OPTIONS")
Font.draw("LEVELS:", 16, 56)
Font.draw(Strings("LEVELS:"), 16, 56)
Font.draw(forceLevelLabel(self.levelChoice or ANY), 88, 56)
Font.draw("A: continue B: back", 8, 128)
Font.draw(Strings("A: continue B: back"), 8, 128)
elseif self.stage == "waitMode" or self.stage == "waitHello" then
drawTitle("CONNECTED!")
if self.stage == "waitHello" then
Font.draw("Checking the", 16, 56)
Font.draw("other game...", 16, 72)
Font.draw(Strings("Checking the"), 16, 56)
Font.draw(Strings("other game..."), 16, 72)
else
Font.draw("Waiting for the", 16, 56)
Font.draw("host to choose...", 16, 72)
Font.draw(Strings("Waiting for the"), 16, 56)
Font.draw(Strings("host to choose..."), 16, 72)
end
elseif self.stage == "notice" then
@@ -652,12 +672,12 @@ function LinkState:draw()
if i > 8 then break end -- what fits above the prompt row
Font.draw(line, 8, 24 + (i - 1) * 12)
end
Font.draw(self.noticeExits and "A: back" or "A: trade anyway", 8, 128)
Font.draw(self.noticeExits and "A: back" or Strings("A: trade anyway"), 8, 128)
elseif self.stage == "trade" then
drawTitle("TRADE")
local t = self.trade
Font.draw("YOURS", 8, 20)
Font.draw(Strings("YOURS"), 8, 20)
for i, mon in ipairs(self.game.save.party) do
local def = self.game.data.pokemon[mon.species]
local label = (mon.nickname or def.name):sub(1, 8)
@@ -665,7 +685,7 @@ function LinkState:draw()
Font.draw(label, 16, 20 + i * 12)
if i == self.index then Font.drawCode(CURSOR, 8, 20 + i * 12) end
end
Font.draw("THEIRS", 84, 20)
Font.draw(Strings("THEIRS"), 84, 20)
for i, mon in ipairs(t.theirParty or {}) do
local def = self.game.data.pokemon[mon.species]
Font.draw((mon.nickname or def.name):sub(1, 8), 92, 20 + i * 12)
@@ -676,16 +696,16 @@ function LinkState:draw()
elseif t.stage == "waitParty" then hint = "Exchanging data..."
elseif t.stage == "picking" then
hint = t:canPick(self.index) and "Pick one to trade"
or "X: not on theirs"
or Strings("X: not on theirs")
elseif t.stage == "waitPick" then hint = "Waiting for them..."
elseif t.stage == "confirming" then
hint = self.confirmed and "Waiting..." or "A: trade B: cancel"
hint = self.confirmed and "Waiting..." or Strings("A: trade B: cancel")
end
Font.draw(hint or "", 8, 132)
elseif self.stage == "battleWait" or self.stage == "battleRunning" then
drawTitle("LINK BATTLE")
Font.draw("Exchanging data...", 16, 64)
Font.draw(Strings("Exchanging data..."), 16, 64)
end
love.graphics.setColor(1, 1, 1, 1)
end
+13 -9
View File
@@ -26,6 +26,7 @@
local Json = require("src.link.Json")
local Logger = require("src.core.Logger")
local Strings = require("src.core.Strings")
local hasEnet, enet = pcall(require, "enet")
if not hasEnet then enet = nil end
@@ -173,7 +174,7 @@ function Net:connectTCP(addr)
tcp:settimeout(5)
local ok, err = tcp:connect(host, port)
if not ok then
self.error = ("can't reach relay %s:%d\n(%s)"):format(host, port, tostring(err))
self.error = Strings("can't reach relay %s:%d\n(%s)", host, port, tostring(err))
return false
end
tcp:settimeout(0)
@@ -236,11 +237,14 @@ local function handleGenericRelayControl(self, msg)
self.paired = true
return true
elseif msg.type == "join_error" then
self.error = ({
not_found = "That code wasn't\nfound.",
full = "That game already\nhas two players.",
expired = "That code has\nexpired.",
})[msg.reason] or ("Couldn't join:\n%s"):format(tostring(msg.reason))
self.error = Strings(({
not_found = Strings.source("That code wasn't\nfound."),
full = Strings.source("That game already\nhas two players."),
expired = Strings.source("That code has\nexpired."),
})[msg.reason] or "")
if self.error == "" then
self.error = Strings("Couldn't join:\n%s", tostring(msg.reason))
end
self.closed = true
return true
elseif msg.type == "peer_gone" then
@@ -328,7 +332,7 @@ function Net:update()
-- an unreachable join target surfaces as a service error
-- (ICMP unreachable on the connected UDP socket)
if self.mode == "joining" and not self.paired then
self.error = ("no answer from\n%s"):format(self.target or "the host")
self.error = Strings("no answer from\n%s", self.target or "the host")
else
self.error = tostring(event)
end
@@ -358,14 +362,14 @@ function Net:update()
self.closed = true
if not self.paired then
self.error = self.error or
("no answer from\n%s"):format(self.target or "the host")
Strings("no answer from\n%s", self.target or "the host")
end
end
end
end
if self.mode == "joining" and not self.paired
and self.joinDeadline and now() > self.joinDeadline then
self.error = ("no answer from\n%s"):format(self.target or "the host")
self.error = Strings("no answer from\n%s", self.target or "the host")
self.closed = true
pcall(function() self.peer:disconnect_now() end)
end
+30 -4
View File
@@ -43,7 +43,12 @@ Protocol.plainCopy = plainCopy
-- serialize a mon instance for the wire (plain data only). ppUps rides
-- along because the real cable transmitted it and its absence silently
-- capped a PP-Upped move at base PP on the receiving side.
-- capped a PP-Upped move at base PP on the receiving side. ot/otId ride
-- along too: pokered's trade sends the whole party block including each
-- mon's OT ID (party_struct MON_OTID) and the OT-names block (wPartyMonOT),
-- and the receiver keeps them verbatim -- a differing OT/ID is what marks a
-- mon as traded (boosted EXP, high-level disobedience). Omitting them made
-- a received mon show the receiver as its OT (#215).
function Protocol.packMon(mon)
local moves = {}
for _, mv in ipairs(mon.moves) do
@@ -59,6 +64,8 @@ function Protocol.packMon(mon)
dvs = mon.dvs,
statExp = mon.statExp,
moves = moves,
ot = mon.ot,
otId = mon.otId,
extra = plainCopy(mon.extra),
}
end
@@ -71,6 +78,14 @@ function Protocol.unpackMon(data, packed, opts)
local Stats = require("src.pokemon.Stats")
local Growth = require("src.pokemon.Growth")
local strict = opts and opts.strict
-- forceLevel comes from an "auto-level" ruling. The picker's ANY choice
-- ("use each mon's real level", Gen1's only mode) is a string sentinel on
-- the LinkState/Tournament side (see levelForWire) that must mean "no
-- forced level" here. Coerce once so a non-numeric level string -- the ANY
-- sentinel, an old peer, or a mod (#204) -- can never reach math.floor
-- below: tonumber("ANY") == nil, i.e. keep the packed real level, while
-- tonumber(50)/tonumber("50") both give 50.
local forceLevel = opts and tonumber(opts.forceLevel) or nil
local def = data.pokemon[packed.species]
if not def then
if strict then return nil, "unknown POKéMON" end
@@ -81,8 +96,8 @@ function Protocol.unpackMon(data, packed, opts)
-- ignored and everyone rebuilds at the same fixed level instead, so a
-- Lv12 and a Lv100 party can battle on equal footing. Both sides pass
-- the identical forceLevel for a given match, so this stays symmetric.
if opts and opts.forceLevel then
level = math.max(2, math.min(100, math.floor(opts.forceLevel)))
if forceLevel then
level = math.max(2, math.min(100, math.floor(forceLevel)))
end
local dvs = {}
for _, k in ipairs({ "hp", "attack", "defense", "speed", "special" }) do
@@ -115,10 +130,19 @@ function Protocol.unpackMon(data, packed, opts)
-- current HP/status (a different level's numbers, possibly mid-fight)
-- isn't meaningful anymore -- auto-level starts everyone full and fresh,
-- same as a standardized tournament format would
local forced = opts and opts.forceLevel
local forced = forceLevel
local hp = forced and stats.hp
or math.max(0, math.min(stats.hp, math.floor(packed.hp or stats.hp)))
local status = forced and nil or packed.status
-- preserve the sender's original-trainer identity (party_struct MON_OTID +
-- wPartyMonOT on a real cable), clamped/typed like every other field so a
-- tampered packet can't inject a bad ID or a huge name. Left nil when the
-- packet omits them (a v1/old peer) -- no worse than before for that legacy
-- path, and once ot is set the load-time stampOT backfill (mon.ot or ...)
-- becomes a no-op so the sender's identity survives save/reload (#215).
local otId = packed.otId
and math.max(0, math.min(65535, math.floor(packed.otId))) or nil
local ot = type(packed.ot) == "string" and packed.ot:sub(1, 10) or nil
return {
species = packed.species,
level = level,
@@ -129,6 +153,8 @@ function Protocol.unpackMon(data, packed, opts)
hp = hp,
status = status,
nickname = packed.nickname,
ot = ot,
otId = otId,
moves = moves,
-- a namespace whose mod this install lacks survives untouched, so the
-- mon keeps it for the trip home
+41 -36
View File
@@ -18,6 +18,7 @@ local Protocol = require("src.link.Protocol")
local Runtime = require("src.mods.Runtime")
local Sound = require("src.core.Sound")
local TextBox = require("src.render.TextBox")
local Strings = require("src.core.Strings")
local Tournament = {}
Tournament.__index = Tournament
@@ -57,7 +58,12 @@ local function levelLabel(v)
end
local function levelForWire(v)
return v == ANY and nil or v
-- ANY ("use each mon's real level") goes on the wire as nil (no forced
-- level). An explicit guard, not `v == ANY and nil or v`: that idiom's
-- true branch is nil, so it falls through to `or v` and returned the
-- literal "ANY" string, which then crashed math.floor in unpackMon (#204).
if v == ANY then return nil end
return v
end
local function forceLevelLabel(v)
@@ -123,7 +129,7 @@ end
function Tournament:startHosting()
self.net = Net.new()
if not self.net:connectTCP(Net.defaultRelayAddress()) then
self:exitWith("Link error:\n" .. (self.net.error or "?"))
self:exitWith(Strings("Link error:\n%s", self.net.error or "?"))
return
end
local size, minL, maxL = partyStats(self.game.save.party)
@@ -146,7 +152,7 @@ end
function Tournament:startJoining(code)
self.net = Net.new()
if not self.net:connectTCP(Net.defaultRelayAddress()) then
self:exitWith("Link error:\n" .. (self.net.error or "?"))
self:exitWith(Strings("Link error:\n%s", self.net.error or "?"))
return
end
local size, minL, maxL = partyStats(self.game.save.party)
@@ -166,9 +172,9 @@ end
-- -------------------------------------------------------------------
local JOIN_ERROR_TEXT = {
not_found = "That code wasn't\nfound.",
already_started = "That tournament\nhas already begun.",
expired = "That code has\nexpired.",
not_found = Strings.source("That code wasn't\nfound."),
already_started = Strings.source("That tournament\nhas already begun."),
expired = Strings.source("That code has\nexpired."),
}
function Tournament:handleMessage(msg)
@@ -193,17 +199,17 @@ function Tournament:handleMessage(msg)
end
elseif msg.type == "tournament_host_error" then
if msg.reason == "party_ineligible" then
self:exitWith(("Can't host:\nneed %d Pokemon\nLv %s-%s."):format(
msg.requiredPartySize, levelLabel(msg.minLevel), levelLabel(msg.maxLevel)))
self:exitWith(Strings("Can't host:\nneed %d Pokemon\nLv %s-%s.", msg.requiredPartySize, levelLabel(msg.minLevel), levelLabel(msg.maxLevel)))
else
self:exitWith("Couldn't host\nthat tournament.")
self:exitWith(Strings("Couldn't host\nthat tournament."))
end
elseif msg.type == "tournament_join_error" then
if msg.reason == "party_ineligible" then
self:exitWith(("Your party needs\n%d Pokemon, Lv\n%s-%s."):format(
msg.requiredPartySize, levelLabel(msg.minLevel), levelLabel(msg.maxLevel)))
self:exitWith(Strings("Your party needs\n%d Pokemon, Lv\n%s-%s.", msg.requiredPartySize, levelLabel(msg.minLevel), levelLabel(msg.maxLevel)))
else
self:exitWith(JOIN_ERROR_TEXT[msg.reason] or "Couldn't join\nthat tournament.")
self:exitWith(JOIN_ERROR_TEXT[msg.reason]
and Strings(JOIN_ERROR_TEXT[msg.reason])
or Strings("Couldn't join\nthat tournament."))
end
elseif msg.type == "tournament_roster" then
self.roster = msg.players
@@ -276,7 +282,7 @@ function Tournament:beginMatchBattle()
-- shouldn't happen (both sides already passed the online-play mods
-- gate), but a mismatched engine/build is still possible -- bail out
-- of just this match rather than crash the tournament
self:exitWith("Link error:\nversion mismatch\nwith opponent.")
self:exitWith(Strings("Link error:\nversion mismatch\nwith opponent."))
return
end
self.linkSeed = self.isHost and love.math.random(1, 2 ^ 30) or nil
@@ -346,11 +352,11 @@ function Tournament:update(dt)
self.net:update()
if self.net.error and self.stage ~= "menu" and self.stage ~= "hostSettings"
and self.stage ~= "codeEntry" then
self:exitWith("Link error:\n" .. self.net.error:sub(1, 60))
self:exitWith(Strings("Link error:\n%s", self.net.error:sub(1, 60)))
return
end
if self.net.closed and self.stage ~= "done" then
self:exitWith("The tournament\nconnection was\nlost.")
self:exitWith(Strings("The tournament\nconnection was\nlost."))
return
end
end
@@ -376,7 +382,7 @@ function Tournament:update(dt)
and LinkBattle.newHost(self.game, self.net, self.pendingBattleOpts)
or LinkBattle.newGuest(self.game, self.net, self.pendingBattleOpts)
if not battle then
self:exitWith(why or "Link battle\ncan't start.")
self:exitWith(why or Strings("Link battle\ncan't start."))
return
end
-- anything after `party` in this same batch belongs to the
@@ -413,7 +419,7 @@ function Tournament:update(dt)
forceLevel = levelForWire(self.settings.forceLevel),
})
if not battle then
self:exitWith(why or "Can't watch this\nmatch.")
self:exitWith(why or Strings("Can't watch this\nmatch."))
return
end
for j = #msgs, i + 1, -1 do
@@ -536,8 +542,8 @@ local SETTINGS_LABELS = { "POKEMON", "MIN LV", "MAX LV", "TIMER", "LEVELS", "PLA
function Tournament:draw()
if self.stage == "menu" then
drawTitle("TOURNAMENT")
Font.draw("HOST", 32, 48)
Font.draw("JOIN", 32, 68)
Font.draw(Strings("HOST"), 32, 48)
Font.draw(Strings("JOIN"), 32, 68)
Font.drawCode(CURSOR, 24, self.index == 1 and 48 or 68)
elseif self.stage == "hostSettings" then
@@ -556,7 +562,7 @@ function Tournament:draw()
Font.draw(values[i], 96, y)
if i == self.settingsIndex then Font.drawCode(CURSOR, 8, y) end
end
Font.draw("START: create", 8, 128)
Font.draw(Strings("START: create"), 8, 128)
elseif self.stage == "codeEntry" then
drawTitle("ENTER CODE")
@@ -568,28 +574,27 @@ function Tournament:draw()
Font.drawCode(0xEE, x, 76)
end
end
Font.draw("A: join B: back", 8, 128)
Font.draw(Strings("A: join B: back"), 8, 128)
elseif self.stage == "registering" then
drawTitle("CONNECTING...")
Font.draw("B: cancel", 8, 128)
Font.draw(Strings("B: cancel"), 8, 128)
elseif self.stage == "bracket" or self.stage == "matchHello"
or self.stage == "matchWaitParty" or self.stage == "spectateWait" then
drawTitle(("TOURNAMENT %s"):format(self.code or "??????"))
drawTitle(Strings("TOURNAMENT %s", self.code or "??????"))
if self.bracket then
local y = 20
for _, round in ipairs(self.bracket.rounds) do
Font.draw(("ROUND %d"):format(round.round), 8, y)
Font.draw(Strings("ROUND %d", round.round), 8, y)
y = y + 10
for _, m in ipairs(round.matches) do
local line
if m.bye then
line = ("%s (bye)"):format(m.a or m.b or "?")
line = Strings("%s (bye)", m.a or m.b or "?")
else
local mark = m.state == "live" and "*" or (m.winner and "" or "")
line = ("%s%s vs %s%s"):format(
m.winner == m.a and ">" or " ", m.a or "?",
line = Strings("%s%s vs %s%s", m.winner == m.a and ">" or " ", m.a or "?",
m.b or "?", m.winner == m.b and "<" or (mark == "*" and " *" or ""))
end
Font.draw(line, 12, y)
@@ -599,11 +604,11 @@ function Tournament:draw()
end
else
if self.participating == false then
Font.draw("(organizing --", 16, 32)
Font.draw("not playing)", 16, 42)
Font.draw(Strings("(organizing --"), 16, 32)
Font.draw(Strings("not playing)"), 16, 42)
end
Font.draw("Waiting for", 16, 48)
Font.draw("players to join:", 16, 60)
Font.draw(Strings("Waiting for"), 16, 48)
Font.draw(Strings("players to join:"), 16, 60)
local y = 60
for i, name in ipairs(self.roster) do
y = 60 + i * 10
@@ -615,18 +620,18 @@ function Tournament:draw()
end
end
if self.isCreator and #self.roster >= 2 and not self.bracket then
Font.draw("A: START B: cancel", 8, 132)
Font.draw(Strings("A: START B: cancel"), 8, 132)
else
Font.draw("B: cancel", 8, 132)
Font.draw(Strings("B: cancel"), 8, 132)
end
elseif self.stage == "done" then
drawTitle("TOURNAMENT OVER")
if self.champion then
Font.draw(("%s is the"):format(self.champion), 16, 56)
Font.draw("champion!", 16, 68)
Font.draw(Strings("%s is the", self.champion), 16, 56)
Font.draw(Strings("champion!"), 16, 68)
end
Font.draw("A: continue", 8, 128)
Font.draw(Strings("A: continue"), 8, 128)
end
love.graphics.setColor(1, 1, 1, 1)
end
+34 -3
View File
@@ -3,11 +3,12 @@
-- 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
-- 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
-- no filesystem, so the engine tier can table-drive it; the discovery and
-- install paths reach for love.filesystem and SaveData.
-- no filesystem, so the engine tier can table-drive it; the discovery,
-- install, and uninstall paths reach for love.filesystem and SaveData.
local Manifest = require("src.mods.Manifest")
local ManagerState = require("src.mods.ManagerState")
@@ -332,4 +333,34 @@ function LauncherMods.installZip(source)
return true, manifest.id
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
+1
View File
@@ -68,6 +68,7 @@ local devShim = { installed = false, permissions = {}, warned = {}, depth = 0 }
local SUPPORTED_REQUIRES = {
["src.mods.Semver"] = true,
["src.audio.ChipAsm"] = true,
["src.pokemon.Stats"] = true, -- Stats.isShiny / calc for indicator mods
}
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 Theme = require("src.ui.Theme")
local OptionRows = require("src.ui.OptionRows")
local Strings = require("src.core.Strings")
local ManagerState = {}
ManagerState.__index = ManagerState
@@ -269,7 +270,7 @@ function ManagerState:modRows()
end
end
if #rows == 0 then
rows[1] = { header = true, label = "NO MODS INSTALLED" }
rows[1] = { header = true, label = Strings("NO MODS INSTALLED") }
end
return rows
end
@@ -281,7 +282,7 @@ function ManagerState:profileRows()
rows[#rows + 1] = { profile = p, label = p.name,
glyph = opts.activeProfile == p.name and GLYPH.errored or " " }
end
rows[#rows + 1] = { saveAs = true, label = "SAVE CURRENT AS.." }
rows[#rows + 1] = { saveAs = true, label = Strings("SAVE CURRENT AS..") }
rows[#rows + 1] = { adhoc = true,
label = opts.activeProfile and "[AD-HOC]" or "[AD-HOC] (LIVE)" }
return rows
@@ -316,30 +317,30 @@ function ManagerState:detailRows(m)
rows[#rows + 1] = { label = m.enabled and "DISABLE" or "ENABLE",
action = function() self:beginToggle(m) end }
if self:schemaFor(m) then
rows[#rows + 1] = { label = "OPTIONS..",
rows[#rows + 1] = { label = Strings("OPTIONS.."),
action = function() self:openOptions(m) end }
end
if m.permissions and #m.permissions > 0 then
rows[#rows + 1] = { label = "PERMISSIONS..",
rows[#rows + 1] = { label = Strings("PERMISSIONS.."),
action = function() self:goTo("permissions") end }
end
if m.error then
rows[#rows + 1] = { label = "VIEW ERROR..",
rows[#rows + 1] = { label = Strings("VIEW ERROR.."),
action = function() self:goTo("errors") end }
end
rows[#rows + 1] = { label = "BACK", action = function() self:goBack() end }
rows[#rows + 1] = { label = Strings("BACK"), action = function() self:goBack() end }
return rows
end
function ManagerState:applyRows()
local rows = {}
rows[#rows + 1] = { label = "APPLY & RESTART", action = function()
rows[#rows + 1] = { label = Strings("APPLY & RESTART"), action = function()
self:openConfirm({ "RESTART NOW?" }, function() self:restartGame() end)
end }
rows[#rows + 1] = { label = "DISCARD CHANGES", action = function()
rows[#rows + 1] = { label = Strings("DISCARD CHANGES"), action = function()
self:discardChanges()
end }
rows[#rows + 1] = { label = "BACK", action = function() self:goBack() end }
rows[#rows + 1] = { label = Strings("BACK"), action = function() self:goBack() end }
return rows
end
@@ -352,7 +353,7 @@ function ManagerState:permissionRows(m)
label = info and info.text or name }
end
if #rows == 0 then
rows[1] = { inert = true, label = "DATA & API ONLY" }
rows[1] = { inert = true, label = Strings("DATA & API ONLY") }
end
return rows
end
@@ -564,7 +565,7 @@ function ManagerState:openCascade(r, m, want)
else
local dep = self.byId[r.alsoDisable[1]]
lines[#lines + 1] = (dep and dep.name or r.alsoDisable[1]) .. " NEEDS THIS."
lines[#lines + 1] = #r.alsoDisable > 1 and "DISABLE ALL?" or "DISABLE BOTH?"
lines[#lines + 1] = #r.alsoDisable > 1 and "DISABLE ALL?" or Strings("DISABLE BOTH?")
end
self.overlay = { kind = "confirm", lines = lines, index = 1,
onYes = function() self:commitToggle(r.apply) end }
@@ -700,7 +701,7 @@ end
function ManagerState:saveCurrentAs()
local NamingScreen = require("src.ui.NamingScreen")
self.game.stack:push(NamingScreen.new(self.game, {
title = "PROFILE NAME?",
title = Strings("PROFILE NAME?"),
maxLen = 10,
onDone = function(name)
local opts = self:optionsTable()
@@ -726,7 +727,7 @@ end
function ManagerState:renameProfile(p)
local NamingScreen = require("src.ui.NamingScreen")
self.game.stack:push(NamingScreen.new(self.game, {
title = "RENAME?",
title = Strings("RENAME?"),
maxLen = 10,
default = p.name,
onDone = function(name)
@@ -886,7 +887,7 @@ function ManagerState:buildOptionRows(m, schema)
end }
end
end
rows[#rows + 1] = { id = "__reset", label = "RESET DEFAULTS",
rows[#rows + 1] = { id = "__reset", label = Strings("RESET DEFAULTS"),
value = function() return "" end,
activate = function()
for _, row in ipairs(schema) do
@@ -1047,7 +1048,7 @@ function ManagerState:drawApply()
y = y + 1
end
if #staged == 0 then
Font.draw(Runtime.safeMode and "SAFE MODE" or "NO CHANGES", 16, y * 8)
Font.draw(Runtime.safeMode and "SAFE MODE" or Strings("NO CHANGES"), 16, y * 8)
y = y + 1
end
local rows = self:rowsForScreen()
@@ -1076,12 +1077,12 @@ function ManagerState:drawOverlay()
end
if overlay.kind == "confirm" then
local yesY = ty + #lines + 1
Font.draw("YES", 5 * 8, yesY * 8)
Font.draw("NO", 5 * 8, (yesY + 1) * 8)
Font.draw(Strings("YES"), 5 * 8, yesY * 8)
Font.draw(Strings("NO"), 5 * 8, (yesY + 1) * 8)
Font.drawCode(Theme.cursor, 4 * 8,
(overlay.index == 1 and yesY or yesY + 1) * 8)
else
Font.draw("A:OK", 5 * 8, (ty + #lines + 1) * 8)
Font.draw(Strings("A:OK"), 5 * 8, (ty + #lines + 1) * 8)
end
end
@@ -1090,7 +1091,7 @@ function ManagerState:draw()
OptionRows.draw(self.game, self.optionRows or {}, self.cursor,
self.scroll or 0)
love.graphics.setColor(0, 0, 0, 1)
Font.draw(self.notice or "B:DONE (NO RESTART)", 8, 136)
Font.draw(self.notice or Strings("B:DONE (NO RESTART)"), 8, 136)
love.graphics.setColor(1, 1, 1, 1)
if self.overlay then self:drawOverlay() end
return
@@ -1099,7 +1100,7 @@ function ManagerState:draw()
love.graphics.rectangle("fill", 0, 0, 160, 144)
love.graphics.setColor(1, 1, 1, 1)
Font.drawBox(0, 0, 20, 18)
Font.draw(self.banner or "MOD MANAGER", 16, 8)
Font.draw(self.banner or Strings("MOD MANAGER"), 16, 8)
if self.screen == "list" then
self:drawList()
elseif self.screen == "detail" then
+31 -2
View File
@@ -584,6 +584,17 @@ R.text = {
example = 'mod.content.text:override("_PalletTownText1", "HELLO!")',
}
-- The engine's own authored text, the half of the game `text` does not
-- cover (src/core/Strings.lua). The id is the English source string, so a
-- translation supplies one entry per literal the engine draws and anything
-- it has not reached yet keeps rendering in English. Ids that collide in
-- English but not elsewhere carry a "context|source" prefix.
R.strings = {
semantics = "record", target = "strings",
value = f.str,
example = 'mod.content.strings:override("But, it failed!", "Echec !")',
}
-- the self-contained bytecode blob ChipAsm.song/sfx emit (13.1 shape 2);
-- shared by every namespace that plays a chip program
local chipProgram = f.rec{
@@ -918,8 +929,20 @@ R.battle_sprite_scales = {
-- 1 = native pixels; the drawn size relative to the pic's own pixels
scale = f.numRange(0.25, 4.0),
},
example = 'mod.content.battle_sprite_scales:register("abra_back", ' ..
'{ path = "assets/generated/battle/back/abrab.png", scale = 1.5 })',
example = [[
mod.content.battle_sprite_scales:register("abra_back", {
path = "assets/generated/battle/back/abrab.png",
scale = 1.5,
})]],
notes = [[
Scales one battle pic by its asset path, overriding the species-level
`battleScaleFront`/`battleScaleBack` (see [pokemon](#pokemon)) for that
image. The only way to scale a pic that is not species-keyed, like the
player's trainer back sprite. Resolution order at draw time:
image-level, then species-level, then the defaults (1x front, 2x back).
The pic stays grounded at any scale: player feet stay flush on the
text-box top, the enemy pic stays bottom-pinned in its slot, and the
scale composes with the send-out grow animation.]],
}
-- ------- progression
@@ -1146,6 +1169,12 @@ R.field = {
name = f.opt(f.str) })),
nest = f.opt(f.any) },
flyOrder = f.list(f.str),
-- the player's own trainer art (FieldDefaults.PLAYER_PICS): the battle
-- back pic, the catch tutorial's old man, and the front pic the intro,
-- trainer card and Hall of Fame share. Every key is optional so a
-- conversion can replace one pic and inherit the rest.
playerPics = f.rec{
back = f.opt(f.str), demoBack = f.opt(f.str), front = f.opt(f.str) },
-- the new-game and boot config a total conversion replaces
boot = f.rec{
startMap = f.opt(f.str), startX = f.opt(f.int(0)), startY = f.opt(f.int(0)),
+67 -11
View File
@@ -14,6 +14,7 @@ local Runtime = require("src.mods.Runtime")
local Screens = require("src.ui.Screens")
local Stats = require("src.pokemon.Stats")
local TextBox = require("src.render.TextBox")
local Strings = require("src.core.Strings")
local Evolution = {}
@@ -23,7 +24,7 @@ Evolution.METHODS = {
return trigger.kind == "levelup" and mon.level >= (evo.level or 0)
end,
describe = function(evo)
return ("Level %d"):format(evo.level or 0)
return Strings("Level %d", evo.level or 0)
end,
},
ITEM = {
@@ -106,21 +107,67 @@ function Evolution.apply(game, mon, newSpecies, via)
})
end
-- After the "evolved into" text, Gen1 re-runs the level-up learn check on
-- the EVOLVED species (engine/pokemon/evos_moves.asm EvolveMon calls the
-- LearnMoveFromLevelUp predef, engine/pokemon/learn_move.asm) -- a mon
-- evolving at exactly a learnset level gains that move (GYARADOS learns
-- BITE at 20, so MAGIKARP->GYARADOS @20 learns BITE, @21 does not) (#12).
-- Mirrors the rare-candy learn loop in src/ui/BagMenu.lua so a full move
-- list opens the forget prompt. mon.species is already the new species
-- (Evolution.apply ran before the congrats text). onDone runs once the
-- learn list is exhausted, replacing the caller's direct onDone.
function Evolution.learnEvolutionMoves(game, mon, onDone)
local Experience = require("src.battle.Experience")
local def = game.data.pokemon[mon.species]
-- movesLearnedAt uses entry.level == level (exact Gen1 rule); do NOT use
-- Pokemon.movesAtLevel (<= level), which would over-grant older moves.
local moves = Experience.movesLearnedAt(def, mon.level)
local i = 0
local function nextStep()
i = i + 1
local moveId = moves[i]
if not moveId then
if onDone then onDone() end
return
end
for _, mv in ipairs(mon.moves) do
if mv.id == moveId then return nextStep() end
end
local mdef = game.data.moves[moveId]
if not mdef then return nextStep() end
local name = mon.nickname or def.name
if #mon.moves < 4 then
table.insert(mon.moves, { id = moveId, pp = mdef.pp })
Runtime.emit("pokemon.move_learned", { mon = mon, moveId = moveId })
game.stack:push(TextBox.new(game,
Strings("%s learned\n%s!", name, mdef.name), nextStep))
else
-- LearnMoveFromLevelUp with a full moveset: the forget UI
Screens.push(game, "MoveLearnMenu", mon, moveId, nextStep)
end
end
nextStep()
end
-- Play the evolution movie (flashing forms), then apply + text.
-- Headless (no real graphics) falls back to the plain text flow.
function Evolution.evolve(game, mon, newSpecies, onDone, via)
if love.image and love.image.newImageData then
Screens.push(game, "EvolutionState", mon, newSpecies, onDone)
-- forward `via` so EvolutionState can keep trade evolutions
-- non-cancelable (LINK_STATE_TRADING) while others accept B (#213)
Screens.push(game, "EvolutionState", mon, newSpecies, onDone, via)
return
end
Music.play(game.data, Music.special(game.data, "evolution"))
local oldName = mon.nickname or game.data.pokemon[mon.species].name
Evolution.apply(game, mon, newSpecies, via)
local msg = ("What?\n%s is\nevolving!\fCongratulations!\nYour %s\nevolved into\n%s!")
:format(oldName, oldName, game.data.pokemon[newSpecies].name)
local msg = Strings("What?\n%s is\nevolving!\fCongratulations!\nYour %s\nevolved into\n%s!",
oldName, oldName, game.data.pokemon[newSpecies].name)
game.stack:push(TextBox.new(game, msg, function()
Music.restoreMap(game.data)
if onDone then onDone() end
-- re-run the evolved species' level-up learn check before onDone
-- (evos_moves.asm EvolveMon -> learn_move.asm LearnMoveFromLevelUp, #12)
Evolution.learnEvolutionMoves(game, mon, onDone)
end))
end
@@ -138,13 +185,22 @@ function Evolution.request(game, mon, trigger, onDone)
return species
end
-- After-battle hook: evolve everyone who qualifies (queued one at a time).
function Evolution.checkParty(game, onDone)
-- After-battle hook: evolve mons that leveled this battle and still
-- qualify (queued one at a time, party order). Gen1 EvolveAfterBattle
-- only considers mons that gained a level during the fight — a B-cancel
-- means "not this time", and the next offer waits for the next level-up
-- (or Rare Candy / stone, which call Evolution.evolve directly).
-- leveledUp is a set of party mon tables; nil/empty yields no evolutions.
function Evolution.checkParty(game, onDone, leveledUp)
local pending = {}
for _, mon in ipairs(game.save.party) do
local target, evo = Evolution.pendingFor(game, mon, { kind = "levelup" })
if target then
table.insert(pending, { mon = mon, to = target, via = evo and evo.method })
if leveledUp then
for _, mon in ipairs(game.save.party) do
if leveledUp[mon] then
local target, evo = Evolution.pendingFor(game, mon, { kind = "levelup" })
if target then
table.insert(pending, { mon = mon, to = target, via = evo and evo.method })
end
end
end
end
local i = 0
+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))
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
+69 -20
View File
@@ -102,35 +102,84 @@ local function pageFor(code)
return nil
end
local SPACE = 0x7F
-- Segment text into glyph spans: `{ from, to, code }` byte ranges, one per
-- drawn glyph, code nil when the charmap has nothing. A span is a whole
-- charmap sequence, so a multi-byte char ("é", "♂") and an ASCII ligature
-- ("<PK>", "'d") are each one glyph.
--
-- Every caller that *measures* or *cuts* text walks these instead of bytes.
-- "POKéMON" is 8 bytes and 7 glyphs: measuring it as 8 wraps lines that fit
-- (25 vanilla lines did), and cutting at byte 8 splits the é into two
-- invalid bytes that both draw as spaces. That distinction is the whole
-- reason a non-English font can be shipped as a mod (#186, #245).
--
-- Safe before Font.load: with no charmap it falls back to UTF-8 lead-byte
-- boundaries, which is all a headless paginate needs.
function Font.split(text)
local spans = {}
local i, n = 1, #text
while i <= n do
local span
local candidates = state and state.byFirstByte[text:byte(i)]
if candidates then
for _, entry in ipairs(candidates) do
local len = #entry.seq
if text:sub(i, i + len - 1) == entry.seq then
span = { from = i, to = i + len - 1, code = entry.code }
break
end
end
end
if not span then
-- Nothing matched. Still keep a UTF-8 sequence whole, so a cut never
-- lands mid-character even for a glyph we cannot draw.
local last = i
if text:byte(i) >= 0xC0 then
local k = i + 1
while k <= n do
local b = text:byte(k)
if b < 0x80 or b > 0xBF then break end
last, k = k, k + 1
end
end
span = { from = i, to = last }
end
spans[#spans + 1] = span
i = span.to + 1
end
return spans
end
-- How many leading spans fit in `budget` pixels. Advances come from each
-- glyph's own page, so a variable-width page measures correctly.
function Font.spansFitting(spans, budget)
local used, fit = 0, 0
for _, span in ipairs(spans) do
used = used + Font.advanceOf(span.code or SPACE)
if used > budget then break end
fit = fit + 1
end
return fit
end
-- Convert a text string into a list of glyph codes. Unknown characters
-- render as space (and are reported once).
local reported = {}
function Font.encode(text)
local codes = {}
local i = 1
while i <= #text do
local candidates = state.byFirstByte[text:byte(i)]
local matched = false
if candidates then
for _, entry in ipairs(candidates) do
local n = #entry.seq
if text:sub(i, i + n - 1) == entry.seq then
codes[#codes + 1] = entry.code
i = i + n
matched = true
break
end
end
end
if not matched then
local ch = text:sub(i, i)
if not reported[ch] and ch:byte() >= 32 then
for _, span in ipairs(Font.split(text)) do
local code = span.code
if not code then
local ch = text:sub(span.from, span.to)
if not reported[ch] and text:byte(span.from) >= 32 then
reported[ch] = true
require("src.core.Logger").warn("font: no glyph for %q", ch)
end
codes[#codes + 1] = 0x7F -- space
i = i + 1
code = SPACE
end
codes[#codes + 1] = code
end
return codes
end
+22 -10
View File
@@ -77,7 +77,17 @@ end
-- one-pixel sliver. The fill is tinted with the SGB bar palettes at
-- GetHealthBarColor's thresholds (>= 27 px green, >= 10 yellow, else
-- red).
function HudTiles.drawHPBar(data, tx, ty, mon, barType)
--
-- grayFill (#229): when the caller will colorize this bar with an SGB
-- region palette (BattleState's zone pass, BATTLE_ZONES pal 0/1 =
-- GetHealthBarColor), leave the fill as its raw DMG shade-2 gray and skip
-- the per-pixel tint -- the DMG hardware bar is ONE gray shade recolored by
-- the region palette (engine/gfx/palettes.asm SetPal_Battle,
-- data/sgb/sgb_packets.asm BlkPacket_Battle), never a per-pixel repaint.
-- Tinting first would double-apply the color: GREENBAR's fill {0,189,0} has
-- red channel 0, so the tint zeroes the whole bar's red and the zone's
-- red-channel-keyed shade shader then maps every pixel to color 3 = black.
function HudTiles.drawHPBar(data, tx, ty, mon, barType, grayFill)
local x, y = tx * 8, ty * 8
HudTiles.tile(0x71, x, y)
HudTiles.tile(0x62, x + 8, y)
@@ -86,15 +96,17 @@ function HudTiles.drawHPBar(data, tx, ty, mon, barType)
px = math.max(1, math.floor(mon.hp * 48 / mon.stats.hp))
end
local tint
local PaletteFX = require("src.render.PaletteFX")
local name = px >= 27 and "GREENBAR" or px >= 10 and "YELLOWBAR" or "REDBAR"
local colors = PaletteFX.pal(data, name)
if colors then
local c = colors[3] -- GB color 2 is the fill shade
-- the fill pixels are the 2/3-gray shade; divide so they land on
-- the palette color exactly (the black outline stays black)
tint = { math.min(1, c[1] / 170), math.min(1, c[2] / 170),
math.min(1, c[3] / 170), 1 }
if not grayFill then
local PaletteFX = require("src.render.PaletteFX")
local name = px >= 27 and "GREENBAR" or px >= 10 and "YELLOWBAR" or "REDBAR"
local colors = PaletteFX.pal(data, name)
if colors then
local c = colors[3] -- GB color 2 is the fill shade
-- the fill pixels are the 2/3-gray shade; divide so they land on
-- the palette color exactly (the black outline stays black)
tint = { math.min(1, c[1] / 170), math.min(1, c[2] / 170),
math.min(1, c[3] / 170), 1 }
end
end
for i = 0, 5 do
local seg = math.min(8, math.max(0, px - i * 8))
+44 -17
View File
@@ -50,14 +50,23 @@ PaletteFX.GBC_OBJ = {
}
-- OG BLUE: Pokemon Blue's Game Boy Color boot-ROM auto-palette. Same
-- one-global-pair scheme as OG RED (Blue also ships no CGB code), but the
-- boot ROM colorizes the background blue instead of red -- so "OG RED" for a
-- Blue playthrough is white -> light blue -> dark blue -> black, mirroring
-- GBC_BG channel-for-channel so the blue reads at the same brightness. The
-- OBJ (sprite) palette stays the same green, matching how Red and Blue share
-- the green-character look on a Game Boy Color.
-- one-global-pair scheme as OG RED (Blue also ships no CGB code), but the boot
-- ROM gives Blue its OWN entry rather than a recolored Red: a light-blue/blue
-- BACKGROUND and -- unlike Red -- a PINK object palette (OBP0). Values from
-- Bulbapedia's "List of color palettes ... Generation I" GBC boot-ROM table
-- (BG 0x63A5FF/0x0000FF, OBJ 0xFF8484/0x943A3A), confirmed against a Gambatte
-- hardware capture. The earlier code mirrored GBC_BG channel-for-channel
-- (0x8484FF/0x3A3A94) and reused Red's green sprites for both versions on the
-- premise that Red and Blue "share the green-character look"; both premises
-- are wrong -- Blue's background is a genuinely different blue and its
-- characters are pink (#155). Lightest shade first, like GBC_BG.
PaletteFX.GBC_BG_BLUE = {
{ 255, 255, 255 }, { 132, 132, 255 }, { 58, 58, 148 }, { 0, 0, 0 },
{ 255, 255, 255 }, { 99, 165, 255 }, { 0, 0, 255 }, { 0, 0, 0 },
}
-- Blue's OBJ palette (OBP0) is the red/pink ramp -- the very same colors OG
-- RED uses for its BACKGROUND (GBC_BG), just applied to objects instead.
PaletteFX.GBC_OBJ_BLUE = {
{ 255, 255, 255 }, { 255, 132, 132 }, { 148, 58, 58 }, { 0, 0, 0 },
}
-- The active game's OG boot-ROM background palette: blue for a Blue
@@ -69,6 +78,16 @@ function PaletteFX.ogBg()
return PaletteFX.GBC_BG
end
-- The active game's OG boot-ROM object palette (OBP0): Blue's pink ramp for a
-- Blue playthrough, Red's green otherwise. Returns the colors AND a
-- version-distinct cache-group string, because SpriteRenderer.getObpImage keys
-- its baked-image cache by (image path, group): a shared group would collide a
-- Red bake with a Blue one and one version would show the other's colors.
function PaletteFX.ogObj()
if GameVersion.isBlue() then return PaletteFX.GBC_OBJ_BLUE, "gbcobj_blue" end
return PaletteFX.GBC_OBJ, "gbcobj"
end
local INV_MAP = { [0] = 3, [1] = 2, [2] = 1, [3] = 0 }
function PaletteFX.shader()
@@ -183,17 +202,25 @@ function PaletteFX.usesGbcPack(mode)
end
-- Whether the active mode bakes a per-OBJ palette onto overworld sprites
-- (the OBP bake + post-zone redraw path). ONLY OG RED does: it wears the
-- GBC boot-ROM green object palette (PaletteFX.GBC_OBJ) so the player and
-- NPCs stay green over the red background, exactly like Pokemon Red on a
-- Game Boy Color. SGB mode deliberately does NOT: an SGB OBJ carries no
-- palette of its own, so the characters tint with the whole-map region
-- palette along with the terrain (the Super Game Boy never colored Pokemon
-- Red's sprites separately -- baking a per-sprite palette there was the
-- "reds coloring on the player/NPCs" bug). RED++ colors sprites through
-- the usesGbcPack() path in SpriteRenderer instead.
-- (the OBP bake + post-zone redraw path). OG RED and SGB both do: characters
-- wear the GBC boot-ROM object palette (PaletteFX.ogObj -- green over Red's red
-- background, pink over Blue's blue background), so the player and NPCs carry a
-- fixed object color instead of tinting with whatever region palette their
-- feet stand over. On real hardware a sprite is an OBJ colored by an OBJ
-- palette (color/sprites.asm ColorOverworldSprite), distinct from the BG it
-- overlaps -- so Red's cap must stay green in tall grass, not turn the ROUTE
-- palette's light-blue (issue #150: SGB region-tinting sent the cap to shade-2
-- = light-blue and the character clashed with the grass it should blend into).
-- Terrain is unaffected -- pal() below still hands SGB its per-map BG palette;
-- only OG RED short-circuits BG to the one global red palette. An EARLIER
-- attempt at per-sprite SGB color baked GBC_BG (the RED background ramp) onto
-- characters -- that was the "reds coloring on the player/NPCs" bug; the object
-- palette is GBC_OBJ (green), so baking it here is the fix, not that
-- regression. RED++ colors sprites through the usesGbcPack() path in
-- SpriteRenderer instead.
function PaletteFX.usesSpriteObp(mode)
return (mode or PaletteFX.mode) == "ogred"
mode = mode or PaletteFX.mode
return mode == "ogred" or mode == "gbc"
end
-- ------- post-zone sprite redraw (GBC mode)
+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
+136 -73
View File
@@ -10,6 +10,8 @@ local Zoom = require("src.render.Zoom")
local Tilt = require("src.render.Tilt")
local PaletteFX = require("src.render.PaletteFX")
local Pipelines = require("src.render.Pipelines")
local PixelCanvas = require("src.render.PixelCanvas")
local Runtime = require("src.mods.Runtime")
local Renderer = {}
@@ -39,32 +41,49 @@ end
-- endFrame composites the padded canvas back with a matching offset.
Renderer.UPRIGHT_MARGIN = 160
-- LOVE units + framebuffer pixels + the live unit→pixel ratio.
-- Android's DisplayMetrics.density (love.graphics.getDPIScale) is often
-- non-integer (1.5, 2.75, …). Integer scaling in units then maps each GB
-- pixel to a fractional number of framebuffer pixels → shimmer, uneven /
-- non-square "pixels", and movement judder (issue #87). Always derive the
-- crisp integer scale from the drawable pixel size; draw with (pixels/dpi)
-- so the GPU lands on whole framebuffer pixels. Desktop dpi=1 is unchanged.
-- LOVE units + framebuffer pixels + per-axis unit→pixel ratios.
-- Android's DisplayMetrics.density is often non-integer (1.5, 2.75, …).
-- Integer scaling in units then maps each GB pixel to a fractional number of
-- framebuffer pixels → shimmer, uneven / non-square "pixels", and movement
-- judder (issue #87). Always derive the crisp integer scale from the
-- *drawable* pixel size (the window framebuffer we present into -- not a
-- combined multi-display metric), then draw with (pixels / axisDpi) so the
-- GPU lands on whole framebuffer pixels. Desktop dpi=1 is unchanged.
--
-- LOVE's projection is anisotropic: 1 unit in X covers pw/ww framebuffer
-- pixels and 1 unit in Y covers ph/wh. Those ratios match on a normal
-- highdpi surface, but diverge when unit sizes are truncated independently
-- (`(int)(pixels/density)`) or when a dual-screen / forced-rotation device
-- reports mismatched unit vs drawable aspects (AYN Thor, issue #208).
-- love.graphics.getDPIScale() is only ph/wh, so using it (or pw/ww alone)
-- for both axes makes the other axis land on a fractional, stretched count.
-- Keep separate dpiX/dpiY so each GB pixel covers fitScale() physical pixels
-- on BOTH axes (square).
local function displayMetrics()
local ww, wh = love.graphics.getDimensions()
local pw, ph = ww, wh
if love.graphics.getPixelDimensions then
pw, ph = love.graphics.getPixelDimensions()
end
local dpi = 1
if ww > 0 and pw > 0 then
dpi = pw / ww
elseif love.graphics.getDPIScale then
dpi = love.graphics.getDPIScale()
local dpiX, dpiY = 1, 1
if ww > 0 and pw > 0 then dpiX = pw / ww end
if wh > 0 and ph > 0 then dpiY = ph / wh end
-- No pixel API (headless / old stub): fall back to getDPIScale, else 1.
if (dpiX == 1 and dpiY == 1) and not love.graphics.getPixelDimensions
and love.graphics.getDPIScale then
local d = love.graphics.getDPIScale()
if d and d > 1e-6 then dpiX, dpiY = d, d end
end
if not dpi or dpi < 1e-6 then dpi = 1 end
return ww, wh, pw, ph, dpi
if dpiX < 1e-6 then dpiX = 1 end
if dpiY < 1e-6 then dpiY = 1 end
return ww, wh, pw, ph, dpiX, dpiY
end
function Renderer:init()
self.canvas = love.graphics.newCanvas(self.WIDTH, self.HEIGHT)
self.canvas:setFilter("nearest", "nearest")
-- 160x144 real pixels, never DPI-scaled: see src/render/PixelCanvas.lua
-- (#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.worldActive = false
-- tilt mode only: a transparent overlay canvas the size of the world
@@ -93,21 +112,43 @@ end
-- Integer framebuffer pixels per GB pixel that fit the window. Zoom /
-- GBCFX / callers treat this as the crisp scale; endFrame converts to LOVE
-- units via / dpi when drawing.
-- units via / dpiX and / dpiY when drawing.
function Renderer:fitScale()
local _, _, pw, ph = displayMetrics()
return math.max(1, math.floor(math.min(pw / self.WIDTH, ph / self.HEIGHT)))
end
-- world-pass canvas size in world pixels: enough to fill the window at s'.
-- In tilt mode the canvas grows (both dimensions, by Tilt.viewGrowth) so
-- the projected ground plane still covers the whole window with no
-- background peeking at the receded top/bottom corners; flat mode returns
-- exactly today's size (growth factor is 1 when tilt is inactive).
-- LOVE-unit draw scales endFrame uses for the UI blit: integer framebuffer
-- scale (fitScale) divided by each axis's unit→pixel factor, so a GB pixel
-- lands on fitScale() whole PHYSICAL pixels on both axes once LOVE applies
-- its projection (fitScale() == drawScaleX() * dpiX == drawScaleY() * dpiY).
-- Exposed for #208's regression (square pixels when dpiX ≠ dpiY).
function Renderer:drawScaleX()
local _, _, _, _, dpiX = displayMetrics()
return self:fitScale() / dpiX
end
function Renderer:drawScaleY()
local _, _, _, _, _, dpiY = displayMetrics()
return self:fitScale() / dpiY
end
-- Back-compat alias: uniform surfaces have drawScaleX == drawScaleY.
function Renderer:drawScale()
return self:drawScaleX()
end
-- world-pass canvas size in world pixels: enough to fill the drawable at s'.
-- Sized from framebuffer pixels (not unit dims) so anisotropic dpiX/dpiY
-- cannot over/under-cover the window. In tilt mode the canvas grows (both
-- dimensions, by Tilt.viewGrowth) so the projected ground plane still covers
-- the whole window with no background peeking at the receded top/bottom
-- corners; flat mode returns exactly today's size (growth factor is 1 when
-- tilt is inactive).
function Renderer:worldViewSize()
local ww, wh, _, _, dpi = displayMetrics()
local s = Zoom.scale(self:fitScale()) / dpi
local vw, vh = Zoom.fillViewSize(s, ww, wh)
local _, _, pw, ph = displayMetrics()
local sp = Zoom.scale(self:fitScale())
local vw, vh = math.ceil(pw / sp), math.ceil(ph / sp)
-- Even sizes keep Camera:follow on integer pixels (viewW/2 is integral),
-- so unfloored FX/sprite math cannot phase-shimmer against the tile layer.
if vw % 2 ~= 0 then vw = vw + 1 end
@@ -145,21 +186,24 @@ end
-- Black 8x8 (scaled) blocks cascading outward from the classic GB letterbox
-- into the surrounding window, matching BattleTransition wipe progress.
-- Tiles that sit entirely inside the 160x144 square are left to the OG wipe.
function Renderer:drawBattleCascade(prog, ww, wh, ox, oy, vpw, vph, S)
-- Sx/Sy are LOVE-unit scales (Sy defaults to Sx on uniform surfaces).
function Renderer:drawBattleCascade(prog, ww, wh, ox, oy, vpw, vph, Sx, Sy)
if not prog or prog <= 0 then return end
local TILE = 8 * S
if TILE < 1 then TILE = 1 end
local cols = math.ceil(ww / TILE)
local rows = math.ceil(wh / TILE)
Sy = Sy or Sx
local TILE_W, TILE_H = 8 * Sx, 8 * Sy
if TILE_W < 1 then TILE_W = 1 end
if TILE_H < 1 then TILE_H = 1 end
local cols = math.ceil(ww / TILE_W)
local rows = math.ceil(wh / TILE_H)
local cx, cy = ox + vpw / 2, oy + vph / 2
local order = {}
for row = 0, rows - 1 do
for col = 0, cols - 1 do
local x, y = col * TILE, row * TILE
local x, y = col * TILE_W, row * TILE_H
-- any tile with area outside the letterbox participates
if x < ox or y < oy or x + TILE > ox + vpw or y + TILE > oy + vph then
local dist = math.max(math.abs(x + TILE / 2 - cx),
math.abs(y + TILE / 2 - cy))
if x < ox or y < oy or x + TILE_W > ox + vpw or y + TILE_H > oy + vph then
local dist = math.max(math.abs(x + TILE_W / 2 - cx),
math.abs(y + TILE_H / 2 - cy))
order[#order + 1] = { x, y, dist }
end
end
@@ -176,7 +220,7 @@ function Renderer:drawBattleCascade(prog, ww, wh, ox, oy, vpw, vph, S)
love.graphics.setScissor(0, 0, ww, wh)
for i = 1, n do
local t = order[i]
love.graphics.rectangle("fill", t[1], t[2], TILE, TILE)
love.graphics.rectangle("fill", t[1], t[2], TILE_W, TILE_H)
end
love.graphics.setScissor()
love.graphics.setColor(1, 1, 1, 1)
@@ -190,8 +234,7 @@ function Renderer:beginWorldPass()
-- the view size every frame, so without this the superseded canvases
-- pile up in VRAM until a GC finalizer happens to run
if self.worldCanvas and self.worldCanvas.release then self.worldCanvas:release() end
self.worldCanvas = love.graphics.newCanvas(vw, vh)
self.worldCanvas:setFilter("nearest", "nearest")
self.worldCanvas = PixelCanvas.new(vw, vh, "nearest")
end
self.worldActive = true
PaletteFX.setPass("world")
@@ -219,8 +262,7 @@ function Renderer:beginUprightPass()
if not self.uprightCanvas or self.uprightCanvas:getWidth() ~= cw
or self.uprightCanvas:getHeight() ~= ch then
if self.uprightCanvas and self.uprightCanvas.release then self.uprightCanvas:release() end
self.uprightCanvas = love.graphics.newCanvas(cw, ch)
self.uprightCanvas:setFilter("nearest", "nearest")
self.uprightCanvas = PixelCanvas.new(cw, ch, "nearest")
end
self.uprightActive = true
PaletteFX.setPass(nil)
@@ -299,10 +341,11 @@ end
-- into (nil = default framebuffer; presentCanvas when CRT is on).
-- Returns true on success; false (no shader/mesh) tells endFrame to fall
-- back to the flat blit unchanged.
function Renderer:drawTiltedWorld(zoneList, s, wox, woy, target)
function Renderer:drawTiltedWorld(zoneList, sx, sy, wox, woy, target)
local shader = self:tiltShader()
local mesh = self:tiltMesh()
if not (shader and mesh) then return false end
sy = sy or sx
local wvw = self.worldCanvas:getWidth()
local wvh = self.worldCanvas:getHeight()
@@ -313,8 +356,7 @@ function Renderer:drawTiltedWorld(zoneList, s, wox, woy, target)
if not self.tiltCanvas or self.tiltCanvas:getWidth() ~= wvw
or self.tiltCanvas:getHeight() ~= wvh then
if self.tiltCanvas and self.tiltCanvas.release then self.tiltCanvas:release() end
self.tiltCanvas = love.graphics.newCanvas(wvw, wvh)
self.tiltCanvas:setFilter("linear", "linear")
self.tiltCanvas = PixelCanvas.new(wvw, wvh, "linear")
end
love.graphics.setCanvas(self.tiltCanvas)
@@ -351,7 +393,7 @@ function Renderer:drawTiltedWorld(zoneList, s, wox, woy, target)
mesh:setVertices(Tilt.meshCorners(wvw, wvh))
love.graphics.push()
love.graphics.translate(wox, woy)
love.graphics.scale(s, s)
love.graphics.scale(sx, sy)
love.graphics.setColor(1, 1, 1, 1)
love.graphics.setShader(shader)
love.graphics.draw(mesh)
@@ -393,14 +435,15 @@ end
-- presented through the GBC FX shader as a final pass.
function Renderer:endFrame(zones, worldZones)
love.graphics.setCanvas()
local ww, wh, pw, ph, dpi = displayMetrics()
-- Sp = integer framebuffer pixels per GB pixel; S = LOVE-unit draw scale.
local ww, wh, pw, ph, dpiX, dpiY = displayMetrics()
-- Sp = integer framebuffer pixels per GB pixel;
-- Sx/Sy = LOVE-unit draw scales (may differ when dpiX ≠ dpiY).
local Sp = self:fitScale()
local S = Sp / dpi
local vpw, vph = self.WIDTH * S, self.HEIGHT * S
local Sx, Sy = Sp / dpiX, Sp / dpiY
local vpw, vph = self.WIDTH * Sx, self.HEIGHT * Sy
-- Snap the letterbox origin to a framebuffer pixel, then convert to units.
local ox = math.floor((pw - self.WIDTH * Sp) / 2) / dpi
local oy = math.floor((ph - self.HEIGHT * Sp) / 2) / dpi
local ox = math.floor((pw - self.WIDTH * Sp) / 2) / dpiX
local oy = math.floor((ph - self.HEIGHT * Sp) / 2) / dpiY
local GBCFX = require("src.render.GBCFX")
-- Forced mono/Classic modes still need a whole-screen zone when a state
-- exposes no SGB packets (raw DMG canvas), so sendColors can remap.
@@ -422,6 +465,14 @@ function Renderer:endFrame(zones, worldZones)
if needPresent then
if not self.presentCanvas or self.presentCanvas:getWidth() ~= ww
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:setFilter("linear", "linear")
end
@@ -444,15 +495,27 @@ function Renderer:endFrame(zones, worldZones)
love.graphics.setColor(clearR, clearG, clearB, 1)
love.graphics.rectangle("fill", 0, 0, ww, wh)
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 `scale` (LOVE units) into origin (bx, by), scissored to
-- the (boxX, boxY, boxW, boxH) screen rect. zoneScale converts zone
-- coords (canvas-space) into screen units.
local function blit(canvas, scale, zoneList, zoneScale, bx, by, boxX, boxY, boxW, boxH)
-- blit `canvas` at (sx, sy) LOVE-unit scales into origin (bx, by),
-- scissored to the (boxX, boxY, boxW, boxH) screen rect. zoneSx/zoneSy
-- convert zone coords (canvas-space) into screen units.
local function blit(canvas, sx, sy, zoneList, zoneSx, zoneSy,
bx, by, boxX, boxY, boxW, boxH)
local shader = zoneList and zoneList[1] and PaletteFX.shader() or nil
if not shader then
love.graphics.setScissor(boxX, boxY, boxW, boxH)
love.graphics.draw(canvas, bx, by, 0, scale, scale)
love.graphics.draw(canvas, bx, by, 0, sx, sy)
love.graphics.setScissor()
return
end
@@ -468,10 +531,10 @@ function Renderer:endFrame(zones, worldZones)
love.graphics.setShader(not plain and shader or nil)
end
if not plain then PaletteFX.sendColors(shader, z.colors) end
if scissorClamped(bx + z.x * zoneScale, by + z.y * zoneScale,
z.w * zoneScale, z.h * zoneScale,
if scissorClamped(bx + z.x * zoneSx, by + z.y * zoneSy,
z.w * zoneSx, z.h * zoneSy,
boxX, boxY, boxW, boxH) then
love.graphics.draw(canvas, bx, by, 0, scale, scale)
love.graphics.draw(canvas, bx, by, 0, sx, sy)
end
end
love.graphics.setScissor()
@@ -486,7 +549,7 @@ function Renderer:endFrame(zones, worldZones)
-- runs, so dialogs, menus and the HUD sit on top as usual.
love.graphics.setColor(1, 1, 1, 1)
love.graphics.setScissor(0, 0, ww, wh)
love.graphics.draw(self.worldOverride, 0, 0, 0, 1 / dpi, 1 / dpi)
love.graphics.draw(self.worldOverride, 0, 0, 0, 1 / dpiX, 1 / dpiY)
love.graphics.setScissor()
-- the screen-space overlays the flat path draws over its composite
local fade = self.worldFadeAlpha
@@ -496,15 +559,15 @@ function Renderer:endFrame(zones, worldZones)
love.graphics.setColor(1, 1, 1, 1)
end
if self.battleCascadeProg then
self:drawBattleCascade(self.battleCascadeProg, ww, wh, ox, oy, vpw, vph, S)
self:drawBattleCascade(self.battleCascadeProg, ww, wh, ox, oy, vpw, vph, Sx, Sy)
end
elseif self.worldActive then
local sp = Zoom.scale(Sp)
local s = sp / dpi
local sx, sy = sp / dpiX, sp / dpiY
local wvw = self.worldCanvas:getWidth()
local wvh = self.worldCanvas:getHeight()
local wox = math.floor((pw - wvw * sp) / 2) / dpi
local woy = math.floor((ph - wvh * sp) / 2) / dpi
local wox = math.floor((pw - wvw * sp) / 2) / dpiX
local woy = math.floor((ph - wvh * sp) / 2) / dpiY
-- Tilt mode projects the ground world pass through the perspective mesh
-- (SGB zones baked in beforehand -- see drawTiltedWorld -- so no zone
-- scissoring here). drawTiltedWorld returns false when tilt is off or
@@ -512,12 +575,12 @@ function Renderer:endFrame(zones, worldZones)
-- falls through to the flat blit, keeping the flat frame byte-for-byte
-- identical to today.
local projected =
Tilt.active() and self:drawTiltedWorld(worldZones or zones, s, wox, woy, present)
Tilt.active() and self:drawTiltedWorld(worldZones or zones, sx, sy, wox, woy, present)
if not projected then
if worldZones then
blit(self.worldCanvas, s, worldZones, s, wox, woy, 0, 0, ww, wh)
blit(self.worldCanvas, sx, sy, worldZones, sx, sy, wox, woy, 0, 0, ww, wh)
else
blit(self.worldCanvas, s, zones, S, wox, woy, 0, 0, ww, wh)
blit(self.worldCanvas, sx, sy, zones, Sx, Sy, wox, woy, 0, 0, ww, wh)
end
-- OBP-baked overworld sprites replay on top of the zone pass (GBC
-- mode per-object coloring; see PaletteFX.markSpriteRedraw). Grass
@@ -538,11 +601,11 @@ function Renderer:endFrame(zones, worldZones)
end
if wanted then PaletteFX.sendColors(wanted, r.colors) end
if r.quad then
love.graphics.draw(r.image, r.quad, wox + r.x * s, woy + r.y * s,
0, s * r.sx, s)
love.graphics.draw(r.image, r.quad, wox + r.x * sx, woy + r.y * sy,
0, sx * r.sx, sy)
else
love.graphics.draw(r.image, wox + r.x * s, woy + r.y * s,
0, s * r.sx, s)
love.graphics.draw(r.image, wox + r.x * sx, woy + r.y * sy,
0, sx * r.sx, sy)
end
end
if activeShader then love.graphics.setShader() end
@@ -559,7 +622,7 @@ function Renderer:endFrame(zones, worldZones)
local M = self.UPRIGHT_MARGIN
love.graphics.setColor(1, 1, 1, 1)
love.graphics.setScissor(0, 0, ww, wh)
love.graphics.draw(self.uprightCanvas, wox - M * s, woy - M * s, 0, s, s)
love.graphics.draw(self.uprightCanvas, wox - M * sx, woy - M * sy, 0, sx, sy)
love.graphics.setScissor()
end
-- Screen-space warp fade (Transition) over the full world composite so
@@ -575,11 +638,11 @@ function Renderer:endFrame(zones, worldZones)
-- Battle transition: cascade black blocks into the area outside the
-- classic 160x144 wipe square (world still shows through until filled).
if self.battleCascadeProg then
self:drawBattleCascade(self.battleCascadeProg, ww, wh, ox, oy, vpw, vph, S)
self:drawBattleCascade(self.battleCascadeProg, ww, wh, ox, oy, vpw, vph, Sx, Sy)
end
end
-- UI stays in the classic centered GB letterbox
blit(self.canvas, S, zones, S, ox, oy, ox, oy, vpw, vph)
blit(self.canvas, Sx, Sy, zones, Sx, Sy, ox, oy, ox, oy, vpw, vph)
if present then
love.graphics.setCanvas()
@@ -589,7 +652,7 @@ function Renderer:endFrame(zones, worldZones)
-- grid itself. Each pass hands back a canvas; with none registered
-- this returns `present` unchanged and the frame is byte-identical.
local composed = Pipelines.present(present,
{ width = ww, height = wh, scale = Sp, dpi = dpi }) or present
{ width = ww, height = wh, scale = Sp, dpi = dpiY, dpiX = dpiX, dpiY = dpiY }) or present
if GBCFX.active() then
-- shader grid/shadow math is in framebuffer pixels
GBCFX.present(composed, Sp)
+11 -6
View File
@@ -105,7 +105,10 @@ function SpriteRenderer:resolveImage()
local colors, group = PaletteFX.spriteObp(self.def, self.seed)
if colors then return getObpImage(self.def.image, colors, group) end
elseif PaletteFX.usesSpriteObp() then
return getObpImage(self.def.image, PaletteFX.GBC_OBJ, "gbcobj")
-- OG boot-ROM OBJ palette: green on Red, pink on Blue (PaletteFX.ogObj
-- returns colors + a version-distinct cache group so the two never
-- collide in obpCache) -- see issue #155
return getObpImage(self.def.image, PaletteFX.ogObj())
end
return self.image
end
@@ -141,11 +144,13 @@ function SpriteRenderer:draw(px, py, camX, camY, facing, walkPhase, stepFlip)
image = getObpImage(self.def.image, colors, group)
end
elseif PaletteFX.usesSpriteObp() and PaletteFX.spriteRedrawPassActive() then
-- OG RED (GBC boot-ROM look): every OBJ wears the one global green
-- object palette. The red BG zone shader still runs over the world
-- canvas, so the baked sprite is queued for a post-zone redraw
-- (PaletteFX.markSpriteRedraw) that restores its green pixels on top.
image = getObpImage(self.def.image, PaletteFX.GBC_OBJ, "gbcobj")
-- OG RED (GBC boot-ROM look): every OBJ wears the one global object
-- palette -- green over Red's red background, pink over Blue's blue
-- background (PaletteFX.ogObj, #155). The BG zone shader still runs over
-- the world canvas, so the baked sprite is queued for a post-zone redraw
-- (PaletteFX.markSpriteRedraw) that restores its object-colored pixels on
-- top.
image = getObpImage(self.def.image, PaletteFX.ogObj())
redraw = true
end
-- single-frame sprites (item balls, fossils...) have one fixed pose;
+31 -4
View File
@@ -92,13 +92,28 @@ end
-- pokered ContText waits for A/B + ▼ before scrolling that line in.
function TextBox.paginate(text, maxCols)
maxCols = maxCols or (Theme.textBox and Theme.textBox.maxCols) or MAX_COLS
-- maxCols is a column count, so the budget is that many vanilla 8px
-- cells. Measuring in pixels rather than columns is what lets a mod's
-- variable-advance page wrap correctly (#186).
local budget = maxCols * 8
local pages = {}
local contBefore = {}
-- Soft-wrap on glyph boundaries, never byte boundaries: a line is over
-- budget by what it *draws*, and the cut falls between glyphs so a
-- multi-byte char is never torn in half.
local function pushLine(lines, conts, line, wait)
while #line > maxCols do
local cut = maxCols
for i = maxCols, 1, -1 do
if line:sub(i, i) == " " then cut = i break end
while true do
local spans = Font.split(line)
local fit = Font.spansFitting(spans, budget)
if fit >= #spans then break end
-- a glyph wider than the whole box still has to advance by one
fit = math.max(fit, 1)
local cut = spans[fit].to
for i = fit, 1, -1 do
if line:sub(spans[i].from, spans[i].to) == " " then
cut = spans[i].to
break
end
end
table.insert(lines, line:sub(1, cut))
table.insert(conts, wait)
@@ -167,6 +182,18 @@ function TextBox:update(dt)
if self.autoSrc and self.autoSrc.isPlaying and self.autoSrc:isPlaying() then
return -- the cry is still sounding (WaitForSoundToFinish)
end
-- auto.wait: the pet-NPC cries (PewterNidoranHouseNidoranText,
-- ViridianNicknameHouseSpearowText) have nothing queued behind the
-- cry, so DisplayTextID's trailing WaitForTextScrollButtonPress still
-- runs once it is over -- their maps enable auto text box drawing,
-- which zeroes wDoNotWaitForButtonPressAfterDisplayingText
-- (home/window.asm AutoTextBoxDrawingCommon). Drop the auto gate and
-- hand the box to the plain A/B path, which also starts the blinking
-- arrow, instead of popping it (#247, #251).
if self.auto.wait then
self.auto = nil
return
end
self.autoTimer = self.autoTimer + 1
local delay = self.auto.delay or 3
-- auto.onOverlap: fired once when the delay elapses but before the
+4 -1
View File
@@ -542,7 +542,10 @@ end
local function bakeBorderFill(self, block)
local border = self.map.tileset.blocks[block + 1]
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.setCanvas(canvas)
love.graphics.clear(1, 1, 1, 1)
+26 -7
View File
@@ -4,19 +4,38 @@
-- zoom. Persisted as save.options.zoom (default 0 = FIT).
-- Spec: docs/new-features.md (survey zoom)
local Runtime = require("src.mods.Runtime")
local Zoom = {}
Zoom.offset = 0
-- effective integer scale s' in [1, 2*S]
function Zoom.scale(S)
return math.max(1, math.min(2 * S, S + Zoom.offset))
end
-- legal offset range for a given fit scale
-- legal offset range for a given fit scale (vanilla: survey at 1 px/world
-- through 2× fit). zoom.range may widen or shrink the window.
function Zoom.offsetRange(S)
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
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 Screens = require("src.ui.Screens")
local TextBox = require("src.render.TextBox")
local Strings = require("src.core.Strings")
local Commands = {}
@@ -74,6 +75,9 @@ end
-- <PROMPT> of its own; the box only closes once WaitForSoundToFinish's poll
-- loop sees the cry channel go quiet, so it's a no-button-wait auto text
-- gated on the cry, not a button-wait one -- see TextBox's opts.auto.
-- play_cry's waitForButton form keeps that cry gate but hands the box back
-- to the A/B path once the cry is over -- see TextBox's opts.auto.wait
-- (#247, #251).
function Commands.show_text(ctx, textId, subs)
local text = ctx.game.data.text[textId]
if not text and ctx.overworld then
@@ -100,10 +104,12 @@ function Commands.show_text(ctx, textId, subs)
local opts
if ctx.pendingCry then
local species = ctx.pendingCry
ctx.pendingCry = nil
local waitForButton = ctx.pendingCryWait
ctx.pendingCry, ctx.pendingCryWait = nil, nil
-- delay 0: WaitForSoundToFinish has no trailing Delay3 of its own
opts = { auto = { sound = function()
return require("src.core.Sound").playCry(ctx.game.data, species)
end, delay = 0 } } -- WaitForSoundToFinish has no trailing Delay3 of its own
end, delay = 0, wait = waitForButton } }
end
-- text_opts armed the next box: auto = true is the plain no-button-wait
-- form, overlap folds under auto, everything else passes through
@@ -189,7 +195,7 @@ function Commands.give_item(ctx, itemId, count, gotText)
-- skips the received text entirely when AddItemToInventory refuses)
if not require("src.inventory.Bag").add(ctx.save, itemId, count or 1) then
Commands.show_text(ctx, ctx.game.data.text
and ctx.game.data.text._BagFullText or "You can't carry\nany more items!")
and ctx.game.data.text._BagFullText or Strings("You can't carry\nany more items!"))
return math.huge
end
local def = ctx.game.data.items[itemId]
@@ -202,7 +208,7 @@ function Commands.give_item(ctx, itemId, count, gotText)
(def and def.keyItem) and "Get_Key_Item" or "Get_Item1")
if gotText ~= false then
Commands.show_text(ctx, gotText
or "{PLAYER} got\n" .. ctx.game.stringBuffer .. "!")
or Strings("{PLAYER} got\n%s!", ctx.game.stringBuffer))
end
end
@@ -469,18 +475,26 @@ function Commands.play_once(ctx, songId)
runner:yield()
end
-- play_cry <species>: PlayCry (home/audio.asm). The text_asm bodies that
-- use it run text_far (a no-button-wait "...@" string) -> PlayCry ->
-- WaitForSoundToFinish, all within the same text ID -- the cry only starts
-- once the box has finished typing, and the box then auto-closes (no A
-- press) the instant the cry finishes, rather than firing immediately
-- play_cry <species> [waitForButton]: PlayCry (home/audio.asm). The
-- text_asm bodies that use it run text_far (a no-button-wait "...@" string)
-- -> PlayCry -> WaitForSoundToFinish, all within the same text ID -- the cry
-- only starts once the box has finished typing, and the box then auto-closes
-- (no A press) the instant the cry finishes, rather than firing immediately
-- alongside the typewriter effect. Script rows run strictly in order, so
-- this stashes the species on ctx for the show_text row that always
-- immediately follows it (Power Plant Zapdos, Seafoam Articuno, Victory
-- Road Moltres, Cerulean Cave Mewtwo battle text) to play once its box is
-- done typing (see show_text's opts.auto). Headless-safe no-op there.
function Commands.play_cry(ctx, species)
--
-- waitForButton is the pet-NPC form (scripts/PewterNidoranHouse.asm and
-- scripts/ViridianNicknameHouse.asm: the text prints, then PlayCry,
-- WaitForSoundToFinish, TextScriptEnd). Nothing is queued behind the cry
-- there, so DisplayTextID's trailing WaitForTextScrollButtonPress still
-- runs and the box holds until A/B like any other NPC line instead of
-- popping itself into a static battle (#247, #251).
function Commands.play_cry(ctx, species, waitForButton)
ctx.pendingCry = species
ctx.pendingCryWait = waitForButton or nil
end
-- check_battle_result <r1> [r2 ...]: lastCheck = the last scripted
@@ -517,7 +531,7 @@ local function askNickname(ctx, mon)
if ctx.game.data.text and ctx.game.data.text._DoYouWantToNicknameText then
Commands.show_text(ctx, "_DoYouWantToNicknameText", { RAM = name })
else
Commands.show_text(ctx, ("Do you want to\ngive a nickname\nto %s?"):format(name))
Commands.show_text(ctx, Strings("Do you want to\ngive a nickname\nto %s?", name))
end
local ChoiceBox = require("src.ui.ChoiceBox")
ctx.game.stack:push(ChoiceBox.new(ctx.game, function(yes)
@@ -527,7 +541,7 @@ local function askNickname(ctx, mon)
return
end
Screens.push(ctx.game, "NamingScreen", {
title = "NICKNAME?", maxLen = 10,
title = Strings("NICKNAME?"), maxLen = 10,
onDone = function(nick)
if nick and #nick > 0 then mon.nickname = nick end
ctx.lastCheck = success
@@ -598,8 +612,9 @@ function Commands.give_pokemon(ctx, species, level)
local TextBox = require("src.render.TextBox")
local raw = ctx.game.data.text and ctx.game.data.text._SentToBoxText
ctx.game.stack:push(TextBox.new(ctx.game, raw or (
"There's no more\nroom for POKéMON!\v" .. name
.. " was\vsent to POKéMON\vBOX " .. boxNum .. " on PC!"),
Strings(
"There's no more\nroom for POKéMON!\v%s was\vsent to POKéMON\vBOX %s on PC!",
name, boxNum)),
function() end))
end
end
+2 -1
View File
@@ -15,6 +15,7 @@
local Data = require("src.core.Data")
local Logger = require("src.core.Logger")
local Runtime = require("src.mods.Runtime")
local Strings = require("src.core.Strings")
local MapScripts = {}
@@ -268,7 +269,7 @@ function MapScripts.validateContribution(contribution, lookup)
end
end
if type(contribution) ~= "table" then
return { "contribution is not a table" }
return { Strings("contribution is not a table") }
end
for textConst, script in pairs(contribution.talk or {}) do
if type(script) == "table" then collect("talk." .. textConst, script) end
+2 -1
View File
@@ -13,6 +13,7 @@
local Commands = require("src.script.Commands")
local Logger = require("src.core.Logger")
local Runtime = require("src.mods.Runtime")
local Strings = require("src.core.Strings")
local unpack = table.unpack or unpack -- LuaJIT (LÖVE) compatibility
@@ -195,7 +196,7 @@ function ScriptRunner:resume(...)
if not ok then
local source = self.ctx and self.ctx.source
local where = source
and (" [%s %s.%s]"):format(tostring(source.modId or "engine"),
and Strings(" [%s %s.%s]", tostring(source.modId or "engine"),
tostring(source.mapId or "?"), tostring(source.hook or "?"))
or ""
Logger.error("script error%s: %s", where, tostring(err))
+49 -27
View File
@@ -9,6 +9,7 @@ local TextBox = require("src.render.TextBox")
local BagMenu = {}
local Bag = require("src.inventory.Bag")
local Strings = require("src.core.Strings")
-- acquisition order like wBagItems (Bag.order), not alphabetical
local function buildItems(game)
@@ -108,13 +109,13 @@ local function useOn(game, battle, id, target, list, moveIndex)
if game.save.onBike then
game.save.onBike = false
Music.playMap(game.data, ow and ow.map.id, false)
showMessages(game, { save_name(game) .. " got off\nthe BICYCLE." })
showMessages(game, { Strings("%s got off\nthe BICYCLE.", save_name(game)) })
elseif bikeAllowed() then
game.save.onBike = true
Music.playMap(game.data, ow.map.id, true)
showMessages(game, { save_name(game) .. " got on\nthe BICYCLE!" })
showMessages(game, { Strings("%s got on\nthe BICYCLE!", save_name(game)) })
else
showMessages(game, { "No cycling\nallowed here." })
showMessages(game, { Strings("No cycling\nallowed here.") })
end
return
end
@@ -130,13 +131,14 @@ local function useOn(game, battle, id, target, list, moveIndex)
return
end
end
showMessages(game, { "No good! It's not\neven near water." })
showMessages(game, { Strings("No good! It's not\neven near water.") })
return
end
if result == "ball" then
if not battle then
showMessages(game, { "OAK: " .. game.save.player.name .. "!\nThis isn't the\ntime to use that!" })
showMessages(game, { Strings("OAK: %s!\nThis isn't the\ntime to use that!",
game.save.player.name) })
return
end
consume(game, id)
@@ -151,7 +153,7 @@ local function useOn(game, battle, id, target, list, moveIndex)
local function teach()
if #target.moves < 4 then
table.insert(target.moves, { id = moveId, pp = mdef.pp })
showMessages(game, { ("%s learned\n%s!"):format(target.nickname or
showMessages(game, { Strings("%s learned\n%s!", target.nickname or
game.data.pokemon[target.species].name, mdef.name) })
if result == "learn" then consume(game, id) end
else
@@ -172,7 +174,7 @@ local function useOn(game, battle, id, target, list, moveIndex)
require("src.ui.Screens").push(game, "TownMap")
end)
if not ok then
showMessages(game, { "The TOWN MAP is\nunreadable here." })
showMessages(game, { Strings("The TOWN MAP is\nunreadable here.") })
end
return
end
@@ -184,10 +186,10 @@ local function useOn(game, battle, id, target, list, moveIndex)
local t = game.data.text
if ow and ow:hasHiddenItemLeft() then
showMessages(game, { t._ItemfinderFoundItemText
or "Yes! ITEMFINDER\nindicates there's\nan item nearby." })
or Strings("Yes! ITEMFINDER\nindicates there's\nan item nearby.") })
else
showMessages(game, { t._ItemfinderFoundNothingText
or "Nope! ITEMFINDER\nisn't responding." })
or Strings("Nope! ITEMFINDER\nisn't responding.") })
end
return
end
@@ -214,14 +216,14 @@ local function useOn(game, battle, id, target, list, moveIndex)
and ow.map.id ~= "AGATHAS_ROOM" then
list:close()
consume(game, id)
require("src.core.Sound").play(game.data, "Teleport_Exit1")
ow.player.surfing = false
-- EnterMapAnim on arrival (BIT_ESCAPE_WARP / special warp path);
-- blackouts omit arrive="teleport" (HandleBlackOut has no LeaveMapAnim)
ow:warpToHealPoint(nil, { arrive = "teleport" })
-- LeaveMapAnim spin-up + SFX_TELEPORT_EXIT_1, a fade, then land OUTSIDE
-- the last Pokémon Center town door like Fly (#196), via the shared
-- departure helper -- the same path Dig/Teleport take from the party menu
ow:beginTeleportOut()
else
showMessages(game, { "OAK: " .. game.save.player.name
.. "!\nThis isn't the\ntime to use that!" })
showMessages(game, { Strings(
"OAK: %s!\nThis isn't the\ntime to use that!",
game.save.player.name) })
end
return
end
@@ -265,7 +267,7 @@ local function useOn(game, battle, id, target, list, moveIndex)
if #target.moves < 4 then
table.insert(target.moves, { id = moveId, pp = mdef.pp })
local name = target.nickname or def.name
showMessages(game, { ("%s learned\n%s!"):format(name, mdef.name) },
showMessages(game, { Strings("%s learned\n%s!", name, mdef.name) },
nextStep)
else
require("src.ui.Screens").push(game, "MoveLearnMenu",
@@ -303,7 +305,8 @@ local function pickTargetAndUse(game, battle, id, list)
-- the ETHERs and PP UP open the move menu after picking a mon
-- (ItemUsePPRestore / ItemUsePPUp); the ELIXERs hit every move
local wantsMove = id == "ETHER" or id == "MAX_ETHER" or id == "PP_UP"
require("src.ui.Screens").push(game, "PartyMenu", {
local def = game.data.items[id]
local opts = {
pickOnly = true,
onSwitch = function(mon)
if not wantsMove then
@@ -326,7 +329,17 @@ local function pickTargetAndUse(game, battle, id, list)
end,
}))
end,
})
}
-- TM/HM: open the party menu in Gen 1's TM/HM display mode so each mon
-- shows ABLE / NOT ABLE from its learnset and the prompt reads "Use TM on
-- which POKeMON?" (engine/items/item_effects.asm ItemUseTMHM ->
-- party_menu.asm TM/HM type). Stones and other pickOnly items keep the
-- plain HP layout (Gen 1 shows no ABLE/NOT ABLE for them), so gate
-- strictly on def.machine. #210
if def and def.machine then
opts.tmhm = { move = def.machine.move, kind = def.machine.kind }
end
require("src.ui.Screens").push(game, "PartyMenu", opts)
end
local function useItem(game, battle, id, list)
@@ -344,8 +357,8 @@ local function useItem(game, battle, id, list)
local moveDef = game.data.moves[def.machine.move]
local moveName = moveDef and moveDef.name or def.machine.move
local booted = def.machine.kind == "HM"
and "Booted up an HM!" or "Booted up a TM!"
showMessages(game, { booted, ("It contained\n%s!"):format(moveName) },
and "Booted up an HM!" or Strings("Booted up a TM!")
showMessages(game, { booted, Strings("It contained\n%s!", moveName) },
function() pickTargetAndUse(game, battle, id, list) end)
return
end
@@ -360,7 +373,10 @@ function BagMenu.new(game, opts)
local battle = opts.battle
local list
list = ListMenu.new(game, "ITEMS", buildItems(game), {
kind = "bag",
footer = ("¥%d"):format(game.save.money),
-- B returns to the start menu when the bag was opened from it
onCancel = opts.onCancel,
-- SELECT reorders items like the original bag (swap_items.asm)
onSelectKey = function(item, l)
if not item then return end
@@ -389,17 +405,23 @@ function BagMenu.new(game, opts)
useItem(game, battle, id, list)
return
end
-- USE / TOSS submenu (the original's item options)
-- USE / TOSS submenu (the original's item options).
-- data/text_boxes.asm USE_TOSS_MENU_TEMPLATE: box (13,10)-(19,14),
-- text at (15,11); start_sub_menus.asm then sets wTopMenuItemY/X to
-- 11/14 for the cursor. Menu's own geometry reproduces all of that
-- from the box alone, so this needs opts rather than a change to the
-- shared Menu. The old 12/10/8/6 box was a column too wide and a row
-- too tall, which left the labels stranded near its top edge (#284).
local Menu = require("src.ui.Menu")
game.stack:push(Menu.new(game, {
{ label = "USE", onSelect = function()
{ label = Strings("USE"), onSelect = function()
useItem(game, battle, id, list)
end },
{ label = "TOSS", onSelect = function()
{ label = Strings("TOSS"), onSelect = function()
-- KeyItemFlags + HMs decide tossability (not price:
-- MOON STONE is price 0 but tossable)
if not def or def.keyItem or id:find("^HM_") then
showMessages(game, { "That's too impor-\ntant to toss!" })
showMessages(game, { Strings("That's too impor-\ntant to toss!") })
return
end
local QuantityBox = require("src.ui.QuantityBox")
@@ -413,12 +435,12 @@ function BagMenu.new(game, opts)
Bag.remove(game.save, id, qty)
list.items = buildItems(game)
list.index = math.min(list.index, math.max(1, #list.items))
showMessages(game, { ("Threw away\n%s."):format(def and def.name or id) })
showMessages(game, { Strings("Threw away\n%s.", def and def.name or id) })
end))
end,
}))
end },
}, { tx = 12, ty = 10, tw = 8, th = 6 }))
}, { tx = 13, ty = 10, tw = 7, th = 5 }))
end,
})
return list
+5 -2
View File
@@ -7,6 +7,7 @@
local Font = require("src.render.Font")
local ListMenu = require("src.ui.ListMenu")
local Input = require("src.core.Input")
local Strings = require("src.core.Strings")
local BindingsMenu = setmetatable({}, { __index = ListMenu })
BindingsMenu.__index = BindingsMenu
@@ -55,7 +56,9 @@ function BindingsMenu.new(game)
and game.save.options.bindings
local items = {}
for i, def in ipairs(BUTTONS) do
items[i] = { label = def.label,
-- translated here, not in ROWS: that table is built at require
-- time, before Strings.load has a catalog to look in
items[i] = { label = Strings(def.label),
right = boundRight(overlay, def), button = def }
end
local self = setmetatable(ListMenu.new(game, "CONTROLS", items, {}),
@@ -111,7 +114,7 @@ function BindingsMenu:draw()
if self.capture then
Font.drawBox(1, 6, 18, 4)
love.graphics.setColor(0, 0, 0, 1)
Font.draw("PRESS A BUTTON", 24, 60)
Font.draw(Strings("PRESS A BUTTON"), 24, 60)
love.graphics.setColor(1, 1, 1, 1)
end
end
+26 -25
View File
@@ -8,12 +8,13 @@ local ListMenu = require("src.ui.ListMenu")
local Menu = require("src.ui.Menu")
local Party = require("src.pokemon.Party")
local TextBox = require("src.render.TextBox")
local Strings = require("src.core.Strings")
local BoxMenu = {}
local function monLabel(game, mon)
local def = game.data.pokemon[mon.species]
return ("%s :L%d"):format(mon.nickname or def.name, mon.level)
return Strings("%s :L%d", mon.nickname or def.name, mon.level)
end
local function monName(game, mon)
@@ -28,13 +29,13 @@ local function monSubmenu(game, action, mon, onAction)
game.stack:push(Menu.new(game, {
{ label = action, onSelect = onAction },
{
label = "STATS",
label = Strings("STATS"),
keepOpen = true,
onSelect = function()
require("src.ui.Screens").push(game, "SummaryMenu", mon)
end,
},
{ label = "CANCEL" },
{ label = Strings("CANCEL") },
}, { tx = 9, ty = 10, tw = 11, th = 8, noSound = true }))
end
@@ -50,12 +51,12 @@ local function withdraw(game)
local t = game.data.text
if #box == 0 then
game.stack:push(TextBox.new(game, t._NoMonText
or "What? There are\nno POKéMON here!"))
or Strings("What? There are\nno POKéMON here!")))
return
end
if #game.save.party >= Party.MAX then
game.stack:push(TextBox.new(game, t._CantTakeMonText
or "You can't take\nany more POKéMON.\fDeposit POKéMON\nfirst."))
or Strings("You can't take\nany more POKéMON.\fDeposit POKéMON\nfirst.")))
return
end
local items = {}
@@ -63,7 +64,7 @@ local function withdraw(game)
table.insert(items, { label = monLabel(game, mon), value = i })
end
game.stack:push(ListMenu.new(game,
("BOX %d (WITHDRAW)"):format(game.save.currentBox), items, {
Strings("BOX %d (WITHDRAW)", game.save.currentBox), items, {
onChoose = function(item, list)
local mon = box[item.value]
if not mon then return end
@@ -78,7 +79,7 @@ local function withdraw(game)
game.stringBuffer = name
require("src.core.Sound").playCry(game.data, mon.species)
afterTransfer(game, list, t._MonIsTakenOutText
or (name .. " is\ntaken out.\vGot " .. name .. "."))
or Strings("%s is\ntaken out.\vGot %s.", name, name))
end)
end,
}))
@@ -88,13 +89,13 @@ local function deposit(game)
local t = game.data.text
if #game.save.party <= 1 then
game.stack:push(TextBox.new(game, t._CantDepositLastMonText
or "You can't deposit\nthe last POKéMON!"))
or Strings("You can't deposit\nthe last POKéMON!")))
return
end
local box = Boxes.active(game.save)
if #box >= Boxes.CAPACITY then
game.stack:push(TextBox.new(game, t._BoxFullText
or "Oops! This Box is\nfull of POKéMON."))
or Strings("Oops! This Box is\nfull of POKéMON.")))
return
end
local items = {}
@@ -107,12 +108,12 @@ local function deposit(game)
if not mon then return end
monSubmenu(game, "DEPOSIT", mon, function()
if #game.save.party <= 1 then
list.footer = "You need at least\none POKéMON!"
list.footer = Strings("You need at least\none POKéMON!")
return
end
local active = Boxes.active(game.save)
if #active >= Boxes.CAPACITY then
list.footer = ("BOX %d is full!"):format(game.save.currentBox)
list.footer = Strings("BOX %d is full!", game.save.currentBox)
return
end
table.remove(game.save.party, item.value)
@@ -122,7 +123,7 @@ local function deposit(game)
game.boxNumString = tostring(game.save.currentBox)
require("src.core.Sound").playCry(game.data, mon.species)
afterTransfer(game, list, t._MonWasStoredText
or (name .. " was\nstored in Box " .. game.boxNumString .. "."))
or Strings("%s was\nstored in Box %s.", name, game.boxNumString))
end)
end,
}))
@@ -135,7 +136,7 @@ local function release(game)
local t = game.data.text
if #box == 0 then
game.stack:push(TextBox.new(game, t._NoMonText
or "What? There are\nno POKéMON here!"))
or Strings("What? There are\nno POKéMON here!")))
return
end
local items = {}
@@ -143,20 +144,20 @@ local function release(game)
table.insert(items, { label = monLabel(game, mon), value = i })
end
game.stack:push(ListMenu.new(game,
("BOX %d (RELEASE)"):format(game.save.currentBox), items, {
Strings("BOX %d (RELEASE)", game.save.currentBox), items, {
onChoose = function(_, list)
local mon = box[list.index]
if not mon then return end
local name = monName(game, mon)
local ChoiceBox = require("src.ui.ChoiceBox")
game.stack:push(TextBox.new(game,
"Once released,\n" .. name .. " is\ngone forever. OK?", function()
Strings("Once released,\n%s is\ngone forever. OK?", name), function()
game.stack:push(ChoiceBox.new(game, function(yes)
if not yes then return end
table.remove(box, list.index)
require("src.core.Sound").playCry(game.data, mon.species)
game.stack:push(TextBox.new(game,
("%s was\nreleased outside.\fBye %s!"):format(name, name)))
Strings("%s was\nreleased outside.\fBye %s!", name, name)))
list:removeCurrent()
end, { defaultNo = true, noSound = true }))
end))
@@ -170,7 +171,7 @@ local function changeBox(game)
for i = 1, Boxes.COUNT do
local mark = i == game.save.currentBox and "*" or " "
table.insert(items, {
label = ("%sBOX %2d"):format(mark, i),
label = Strings("%sBOX %2d", mark, i),
right = ("%d/%d"):format(#boxes[i], Boxes.CAPACITY),
value = i,
})
@@ -181,7 +182,7 @@ local function changeBox(game)
-- BOX, data will be saved. OK?"); declining aborts the change
local ChoiceBox = require("src.ui.ChoiceBox")
game.stack:push(TextBox.new(game,
"When you change a\nPOKéMON BOX, data\nwill be saved. OK?", function()
Strings("When you change a\nPOKéMON BOX, data\nwill be saved. OK?"), function()
game.stack:push(ChoiceBox.new(game, function(yes)
if not yes then return end
game.save.currentBox = item.value
@@ -197,10 +198,10 @@ end
local function drawChrome(game)
Font.drawBox(0, 12, 20, 6)
love.graphics.setColor(0, 0, 0, 1)
Font.draw("What?", 8, 112)
Font.draw(Strings("What?"), 8, 112)
Font.drawBox(9, 14, 11, 4)
love.graphics.setColor(0, 0, 0, 1)
Font.draw("BOX No.", 80, 128)
Font.draw(Strings("BOX No."), 80, 128)
local n = game.save.currentBox or 1
if n >= 10 then
Font.draw(tostring(n), 136, 128)
@@ -217,15 +218,15 @@ function BoxMenu.new(game)
-- full interior (cursor col + label). keepOpen so WITHDRAW/DEPOSIT/
-- RELEASE/CHANGE BOX leave this menu underneath (jp BillsPCMenu).
local menu = Menu.new(game, {
{ label = "WITHDRAW <PK><MN>", keepOpen = true,
{ label = Strings("WITHDRAW <PK><MN>"), keepOpen = true,
onSelect = function() withdraw(game) end },
{ label = "DEPOSIT <PK><MN>", keepOpen = true,
{ label = Strings("DEPOSIT <PK><MN>"), keepOpen = true,
onSelect = function() deposit(game) end },
{ label = "RELEASE <PK><MN>", keepOpen = true,
{ label = Strings("RELEASE <PK><MN>"), keepOpen = true,
onSelect = function() release(game) end },
{ label = "CHANGE BOX", keepOpen = true,
{ label = Strings("CHANGE BOX"), keepOpen = true,
onSelect = function() changeBox(game) end },
{ label = "SEE YA!" },
{ label = Strings("SEE YA!") },
-- Bill's PC runs silent end to end (BIT_NO_MENU_BUTTON_SOUND,
-- engine/menus/pokemon_pc.asm)
}, { tx = 0, ty = 0, tw = 14, th = 12, noSound = true })
+3 -2
View File
@@ -2,6 +2,7 @@
local Font = require("src.render.Font")
local Theme = require("src.ui.Theme")
local Strings = require("src.core.Strings")
local ChoiceBox = {}
ChoiceBox.__index = ChoiceBox
@@ -46,8 +47,8 @@ function ChoiceBox:draw()
local tx, ty, tw, th = self.tx, self.ty, self.tw, self.th
Font.drawBox(tx, ty, tw, th)
love.graphics.setColor(0, 0, 0, 1)
Font.draw("YES", (tx + 2) * 8, (ty + 1) * 8)
Font.draw("NO", (tx + 2) * 8, (ty + 3) * 8)
Font.draw(Strings("YES"), (tx + 2) * 8, (ty + 1) * 8)
Font.draw(Strings("NO"), (tx + 2) * 8, (ty + 3) * 8)
Font.drawCode(Theme.cursor, (tx + 1) * 8,
(ty + (self.index == 1 and 1 or 3)) * 8)
love.graphics.setColor(1, 1, 1, 1)
+9 -7
View File
@@ -31,6 +31,7 @@
local Font = require("src.render.Font")
local Music = require("src.core.Music")
local Strings = require("src.core.Strings")
local Credits = {}
Credits.__index = Credits
@@ -140,8 +141,9 @@ function Credits:enter()
end
function Credits:monSprite(species)
local def = self.game.data.pokemon and self.game.data.pokemon[species]
return silhouette(def and def.spriteFront)
local path = require("src.pokemon.Sprites").path(
self.game.data, species, "front", { kind = "credits" })
return silhouette(path)
end
-- advance to the next CreditsOrder screen (Credits .nextCreditsScreen);
@@ -260,14 +262,14 @@ function Credits:drawCopyright(xoff)
love.graphics.draw(self.gfImg, xoff + 80, 88)
else
love.graphics.setColor(0, 0, 0, 1)
Font.draw("GAME FREAK", xoff + 80, 88)
Font.draw(Strings("GAME FREAK"), xoff + 80, 88)
love.graphics.setColor(1, 1, 1, 1)
end
else
love.graphics.setColor(0, 0, 0, 1)
Font.draw("Nintendo", xoff + 80, 56)
Font.draw("Creatures inc.", xoff + 80, 72)
Font.draw("GAME FREAK inc.", xoff + 16, 88)
Font.draw(Strings("Nintendo"), xoff + 80, 56)
Font.draw(Strings("Creatures inc."), xoff + 80, 72)
Font.draw(Strings("GAME FREAK inc."), xoff + 16, 88)
love.graphics.setColor(1, 1, 1, 1)
end
end
@@ -297,7 +299,7 @@ function Credits:drawTheEnd()
end
else
love.graphics.setColor(0, 0, 0, 1)
Font.draw((te and te.display) or "T H E E N D", 32, 64)
Font.draw((te and te.display) or Strings("T H E E N D"), 32, 64)
love.graphics.setColor(1, 1, 1, 1)
end
end
+7 -4
View File
@@ -8,6 +8,7 @@
-- without permanently marking the mon owned.
local Font = require("src.render.Font")
local Strings = require("src.core.Strings")
local DexEntryMenu = {}
DexEntryMenu.__index = DexEntryMenu
@@ -34,7 +35,9 @@ function DexEntryMenu.new(game, speciesOrOpts)
local species, forceOwned = resolveArgs(speciesOrOpts)
local self = setmetatable({ game = game, forceOwned = forceOwned }, DexEntryMenu)
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
require("src.core.Sound").playCry(game.data, species)
return self
@@ -74,8 +77,8 @@ function DexEntryMenu:draw()
-- feet/inches use the dex screen's /″ glyphs ("HT ???″" in
-- pokedex.asm; the tiles come from gfx/pokedex/pokedex.png via
-- engine/gfx/load_pokedex_tiles.asm)
Font.draw(("HT %d%02d″"):format(e.heightFt, e.heightIn or 0), 72, 44)
Font.draw(("WT %.1flb"):format((e.weight or 0) / 10), 72, 54)
Font.draw(Strings("HT %d%02d″", e.heightFt, e.heightIn or 0), 72, 44)
Font.draw(Strings("WT %.1flb", (e.weight or 0) / 10), 72, 54)
end
local text = owned and e.text and self.game.data.text[e.text] or nil
local y = 72
@@ -86,7 +89,7 @@ function DexEntryMenu:draw()
y = y + 10
end
else
Font.draw("Data unknown.", 8, y)
Font.draw(Strings("Data unknown."), 8, y)
end
love.graphics.setColor(1, 1, 1, 1)
end
+62 -21
View File
@@ -1,11 +1,17 @@
-- The evolution movie (engine/movie/evolution.asm): the mon's pic
-- flashes back and forth with the evolved form, speeding up, then the
-- new form appears with its cry and the congratulations text.
-- B during the flash cancels ("Huh? ... stopped evolving!"? -- Gen 1
-- has no cancel; the flash always completes).
-- pokered engine/movie/evolution.asm (Evolution_CheckForCancel) polls the
-- joypad during the flash: holding B aborts the evolution -- the mon keeps
-- its species and _StoppedEvolvingText ("Huh? MON stopped evolving!")
-- prints. Two kinds are exempt: trade evolutions, which evos_moves.asm
-- routes past the poll entirely (wLinkState == LINK_STATE_TRADING, #213),
-- and stone evolutions, where the B press is read but thrown away because
-- ItemUseEvoStone left wForceEvolution set (#290).
local Font = require("src.render.Font")
local Music = require("src.core.Music")
local Strings = require("src.core.Strings")
local EvolutionState = {}
EvolutionState.__index = EvolutionState
@@ -14,7 +20,10 @@ EvolutionState.isOpaque = true
-- SGB: SetPal_PokemonWholeScreen for the mon on display
function EvolutionState:sgbPalettes(game)
local P = require("src.render.PaletteFX")
local species = self.done and self.newSpecies or self.mon.species
-- a cancelled evolution keeps the old species (never applied), so only
-- colorize with the new form once it has actually evolved
local species = (self.done and not self.canceled) and self.newSpecies
or self.mon.species
local c = P.monPal(game.data, species)
if c then return { P.whole(c) } end
return P.wholeNamed(game.data, "MEWMON")
@@ -22,24 +31,33 @@ end
local FLASH_FRAMES = 220
local function frontSprite(game, species)
local def = game.data.pokemon[species]
if not (def and def.spriteFront) then return nil end
local ok, img = pcall(love.graphics.newImage, def.spriteFront)
local function frontSprite(game, species, mon)
local path = require("src.pokemon.Sprites").path(game.data, species, "front",
{ mon = mon, kind = "evolution" })
if not path then return nil end
local ok, img = pcall(love.graphics.newImage, path)
return ok and img or nil
end
function EvolutionState.new(game, mon, newSpecies, onDone)
function EvolutionState.new(game, mon, newSpecies, onDone, via)
local self = setmetatable({}, EvolutionState)
self.game = game
self.mon = mon
self.newSpecies = newSpecies
self.onDone = onDone
self.via = via
-- evolution.asm Evolution_CheckForCancel: a B press is discarded when
-- wForceEvolution is set, and ItemUseEvoStone sets it before calling
-- TryEvolvingMon, so a stone evolution (via == "ITEM") cannot be
-- cancelled either. Only level-up and rare-candy evolutions run with
-- wForceEvolution clear and so honour B (#290, #213).
self.cancelable = (via ~= "TRADE" and via ~= "ITEM")
self.oldName = mon.nickname or game.data.pokemon[mon.species].name
self.oldSprite = frontSprite(game, mon.species)
self.newSprite = frontSprite(game, newSpecies)
self.oldSprite = frontSprite(game, mon.species, mon)
self.newSprite = frontSprite(game, newSpecies, mon)
self.t = 0
self.done = false
self.canceled = false
Music.play(game.data, Music.special(game.data, "evolution"))
return self
end
@@ -47,22 +65,44 @@ end
function EvolutionState:update(dt)
self.t = self.t + 1
if self.done then return end
if self.t >= FLASH_FRAMES then
local game = self.game
-- evos_moves.asm EvolveMon: each flash iteration polls hJoyHeld and, for
-- a cancelable evolution, aborts when B is held -- the mon keeps its
-- species (Evolution.apply never runs) and _StoppedEvolvingText prints.
if self.cancelable and game.input:isDown("b") then
self.done = true
local game = self.game
local Evolution = require("src.pokemon.Evolution")
Evolution.apply(game, self.mon, self.newSpecies)
require("src.core.Sound").playCry(game.data, self.newSpecies)
self.canceled = true
local TextBox = require("src.render.TextBox")
local newName = game.data.pokemon[self.newSpecies].name
-- mirrors data/generated/text.lua _StoppedEvolvingText
game.stack:push(TextBox.new(game,
("Congratulations!\nYour %s\nevolved into\n%s!")
:format(self.oldName, newName),
Strings("Huh? %s\nstopped evolving!", self.oldName),
function()
Music.restoreMap(game.data)
game.stack:pop() -- the evolution screen itself
if self.onDone then self.onDone() end
end))
return
end
if self.t >= FLASH_FRAMES then
self.done = true
local Evolution = require("src.pokemon.Evolution")
Evolution.apply(game, self.mon, self.newSpecies, self.via)
require("src.core.Sound").playCry(game.data, self.newSpecies)
local TextBox = require("src.render.TextBox")
local newName = game.data.pokemon[self.newSpecies].name
game.stack:push(TextBox.new(game,
Strings("Congratulations!\nYour %s\nevolved into\n%s!",
self.oldName, newName),
function()
Music.restoreMap(game.data)
game.stack:pop() -- the evolution screen itself
-- Gen1 re-runs the level-up learn check on the evolved species
-- after the "evolved into" text (evos_moves.asm EvolveMon ->
-- learn_move.asm LearnMoveFromLevelUp, #12). Pop the evo screen
-- first so the "learned MOVE!" text / forget prompt push onto the
-- overworld / battle-return, not this state.
Evolution.learnEvolutionMoves(game, self.mon, self.onDone)
end))
end
end
@@ -73,7 +113,8 @@ function EvolutionState:draw()
-- accelerating flash between the two forms
local sprite
if self.done then
sprite = self.newSprite
-- a cancelled evolution settles back on the original form
sprite = self.canceled and self.oldSprite or self.newSprite
else
local period = math.max(4, 28 - math.floor(self.t / 40) * 6)
local showNew = math.floor(self.t / period) % 2 == 1
@@ -86,9 +127,9 @@ function EvolutionState:draw()
love.graphics.setColor(0, 0, 0, 1)
if not self.done then
Font.draw("What?", 8, 104)
Font.draw(Strings("What?"), 8, 104)
Font.draw(self.oldName .. " is", 8, 114)
Font.draw("evolving!", 8, 124)
Font.draw(Strings("evolving!"), 8, 124)
end
love.graphics.setColor(1, 1, 1, 1)
end
+6 -2
View File
@@ -11,9 +11,13 @@ function FlyMenu.new(game)
local visited = game.save.visited or {}
local seen = {}
for _, mapId in ipairs(game.data.field.flyOrder or {}) do
-- towns only (dungeon escape spots share the table), each listed once
-- towns only (dungeon escape spots share the table), each listed once.
-- Indigo Plateau (tileset PLATEAU) is a valid Fly destination too, so allow
-- it past the OVERWORLD-only isOutdoor gate while the CAVERN/FACILITY escape
-- spots stay excluded (LoadTownMap_Fly cycles it like any town, #203).
local def = game.data.maps[mapId]
if visited[mapId] and def and Map.isOutdoor(def) and not seen[mapId] then
if visited[mapId] and def and not seen[mapId]
and (Map.isOutdoor(def) or def.tileset == "PLATEAU") then
seen[mapId] = true
table.insert(items, {
value = mapId,
+19 -13
View File
@@ -7,10 +7,12 @@
-- name/time/money boxes plus the dex rating. Plays Music_HallOfFame
-- when the audio data has it. Calls onDone() after popping itself.
local Assets = require("src.render.Assets")
local Font = require("src.render.Font")
local Music = require("src.core.Music")
local Sound = require("src.core.Sound")
local TypeChart = require("src.battle.TypeChart")
local Strings = require("src.core.Strings")
local HallOfFame = {}
HallOfFame.__index = HallOfFame
@@ -47,9 +49,11 @@ local FADE_FRAMES = 20
-- HoFPrintTextAndDelay after each dex line
local DEX_HOLD = 120
-- through Assets.resolve so an enabled mod's overrides/ shadows these the
-- same way it shadows every other generated asset
local function tryImage(path)
if not path then return nil end
local ok, img = pcall(love.graphics.newImage, path)
local ok, img = pcall(love.graphics.newImage, Assets.resolve(path))
return ok and img or nil
end
@@ -79,7 +83,8 @@ function HallOfFame.new(game, onDone)
self.timer = 0
self.phase = "mons"
self.sprites = {} -- species -> image or false
self.playerPic = tryImage("assets/generated/trainer_card/red.png")
self.playerPic = tryImage(require("src.pokemon.Sprites").playerPath(
game.data, "front", { kind = "hof" }))
self.scrollX = PIC_X
self.showHofBanner = false
self.fade = 0
@@ -118,8 +123,9 @@ end
function HallOfFame:spriteFor(species)
local cached = self.sprites[species]
if cached == nil then
local def = self.game.data.pokemon[species]
cached = tryImage(def and def.spriteFront) or false
local path = require("src.pokemon.Sprites").path(
self.game.data, species, "front", { kind = "hof" })
cached = tryImage(path) or false
self.sprites[species] = cached
end
return cached or nil
@@ -205,13 +211,13 @@ function HallOfFame:drawMonInfo(mon)
love.graphics.setColor(0, 0, 0, 1)
local name = mon.nickname or (def and def.name) or mon.species
Font.draw(name, 1 * 8, 4 * 8)
Font.draw("LEVEL/", 2 * 8, 6 * 8)
Font.draw("TYPE1/", 2 * 8, 7 * 8)
Font.draw(Strings("LEVEL/"), 2 * 8, 6 * 8)
Font.draw(Strings("TYPE1/"), 2 * 8, 7 * 8)
local t1 = def and def.types and def.types[1]
local t2 = def and def.types and def.types[2]
local dual = t2 and t2 ~= t1
if dual then
Font.draw("TYPE2/", 2 * 8, 8 * 8)
Font.draw(Strings("TYPE2/"), 2 * 8, 8 * 8)
end
-- PrintLevelCommon at (8,7): bare level digits (no <LV> tile here)
Font.draw(tostring(mon.level), 8 * 8, 7 * 8)
@@ -228,7 +234,7 @@ end
function HallOfFame:drawHofBanner()
Font.drawBox(2, 13, 16, 5)
love.graphics.setColor(0, 0, 0, 1)
Font.draw("HALL OF FAME", 4 * 8, 15 * 8)
Font.draw(Strings("HALL OF FAME"), 4 * 8, 15 * 8)
end
function HallOfFame:drawPic(img)
@@ -248,11 +254,11 @@ function HallOfFame:drawPlayerStats()
-- play time / money box: TextBoxBorder (0,4) b=6,c=10 → drawBox(0,4,12,8)
Font.drawBox(0, 4, 12, 8)
love.graphics.setColor(0, 0, 0, 1)
Font.draw("PLAY TIME", 1 * 8, 6 * 8)
Font.draw(Strings("PLAY TIME"), 1 * 8, 6 * 8)
local t = math.floor(save.playTime or 0)
Font.draw(("%3d:%02d"):format(math.floor(t / 3600), math.floor(t / 60) % 60),
5 * 8, 7 * 8)
Font.draw("MONEY", 1 * 8, 9 * 8)
Font.draw(Strings("MONEY"), 1 * 8, 9 * 8)
-- PrintBCDNumber with MONEY_SIGN; port uses ¥ like TrainerCard
Font.draw(("¥%d"):format(save.money or 0), 4 * 8, 10 * 8)
end
@@ -265,16 +271,16 @@ function HallOfFame:drawDexBox(kind)
if kind == "seen" then
local seen, owned = self:dexSeenOwned()
local seenOwned = text._DexSeenOwnedText
or "POKéDEX Seen:{NUM:wDexRatingNumMonsSeen, 1, 3}\n Owned:{NUM:wDexRatingNumMonsOwned, 1, 3}"
or Strings("POKéDEX Seen:{NUM:wDexRatingNumMonsSeen, 1, 3}\n Owned:{NUM:wDexRatingNumMonsOwned, 1, 3}")
seenOwned = seenOwned
:gsub("{NUM:wDexRatingNumMonsSeen[^}]*}", tostring(seen))
:gsub("{NUM:wDexRatingNumMonsOwned[^}]*}", tostring(owned))
drawTextBlock(seenOwned, 1 * 8, 14 * 8, 17 * 8)
else
local _, owned = self:dexSeenOwned()
local ratingHeader = (text._DexRatingText or "POKéDEX Rating{COLON}"):gsub("{COLON}", ":")
local ratingHeader = (text._DexRatingText or Strings("POKéDEX Rating{COLON}")):gsub("{COLON}", ":")
Font.draw(ratingHeader, 1 * 8, 14 * 8)
local rating = text[dexRatingKey(owned)] or "Keep it up!"
local rating = text[dexRatingKey(owned)] or Strings("Keep it up!")
drawTextBlock(rating, 1 * 8, 15 * 8, 17 * 8)
end
end
+4 -3
View File
@@ -27,6 +27,7 @@
local Font = require("src.render.Font")
local Music = require("src.core.Music")
local Sound = require("src.core.Sound")
local Strings = require("src.core.Strings")
local IntroMovie = {}
IntroMovie.__index = IntroMovie
@@ -287,7 +288,7 @@ function IntroMovie:drawSplash()
end
-- custom studio name (replaces the GAME FREAK splash text)
love.graphics.setColor(0, 0, 0, dim and 0.35 or 1)
local card = self.studio.card or "bois club games"
local card = self.studio.card or Strings("bois club games")
Font.draw(card, (160 - #card * 8) / 2, TEXT_Y)
love.graphics.setColor(1, 1, 1, 1)
end
@@ -348,7 +349,7 @@ function IntroMovie:drawFight()
if not gengar and not nido then
love.graphics.setColor(0, 0, 0, 1)
Font.draw("GENGAR VS NIDORINO", (160 - 18 * 8) / 2, 64)
Font.draw(Strings("GENGAR VS NIDORINO"), (160 - 18 * 8) / 2, 64)
love.graphics.setColor(1, 1, 1, 1)
end
drawBars()
@@ -366,7 +367,7 @@ function IntroMovie:draw()
-- custom boot card (replaces the Nintendo / GAME FREAK copyright
-- card; no (c) glyph in the charmap, keep it ASCII-safe)
love.graphics.setColor(0, 0, 0, 1)
local credit = self.studio.credit or "bois club"
local credit = self.studio.credit or Strings("bois club")
Font.draw("2026", (160 - 4 * 8) / 2, 48)
Font.draw(credit, (160 - #credit * 8) / 2, 64)
Font.draw("bryanthaboi", (160 - 11 * 8) / 2, 80)
+102 -9
View File
@@ -3,7 +3,9 @@
-- box and the Pokédex.
local Font = require("src.render.Font")
local Runtime = require("src.mods.Runtime")
local Theme = require("src.ui.Theme")
local Strings = require("src.core.Strings")
local ListMenu = {}
ListMenu.__index = ListMenu
@@ -15,9 +17,37 @@ function ListMenu:sgbPalettes(game)
end
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)
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)
self.game = game
self.title = title
@@ -28,6 +58,12 @@ function ListMenu.new(game, title, items, opts)
self.onCancel = opts.onCancel
self.footer = opts.footer
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
-- scripted mode (the old man tutorial): update() runs the script
-- every frame INSTEAD of reading input -- DisplayListMenuID's old-man
@@ -46,6 +82,42 @@ function ListMenu.new(game, title, items, opts)
return self
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)
if self.script then
self.script(self)
@@ -59,14 +131,20 @@ function ListMenu:update(dt)
end
return
end
local moved = false
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
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
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
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
self.onSelectKey(self.items[self.index], self)
elseif input:wasPressed("b") then
@@ -80,10 +158,22 @@ function ListMenu:update(dt)
end
return
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
if self.index - self.scroll < 1 then self.scroll = self.index - 1 end
if not moved then syncScroll(self) end
end
-- remove current item (e.g. consumed); keeps cursor valid
@@ -103,7 +193,7 @@ function ListMenu:draw()
love.graphics.setColor(0, 0, 0, 1)
Font.draw(self.title, 8, 4)
if #self.items == 0 then
Font.draw("Nothing here.", 16, 64)
Font.draw(Strings("Nothing here."), 16, 64)
end
for row = 1, self.rows do
local i = self.scroll + row
@@ -112,7 +202,10 @@ function ListMenu:draw()
local y = 8 + row * 16
Font.draw(item.label, 16, y)
if item.ball then -- the Pokédex owned-ball marker tile
local bx = 16 + (#item.label + 1) * 8 + 3
-- one blank glyph after the name, measured in glyph advances rather
-- than bytes: NIDORAN♂/♀ carry a multi-byte charmap entry, so
-- `#item.label` overcounted by 2 and pushed their ball 16px right (#285)
local bx = 16 + Font.width(item.label) + 8 + 3
local by = y + 3
love.graphics.circle("fill", bx, by, 3.5)
love.graphics.setColor(1, 1, 1, 1)

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