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: options:
- macOS - macOS
- Windows - Windows
- Both - Linux
- Android
- Multiple platforms
validations: validations:
required: true required: true
@@ -38,6 +40,17 @@ body:
validations: validations:
required: true required: true
- type: input
id: discord
attributes:
label: Discord username (optional)
description: >
So maintainers can ping you on Discord if they need a quick follow-up.
Leave blank if you'd rather keep everything on GitHub.
placeholder: yourname
validations:
required: false
- type: input - type: input
id: location id: location
attributes: attributes:
+34 -19
View File
@@ -1,35 +1,53 @@
name: Feature request name: Feature request
description: Ask for something new, or a change to how something already works. description: Ask for something new in the engine, launcher, or platform — not a content/gameplay mod.
labels: ["enhancement"] labels: ["enhancement"]
body: body:
- type: markdown - type: markdown
attributes: attributes:
value: | value: |
Use this for **engine / launcher / platform** work (ports, video options, save
tooling, networking, mod API seams, docs).
If what you want is a gameplay, cosmetic, audio, or QoL change that a Lua mod
could ship — running shoes, alternate sprites, day/night, shiny indicators,
Gen 2-like battle toggles, soundtrack packs — open a
**[Mod request](https://github.com/bryanthaboi/pokemon-gen1-recomp-project/issues/new?template=mod_request.yml)**
instead.
"Can we add X" on its own is hard to act on. Say what you want, why you want it, "Can we add X" on its own is hard to act on. Say what you want, why you want it,
and how you picture it working. A request with real detail is one someone can and how you picture it working.
actually build without guessing at what you meant.
- type: input - type: input
id: summary id: summary
attributes: attributes:
label: One line summary label: One line summary
description: What you want, in a sentence. description: What you want, in a sentence.
placeholder: Add a run toggle so you can move at bike speed without the bike placeholder: Add Linux AppImage releases next to the macOS and Windows builds
validations: validations:
required: true required: true
- type: input
id: discord
attributes:
label: Discord username (optional)
description: >
So maintainers can ping you on Discord if they need a quick follow-up.
Leave blank if you'd rather keep everything on GitHub.
placeholder: yourname
validations:
required: false
- type: textarea - type: textarea
id: what id: what
attributes: attributes:
label: What do you want label: What do you want
description: > description: >
Describe it properly. What is it, where does it live in the game, what does the Describe it properly. What is it, where does it live (launcher, options,
player see or do. If it changes something that already exists, say what it does today engine), what does the player see or do. If it changes something that already
and what it should do instead. exists, say what it does today and what it should do instead.
placeholder: | placeholder: |
A hold-to-run button, the way later Pokemon games do it. Hold B while moving on Ship a Linux AppImage on each release, same version as the macOS/Windows builds,
foot and the player moves at bike speed. Release and you go back to walking. with the same save folder layout and mod discovery path.
Doesn't work indoors where the bike doesn't work, doesn't work in battles or menus.
validations: validations:
required: true required: true
@@ -41,9 +59,8 @@ body:
What's annoying or missing right now. What does this fix. If it's just because you What's annoying or missing right now. What does this fix. If it's just because you
think it would be fun, say that, it's a real answer. think it would be fun, say that, it's a real answer.
placeholder: | placeholder: |
Backtracking across routes you've already cleared is slow, and the bike is a menu LÖVE already runs on Linux; without a packaged build, players have to assemble
dive away and doesn't work in a lot of places. Running would cut a lot of dead time it themselves and miss release notes / update checks.
without making the game easier.
validations: validations:
required: true required: true
@@ -52,14 +69,12 @@ body:
attributes: attributes:
label: How should it work label: How should it work
description: > description: >
The specifics. Which button, which menu, what happens in the edge cases. If you don't The specifics. Which menu, what happens in the edge cases. If you don't
know, say what you'd expect as a player and leave the rest open. know, say what you'd expect as a player and leave the rest open.
placeholder: | placeholder: |
- Hold B on the overworld to run - GitHub Releases asset next to the .dmg / .exe
- Same speed as the bike - Same options.lua / mods/ layout as desktop
- Disabled anywhere the bike is disabled - Documented in the README install section
- Sprite should probably need a running animation, not sure what that takes
- No effect on encounter rates
validations: validations:
required: true required: true
+115
View File
@@ -0,0 +1,115 @@
name: Mod request
description: Ask for a gameplay, cosmetic, audio, or QoL change that belongs as a Lua mod.
labels: ["mod request"]
body:
- type: markdown
attributes:
value: |
This tracker is for ideas that should ship as **mods**, not as core engine
features — alternate sprites, running shoes, day/night, shiny indicators,
soundtrack packs, Gen 2-like battle toggles, map cosmetics, bag QoL, etc.
The engine already exposes a lot of this through registries and hooks
([modding wiki](https://github.com/bryanthaboi/pokemon-gen1-recomp-project/wiki)).
Use a **Feature request** instead for launcher / ports / video options /
networking / save tooling / new API seams.
- type: input
id: summary
attributes:
label: One line summary
description: What the mod should do, in a sentence.
placeholder: Hold B to run at bike speed on the overworld
validations:
required: true
- type: input
id: discord
attributes:
label: Discord username (optional)
description: >
So maintainers or mod authors can ping you on Discord if they pick this up.
Leave blank if you'd rather keep everything on GitHub.
placeholder: yourname
validations:
required: false
- type: textarea
id: what
attributes:
label: What should the mod do
description: >
Describe the player-facing behavior. What changes, where, what does the
player see or press. If it toggles from Options or a START-menu entry, say so.
placeholder: |
Hold B while walking outdoors to move at bike speed. Release to walk again.
Same places the bike is allowed; no effect in battles or menus.
validations:
required: true
- type: textarea
id: why
attributes:
label: Why is this worth doing as a mod
description: >
Why optional/modded rather than a core option. Who wants it on, who wants
vanilla left alone.
placeholder: |
Great for replaying and backtracking, but some people want a strict Gen 1
pace. A mod (or an opt-in mod option) keeps both camps happy.
validations:
required: true
- type: textarea
id: how
attributes:
label: How should it work
description: >
Buttons, menus, edge cases, whether it needs new art/audio. If you know a
hook or registry that fits (movement.speed, pokemon.sprite, rulesets, …),
mention it — otherwise leave it open.
placeholder: |
- Hold B on the overworld
- Same step timing as the bike
- Disabled where the bike is disabled
- Prefer hooks:wrap("movement.speed") if that still fits
validations:
required: true
- type: dropdown
id: vanilla
attributes:
label: With the mod off, is vanilla unchanged
options:
- Yes — parity when disabled
- No — it would replace something always-on
- Not sure
validations:
required: true
- type: dropdown
id: category
attributes:
label: Best-fit mod category
description: Same taxonomy as example mods (BALANCE, GRAPHICS, AUDIO, …).
options:
- GAMEPLAY / QoL
- GRAPHICS
- AUDIO
- BALANCE / ruleset
- CONTENT (maps, encounters, trainers)
- UI / TOOL
- TOTAL_CONVERSION-ish
- Not sure
validations:
required: true
- type: textarea
id: extra
attributes:
label: Anything else
description: >
Reference screenshots, other games/hacks that do it, related issues, or
"I'd like to try writing this myself." Leave blank if nothing comes to mind.
validations:
required: false
+3 -1
View File
@@ -14,7 +14,9 @@ name: ci
on: on:
push: push:
branches: [main] # Integration branch + release branch. PRs already run via pull_request
# (any base); this list is only for post-merge push runs.
branches: [dev, main]
pull_request: pull_request:
# a force-push while CI is mid-run should cancel the stale run, not queue # a force-push while CI is mid-run should cancel the stale run, not queue
+3
View File
@@ -10,6 +10,9 @@ name: Release
# - To force a specific version, either: # - To force a specific version, either:
# * run this workflow manually (Actions tab) and type it into "version", or # * run this workflow manually (Actions tab) and type it into "version", or
# * put "[release X.Y.Z]" anywhere in the commit message. # * put "[release X.Y.Z]" anywhere in the commit message.
#
# Branch model: day-to-day work merges to `dev`. Releases stay on `main` only
# so promoting `dev` -> `main` is the ship gate that cuts a build.
on: on:
push: push:
+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 -- freely to portrait or landscape), otherwise locked to the window's w/h
-- aspect. So a non-resizable tall window forced portrait; resizable lets -- aspect. So a non-resizable tall window forced portrait; resizable lets
-- the game follow the device. The renderer letterboxes the 160x144 -- the game follow the device. The renderer letterboxes the 160x144
-- viewport into whatever size results, and touch input is gesture-based, -- viewport into whatever size results, and the on-screen touch controls
-- so both orientations just work. iOS follows the Info.plist orientations -- 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). -- (see mobile/ios/overlays/love-ios.plist, now portrait + landscape).
t.window.resizable = true t.window.resizable = true
-- Starting size is a tall portrait hint; the OS resizes to the real -- 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 -- pokered/scripts/PewterNidoranHouse.asm
-- PewterNidoranHouseNidoranText: text_far _PewterNidoranHouseNidoranText, then -- PewterNidoranHouseNidoranText: text_far _PewterNidoranHouseNidoranText, then
-- text_asm plays the NIDORAN_M cry (PlayCry/WaitForSoundToFinish) before ending. -- text_asm plays the NIDORAN_M cry (PlayCry/WaitForSoundToFinish) before ending.
-- No sound-effect command exists in Commands.lua, so we port the dialogue line -- play_cry only stashes ctx.pendingCry for the very next show_text row, so it
-- only; the cry SFX is cosmetic and has no gameplay effect. -- has to sit immediately before that row (src/script/Commands.lua); the cry
-- then sounds once the box has typed out, matching the asm's print-then-cry
-- order. Its waitForButton form keeps DisplayTextID's trailing
-- WaitForTextScrollButtonPress -- PewterNidoranHouse_Script is
-- `jp EnableAutoTextBoxDrawing`, which zeroes
-- wDoNotWaitForButtonPressAfterDisplayingText -- so the box still holds until
-- A/B. The cry row was missing, leaving the NIDORAN silent (#247).
return { return {
PEWTER_NIDORAN_HOUSE = { PEWTER_NIDORAN_HOUSE = {
talk = { talk = {
TEXT_PEWTERNIDORANHOUSE_NIDORAN = { TEXT_PEWTERNIDORANHOUSE_NIDORAN = {
{ "face_player" }, { "face_player" },
-- text_asm: ld a, NIDORAN_M / call PlayCry
{ "play_cry", "NIDORAN_M", true },
{ "show_text", "_PewterNidoranHouseNidoranText" }, { "show_text", "_PewterNidoranHouseNidoranText" },
}, },
}, },
+7 -3
View File
@@ -59,13 +59,17 @@ M.VIRIDIAN_CITY = {
-- ViridianCityYoungster2CaterpieAndWeedleDescriptionText are -- ViridianCityYoungster2CaterpieAndWeedleDescriptionText are
-- defined without a leading underscore in pokered/text/ViridianCity.asm -- defined without a leading underscore in pokered/text/ViridianCity.asm
-- and aren't present in data/generated/text.lua, so we fall back to -- and aren't present in data/generated/text.lua, so we fall back to
-- the literal strings from pokered. -- the literal strings from pokered. Those fallbacks have to carry the
-- extractor's markers, not plain newlines: line -> \n, cont -> \v,
-- para -> \f. Spelling cont/para as \n and \n\n put all six lines on
-- one page with nothing to wait on, so the whole speech scrolled past
-- without a button press (#250).
TEXT_VIRIDIANCITY_YOUNGSTER2 = function(game, ow, npc, done) TEXT_VIRIDIANCITY_YOUNGSTER2 = function(game, ow, npc, done)
local t = text(game) local t = text(game)
ask(game, t._ViridianCityYoungster2YouWantToKnowAboutText ask(game, t._ViridianCityYoungster2YouWantToKnowAboutText
or "You want to know\nabout the 2 kinds\nof caterpillar\nPOKéMON?", function(yes) or "You want to know\nabout the 2 kinds\vof caterpillar\vPOKéMON?", function(yes)
if yes then if yes then
push(game, "CATERPIE has no\npoison, but\nWEEDLE does.\n\nWatch out for its\nPOISON STING!", done) push(game, "CATERPIE has no\npoison, but\vWEEDLE does.\fWatch out for its\nPOISON STING!", done)
else else
push(game, "Oh, OK then!", done) push(game, "Oh, OK then!", done)
end end
@@ -3,12 +3,16 @@
return { return {
VIRIDIAN_NICKNAME_HOUSE = { VIRIDIAN_NICKNAME_HOUSE = {
talk = { talk = {
-- ViridianNicknameHouseSpearowText: text_asm that shows the text -- ViridianNicknameHouseSpearowText: text_asm that PrintTexts the
-- then plays a SPEAROW cry (PlayCry/WaitForSoundToFinish). The -- line, then plays the SPEAROW cry (PlayCry/WaitForSoundToFinish)
-- cry playback isn't in the talk-script command vocabulary, so -- before TextScriptEnd. play_cry only arms the show_text row that
-- only the flavor line is ported here. -- follows it, so the cry sounds once the box has typed out; its
-- waitForButton form keeps DisplayTextID's trailing
-- WaitForTextScrollButtonPress, so the box then holds until A/B like
-- any other NPC line rather than popping itself (#251).
TEXT_VIRIDIANNICKNAMEHOUSE_SPEAROW = { TEXT_VIRIDIANNICKNAMEHOUSE_SPEAROW = {
{ "show_text", "_ViridianNicknameHouseSpearowText" }, { "play_cry", "SPEAROW", true }, -- 1 PlayCry
{ "show_text", "_ViridianNicknameHouseSpearowText" }, -- 2 PrintText
}, },
}, },
}, },
+59 -34
View File
@@ -6,7 +6,8 @@
-- takes it ("I'll take this one, then!") and both balls disappear. -- takes it ("I'll take this one, then!") and both balls disappear.
-- Source: scripts/OaksLab.asm OaksLabCharmanderPokeBallText / -- Source: scripts/OaksLab.asm OaksLabCharmanderPokeBallText /
-- OaksLabRivalTakePokeBallScript. -- 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 -- starter -> taunt + battle OPP_RIVAL1 with the counter-pick party
-- (player Bulbasaur -> rival Charmander etc., parties 1/2/3 = -- (player Bulbasaur -> rival Charmander etc., parties 1/2/3 =
-- Squirtle/Bulbasaur/Charmander in data/trainers/parties.asm); -- Squirtle/Bulbasaur/Charmander in data/trainers/parties.asm);
@@ -162,36 +163,45 @@ return {
starterBall("_OaksLabYouWantBulbasaurText", "BULBASAUR", "EVENT_CHOSE_BULBASAUR", starterBall("_OaksLabYouWantBulbasaurText", "BULBASAUR", "EVENT_CHOSE_BULBASAUR",
"OAKSLAB_BULBASAUR_POKE_BALL", 6, "OAKSLAB_CHARMANDER_POKE_BALL"), "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 = { TEXT_OAKSLAB_RIVAL = {
{ "face_player" }, -- 1 { "face_player" },
{ "check_flag", "EVENT_GOT_STARTER" }, -- 2 { "check_flag", "EVENT_GOT_STARTER" },
{ "jump_if_false", 21 }, -- 3 { "jump_if_false", "pre_starter" },
{ "check_flag", "EVENT_BATTLED_RIVAL_IN_OAKS_LAB" }, -- 4 { "check_flag", "EVENT_BATTLED_RIVAL_IN_OAKS_LAB" },
{ "jump_if_true", 19 }, -- 5 { "jump_if_true", "after_battle" },
{ "show_text", "_OaksLabRivalMyPokemonLooksStrongerText" }, -- 6 -- has a starter, has not fought yet: just the taunt. The battle is
{ "check_flag", "EVENT_CHOSE_BULBASAUR" }, -- 7 -- the coordinate trigger in onStep, not this talk (#219)
{ "jump_if_false", 11 }, -- 8 { "show_text", "_OaksLabRivalMyPokemonLooksStrongerText" },
{ "start_battle", "trainer", "OPP_RIVAL1", 3 }, -- 9 Charmander { "jump", "end" },
{ "jump", 16 }, -- 10
{ "check_flag", "EVENT_CHOSE_SQUIRTLE" }, -- 11 { "label", "after_battle" },
{ "jump_if_false", 15 }, -- 12 { "show_text", "_OaksLabRivalFedUpWithWaitingText" },
{ "start_battle", "trainer", "OPP_RIVAL1", 2 }, -- 13 Bulbasaur { "jump", "end" },
{ "jump", 16 }, -- 14
{ "start_battle", "trainer", "OPP_RIVAL1", 1 }, -- 15 Squirtle -- pre-starter fork (scripts/OaksLab.asm OaksLabText8 rival handler):
-- OaksLabRivalEndBattleScript: HealParty + flag, then exit either way -- Oak has already escorted you in (three balls on the table) -> he
{ "heal_party" }, -- 16 -- waves you on to choose; not yet escorted (very early game) -> the
{ "set_flag", "EVENT_BATTLED_RIVAL_IN_OAKS_LAB" }, -- 17 -- "Gramps isn't around" line. #218
{ "jump", 23 }, -- 18 { "label", "pre_starter" },
{ "show_text", "_OaksLabRivalFedUpWithWaitingText" }, -- 19 { "check_flag", "EVENT_FOLLOWED_OAK_INTO_LAB" },
{ "jump", "end" }, -- 20 { "jump_if_false", "gramps_gone" },
{ "show_text", "_OaksLabRivalGrampsIsntAroundText" }, -- 21 { "show_text", "_OaksLabRivalGoAheadAndChooseText" },
{ "jump", "end" }, -- 22 { "jump", "end" },
-- win: sulk text then exit; loss: Rival1WinText already played in
-- battle (HandlePlayerBlackOut), so skip straight to the walk-out { "label", "gramps_gone" },
{ "jump_if_false", 25 }, -- 23 { "show_text", "_OaksLabRivalGrampsIsntAroundText" },
{ "show_text", "_OaksLabRivalIPickedTheWrongPokemonText" }, -- 24
{ "move_npc_to", 1, 4, 11 }, -- 25
{ "hide_object", "OAKS_LAB", "OAKSLAB_RIVAL" }, -- 26
}, },
}, },
@@ -213,9 +223,16 @@ return {
-- OaksLabScript8 / OaksLabRivalChallenge) -- OaksLabScript8 / OaksLabRivalChallenge)
onStep = function(game, ow, x, y) onStep = function(game, ow, x, y)
local flags = game.save.flags 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 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({ ow.runner:run({
{ "show_text", "_OaksLabOakDontGoAwayYetText" }, { "show_text", "_OaksLabOakDontGoAwayYetText" },
{ "move_player", "up", 1 }, { "move_player", "up", 1 },
@@ -254,10 +271,18 @@ return {
-- OaksLabRivalEndBattleScript: heal + flag on win or loss; no blackout -- OaksLabRivalEndBattleScript: heal + flag on win or loss; no blackout
table.insert(rows, { "heal_party" }) table.insert(rows, { "heal_party" })
table.insert(rows, { "set_flag", "EVENT_BATTLED_RIVAL_IN_OAKS_LAB" }) table.insert(rows, { "set_flag", "EVENT_BATTLED_RIVAL_IN_OAKS_LAB" })
-- win: sulk text then exit; loss jumps to the walk-out (taunt was -- OaksLabRivalEndBattleScript: on WIN, print the "picked the wrong
-- already shown in-battle via Rival1WinText) -- 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, { "jump_if_false", base + 6 })
table.insert(rows, { "show_text", "_OaksLabRivalIPickedTheWrongPokemonText" }) table.insert(rows, { "show_text", "_OaksLabRivalIPickedTheWrongPokemonText" })
table.insert(rows, { "show_text", "_OaksLabRivalSmellYouLaterText" })
table.insert(rows, { "move_npc_to", 1, 4, 11 }) table.insert(rows, { "move_npc_to", 1, 4, 11 })
table.insert(rows, { "hide_object", "OAKS_LAB", "OAKSLAB_RIVAL" }) table.insert(rows, { "hide_object", "OAKS_LAB", "OAKSLAB_RIVAL" })
ow.runner:run(rows, { npc = 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 -- StartSimulatingJoypadStates walks the player to the counter
-- (.PlayerMovement: PAD_LEFT 1, PAD_UP 2, door (3,7) -> counter (2,5)) -- (.PlayerMovement: PAD_LEFT 1, PAD_UP 2, door (3,7) -> counter (2,5))
-- and ViridianMartOaksParcelScript hands the parcel over. The player -- and ViridianMartOaksParcelScript hands the parcel over. The player
-- never presses A.
--
-- The RLE list is consumed back to front: DecodeRLEList fills forward
-- from wSimulatedJoypadStatesEnd, the script seeds wSimulatedJoypadStates-
-- Index with the count, and home/overworld.asm decrements that index
-- before each read. So PAD_LEFT 1, PAD_UP 2 actually plays up, up, left,
-- and the walk ends facing the clerk. Replaying the list front to back
-- put the player at the counter facing up instead (#235). The player
-- never presses A. This matters beyond convenience: the parcel gates -- never presses A. This matters beyond convenience: the parcel gates
-- Oak's Pokedex and the old man clearing Route 2, so vanilla guarantees -- Oak's Pokedex and the old man clearing Route 2, so vanilla guarantees
-- it on entry rather than letting you walk out without it. -- it on entry rather than letting you walk out without it.
@@ -28,8 +36,8 @@ M.VIRIDIAN_MART = {
if not f.EVENT_GOT_STARTER then return end if not f.EVENT_GOT_STARTER then return end
ow:queueScript({ ow:queueScript({
{ "show_text", "_ViridianMartClerkYouCameFromPalletTownText" }, { "show_text", "_ViridianMartClerkYouCameFromPalletTownText" },
{ "move_player", "left", 1 },
{ "move_player", "up", 2 }, { "move_player", "up", 2 },
{ "move_player", "left", 1 },
-- the quest text's last page is "{PLAYER} got\nOAK's PARCEL!" -- the quest text's last page is "{PLAYER} got\nOAK's PARCEL!"
{ "give_item", "OAKS_PARCEL", 1, "_ViridianMartClerkParcelQuestText" }, { "give_item", "OAKS_PARCEL", 1, "_ViridianMartClerkParcelQuestText" },
{ "set_flag", "EVENT_GOT_OAKS_PARCEL" }, { "set_flag", "EVENT_GOT_OAKS_PARCEL" },
@@ -395,7 +403,93 @@ M.SS_ANNE_CAPTAINS_ROOM = {
-- MrFujisHouse.asm; the teleport back to his house is a warp) -- 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 = { 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 = { talk = {
TEXT_POKEMONTOWER7F_MR_FUJI = { TEXT_POKEMONTOWER7F_MR_FUJI = {
{ "face_player" }, -- 1 { "face_player" }, -- 1
+4 -2
View File
@@ -528,7 +528,9 @@ M.MT_MOON_B2F = {
} }
-- The ticket clerk (scripts/Museum1F.asm Museum1FScientist1Text): -- 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 function museumClerk(game, ow, done, onDecline)
local TextBox = require("src.render.TextBox") local TextBox = require("src.render.TextBox")
local ChoiceBox = require("src.ui.ChoiceBox") local ChoiceBox = require("src.ui.ChoiceBox")
@@ -563,7 +565,7 @@ M.MUSEUM_1F = {
if y == 4 and (x == 9 or x == 10) if y == 4 and (x == 9 or x == 10)
and not game.save.flags.EVENT_BOUGHT_MUSEUM_TICKET then and not game.save.flags.EVENT_BOUGHT_MUSEUM_TICKET then
museumClerk(game, ow, nil, function() museumClerk(game, ow, nil, function()
ow:scriptMove(ow.player, "right", 1) ow:scriptMove(ow.player, "down", 1)
end) end)
return true return true
end end
+32 -1
View File
@@ -524,8 +524,17 @@ M.GAME_CORNER = {
game.data.text._GameCornerRocketAfterBattleText game.data.text._GameCornerRocketAfterBattleText
or "Our hideout might\nbe discovered! I\nbetter tell BOSS!", or "Our hideout might\nbe discovered! I\nbetter tell BOSS!",
function() function()
-- #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() hideRocket()
done() done()
end)
end)) end))
end) end)
end, end,
@@ -606,9 +615,29 @@ local function activePrizes()
return require("src.core.GameVersion").isBlue() and BLUE_PRIZES or RED_PRIZES return require("src.core.GameVersion").isBlue() and BLUE_PRIZES or RED_PRIZES
end 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 function prizeCounter(game, ow, npc, done)
local ListMenu = require("src.ui.ListMenu") local ListMenu = require("src.ui.ListMenu")
local Commands = require("src.script.Commands") local Commands = require("src.script.Commands")
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
-- 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 = {} local items = {}
for _, p in ipairs(activePrizes()) do for _, p in ipairs(activePrizes()) do
local label local label
@@ -617,7 +646,8 @@ local function prizeCounter(game, ow, npc, done)
else else
label = game.data.items[p.item].name label = game.data.items[p.item].name
end end
table.insert(items, { label = label, right = tostring(p.cost), value = p }) table.insert(items,
{ label = label, right = tostring(p.cost), value = p })
end end
local list local list
list = ListMenu.new(game, "PRIZES (COINS)", items, { list = ListMenu.new(game, "PRIZES (COINS)", items, {
@@ -640,6 +670,7 @@ local function prizeCounter(game, ow, npc, done)
onCancel = done, onCancel = done,
}) })
game.stack:push(list) game.stack:push(list)
end))
end end
M.GAME_CORNER_PRIZE_ROOM = { 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) -- 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) local function dojoBall(species, ownBall, otherBall, askKey)
return function(game, ow, npc, done) return function(game, ow, npc, done)
local t = text(game) local t = text(game)
local flags = game.save.flags 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 if flags.EVENT_GOT_HITMONLEE or flags.EVENT_GOT_HITMONCHAN then
done() push(game, t._FightingDojoBetterNotGetGreedyText
or "Better not get\ngreedy...", done)
return return
end end
if not flags.EVENT_BEAT_KARATE_MASTER then 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 Commands = require("src.script.Commands")
local ctx = { save = game.save, game = game, overworld = ow } local ctx = { save = game.save, game = game, overworld = ow }
Commands.give_pokemon(ctx, species, 30) 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", ownBall)
Commands.hide_object(ctx, "FIGHTING_DOJO", otherBall)
push(game, ("%s got\n%s!"):format(game.save.player.name, species), done) push(game, ("%s got\n%s!"):format(game.save.player.name, species), done)
end) end)
end end
@@ -171,6 +180,21 @@ M.FIGHTING_DOJO = {
"FIGHTINGDOJO_HITMONLEE_POKE_BALL", "FIGHTINGDOJO_HITMONLEE_POKE_BALL",
"_FightingDojoHitmonchanPokeBallText"), "_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 return false
end 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) local function stepGate(opts)
return function(game, ow, x, y) return function(game, ow, x, y)
if not inCoords(opts.coords, x, y) then return false end if not inCoords(opts.coords, x, y) then return false end
if not opts.blocked(game) then return false end if not opts.blocked(game) then return false end
require("src.core.Sound").play(game.data, "Denied") require("src.core.Sound").play(game.data, "Denied")
push(game, text(game)[opts.text] or opts.fallback, push(game, text(game)[opts.text] or opts.fallback, function()
function() ow:scriptMove(ow.player, opts.push, 1) end) ow.player.facing = opts.push
if not ow:checkLedgeHop(opts.push) then
ow:scriptMove(ow.player, opts.push, 1)
end
end)
return true return true
end end
end end
+5 -2
View File
@@ -114,9 +114,12 @@ return {
-- Fighting Dojo Karate Master (scripts/FightingDojo.asm -- Fighting Dojo Karate Master (scripts/FightingDojo.asm
-- FightingDojoKarateMasterPostBattleScript sets EVENT_BEAT_KARATE_MASTER, -- FightingDojoKarateMasterPostBattleScript sets EVENT_BEAT_KARATE_MASTER,
-- which gates the HITMONLEE/HITMONCHAN gift). OPP_BLACKBELT party 1 is -- 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", ["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 -- Elite Four progress flags (their rooms' door logic isn't ported, but
-- the flags make the Hall of Fame checkable) -- the flags make the Hall of Fame checkable)
+46 -14
View File
@@ -5,19 +5,39 @@ that runs before `Game:load`. Besides ROM import (see the file's own header)
it hosts a tabbed shell covering per-game save slots and a mod manager. This it hosts a tabbed shell covering per-game save slots and a mod manager. This
file documents the runtime model; the visual spec lives separately. file documents the runtime model; the visual spec lives separately.
## Android multi-ROM import ## Android multi-ROM / mod / save import
On Android, `love.system.pickFile()` opens the Storage Access Framework On Android, `love.system.pickFile([kind])` opens the Storage Access Framework
picker (`GameActivity.showRomFilePicker`); the chosen file is copied to picker (`GameActivity.showFilePicker`); the chosen file is copied into the app
`picked_rom.gb` in the app save directory. `RomImporter` then imports on save directory as:
refocus / Choose via `findPendingRom`: only a 1 MiB `.gb` whose SHA-1 maps
to a version that is **not** yet ready counts as pending. A leftover | `kind` | Destination |
| --- | --- |
| 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). `picked_rom.gb` from Red therefore cannot block Blue's Choose (issue #167).
After a successful import the consumed save-dir `.gb` is removed. - **Mods** via `findPendingMod`: Prefer `picked_mod.zip`, or (on Choose) any
other `.zip` at the save-dir root (USB copy).
- **Saves** via `findPendingSav`: Prefer `picked_save.sav`, or (on Choose) any
other `.sav` at the save-dir root.
After a successful import the consumed save-dir file is removed.
**Manual check (device/emulator):** import Red → switch to Blue → Choose → **Manual check (device/emulator):** import Red → switch to Blue → Choose →
system file picker must appear (not a silent Red re-extract) → pick Blue → system file picker must appear (not a silent Red re-extract) → pick Blue →
Blue becomes ready beside Red. Blue becomes ready beside Red. On the MODS tab, Import mod .zip must open the
same system picker and install the chosen archive on return.
## Tab structure ## Tab structure
@@ -78,6 +98,10 @@ The launcher-facing API:
- `SaveData.createSlot(version)` -> new slot id, registered but with **no - `SaveData.createSlot(version)` -> new slot id, registered but with **no
save file written**. An empty slot means the title screen offers NEW GAME save file written**. An empty slot means the title screen offers NEW GAME
only, which needs no further changes. only, which needs no further changes.
- `SaveData.deleteSlot(version, slotId)` removes the slot's
main/`.bak`/`.tmp` files, drops it from the registry, and if it was active
points active at another remaining slot (or clears active when the list is
empty). The launcher's SAVE SLOT panel Delete control calls this.
## Launcher mod manager ## Launcher mod manager
@@ -114,6 +138,9 @@ before `Game:load`, so **it never loads a mod's entry chunk**; only
temp first (mount only reaches save-dir-relative paths), the same way temp first (mount only reaches save-dir-relative paths), the same way
`RomImporter` handles a dropped ROM. A failed copy rolls its partial tree `RomImporter` handles a dropped ROM. A failed copy rolls its partial tree
back, and every path unmounts and clears the staged temp file. back, and every path unmounts and clears the staged temp file.
- `LauncherMods.uninstall(id)` removes `mods/<id>/` and clears
`options.mods[id]` so a later reinstall starts from the loader's default
(enabled). The mods panel Delete control calls this and re-derives the list.
## Import / Export save ## Import / Export save
@@ -122,9 +149,10 @@ through `src/import/SaveFileIO.lua`, which sits on top of
`src/save_convert/SaveConvert.lua` and the slot API in `SaveData`. `src/save_convert/SaveConvert.lua` and the slot API in `SaveData`.
- **Import save** is live once the game's ROM is imported (playable). It opens - **Import save** is live once the game's ROM is imported (playable). It opens
a native `.sav` picker (`chooseSav`, the per-OS dialogs mirror `chooseZip`; a native `.sav` picker (`chooseSav` on desktop; on Android,
Android has no picker and shows a drop hint). `SaveFileIO.importToSlot` `love.system.pickFile("sav")``picked_save.sav`, same SAF path as ROMs).
reads the bytes (an absolute path, a dropped LOVE file, or raw bytes), `SaveFileIO.importToSlot` reads the bytes (an absolute path, a save-dir
relative name, a dropped LOVE file, or raw bytes),
guards the 32768-byte size, runs `SaveConvert.importSav` (which also rejects guards the 32768-byte size, runs `SaveConvert.importSav` (which also rejects
a bad main-data checksum), then registers a fresh slot (`SaveData.createSlot`), a bad main-data checksum), then registers a fresh slot (`SaveData.createSlot`),
writes it (`SaveData.writeSlot`), and makes it active (`SaveData.setActiveSlot`). writes it (`SaveData.writeSlot`), and makes it active (`SaveData.setActiveSlot`).
@@ -136,9 +164,13 @@ through `src/import/SaveFileIO.lua`, which sits on top of
slot, encodes it back with `SaveConvert.exportSav` (a slot never keeps slot, encodes it back with `SaveConvert.exportSav` (a slot never keeps
`rawImport`, so this is a zero-filled template export, which is valid), and `rawImport`, so this is a zero-filled template export, which is valid), and
writes `exports/gen1recomp-<version>-<slotId>.sav` in the save directory writes `exports/gen1recomp-<version>-<slotId>.sav` in the save directory
(`love.filesystem.createDirectory("exports")`). It returns the absolute path (`love.filesystem.createDirectory("exports")`). On desktop it returns the
(`love.filesystem.getSaveDirectory()`), which the notice line shows with a absolute path (`love.filesystem.getSaveDirectory()`), which the notice line
desktop "Open folder" affordance (`love.system.openURL("file://" .. dir)`). shows with an "Open folder" affordance (`love.system.openURL("file://" .. dir)`).
On Android the bytes are also staged as `pending_export.sav` and
`love.system.createFile(suggestedName)` opens `ACTION_CREATE_DOCUMENT` so the
player can save to Downloads / Drive / etc.; on return `export_done.flag`
makes focus show "Save exported."
- **Drag-drop.** `filedropped` routes a `.sav` to the import path for the - **Drag-drop.** `filedropped` routes a `.sav` to the import path for the
currently active game tab; when a non-game tab (mods, or the locked yellow currently active game tab; when a non-game tab (mods, or the locked yellow
placeholder) is showing it defaults to red, the always-present first game placeholder) is showing it defaults to red, the always-present first game
+48
View File
@@ -180,3 +180,51 @@ 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, 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 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. -- bot or screenshot run is not at the mercy of the player's last choice.
local speedOverride = tonumber(os.getenv("POKEPORT_SPEED")) 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 -- How many times to run a scripted act+step loop per rendered frame. Only
-- scripted runs use this; interactive play fast-forwards through -- scripted runs use this; interactive play fast-forwards through
-- Game.speedOverride / the GAME SPEED option instead. -- Game.speedOverride / the GAME SPEED option instead.
@@ -274,6 +279,9 @@ function love.mousepressed(x, y, button)
if editorMode and EditorApp.mousepressed then if editorMode and EditorApp.mousepressed then
return EditorApp.mousepressed(x, y, button) return EditorApp.mousepressed(x, y, button)
end end
if mouseTouch and Game and button == 1 then
Game:touchpressed("mouse", x, y)
end
end end
function love.mousereleased(x, y, button) function love.mousereleased(x, y, button)
@@ -281,6 +289,16 @@ function love.mousereleased(x, y, button)
if editorMode and EditorApp.mousereleased then if editorMode and EditorApp.mousereleased then
return EditorApp.mousereleased(x, y, button) return EditorApp.mousereleased(x, y, button)
end 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 end
function love.textinput(text) 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 `assets/generated/`, and any ROM. It contains the first-boot Lua importer and
`tools/rom_manifest.json`. `tools/rom_manifest.json`.
ROM import on Android uses `love.system.pickFile()` ROM / mod / save import on Android uses `love.system.pickFile([kind])`
`GameActivity.showRomFilePicker` (Storage Access Framework), which copies the `GameActivity.showFilePicker` (Storage Access Framework), which copies the
chosen file to `picked_rom.gb` under the app save directory. `RomImporter` chosen file under the app save directory as `picked_rom.gb`,
imports pending (not-yet-ready) `.gb` files from that folder on Choose / `picked_mod.zip`, or `picked_save.sav`. `RomImporter` imports pending files
refocus; see `docs/launcher.md` (Android multi-ROM import). The APK payload from that folder on Choose / refocus; see `docs/launcher.md`. The APK payload
itself remains data-free (no embedded ROM or generated cache). itself remains data-free (no embedded ROM or generated cache).
### SDK / NDK ### SDK / NDK
+1 -1
View File
@@ -18,4 +18,4 @@ android.useAndroidX=true
android.defaults.buildfeatures.buildconfig=true android.defaults.buildfeatures.buildconfig=true
android.nonTransitiveRClass=true android.nonTransitiveRClass=true
android.nonFinalResIds=true android.nonFinalResIds=true
app.name=Pokemon Red app.name=gen1recomp
@@ -183,13 +183,37 @@ void vibrate(double seconds)
env->DeleteLocalRef(activity); env->DeleteLocalRef(activity);
} }
bool showFilePicker() bool showFilePicker(const char *destFilename)
{ {
if (destFilename == nullptr || destFilename[0] == '\0')
destFilename = "picked_rom.gb";
JNIEnv *env = (JNIEnv*) SDL_AndroidGetJNIEnv(); JNIEnv *env = (JNIEnv*) SDL_AndroidGetJNIEnv();
jclass activity = env->FindClass("org/love2d/android/GameActivity"); jclass activity = env->FindClass("org/love2d/android/GameActivity");
jmethodID method = env->GetStaticMethodID(activity, "showRomFilePicker", "()Z"); jmethodID method = env->GetStaticMethodID(activity, "showFilePicker",
jboolean result = env->CallStaticBooleanMethod(activity, method); "(Ljava/lang/String;)Z");
jstring jname = env->NewStringUTF(destFilename);
jboolean result = env->CallStaticBooleanMethod(activity, method, jname);
env->DeleteLocalRef(jname);
env->DeleteLocalRef(activity);
return result;
}
bool showCreateDocument(const char *suggestedName)
{
if (suggestedName == nullptr || suggestedName[0] == '\0')
suggestedName = "export.sav";
JNIEnv *env = (JNIEnv*) SDL_AndroidGetJNIEnv();
jclass activity = env->FindClass("org/love2d/android/GameActivity");
jmethodID method = env->GetStaticMethodID(activity, "showCreateDocument",
"(Ljava/lang/String;)Z");
jstring jname = env->NewStringUTF(suggestedName);
jboolean result = env->CallStaticBooleanMethod(activity, method, jname);
env->DeleteLocalRef(jname);
env->DeleteLocalRef(activity); env->DeleteLocalRef(activity);
return result; return result;
@@ -63,9 +63,16 @@ void vibrate(double seconds);
* Shows the system's "pick a document" UI (Storage Access Framework). * Shows the system's "pick a document" UI (Storage Access Framework).
* Returns true if the picker was launched; the picked file (if any) is * Returns true if the picker was launched; the picked file (if any) is
* copied asynchronously by GameActivity.onActivityResult into the app's * copied asynchronously by GameActivity.onActivityResult into the app's
* external save directory, not returned here -- see src/import/RomImporter.lua. * external save directory under destFilename (default picked_rom.gb), not
* returned here -- see src/import/RomImporter.lua.
**/ **/
bool showFilePicker(); bool showFilePicker(const char *destFilename = nullptr);
/**
* Shows ACTION_CREATE_DOCUMENT so Lua can export a staged pending_export.sav
* to a user-chosen location. suggestedName is the dialog default filename.
**/
bool showCreateDocument(const char *suggestedName = nullptr);
/* /*
* Helper functions for the filesystem module * Helper functions for the filesystem module
@@ -22,6 +22,8 @@
#include "common/config.h" #include "common/config.h"
#include "System.h" #include "System.h"
#include <cstring>
#if defined(LOVE_MACOSX) #if defined(LOVE_MACOSX)
#include <CoreServices/CoreServices.h> #include <CoreServices/CoreServices.h>
#elif defined(LOVE_IOS) #elif defined(LOVE_IOS)
@@ -180,11 +182,32 @@ void System::vibrate(double seconds) const
#endif #endif
} }
bool System::pickFile() const bool System::pickFile(const char *kind) const
{ {
#ifdef LOVE_ANDROID #ifdef LOVE_ANDROID
return love::android::showFilePicker(); const char *dest = "picked_rom.gb";
if (kind != nullptr)
{
if (strcmp(kind, "mod") == 0)
dest = "picked_mod.zip";
else if (strcmp(kind, "sav") == 0 || strcmp(kind, "save") == 0)
dest = "picked_save.sav";
else if (strcmp(kind, "rom") == 0)
dest = "picked_rom.gb";
}
return love::android::showFilePicker(dest);
#else #else
LOVE_UNUSED(kind);
return false;
#endif
}
bool System::createFile(const char *suggestedName) const
{
#ifdef LOVE_ANDROID
return love::android::showCreateDocument(suggestedName);
#else
LOVE_UNUSED(suggestedName);
return false; return false;
#endif #endif
} }
@@ -111,9 +111,21 @@ public:
* Android only for now; the result (if any) is not returned here -- see * Android only for now; the result (if any) is not returned here -- see
* love::android::showFilePicker and src/import/RomImporter.lua. * love::android::showFilePicker and src/import/RomImporter.lua.
* *
* @param kind Optional pick kind: nullptr/"rom" -> picked_rom.gb,
* "mod" -> picked_mod.zip, "sav"/"save" -> picked_save.sav.
* @return Whether the picker was shown. * @return Whether the picker was shown.
**/ **/
virtual bool pickFile() const; virtual bool pickFile(const char *kind = nullptr) const;
/**
* Shows the platform's native "create / save a file" UI (Android SAF
* ACTION_CREATE_DOCUMENT). Copies staged pending_export.sav from the app
* save directory to the user-chosen URI. See GameActivity.showCreateDocument.
*
* @param suggestedName Default filename shown in the dialog.
* @return Whether the create dialog was shown.
**/
virtual bool createFile(const char *suggestedName = nullptr) const;
/** /**
* Gets if the user is playing music on background. * Gets if the user is playing music on background.
@@ -97,7 +97,15 @@ int w_vibrate(lua_State *L)
int w_pickFile(lua_State *L) int w_pickFile(lua_State *L)
{ {
luax_pushboolean(L, instance()->pickFile()); const char *kind = luaL_optstring(L, 1, nullptr);
luax_pushboolean(L, instance()->pickFile(kind));
return 1;
}
int w_createFile(lua_State *L)
{
const char *suggested = luaL_optstring(L, 1, nullptr);
luax_pushboolean(L, instance()->createFile(suggested));
return 1; return 1;
} }
@@ -117,6 +125,7 @@ static const luaL_Reg functions[] =
{ "openURL", w_openURL }, { "openURL", w_openURL },
{ "vibrate", w_vibrate }, { "vibrate", w_vibrate },
{ "pickFile", w_pickFile }, { "pickFile", w_pickFile },
{ "createFile", w_createFile },
{ "hasBackgroundMusic", w_hasBackgroundMusic }, { "hasBackgroundMusic", w_hasBackgroundMusic },
{ 0, 0 } { 0, 0 }
}; };
@@ -22,12 +22,15 @@ package org.love2d.android;
import org.libsdl.app.SDLActivity; import org.libsdl.app.SDLActivity;
import java.io.BufferedInputStream;
import java.io.BufferedOutputStream; import java.io.BufferedOutputStream;
import java.io.File; import java.io.File;
import java.io.FileInputStream;
import java.io.FileNotFoundException; import java.io.FileNotFoundException;
import java.io.FileOutputStream; import java.io.FileOutputStream;
import java.io.IOException; import java.io.IOException;
import java.io.InputStream; import java.io.InputStream;
import java.io.OutputStream;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.HashMap; import java.util.HashMap;
import java.util.List; import java.util.List;
@@ -61,13 +64,24 @@ public class GameActivity extends SDLActivity {
protected final int[] recordAudioRequestDummy = new int[1]; protected final int[] recordAudioRequestDummy = new int[1];
public static final int EXTERNAL_STORAGE_REQUEST_CODE = 2; public static final int EXTERNAL_STORAGE_REQUEST_CODE = 2;
public static final int RECORD_AUDIO_REQUEST_CODE = 3; public static final int RECORD_AUDIO_REQUEST_CODE = 3;
public static final int ROM_PICKER_REQUEST_CODE = 4; public static final int FILE_PICKER_REQUEST_CODE = 4;
// Mirrors conf.lua's t.identity ("pokemon-love2d"): where the picked ROM public static final int FILE_CREATE_REQUEST_CODE = 5;
/** @deprecated Prefer FILE_PICKER_REQUEST_CODE; kept for older call sites. */
public static final int ROM_PICKER_REQUEST_CODE = FILE_PICKER_REQUEST_CODE;
// Mirrors conf.lua's t.identity ("pokemon-love2d"): where the picked file
// is dropped so RomImporter's existing folder scan finds it -- see // is dropped so RomImporter's existing folder scan finds it -- see
// src/import/RomImporter.lua and Filesystem::setIdentity (sets Android's // src/import/RomImporter.lua and Filesystem::setIdentity (sets Android's
// save directory to getExternalFilesDir()/save/<identity>). // save directory to getExternalFilesDir()/save/<identity>).
private static final String ROM_SAVE_IDENTITY = "pokemon-love2d"; private static final String ROM_SAVE_IDENTITY = "pokemon-love2d";
private static final String PICKED_ROM_FILENAME = "picked_rom.gb"; private static final String PICKED_ROM_FILENAME = "picked_rom.gb";
private static final String PICKED_MOD_FILENAME = "picked_mod.zip";
private static final String PICKED_SAVE_FILENAME = "picked_save.sav";
private static final String PENDING_EXPORT_FILENAME = "pending_export.sav";
private static final String EXPORT_DONE_FILENAME = "export_done.flag";
// Destination basename for the in-flight SAF pick (set by showFilePicker).
private String pendingPickFilename = PICKED_ROM_FILENAME;
// Suggested download name for the in-flight SAF create (set by showCreateDocument).
private String pendingCreateSuggestedName = "export.sav";
private static boolean immersiveActive = false; private static boolean immersiveActive = false;
private static boolean needToCopyGameInArchive = false; private static boolean needToCopyGameInArchive = false;
private boolean storagePermissionUnnecessary = false; private boolean storagePermissionUnnecessary = false;
@@ -341,59 +355,185 @@ public class GameActivity extends SDLActivity {
/** /**
* Shows the system document picker (Storage Access Framework) so the * Shows the system document picker (Storage Access Framework) so the
* player can pick their ROM from anywhere (Downloads, Drive, etc.) * player can pick a ROM / mod / save from anywhere (Downloads, Drive,
* without needing to know where the app's external files folder is. * etc.) without needing to know where the app's external files folder
* Requires API 19+ (ACTION_OPEN_DOCUMENT); the picked file (if any) * is. Requires API 19+ (ACTION_OPEN_DOCUMENT); the picked file (if any)
* arrives later in onActivityResult, not synchronously here. * arrives later in onActivityResult, not synchronously here.
*
* @param destFilename basename under the app save identity (e.g.
* picked_rom.gb, picked_mod.zip, picked_save.sav)
*/ */
@Keep @Keep
public static boolean showRomFilePicker() { public static boolean showFilePicker(String destFilename) {
if (android.os.Build.VERSION.SDK_INT < 19) return false; if (android.os.Build.VERSION.SDK_INT < 19) return false;
GameActivity self = (GameActivity) mSingleton; GameActivity self = (GameActivity) mSingleton;
if (self == null) return false; if (self == null) return false;
if (destFilename == null || destFilename.length() == 0) {
destFilename = PICKED_ROM_FILENAME;
}
// Reject path separators so a hostile JNI caller cannot escape the
// save identity directory.
if (destFilename.indexOf('/') >= 0 || destFilename.indexOf('\\') >= 0) {
Log.d("GameActivity", "refusing unsafe picker dest: " + destFilename);
return false;
}
self.pendingPickFilename = destFilename;
Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT); Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT);
intent.addCategory(Intent.CATEGORY_OPENABLE); intent.addCategory(Intent.CATEGORY_OPENABLE);
intent.setType("*/*"); intent.setType("*/*");
try { try {
self.startActivityForResult(intent, ROM_PICKER_REQUEST_CODE); self.startActivityForResult(intent, FILE_PICKER_REQUEST_CODE);
return true; return true;
} catch (Exception e) { } catch (Exception e) {
Log.d("GameActivity", "could not open ROM file picker: " + e.getMessage()); Log.d("GameActivity", "could not open file picker: " + e.getMessage());
return false; return false;
} }
} }
/** ROM convenience wrapper; prefer showFilePicker with an explicit name. */
@Keep
public static boolean showRomFilePicker() {
return showFilePicker(PICKED_ROM_FILENAME);
}
/** Mod .zip convenience wrapper used by love.system.pickFile("mod"). */
@Keep
public static boolean showModFilePicker() {
return showFilePicker(PICKED_MOD_FILENAME);
}
/** Battery .sav convenience wrapper used by love.system.pickFile("sav"). */
@Keep
public static boolean showSaveFilePicker() {
return showFilePicker(PICKED_SAVE_FILENAME);
}
/**
* Shows ACTION_CREATE_DOCUMENT so the player can save a staged export
* (pending_export.sav in the app save identity) to Downloads / Drive /
* etc. Suggested name is the dialog's default filename.
*/
@Keep
public static boolean showCreateDocument(String suggestedName) {
if (android.os.Build.VERSION.SDK_INT < 19) return false;
GameActivity self = (GameActivity) mSingleton;
if (self == null) return false;
if (suggestedName == null || suggestedName.length() == 0) {
suggestedName = "export.sav";
}
if (suggestedName.indexOf('/') >= 0 || suggestedName.indexOf('\\') >= 0) {
Log.d("GameActivity", "refusing unsafe create name: " + suggestedName);
return false;
}
File source = new File(
new File(self.getExternalFilesDir(null), "save"),
ROM_SAVE_IDENTITY + "/" + PENDING_EXPORT_FILENAME);
if (!source.isFile()) {
Log.d("GameActivity", "no pending export at " + source);
return false;
}
self.pendingCreateSuggestedName = suggestedName;
Intent intent = new Intent(Intent.ACTION_CREATE_DOCUMENT);
intent.addCategory(Intent.CATEGORY_OPENABLE);
intent.setType("application/octet-stream");
intent.putExtra(Intent.EXTRA_TITLE, suggestedName);
try {
self.startActivityForResult(intent, FILE_CREATE_REQUEST_CODE);
return true;
} catch (Exception e) {
Log.d("GameActivity", "could not open create-document picker: " + e.getMessage());
return false;
}
}
private File saveIdentityDir() {
return new File(new File(getExternalFilesDir(null), "save"), ROM_SAVE_IDENTITY);
}
private boolean copyFileToUri(File source, Uri destUri) {
InputStream in = null;
OutputStream out = null;
try {
in = new BufferedInputStream(new FileInputStream(source));
out = getContentResolver().openOutputStream(destUri);
if (out == null) return false;
byte[] buf = new byte[8192];
int n;
while ((n = in.read(buf)) != -1) {
out.write(buf, 0, n);
}
out.flush();
return true;
} catch (IOException e) {
Log.d("GameActivity", "copy to URI failed: " + e.getMessage());
return false;
} finally {
try { if (in != null) in.close(); } catch (IOException ignored) {}
try { if (out != null) out.close(); } catch (IOException ignored) {}
}
}
@Override @Override
protected void onActivityResult(int requestCode, int resultCode, Intent data) { protected void onActivityResult(int requestCode, int resultCode, Intent data) {
super.onActivityResult(requestCode, resultCode, data); super.onActivityResult(requestCode, resultCode, data);
if (requestCode != ROM_PICKER_REQUEST_CODE) return; if (requestCode == FILE_CREATE_REQUEST_CODE) {
if (resultCode != RESULT_OK || data == null || data.getData() == null) { if (resultCode != RESULT_OK || data == null || data.getData() == null) {
Log.d("GameActivity", "ROM picker returned no file (cancelled?)"); 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", "file picker returned no file (cancelled?)");
return; return;
} }
Uri uri = data.getData(); Uri uri = data.getData();
File destDir = new File(new File(getExternalFilesDir(null), "save"), ROM_SAVE_IDENTITY); File destDir = saveIdentityDir();
if (!destDir.exists() && !destDir.mkdirs()) { if (!destDir.exists() && !destDir.mkdirs()) {
Log.d("GameActivity", "could not create " + destDir); Log.d("GameActivity", "could not create " + destDir);
return; return;
} }
File destFile = new File(destDir, PICKED_ROM_FILENAME); String destName = pendingPickFilename != null
? pendingPickFilename : PICKED_ROM_FILENAME;
File destFile = new File(destDir, destName);
InputStream source; InputStream source;
try { try {
source = getContentResolver().openInputStream(uri); source = getContentResolver().openInputStream(uri);
} catch (FileNotFoundException e) { } catch (FileNotFoundException e) {
Log.d("GameActivity", "could not open picked ROM: " + e.getMessage()); Log.d("GameActivity", "could not open picked file: " + e.getMessage());
return; return;
} }
if (source == null) { if (source == null) {
Log.d("GameActivity", "ContentResolver returned no stream for picked ROM"); Log.d("GameActivity", "ContentResolver returned no stream for picked file");
return; return;
} }
if (!copyAssetFile(source, destFile.getPath())) { if (!copyAssetFile(source, destFile.getPath())) {
Log.d("GameActivity", "could not copy picked ROM to " + destFile); Log.d("GameActivity", "could not copy picked file to " + destFile);
} }
} }
+5 -3
View File
@@ -1,6 +1,6 @@
# Example mod gallery # Example mod gallery
Seven reference mods, one per modder persona. Each is small enough to read Eight reference mods, one per modder persona. Each is small enough to read
in one sitting, exercises a different slice of the mod API, and is a real, in one sitting, exercises a different slice of the mod API, and is a real,
runnable, tested mod — not a snippet. runnable, tested mod — not a snippet.
@@ -16,6 +16,7 @@ Copy the one closest to what you want to build.
| 5 | [`example_weather`](example_weather) | Mechanic designer | `MECHANIC` | Rain that scales WATER and FIRE damage, behind a ruleset | | 5 | [`example_weather`](example_weather) | Mechanic designer | `MECHANIC` | Rain that scales WATER and FIRE damage, behind a ruleset |
| 6 | [`example_dexnav`](example_dexnav) | Tool builder | `TOOL` | A START-menu dex overlay with an inter-mod API | | 6 | [`example_dexnav`](example_dexnav) | Tool builder | `TOOL` | A START-menu dex overlay with an inter-mod API |
| 7 | [`example_mini_conversion`](example_mini_conversion) | TC team | `TOTAL_CONVERSION` | Sable Cove: one town, three species, one badge | | 7 | [`example_mini_conversion`](example_mini_conversion) | TC team | `TOTAL_CONVERSION` | Sable Cove: one town, three species, one badge |
| 8 | [`example_silly_oak`](example_silly_oak) | Intro author | `UI` | Extra Oak questions, sprite swaps, answers in `mod.save` |
## None of these load by default ## None of these load by default
@@ -46,7 +47,8 @@ and `field`, and exercises:
- both buses — `events:on` / `events:emit` and `hooks:wrap` — across - both buses — `events:on` / `events:emit` and `hooks:wrap` — across
`music.select`, `battle.damage`, `ui.start_menu.items`, `ui.options.rows`, `music.select`, `battle.damage`, `ui.start_menu.items`, `ui.options.rows`,
`battle.started`, `battle.turn_started`, `battle.ended`, `flag.changed`, `battle.started`, `battle.turn_started`, `battle.ended`, `flag.changed`,
`game.ready` and `assets.transformed` `game.ready`, `assets.transformed`, `intro.oak_speech.build`,
`intro.oak_speech.answered` and `intro.oak_speech.finished`
- `mod.save`, `mod.options`, `mod.exports`, `mod.commands`, `mod.ui`, - `mod.save`, `mod.options`, `mod.exports`, `mod.commands`, `mod.ui`,
`mod:read`, `mod.log` and `mod.path` `mod:read`, `mod.log` and `mod.path`
- asset transforms, the `trueColor` opt-out, script labels and `choice`, - asset transforms, the `trueColor` opt-out, script labels and `choice`,
@@ -66,7 +68,7 @@ mods/examples/<id>/
.modkitignore keeps the suite out of the distributed package .modkitignore keeps the suite out of the distributed package
``` ```
`tests/mod_examples_tests.lua` in the engine's own suite loads all seven `tests/mod_examples_tests.lua` in the engine's own suite loads all eight
together and asserts the above, so the gallery cannot rot. together and asserts the above, so the gallery cannot rot.
These example mods arent perfect and are just examples to show you basics of wahts possible, but way more than just this is possible. These example mods arent perfect and are just examples to show you basics of wahts possible, but way more than just this is possible.
@@ -0,0 +1,12 @@
# Changelog
Format: [keep a changelog](https://keepachangelog.com/en/1.1.0/).
Version headings match `manifest.json`'s `version`.
## 1.0.0
### Added
- `intro.oak_speech.build` wrap that injects toast / MEW / snack / rival-trust / pineapple beats.
- Answers written to `mod.save` via `intro.oak_speech.answered`.
- Custom `toast_kid.png` sprite shown mid-speech.
+43
View File
@@ -0,0 +1,43 @@
# Silly Oak Intro Example
Hooks Oak's NEW GAME speech: extra questions, sprite swaps (Oak, rival,
player, MEW, and a custom Toast Kid pic), and answers stored in `mod.save`.
## Try it (play through yourself)
```sh
rm -rf mods/example_silly_oak
cp -r mods/examples/example_silly_oak mods/
love .
```
Then **NEW GAME** and mash A / pick the menus. Disable or delete
`mods/example_silly_oak` when you're done so vanilla boots clean.
## Headless check
```sh
luajit mods/examples/example_silly_oak/tests/example_silly_oak_test.lua
```
## Auto driver (screenshots + save asserts)
```sh
rm -rf mods/example_silly_oak
cp -r mods/examples/example_silly_oak mods/
SHOT_DIR=/tmp/silly_oak POKEPORT_IDENTITY=silly_oak_driver_test \
POKEPORT_DRIVER=tests/drivers/silly_oak_intro_test.lua POKEPORT_SPEED=8 love .
```
`POKEPORT_IDENTITY` keeps this run's save out of your normal slot.
## What it demonstrates
| Seam | Where |
|---|---|
| `hooks:wrap("intro.oak_speech.build")` | `main.lua` — reshape the step list |
| `mod.ui.insertStepAfter` / `insertStepBefore` | `main.lua` — anchored on vanilla step ids |
| step kinds `say` / `yesno` / `choice` | `main.lua` |
| pics: `"oak"`, `"rival"`, `"player"`, pokemon, custom image | `main.lua` |
| `events:on("intro.oak_speech.answered")` | `main.lua``mod.save` |
| `events:on("intro.oak_speech.finished")` | `main.lua` |
Binary file not shown.

After

Width:  |  Height:  |  Size: 245 B

+107
View File
@@ -0,0 +1,107 @@
-- Gallery entry: reshape Oak's intro speech with extra questions, sprite
-- swaps (oak / rival / player / pokemon / a custom image), and answers
-- that land in mod.save.
--
-- Uses hooks:wrap("intro.oak_speech.build") plus intro.oak_speech.answered.
return function(mod)
local toastPic = mod.path .. "/assets/toast_kid.png"
mod.hooks:wrap("intro.oak_speech.build", function(next, steps, speech)
steps = next(steps, speech)
-- after oak says hello, immediately derail
mod.ui.insertStepAfter(steps, "oak_welcome", {
id = "silly_quiz_intro",
kind = "say",
pic = "oak",
text = "Before we start,\nI have a few\vquestions.\fImportant ones.\nScientific ones.",
})
mod.ui.insertStepAfter(steps, "silly_quiz_intro", {
id = "silly_toast",
kind = "yesno",
pic = "oak",
saveKey = "likes_toast",
text = "Do you like\ntoast?",
})
-- brand new sprite mid-speech
mod.ui.insertStepAfter(steps, "silly_toast", {
id = "silly_toast_kid",
kind = "say",
pic = { type = "image", path = toastPic },
reveal = "fade",
saveKey = nil,
text = "This is Toast Kid.\nHe is not a\vPOKéMON.\fHe just showed up\none day.\fAnyway.",
})
-- existing mon with a wipe + cry, parked after the real demo mon
mod.ui.insertStepAfter(steps, "demo_mon", {
id = "silly_mew",
kind = "say",
pic = { type = "pokemon", id = "MEW" },
reveal = "wipe",
cry = "MEW",
text = "This is MEW.\nPlease do not\vtell anyone\vI showed you.",
})
mod.ui.insertStepAfter(steps, "silly_mew", {
id = "silly_snack",
kind = "choice",
pic = "oak",
saveKey = "snack",
text = "Pick a snack.\nThis goes on\vyour permanent\vrecord.",
choices = { "BERRIES", "LEFTOVERS", "OLD ROD" },
})
-- swap to rival pic for a loaded question before naming him
mod.ui.insertStepBefore(steps, "ask_rival_name", {
id = "silly_trust",
kind = "choice",
pic = "rival",
reveal = "fade",
saveKey = "trusts_rival",
text = "Look at this kid.\nTrustworthy?",
choices = { "SURE", "NO" },
values = { true, false },
})
-- player pic for one last bit after both names are set
mod.ui.insertStepAfter(steps, "name_rival", {
id = "silly_pineapple",
kind = "yesno",
pic = "player",
saveKey = "pineapple_on_pizza",
text = "{PLAYER}. Be honest.\nPineapple on\vpizza?",
})
mod.ui.insertStepAfter(steps, "silly_pineapple", {
id = "silly_closing",
kind = "say",
pic = "oak",
text = "Great. Terrible.\nI have notes.\fLet's pretend this\nwas normal.",
})
return steps
end)
-- every answered step with a saveKey lands in mod.save (and therefore
-- save.modData[mod.id] once the slot is written)
mod.events:on("intro.oak_speech.answered", function(ev)
if not ev.saveKey then return end
mod.save:set(ev.saveKey, ev.value)
mod.log:info("intro answer %s = %s", tostring(ev.saveKey), tostring(ev.value))
end)
mod.events:on("intro.oak_speech.finished", function(ev)
local answers = ev.answers or {}
for key, value in pairs(answers) do
if mod.save:get(key) == nil then
mod.save:set(key, value)
end
end
mod.save:set("quiz_done", true)
mod.log:info("silly oak quiz done")
end)
end
@@ -0,0 +1,15 @@
{
"id": "example_silly_oak",
"name": "Silly Oak Intro Example",
"version": "1.0.0",
"api": 2,
"entry": "main.lua",
"profile": "content",
"category": "UI",
"game_version": ">=0.0.0-0 <2.0.0",
"priority": 100,
"dependencies": [],
"optional_dependencies": [],
"conflicts": [],
"description": "Reshapes Oak's intro speech with extra questions, sprite swaps, and answers saved to mod.save."
}
+21
View File
@@ -0,0 +1,21 @@
-- Sharing metadata for the manager detail pane.
return {
summary = "Oak asks dumb questions during the intro and remembers your answers.",
author = "Pokemon Gen 1 Recompilation Project",
contact = "https://github.com/bryanthaboi/pokemon-gen1-recomp-project",
tags = { "intro", "ui", "oak", "hooks" },
differences = {
changed = {
"Oak's NEW GAME speech gains extra questions and sprite beats",
},
added = {
"mod.save keys: likes_toast, snack, trusts_rival, pineapple_on_pizza, quiz_done",
"Custom Toast Kid pic mid-intro",
},
known = { "vanilla naming and the shrink-away still run" },
},
credits = {
{ who = "Pokemon Gen 1 Recompilation Project", for_ = "the intro.oak_speech hooks" },
},
compat = { engine = ">=0.0.0 <2.0.0", modApi = 2 },
}
@@ -0,0 +1,159 @@
-- Standalone: luajit mods/examples/example_silly_oak/tests/example_silly_oak_test.lua
-- Covers the intro.oak_speech build hook, step helpers, sprite descriptors,
-- and answers landing in mod.save.
--
-- Needs an imported ROM dataset (data/generated/). Headless CI and a
-- fresh checkout without a ROM skip cleanly -- the gallery is also
-- covered by tests/mod_examples_tests.lua when generated data is present.
package.path = "./?.lua;./?/init.lua;" .. package.path
local function hasGenerated()
local handle = io.open("data/generated/constants.lua", "r")
if handle then handle:close() return true end
return false
end
if not hasGenerated() then
print("example_silly_oak_test skipped (needs data/generated/)")
os.exit(0)
end
local T = require("tests.modkit")
local Runtime = require("src.mods.Runtime")
local OakSpeech = require("src.ui.OakSpeech")
local Data = require("src.core.Data")
Data:load()
local run = T.sdk.loadMod("mods/examples/example_silly_oak", { data = Data })
T.eq(#run.errors, 0, "loads clean (" .. tostring(run.errors[1]) .. ")")
local mod = run.mod
T.check(mod ~= nil and mod.state == "loaded", "mod reached the loaded state")
local ModUI = require("src.ui.ModUI")
local bucket = function()
return run.loader.modSave.example_silly_oak or {}
end
local toastPath = (mod.path or "mods/examples/example_silly_oak")
.. "/assets/toast_kid.png"
-- ------- build hook injects every silly beat around vanilla anchors
local speech = OakSpeech.new({
data = Data,
save = { player = { name = "RED", rival = "BLUE" } },
stack = { push = function() end, pop = function() end },
}, nil)
local steps = speech:buildSteps()
local ids = {}
for _, step in ipairs(steps) do ids[#ids + 1] = step.id end
local function has(id)
for _, x in ipairs(ids) do if x == id then return true end end
return false
end
T.check(has("oak_welcome") and has("name_player") and has("shrink"),
"vanilla anchors still present")
T.check(has("silly_quiz_intro") and has("silly_toast") and has("silly_toast_kid"),
"toast quiz beats injected")
T.check(has("silly_mew") and has("silly_snack"),
"MEW reveal and snack choice injected")
T.check(has("silly_trust") and has("silly_pineapple") and has("silly_closing"),
"rival trust + pineapple beats injected")
-- order: toast kid before demo_mon, mew after demo_mon, trust before rival ask
local function indexOf(id)
for i, x in ipairs(ids) do if x == id then return i end end
return 0
end
T.check(indexOf("silly_toast_kid") < indexOf("demo_mon"),
"Toast Kid shows before the demo mon")
T.check(indexOf("demo_mon") < indexOf("silly_mew"),
"MEW shows after the demo mon")
T.check(indexOf("silly_trust") < indexOf("ask_rival_name"),
"trust question is before rival naming")
T.check(indexOf("name_rival") < indexOf("silly_pineapple")
and indexOf("silly_pineapple") < indexOf("legend"),
"pineapple lands between rival name and the legend beat")
-- ------- step shapes cover choice / yesno / custom image / pokemon
local byId = {}
for _, step in ipairs(steps) do byId[step.id] = step end
T.eq(byId.silly_toast.kind, "yesno", "toast is a yes/no")
T.eq(byId.silly_toast.saveKey, "likes_toast", "toast writes likes_toast")
T.eq(byId.silly_snack.kind, "choice", "snack is a multi choice")
T.eq(#byId.silly_snack.choices, 3, "snack has three options")
T.check(byId.silly_toast_kid.pic and byId.silly_toast_kid.pic.type == "image",
"Toast Kid uses a custom image pic")
T.check(byId.silly_mew.pic and byId.silly_mew.pic.type == "pokemon"
and byId.silly_mew.pic.id == "MEW" and byId.silly_mew.cry == "MEW",
"MEW beat uses pokemon pic + cry")
T.eq(byId.silly_trust.pic, "rival", "trust question shows the rival pic")
-- ------- resolvePic covers trainer / pokemon / player / image shorthand
local oakImg = OakSpeech.resolvePic({ data = Data }, "oak", speech)
local rivalImg = OakSpeech.resolvePic({ data = Data }, "rival", speech)
local playerImg = OakSpeech.resolvePic({ data = Data }, "player", speech)
local mewImg, mewFlip = OakSpeech.resolvePic({ data = Data },
{ type = "pokemon", id = "MEW", flip = true }, speech)
local customImg = OakSpeech.resolvePic({ data = Data },
{ type = "image", path = toastPath }, speech)
-- headless love stub may return nil images; the call itself must not throw
T.check(oakImg == speech.oakPic or oakImg == nil or type(oakImg) == "userdata"
or type(oakImg) == "table",
"oak shorthand resolves without error")
T.check(rivalImg == speech.rivalPic or rivalImg == nil or type(rivalImg) == "userdata"
or type(rivalImg) == "table",
"rival shorthand resolves without error")
T.check(playerImg == speech.playerPic or playerImg == nil
or type(playerImg) == "userdata" or type(playerImg) == "table",
"player shorthand resolves without error")
T.check(mewFlip == true, "pokemon flip flag is honored")
T.check(customImg ~= nil or true, "custom image path is accepted")
-- ------- answered event writes mod.save (loader.modSave bucket)
Runtime.emit("intro.oak_speech.answered", {
saveKey = "likes_toast", value = true, label = "YES", index = 1,
step = byId.silly_toast, speech = speech,
})
Runtime.emit("intro.oak_speech.answered", {
saveKey = "snack", value = "OLD ROD", label = "OLD ROD", index = 3,
step = byId.silly_snack, speech = speech,
})
Runtime.emit("intro.oak_speech.answered", {
saveKey = "trusts_rival", value = false, label = "NO", index = 2,
step = byId.silly_trust, speech = speech,
})
Runtime.emit("intro.oak_speech.answered", {
saveKey = "pineapple_on_pizza", value = true, label = "YES", index = 1,
step = byId.silly_pineapple, speech = speech,
})
Runtime.emit("intro.oak_speech.finished", {
speech = speech, answers = speech.answers,
})
local saved = bucket()
T.eq(saved.likes_toast, true, "likes_toast saved")
T.eq(saved.snack, "OLD ROD", "snack saved")
T.eq(saved.trusts_rival, false, "trusts_rival saved")
T.eq(saved.pineapple_on_pizza, true, "pineapple_on_pizza saved")
T.eq(saved.quiz_done, true, "quiz_done stamped on finish")
-- ------- ModUI step helpers (public surface)
local tiny = {
{ id = "a", kind = "say" },
{ id = "b", kind = "say" },
}
ModUI.insertStepAfter(tiny, "a", { id = "mid", kind = "choice" })
T.eq(tiny[2].id, "mid", "insertStepAfter lands behind the anchor")
ModUI.insertStepBefore(tiny, "b", { id = "pre_b", kind = "yesno" })
T.eq(tiny[3].id, "pre_b", "insertStepBefore lands ahead of the anchor")
ModUI.removeStep(tiny, "mid")
T.check(tiny[2].id ~= "mid", "removeStep drops by id")
run.release()
T.finish("example_silly_oak")
+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 MoveEffects = require("src.battle.MoveEffects")
local Runtime = require("src.mods.Runtime") local Runtime = require("src.mods.Runtime")
local StatusRegistry = require("src.battle.StatusRegistry") local StatusRegistry = require("src.battle.StatusRegistry")
local Strings = require("src.core.Strings")
local EffectRegistry = {} local EffectRegistry = {}
@@ -91,7 +92,7 @@ function EffectRegistry.runDamaging(battle, ctx, record)
if target.invulnerable and not neverMiss then if target.invulnerable and not neverMiss then
-- Explosion/Selfdestruct still animate on a miss (HandleIfPlayerMoveMissed) -- Explosion/Selfdestruct still animate on a miss (HandleIfPlayerMoveMissed)
if not (record and record.explode) then battle:cancelMoveAnim() end if not (record and record.explode) then battle:cancelMoveAnim() end
battle:sayNext(("%s's\nattack missed!"):format(displayName(user))) battle:sayNext(Strings("%s's\nattack missed!", displayName(user)))
return return
end end
@@ -113,7 +114,7 @@ function EffectRegistry.runDamaging(battle, ctx, record)
if not battle:accuracyRoll(move, user, target) then if not battle:accuracyRoll(move, user, target) then
-- Explosion/Selfdestruct still animate on a miss (HandleIfPlayerMoveMissed) -- Explosion/Selfdestruct still animate on a miss (HandleIfPlayerMoveMissed)
if not (record and record.explode) then battle:cancelMoveAnim() end if not (record and record.explode) then battle:cancelMoveAnim() end
battle:sayNext(("%s's\nattack missed!"):format(displayName(user))) battle:sayNext(Strings("%s's\nattack missed!", displayName(user)))
-- Jump Kick crash, Explode self-destruct -- Jump Kick crash, Explode self-destruct
if record and record.onMiss then record.onMiss(ctx, "accuracy") end if record and record.onMiss then record.onMiss(ctx, "accuracy") end
user.trappingTurns = nil user.trappingTurns = nil
@@ -140,7 +141,7 @@ function EffectRegistry.runDamaging(battle, ctx, record)
end end
if not counterable or (battle.lastDamage or 0) == 0 then if not counterable or (battle.lastDamage or 0) == 0 then
battle:cancelMoveAnim() battle:cancelMoveAnim()
battle:sayNext(("%s's\nattack missed!"):format(displayName(user))) battle:sayNext(Strings("%s's\nattack missed!", displayName(user)))
return return
end end
dmg = math.min(65535, battle.lastDamage * 2) dmg = math.min(65535, battle.lastDamage * 2)
@@ -163,14 +164,14 @@ function EffectRegistry.runDamaging(battle, ctx, record)
if info.typeMult == 0 then if info.typeMult == 0 then
-- type immunity zeros damage and sets wMoveMissed in Gen 1, so no anim -- type immunity zeros damage and sets wMoveMissed in Gen 1, so no anim
if not (record and record.explode) then battle:cancelMoveAnim() end if not (record and record.explode) then battle:cancelMoveAnim() end
battle:sayNext(("It doesn't affect\n%s!"):format(displayName(target))) battle:sayNext(Strings("It doesn't affect\n%s!", displayName(target)))
if record and record.onMiss then record.onMiss(ctx, "immune") end if record and record.onMiss then record.onMiss(ctx, "immune") end
return return
end end
if info.missed then if info.missed then
-- 0.25x floored the damage to zero: the original registers a miss -- 0.25x floored the damage to zero: the original registers a miss
if not (record and record.explode) then battle:cancelMoveAnim() end if not (record and record.explode) then battle:cancelMoveAnim() end
battle:sayNext(("%s's\nattack missed!"):format(displayName(user))) battle:sayNext(Strings("%s's\nattack missed!", displayName(user)))
if record and record.onMiss then record.onMiss(ctx, "floored") end if record and record.onMiss then record.onMiss(ctx, "floored") end
return return
end end
@@ -214,12 +215,12 @@ function EffectRegistry.runDamaging(battle, ctx, record)
-- multi-hit loop (core.asm .moveDidNotMiss before the jump back -- multi-hit loop (core.asm .moveDidNotMiss before the jump back
-- to GetPlayerAnimationType), so crit/effectiveness reprint on -- to GetPlayerAnimationType), so crit/effectiveness reprint on
-- every strike -- damage was only rolled once -- every strike -- damage was only rolled once
if info.crit then battle:sayNext("Critical hit!") end if info.crit then battle:sayNext(Strings("Critical hit!")) end
if info.ohko then battle:sayNext("One-hit KO!") end if info.ohko then battle:sayNext(Strings("One-hit KO!")) end
if info.typeMult > 10 then if info.typeMult > 10 then
battle:sayNext("It's super\neffective!") battle:sayNext(Strings("It's super\neffective!"))
elseif info.typeMult < 10 then elseif info.typeMult < 10 then
battle:sayNext("It's not very\neffective...") battle:sayNext(Strings("It's not very\neffective..."))
end end
if Runtime.wants("battle.damage_dealt") then if Runtime.wants("battle.damage_dealt") then
Runtime.emit("battle.damage_dealt", { Runtime.emit("battle.damage_dealt", {
@@ -237,9 +238,9 @@ function EffectRegistry.runDamaging(battle, ctx, record)
if hits > 1 then if hits > 1 then
-- player: _MultiHitText; enemy: _HitXTimesText (always plural) -- player: _MultiHitText; enemy: _HitXTimesText (always plural)
if user.isPlayer then if user.isPlayer then
battle:sayNext(("Hit the enemy\n%d times!"):format(hits)) battle:sayNext(Strings("Hit the enemy\n%d times!", hits))
else else
battle:sayNext(("Hit %d times!"):format(hits)) battle:sayNext(Strings("Hit %d times!", hits))
end end
end end
@@ -251,7 +252,7 @@ function EffectRegistry.runDamaging(battle, ctx, record)
elseif moveInst.struggle then elseif moveInst.struggle then
-- struggle recoils even when its effect id resolves to no record -- struggle recoils even when its effect id resolves to no record
local recoil = math.max(1, math.floor(dmg / 2)) local recoil = math.max(1, math.floor(dmg / 2))
battle:sayNext(("%s's\nhit with recoil!"):format(displayName(user))) battle:sayNext(Strings("%s's\nhit with recoil!", displayName(user)))
battle:applyDamage(user, recoil) battle:applyDamage(user, recoil)
end end
+65 -58
View File
@@ -15,6 +15,7 @@ local Logger = require("src.core.Logger")
local StatusRegistry = require("src.battle.StatusRegistry") local StatusRegistry = require("src.battle.StatusRegistry")
local TurnOrder = require("src.battle.TurnOrder") local TurnOrder = require("src.battle.TurnOrder")
local TypeChart = require("src.battle.TypeChart") local TypeChart = require("src.battle.TypeChart")
local Strings = require("src.core.Strings")
local MoveEffects = {} local MoveEffects = {}
@@ -36,14 +37,14 @@ local STAT_LABEL = {
local function changeStage(battle, who, stat, delta, fromEnemy) local function changeStage(battle, who, stat, delta, fromEnemy)
if fromEnemy and (who.substituteHP or who.mist) then if fromEnemy and (who.substituteHP or who.mist) then
if who.mist then if who.mist then
return { displayName(who) .. " is\nprotected by MIST!" } return { Strings("%s is\nprotected by MIST!", displayName(who)) }
end end
return { "But, it failed!" } return { Strings("But, it failed!") }
end end
local cur = who.stages[stat] or 0 local cur = who.stages[stat] or 0
local new = math.max(-6, math.min(6, cur + delta)) local new = math.max(-6, math.min(6, cur + delta))
if new == cur then if new == cur then
return { ("Nothing happened!") } return { Strings("Nothing happened!") }
end end
who.stages[stat] = new who.stages[stat] = new
-- effects.asm:505-506: after any stat-stage change, modified stats are -- effects.asm:505-506: after any stat-stage change, modified stats are
@@ -53,13 +54,13 @@ local function changeStage(battle, who, stat, delta, fromEnemy)
-- _MonsStatsRoseText/_MonsStatsFellText: "X's / STAT rose!"; the -- _MonsStatsRoseText/_MonsStatsFellText: "X's / STAT rose!"; the
-- two-stage variants scroll "greatly" onto a third line -- two-stage variants scroll "greatly" onto a third line
if delta >= 2 then if delta >= 2 then
return { ("%s's\n%s\ngreatly rose!"):format(displayName(who), STAT_LABEL[stat]) } return { Strings("%s's\n%s\ngreatly rose!", displayName(who), STAT_LABEL[stat]) }
elseif delta == 1 then elseif delta == 1 then
return { ("%s's\n%s rose!"):format(displayName(who), STAT_LABEL[stat]) } return { Strings("%s's\n%s rose!", displayName(who), STAT_LABEL[stat]) }
elseif delta == -1 then elseif delta == -1 then
return { ("%s's\n%s fell!"):format(displayName(who), STAT_LABEL[stat]) } return { Strings("%s's\n%s fell!", displayName(who), STAT_LABEL[stat]) }
end end
return { ("%s's\n%s\ngreatly fell!"):format(displayName(who), STAT_LABEL[stat]) } return { Strings("%s's\n%s\ngreatly fell!", displayName(who), STAT_LABEL[stat]) }
end end
MoveEffects.changeStage = changeStage MoveEffects.changeStage = changeStage
@@ -88,10 +89,10 @@ end
local function statusMove(status) local function statusMove(status)
return function(battle, user, target, move) return function(battle, user, target, move)
if target.mon.status then if target.mon.status then
return { "But, it failed!" } return { Strings("But, it failed!") }
end end
if status == "PSN" and target.substituteHP then if status == "PSN" and target.substituteHP then
return { "But, it failed!" } return { Strings("But, it failed!") }
end end
local msgs = inflictStatus(battle, target, status, { local msgs = inflictStatus(battle, target, status, {
toxic = move and move.id == "TOXIC", toxic = move and move.id == "TOXIC",
@@ -99,7 +100,7 @@ local function statusMove(status)
source = move and move.id, source = move and move.id,
}) })
if #msgs == 0 then if #msgs == 0 then
return { "But, it failed!" } return { Strings("But, it failed!") }
end end
return msgs return msgs
end end
@@ -111,7 +112,7 @@ local function statusSide(status, chance)
-- target (regardless of the burn roll) -- target (regardless of the burn roll)
if move and move.type == "FIRE" and target.mon.status == "FRZ" then if move and move.type == "FIRE" and target.mon.status == "FRZ" then
target.mon.status = nil target.mon.status = nil
return { ("Fire defrosted\n%s!"):format(displayName(target)) } return { Strings("Fire defrosted\n%s!", displayName(target)) }
end end
if battle.rng(0, 255) >= chance then return {} end if battle.rng(0, 255) >= chance then return {} end
return inflictStatus(battle, target, status, { return inflictStatus(battle, target, status, {
@@ -144,10 +145,10 @@ end
local function confuse(battle, target, pierceSub) local function confuse(battle, target, pierceSub)
if target.confusedTurns or (target.substituteHP and not pierceSub) then if target.confusedTurns or (target.substituteHP and not pierceSub) then
return { "But, it failed!" } return { Strings("But, it failed!") }
end end
target.confusedTurns = battle.rng(2, 5) target.confusedTurns = battle.rng(2, 5)
return { ("%s\nbecame confused!"):format(displayName(target)) } return { Strings("%s\nbecame confused!", displayName(target)) }
end end
-- --------------------------------------------------------------------- -- ---------------------------------------------------------------------
@@ -181,53 +182,53 @@ MoveEffects.primary = {
LEECH_SEED_EFFECT = function(battle, user, target) LEECH_SEED_EFFECT = function(battle, user, target)
-- leech_seed.asm has no substitute check: seeding lands through one -- leech_seed.asm has no substitute check: seeding lands through one
if target.leechSeeded then if target.leechSeeded then
return { "But, it failed!" } return { Strings("But, it failed!") }
end end
for _, t in ipairs(target.curTypes) do for _, t in ipairs(target.curTypes) do
if t == "GRASS" then return { "But, it failed!" } end if t == "GRASS" then return { Strings("But, it failed!") } end
end end
target.leechSeeded = true target.leechSeeded = true
return { ("%s\nwas seeded!"):format(displayName(target)) } return { Strings("%s\nwas seeded!", displayName(target)) }
end, end,
HEAL_EFFECT = function(battle, user, target, move) HEAL_EFFECT = function(battle, user, target, move)
local mon = user.mon local mon = user.mon
if move.id == "REST" then if move.id == "REST" then
if mon.hp == mon.stats.hp then return { "But, it failed!" } end if mon.hp == mon.stats.hp then return { Strings("But, it failed!") } end
mon.hp = mon.stats.hp mon.hp = mon.stats.hp
mon.status = "SLP" mon.status = "SLP"
user.sleepTurns = 2 user.sleepTurns = 2
user.toxicCounter = nil user.toxicCounter = nil
return { ("%s\nstarted sleeping!"):format(displayName(user)) } return { Strings("%s\nstarted sleeping!", displayName(user)) }
end end
if mon.hp == mon.stats.hp then return { "But, it failed!" } end if mon.hp == mon.stats.hp then return { Strings("But, it failed!") } end
mon.hp = math.min(mon.stats.hp, mon.hp + math.floor(mon.stats.hp / 2)) mon.hp = math.min(mon.stats.hp, mon.hp + math.floor(mon.stats.hp / 2))
return { ("%s\nregained health!"):format(displayName(user)) } return { Strings("%s\nregained health!", displayName(user)) }
end, end,
LIGHT_SCREEN_EFFECT = function(battle, user) LIGHT_SCREEN_EFFECT = function(battle, user)
if user.lightScreen then return { "But, it failed!" } end if user.lightScreen then return { Strings("But, it failed!") } end
user.lightScreen = true user.lightScreen = true
return { ("%s's\nprotected against\nspecial attacks!"):format(displayName(user)) } return { Strings("%s's\nprotected against\nspecial attacks!", displayName(user)) }
end, end,
REFLECT_EFFECT = function(battle, user) REFLECT_EFFECT = function(battle, user)
if user.reflect then return { "But, it failed!" } end if user.reflect then return { Strings("But, it failed!") } end
user.reflect = true user.reflect = true
return { ("%s\ngained armor!"):format(displayName(user)) } return { Strings("%s\ngained armor!", displayName(user)) }
end, end,
MIST_EFFECT = function(battle, user) MIST_EFFECT = function(battle, user)
if user.mist then return { "But, it failed!" } end if user.mist then return { Strings("But, it failed!") } end
user.mist = true user.mist = true
-- _ShroudedInMistText (lowercase "mist") -- _ShroudedInMistText (lowercase "mist")
return { ("%s's\nshrouded in mist!"):format(displayName(user)) } return { Strings("%s's\nshrouded in mist!", displayName(user)) }
end, end,
FOCUS_ENERGY_EFFECT = function(battle, user) FOCUS_ENERGY_EFFECT = function(battle, user)
if user.focusEnergy then return { "But, it failed!" } end if user.focusEnergy then return { Strings("But, it failed!") } end
user.focusEnergy = true user.focusEnergy = true
return { ("%s's\ngetting pumped!"):format(displayName(user)) } return { Strings("%s's\ngetting pumped!", displayName(user)) }
end, end,
HAZE_EFFECT = function(battle, user, target) HAZE_EFFECT = function(battle, user, target)
@@ -254,33 +255,33 @@ MoveEffects.primary = {
target.skipMove = true target.skipMove = true
end end
target.mon.status = nil target.mon.status = nil
return { "All STATUS changes\nare eliminated!" } return { Strings("All STATUS changes\nare eliminated!") }
end, end,
SUBSTITUTE_EFFECT = function(battle, user) SUBSTITUTE_EFFECT = function(battle, user)
if user.substituteHP then return { ("%s\nhas a SUBSTITUTE!"):format(displayName(user)) } end if user.substituteHP then return { Strings("%s\nhas a SUBSTITUTE!", displayName(user)) } end
local cost = math.floor(user.mon.stats.hp / 4) local cost = math.floor(user.mon.stats.hp / 4)
-- substitute.asm only fails on subtraction underflow (current HP -- substitute.asm only fails on subtraction underflow (current HP
-- strictly below maxHP/4); at equality the substitute is built and -- strictly below maxHP/4); at equality the substitute is built and
-- the user is left standing on exactly 0 HP (it faints only when -- the user is left standing on exactly 0 HP (it faints only when
-- the engine next checks HP, not here) -- the engine next checks HP, not here)
if user.mon.hp < cost then if user.mon.hp < cost then
return { "Too weak to make\na SUBSTITUTE!" } return { Strings("Too weak to make\na SUBSTITUTE!") }
end end
user.mon.hp = user.mon.hp - cost user.mon.hp = user.mon.hp - cost
user.substituteHP = cost + 1 user.substituteHP = cost + 1
-- _SubstituteText -- _SubstituteText
return { "It created a\nSUBSTITUTE!" } return { Strings("It created a\nSUBSTITUTE!") }
end, end,
CONVERSION_EFFECT = function(battle, user, target) CONVERSION_EFFECT = function(battle, user, target)
-- conversion.asm fails against a mid-Fly/Dig target (INVULNERABLE) -- conversion.asm fails against a mid-Fly/Dig target (INVULNERABLE)
if target.invulnerable then if target.invulnerable then
return { "But, it failed!" } return { Strings("But, it failed!") }
end end
user.curTypes = { target.curTypes[1], target.curTypes[2] } user.curTypes = { target.curTypes[1], target.curTypes[2] }
-- _ConvertedTypeText -- _ConvertedTypeText
return { ("Converted type to\n%s's!"):format(displayName(target)) } return { Strings("Converted type to\n%s's!", displayName(target)) }
end, end,
-- MIMIC_EFFECT lives in BattleState:resolveMimic: MimicEffect -- MIMIC_EFFECT lives in BattleState:resolveMimic: MimicEffect
@@ -311,27 +312,27 @@ MoveEffects.primary = {
table.insert(user.curMoves, { id = mv.id, pp = 5, mimic = true }) table.insert(user.curMoves, { id = mv.id, pp = 5, mimic = true })
end end
-- _TransformedText: the copied name prints bare (wNameBuffer) -- _TransformedText: the copied name prints bare (wNameBuffer)
return { ("%s\ntransformed into\n%s!"):format(displayName(user), target.name) } return { Strings("%s\ntransformed into\n%s!", displayName(user), target.name) }
end, end,
DISABLE_EFFECT = function(battle, user, target) DISABLE_EFFECT = function(battle, user, target)
if target.disabledSlot then return { "But, it failed!" } end if target.disabledSlot then return { Strings("But, it failed!") } end
local usable = {} local usable = {}
for i, mv in ipairs(target.curMoves) do for i, mv in ipairs(target.curMoves) do
if mv.pp > 0 then table.insert(usable, i) end if mv.pp > 0 then table.insert(usable, i) end
end end
if #usable == 0 then return { "But, it failed!" } end if #usable == 0 then return { Strings("But, it failed!") } end
local slot = usable[battle.rng(1, #usable)] local slot = usable[battle.rng(1, #usable)]
target.disabledSlot = slot target.disabledSlot = slot
target.disabledTurns = battle.rng(1, 8) target.disabledTurns = battle.rng(1, 8)
local id = target.curMoves[slot].id local id = target.curMoves[slot].id
-- _MoveWasDisabledText: "X's / MOVE was / disabled!" -- _MoveWasDisabledText: "X's / MOVE was / disabled!"
return { ("%s's\n%s was\ndisabled!"):format(displayName(target), return { Strings("%s's\n%s was\ndisabled!", displayName(target),
battle.data.moves[id].name) } battle.data.moves[id].name) }
end, end,
SPLASH_EFFECT = function() SPLASH_EFFECT = function()
return { "No effect!" } return { Strings("No effect!") }
end, end,
} }
@@ -428,7 +429,9 @@ local function drainHalf(text)
local mon = ctx.user.mon local mon = ctx.user.mon
mon.hp = math.min(mon.stats.hp, mon.hp + heal) mon.hp = math.min(mon.stats.hp, mon.hp + heal)
ctx.drain() ctx.drain()
ctx.say(text:format(displayName(ctx.target))) -- `text` arrives as a source string (Strings.source at the call
-- site keeps it in the catalog); look it up here, at use time
ctx.say(Strings(text, displayName(ctx.target)))
end end
end end
@@ -436,7 +439,7 @@ end
-- flags the miss before the special-damage override) -- flags the miss before the special-damage override)
local function immuneMsg(ctx) local function immuneMsg(ctx)
if TypeChart.effectiveness(ctx.move.type, ctx.target.curTypes) == 0 then if TypeChart.effectiveness(ctx.move.type, ctx.target.curTypes) == 0 then
return ("It doesn't affect\n%s!"):format(displayName(ctx.target)) return Strings("It doesn't affect\n%s!", displayName(ctx.target))
end end
return nil return nil
end end
@@ -498,12 +501,12 @@ MoveEffects.full = {
-- removed): overkill and substitute hits recoil at full strength -- removed): overkill and substitute hits recoil at full strength
local recoil = math.max(1, math.floor(ctx.rawDamage local recoil = math.max(1, math.floor(ctx.rawDamage
/ (ctx.moveInst.struggle and 2 or 4))) / (ctx.moveInst.struggle and 2 or 4)))
ctx.say(("%s's\nhit with recoil!"):format(displayName(ctx.user))) ctx.say(Strings("%s's\nhit with recoil!", displayName(ctx.user)))
ctx.battle:applyDamage(ctx.user, recoil) ctx.battle:applyDamage(ctx.user, recoil)
end, end,
}, },
DRAIN_HP_EFFECT = { DRAIN_HP_EFFECT = {
afterDamage = drainHalf("Sucked health from\n%s!"), afterDamage = drainHalf(Strings.source("Sucked health from\n%s!")),
}, },
DREAM_EATER_EFFECT = { DREAM_EATER_EFFECT = {
-- only works on sleeping targets (checked before damage) -- only works on sleeping targets (checked before damage)
@@ -511,7 +514,7 @@ MoveEffects.full = {
if ctx.target.mon.status ~= "SLP" then return false, "But, it failed!" end if ctx.target.mon.status ~= "SLP" then return false, "But, it failed!" end
return true return true
end, end,
afterDamage = drainHalf("%s's\ndream was eaten!"), afterDamage = drainHalf(Strings.source("%s's\ndream was eaten!")),
}, },
-- charge moves: first turn just charges; Fly AND Dig go -- charge moves: first turn just charges; Fly AND Dig go
@@ -557,7 +560,7 @@ MoveEffects.full = {
user.thrashTurns, user.thrashMove, user.thrashAnnounced = nil, nil, nil user.thrashTurns, user.thrashMove, user.thrashAnnounced = nil, nil, nil
if not user.confusedTurns then if not user.confusedTurns then
user.confusedTurns = ctx.rng(2, 5) user.confusedTurns = ctx.rng(2, 5)
ctx.say(("%s\nbecame confused!"):format(displayName(user))) ctx.say(Strings("%s\nbecame confused!", displayName(user)))
end end
end end
end end
@@ -566,7 +569,7 @@ MoveEffects.full = {
JUMP_KICK_EFFECT = { JUMP_KICK_EFFECT = {
onMiss = function(ctx, reason) onMiss = function(ctx, reason)
if reason ~= "accuracy" then return end if reason ~= "accuracy" then return end
ctx.say(("%s\nkept going and\ncrashed!"):format(displayName(ctx.user))) ctx.say(Strings("%s\nkept going and\ncrashed!", displayName(ctx.user)))
ctx.damage(ctx.user, 1) ctx.damage(ctx.user, 1)
end, end,
}, },
@@ -581,8 +584,12 @@ MoveEffects.full = {
}, },
HYPER_BEAM_EFFECT = { HYPER_BEAM_EFFECT = {
afterDamage = function(ctx) afterDamage = function(ctx)
-- no recharge when the target faints OR its substitute breaks -- Gen 1: no recharge when the target faints OR its substitute breaks.
if ctx.target.mon.hp > 0 and not ctx.brokeSub then -- Ruleset hyperBeamSkipRechargeOnKO=false forces Gen 2+ always-recharge.
local ruleset = ctx.battle and ctx.battle.ruleset
local skipOnKO = not ruleset or ruleset.hyperBeamSkipRechargeOnKO ~= false
local targetDown = ctx.target.mon.hp <= 0 or ctx.brokeSub
if not skipOnKO or not targetDown then
ctx.user.mustRecharge = true ctx.user.mustRecharge = true
end end
end, end,
@@ -591,7 +598,7 @@ MoveEffects.full = {
afterDamage = function(ctx) afterDamage = function(ctx)
local battle = ctx.battle local battle = ctx.battle
battle.payDay = (battle.payDay or 0) + 2 * ctx.user.mon.level battle.payDay = (battle.payDay or 0) + 2 * ctx.user.mon.level
ctx.say("Coins scattered\neverywhere!") ctx.say(Strings("Coins scattered\neverywhere!"))
end, end,
}, },
SWIFT_EFFECT = { neverMiss = true }, SWIFT_EFFECT = { neverMiss = true },
@@ -606,7 +613,7 @@ MoveEffects.full = {
local user = ctx.user local user = ctx.user
user.bideTurns = ctx.rng(2, 3) user.bideTurns = ctx.rng(2, 3)
user.bideDamage = 0 user.bideDamage = 0
ctx.say(("%s\nis storing energy!"):format(displayName(user))) ctx.say(Strings("%s\nis storing energy!", displayName(user)))
end, end,
}, },
SWITCH_AND_TELEPORT_EFFECT = { SWITCH_AND_TELEPORT_EFFECT = {
@@ -627,27 +634,27 @@ MoveEffects.full = {
end end
if ok then if ok then
if move.id == "ROAR" then if move.id == "ROAR" then
ctx.say(("%s\nran away scared!"):format(displayName(target))) ctx.say(Strings("%s\nran away scared!", displayName(target)))
elseif move.id == "WHIRLWIND" then elseif move.id == "WHIRLWIND" then
ctx.say(("%s\nwas blown away!"):format(displayName(target))) ctx.say(Strings("%s\nwas blown away!", displayName(target)))
else else
ctx.say(("%s\nran from battle!"):format(displayName(user))) ctx.say(Strings("%s\nran from battle!", displayName(user)))
end end
battle.result = "run" battle.result = "run"
battle.afterQueue = "finish" battle.afterQueue = "finish"
elseif move.id == "TELEPORT" then elseif move.id == "TELEPORT" then
battle:cancelMoveAnim() battle:cancelMoveAnim()
ctx.say("But, it failed!") ctx.say(Strings("But, it failed!"))
else else
battle:cancelMoveAnim() battle:cancelMoveAnim()
ctx.say(("It didn't affect\n%s!"):format(displayName(target))) ctx.say(Strings("It didn't affect\n%s!", displayName(target)))
end end
elseif move.id == "TELEPORT" then elseif move.id == "TELEPORT" then
battle:cancelMoveAnim() battle:cancelMoveAnim()
ctx.say("But, it failed!") ctx.say(Strings("But, it failed!"))
else else
battle:cancelMoveAnim() battle:cancelMoveAnim()
ctx.say(("%s\nis unaffected!"):format(displayName(target))) ctx.say(Strings("%s\nis unaffected!", displayName(target)))
end end
end, end,
}, },
@@ -665,7 +672,7 @@ MoveEffects.full = {
callsMove = function(ctx) callsMove = function(ctx)
local last = ctx.target.lastMove local last = ctx.target.lastMove
if not last then if not last then
ctx.say("The MIRROR MOVE\nfailed!") ctx.say(Strings("The MIRROR MOVE\nfailed!"))
return nil return nil
end end
return last return last
+31 -21
View File
@@ -5,6 +5,8 @@
-- and residual sweep. Callers without a battle (pure-module tests) fall -- and residual sweep. Callers without a battle (pure-module tests) fall
-- back to the vanilla records, which is bit-identical behavior. -- back to the vanilla records, which is bit-identical behavior.
local Strings = require("src.core.Strings")
local Status = {} local Status = {}
-- pokered's <USER>/<TARGET> text macros print "Enemy " before the enemy -- pokered's <USER>/<TARGET> text macros print "Enemy " before the enemy
@@ -28,8 +30,11 @@ local function hasType(battler, wanted)
end end
-- shared PSN/BRN residual: 1/16 max HP, multiplied (and advanced) by the -- shared PSN/BRN residual: 1/16 max HP, multiplied (and advanced) by the
-- Toxic counter (HandlePoisonBurnLeechSeed) -- Toxic counter (HandlePoisonBurnLeechSeed). The caller passes the whole
local function damageOverTime(what) -- sentence rather than the noun: "hurt by poison" and "hurt by the burn"
-- decline differently once translated, so a shared fragment cannot be the
-- translatable unit.
local function damageOverTime(template)
return function(battler) return function(battler)
local mon = battler.mon local mon = battler.mon
local base = math.max(1, math.floor(mon.stats.hp / 16)) local base = math.max(1, math.floor(mon.stats.hp / 16))
@@ -39,10 +44,15 @@ local function damageOverTime(what)
battler.toxicCounter = battler.toxicCounter + 1 battler.toxicCounter = battler.toxicCounter + 1
end end
mon.hp = math.max(0, mon.hp - dmg) mon.hp = math.max(0, mon.hp - dmg)
return { ("%s's\nhurt by %s!"):format(name(battler), what) } return { Strings(template, name(battler)) }
end end
end end
-- Labels stay plain literals on purpose: this table is built at require
-- time, before Strings.load has a catalog, so a Strings() here would
-- freeze the English. They are already translatable through the
-- statuses registry (mod.content.statuses:patch(id, { label = ... })).
--
-- The five persistent conditions as records: the beforeMove gauntlet, the -- The five persistent conditions as records: the beforeMove gauntlet, the
-- residual sweep, the inflict text/immunities (StatusRegistry.inflict), -- residual sweep, the inflict text/immunities (StatusRegistry.inflict),
-- the catch/wobble bonuses (Catching.attempt), the HUD label, and the -- the catch/wobble bonuses (Catching.attempt), the HUD label, and the
@@ -57,13 +67,13 @@ Status.RECORDS = {
battler.sleepTurns = (battler.sleepTurns or 1) - 1 battler.sleepTurns = (battler.sleepTurns or 1) - 1
if battler.sleepTurns <= 0 then if battler.sleepTurns <= 0 then
battler.mon.status = nil battler.mon.status = nil
return false, { name(battler) .. "\nwoke up!" } -- wakes but loses the turn return false, { Strings("%s\nwoke up!", name(battler)) } -- wakes, loses the turn
end end
return false, { name(battler) .. "\nis fast asleep!" } return false, { Strings("%s\nis fast asleep!", name(battler)) }
end, end,
onInflict = function(battle, target, opts, display) onInflict = function(battle, target, opts, display)
target.sleepTurns = battle.rng(1, 7) target.sleepTurns = battle.rng(1, 7)
return { ("%s\nfell asleep!"):format(display) } return { Strings("%s\nfell asleep!", display) }
end, end,
}, },
FRZ = { FRZ = {
@@ -71,35 +81,35 @@ Status.RECORDS = {
catchBonus = 25, shakeBonus = 10, catchBonus = 25, shakeBonus = 10,
beforeMovePriority = 30, beforeMovePriority = 30,
beforeMove = function(battler) beforeMove = function(battler)
return false, { name(battler) .. "\nis frozen solid!" } return false, { Strings("%s\nis frozen solid!", name(battler)) }
end, end,
canInflict = function(target) return not hasType(target, "ICE") end, canInflict = function(target) return not hasType(target, "ICE") end,
onInflict = function(_, _, _, display) onInflict = function(_, _, _, display)
return { ("%s\nwas frozen solid!"):format(display) } return { Strings("%s\nwas frozen solid!", display) }
end, end,
}, },
PSN = { PSN = {
id = "PSN", label = "PSN", hudLabel = "PSN", id = "PSN", label = "PSN", hudLabel = "PSN",
catchBonus = 12, shakeBonus = 5, catchBonus = 12, shakeBonus = 5,
residual = damageOverTime("poison"), residual = damageOverTime(Strings.source("%s's\nhurt by poison!")),
canInflict = function(target) return not hasType(target, "POISON") end, canInflict = function(target) return not hasType(target, "POISON") end,
onInflict = function(_, target, opts, display) onInflict = function(_, target, opts, display)
if opts.toxic then if opts.toxic then
target.toxicCounter = 1 target.toxicCounter = 1
-- _BadlyPoisonedText -- _BadlyPoisonedText
return { ("%s's\nbadly poisoned!"):format(display) } return { Strings("%s's\nbadly poisoned!", display) }
end end
return { ("%s\nwas poisoned!"):format(display) } return { Strings("%s\nwas poisoned!", display) }
end, end,
}, },
BRN = { BRN = {
id = "BRN", label = "BRN", hudLabel = "BRN", id = "BRN", label = "BRN", hudLabel = "BRN",
catchBonus = 12, shakeBonus = 5, catchBonus = 12, shakeBonus = 5,
statPenalty = { stat = "attack", div = 2 }, statPenalty = { stat = "attack", div = 2 },
residual = damageOverTime("the burn"), residual = damageOverTime(Strings.source("%s's\nhurt by the burn!")),
canInflict = function(target) return not hasType(target, "FIRE") end, canInflict = function(target) return not hasType(target, "FIRE") end,
onInflict = function(_, _, _, display) onInflict = function(_, _, _, display)
return { ("%s\nwas burned!"):format(display) } return { Strings("%s\nwas burned!", display) }
end, end,
}, },
PAR = { PAR = {
@@ -110,7 +120,7 @@ Status.RECORDS = {
beforeMove = function(battler, rng) beforeMove = function(battler, rng)
-- cp 25 percent / jr nc: fully paralyzed on rand < 63 (63/256) -- cp 25 percent / jr nc: fully paralyzed on rand < 63 (63/256)
if rng(0, 255) < 63 then if rng(0, 255) < 63 then
return false, { name(battler) .. "'s\nfully paralyzed!" } return false, { Strings("%s's\nfully paralyzed!", name(battler)) }
end end
return true, {} return true, {}
end, end,
@@ -120,7 +130,7 @@ Status.RECORDS = {
end, end,
onInflict = function(_, _, _, display) onInflict = function(_, _, _, display)
-- _ParalyzedMayNotAttackText (primary and secondary paralysis) -- _ParalyzedMayNotAttackText (primary and secondary paralysis)
return { ("%s's\nparalyzed! It may\nnot attack!"):format(display) } return { Strings("%s's\nparalyzed! It may\nnot attack!", display) }
end, end,
}, },
} }
@@ -156,7 +166,7 @@ function Status.beforeMove(battler, rng, battle)
end end
if battler.flinched then if battler.flinched then
battler.flinched = false battler.flinched = false
return false, { name(battler) .. "\nflinched!" } return false, { Strings("%s\nflinched!", name(battler)) }
end end
local record = Status.recordFor(battleStatuses(battle), mon.status) local record = Status.recordFor(battleStatuses(battle), mon.status)
local handler = record and record.beforeMove local handler = record and record.beforeMove
@@ -174,23 +184,23 @@ function Status.beforeMove(battler, rng, battle)
end end
if battler.boundTurns and battler.boundTurns > 0 then if battler.boundTurns and battler.boundTurns > 0 then
battler.boundTurns = battler.boundTurns - 1 battler.boundTurns = battler.boundTurns - 1
msgs[#msgs + 1] = name(battler) .. "\ncan't move!" msgs[#msgs + 1] = Strings("%s\ncan't move!", name(battler))
return false, msgs return false, msgs
end end
if battler.disabledTurns then if battler.disabledTurns then
battler.disabledTurns = battler.disabledTurns - 1 battler.disabledTurns = battler.disabledTurns - 1
if battler.disabledTurns <= 0 then if battler.disabledTurns <= 0 then
battler.disabledTurns, battler.disabledSlot = nil, nil battler.disabledTurns, battler.disabledSlot = nil, nil
table.insert(msgs, name(battler) .. "'s\ndisabled no more!") table.insert(msgs, Strings("%s's\ndisabled no more!", name(battler)))
end end
end end
if battler.confusedTurns then if battler.confusedTurns then
battler.confusedTurns = battler.confusedTurns - 1 battler.confusedTurns = battler.confusedTurns - 1
if battler.confusedTurns <= 0 then if battler.confusedTurns <= 0 then
battler.confusedTurns = nil battler.confusedTurns = nil
table.insert(msgs, name(battler) .. "\nsnapped out of\nconfusion!") table.insert(msgs, Strings("%s\nsnapped out of\nconfusion!", name(battler)))
else else
table.insert(msgs, name(battler) .. "\nis confused!") table.insert(msgs, Strings("%s\nis confused!", name(battler)))
-- cp 50 percent + 1 / jr c: hurt itself on rand >= 128 (128/256) -- cp 50 percent + 1 / jr c: hurt itself on rand >= 128 (128/256)
if rng(0, 255) < 128 then if rng(0, 255) < 128 then
return false, msgs, true -- hurt itself return false, msgs, true -- hurt itself
@@ -231,7 +241,7 @@ function Status.residual(battler, opponent, battle)
dmg = math.min(dmg, mon.hp) dmg = math.min(dmg, mon.hp)
mon.hp = mon.hp - dmg mon.hp = mon.hp - dmg
opponent.mon.hp = math.min(opponent.mon.stats.hp, opponent.mon.hp + dmg) opponent.mon.hp = math.min(opponent.mon.stats.hp, opponent.mon.hp + dmg)
table.insert(msgs, ("LEECH SEED saps\n%s!"):format(name(battler))) table.insert(msgs, Strings("LEECH SEED saps\n%s!", name(battler)))
end end
return msgs return msgs
end end
+2 -1
View File
@@ -5,6 +5,7 @@
local Runtime = require("src.mods.Runtime") local Runtime = require("src.mods.Runtime")
local Status = require("src.battle.Status") local Status = require("src.battle.Status")
local Strings = require("src.core.Strings")
local StatusRegistry = {} local StatusRegistry = {}
@@ -45,7 +46,7 @@ function StatusRegistry.inflict(battle, target, status, opts)
if record and record.onInflict then if record and record.onInflict then
msgs = record.onInflict(battle, target, opts, display) msgs = record.onInflict(battle, target, opts, display)
else else
msgs = { ("%s\nwas afflicted\nby %s!"):format(display, msgs = { Strings("%s\nwas afflicted\nby %s!", display,
record and record.label or tostring(status)) } record and record.label or tostring(status)) }
end end
Runtime.emit("battle.status_inflicted", { Runtime.emit("battle.status_inflicted", {
+4 -3
View File
@@ -19,6 +19,7 @@
-- enemy PP — Gen 1 AI never reads wEnemyMonPP). -- enemy PP — Gen 1 AI never reads wEnemyMonPP).
local TypeChart = require("src.battle.TypeChart") local TypeChart = require("src.battle.TypeChart")
local Strings = require("src.core.Strings")
local TrainerAI = {} local TrainerAI = {}
@@ -99,7 +100,7 @@ function TrainerAI.useItem(battle, item)
local enemy = battle.enemy local enemy = battle.enemy
local trainerName = battle.trainer.name local trainerName = battle.trainer.name
local itemName = battle.data.items[item] and battle.data.items[item].name or item local itemName = battle.data.items[item] and battle.data.items[item].name or item
local msgs = { ("%s\nused %s!"):format(trainerName, itemName) } local msgs = { Strings("%s\nused %s!", trainerName, itemName) }
if item == "FULL_HEAL" then if item == "FULL_HEAL" then
enemy.mon.status = nil enemy.mon.status = nil
enemy.toxicCounter = nil enemy.toxicCounter = nil
@@ -112,10 +113,10 @@ function TrainerAI.useItem(battle, item)
elseif X_STAT[item] then elseif X_STAT[item] then
local stat = X_STAT[item] local stat = X_STAT[item]
enemy.stages[stat] = math.min(6, (enemy.stages[stat] or 0) + 1) enemy.stages[stat] = math.min(6, (enemy.stages[stat] or 0) + 1)
table.insert(msgs, ("%s's\n%s rose!"):format(enemy.name, stat:upper())) table.insert(msgs, Strings("%s's\n%s rose!", enemy.name, stat:upper()))
elseif item == "GUARD_SPEC" then elseif item == "GUARD_SPEC" then
enemy.mist = true enemy.mist = true
table.insert(msgs, ("%s's\nprotected against\nstat changes!"):format(enemy.name)) table.insert(msgs, Strings("%s's\nprotected against\nstat changes!", enemy.name))
end end
return msgs return msgs
end end
+3
View File
@@ -16,4 +16,7 @@ return {
-- Wild/trainer enemies never spend PP (DecrementPP only touches the -- Wild/trainer enemies never spend PP (DecrementPP only touches the
-- player side in pokered). They therefore never Struggle from empty PP. -- player side in pokered). They therefore never Struggle from empty PP.
enemyUnlimitedPP = true, enemyUnlimitedPP = true,
-- Gen 1 Hyper Beam: no recharge when the target faints (or its
-- substitute breaks). Set false to always recharge like Gen 2+.
hyperBeamSkipRechargeOnKO = true,
} }
+2
View File
@@ -11,4 +11,6 @@ return {
focusEnergyBug = false, focusEnergyBug = false,
-- Gen 2+ style: AI opponents deplete PP and can Struggle when empty. -- Gen 2+ style: AI opponents deplete PP and can Struggle when empty.
enemyUnlimitedPP = false, enemyUnlimitedPP = false,
-- Gen 2+: Hyper Beam always forces a recharge turn, even on a KO.
hyperBeamSkipRechargeOnKO = false,
} }
+31
View File
@@ -102,10 +102,41 @@ function Data:seedDefaults()
-- after-battle rows so Blaine's SetEventRange deactivation and talk -- after-battle rows so Blaine's SetEventRange deactivation and talk
-- after-text work like the other gyms (scripts/CinnabarGym.asm). -- after-text work like the other gyms (scripts/CinnabarGym.asm).
self:seedCinnabarGymTrainerHeaders() 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) -- #189: 1F cabin door order vs rooms map (survey zoom)
require("src.world.SsAnneLayout").apply(self.maps) require("src.world.SsAnneLayout").apply(self.maps)
end 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() function Data:seedCinnabarGymTrainerHeaders()
local headers = self.trainer_headers local headers = self.trainer_headers
if not headers or headers.CinnabarGym then return end if not headers or headers.CinnabarGym then return end
+7 -6
View File
@@ -9,6 +9,7 @@
local Json = require("src.link.Json") local Json = require("src.link.Json")
local Logger = require("src.core.Logger") local Logger = require("src.core.Logger")
local Runtime = require("src.mods.Runtime") local Runtime = require("src.mods.Runtime")
local Strings = require("src.core.Strings")
local DiscordPresence = { local DiscordPresence = {
APP_ID = "1529183141267374262", APP_ID = "1529183141267374262",
@@ -436,7 +437,7 @@ end
local function buildActivity() local function buildActivity()
local details, activityState local details, activityState
if state.activity == "battle" then if state.activity == "battle" then
details = state.battleLabel or "In battle" details = state.battleLabel or Strings("In battle")
activityState = state.location and (state.location) or "Kanto" activityState = state.location and (state.location) or "Kanto"
elseif state.activity == "exploring" and state.location then elseif state.activity == "exploring" and state.location then
details = state.location details = state.location
@@ -550,20 +551,20 @@ local function subscribe(game)
track("battle.started", function(ev) track("battle.started", function(ev)
local battle = ev and ev.battle local battle = ev and ev.battle
local label = "In battle" local label = Strings("In battle")
if ev and ev.kind == "wild" then if ev and ev.kind == "wild" then
local name = speciesName(game, ev.species) local name = speciesName(game, ev.species)
if name then if name then
label = "Battling wild " .. name label = "Battling wild " .. name
if ev.level then label = label .. " Lv" .. tostring(ev.level) end if ev.level then label = label .. " Lv" .. tostring(ev.level) end
else else
label = "Wild battle" label = Strings("Wild battle")
end end
elseif ev and ev.kind == "trainer" then elseif ev and ev.kind == "trainer" then
local tname = battle and battle.trainer and battle.trainer.name local tname = battle and battle.trainer and battle.trainer.name
label = tname and ("Battling " .. tname) or "Trainer battle" label = tname and ("Battling " .. tname) or Strings("Trainer battle")
elseif ev and ev.kind == "link" then elseif ev and ev.kind == "link" then
label = "Link battle" label = Strings("Link battle")
end end
local mapId = state.mapId local mapId = state.mapId
or (game.save and game.save.player and game.save.player.map) or (game.save and game.save.player and game.save.player.map)
@@ -594,7 +595,7 @@ local function subscribe(game)
state.mapId = nil state.mapId = nil
setPresence({ setPresence({
activity = "menu", activity = "menu",
location = id == "IntroMovie" and "Watching the intro" or "Title screen", location = id == "IntroMovie" and "Watching the intro" or Strings("Title screen"),
clearBattle = true, clearBattle = true,
}) })
end end
+33 -12
View File
@@ -8,7 +8,7 @@ local Logger = require("src.core.Logger")
local Renderer = require("src.render.Renderer") local Renderer = require("src.render.Renderer")
local SaveData = require("src.core.SaveData") local SaveData = require("src.core.SaveData")
local StateStack = require("src.core.StateStack") 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 ModLoader = require("src.mods.Loader")
local ModRuntime = require("src.mods.Runtime") local ModRuntime = require("src.mods.Runtime")
local Screens = require("src.ui.Screens") local Screens = require("src.ui.Screens")
@@ -44,8 +44,8 @@ function Game:load()
self.input = Input self.input = Input
Input:init() Input:init()
self.touchInput = TouchInput self.touchControls = TouchControls
TouchInput:init() TouchControls:init()
self.renderer = Renderer self.renderer = Renderer
Renderer:init() Renderer:init()
@@ -53,6 +53,9 @@ function Game:load()
require("src.render.Font").load(Data) require("src.render.Font").load(Data)
-- menu cursor/border/geometry constants; field.theme restyles them -- menu cursor/border/geometry constants; field.theme restyles them
require("src.ui.Theme").load(Data) require("src.ui.Theme").load(Data)
-- the engine's own text, after the merge so a translation mod's catalog
-- is already in Data.strings; empty on a mod-free boot and skipped
require("src.core.Strings").load(Data)
self.stack = StateStack self.stack = StateStack
StateStack:init() StateStack:init()
@@ -96,6 +99,20 @@ function Game:load()
end end
Logger.info("game loaded") Logger.info("game loaded")
-- Scaling bug reports (#87, #208) are unanswerable without these three
-- numbers: LOVE units, drawable pixels, and the integer physical pixels
-- per GB pixel the renderer settled on. Cheap, once, and it turns "it
-- looks stretched" into something reproducible.
if love.graphics and love.graphics.getDimensions then
local ww, wh = love.graphics.getDimensions()
local pw, ph = ww, wh
if love.graphics.getPixelDimensions then
pw, ph = love.graphics.getPixelDimensions()
end
Logger.info(string.format(
"display: %dx%d units, %dx%d px, fit scale %d px/GB px",
ww, wh, pw, ph, Renderer:fitScale()))
end
end end
-- the merged field.boot: spawn, names, money and the naming presets a -- the merged field.boot: spawn, names, money and the naming presets a
@@ -180,9 +197,6 @@ function Game:logicSpeed()
end end
function Game:update(dt) 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). -- Fast-forward scales only the logic clock (see src/core/GameSpeed.lua).
-- Give the accumulator room for one full frame at the current speed, -- Give the accumulator room for one full frame at the current speed,
-- or the anti-spiral clamp quietly caps every level above ~15X. -- or the anti-spiral clamp quietly caps every level above ~15X.
@@ -246,6 +260,8 @@ function Game:draw()
worldZones = self.overworld:sgbWorldZones() worldZones = self.overworld:sgbWorldZones()
end end
Renderer:endFrame(zones, worldZones) Renderer:endFrame(zones, worldZones)
-- on-screen mobile controls: pure screen-space, over the finished frame
TouchControls:draw()
end end
-- overworld survey zoom: wheel up / '=' zooms in, wheel down / '-' out -- overworld survey zoom: wheel up / '=' zooms in, wheel down / '-' out
@@ -375,6 +391,9 @@ function Game:keyreleased(key)
end end
function Game:gamepadpressed(joystick, button) 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 -- BindingsMenu's pad capture rides the same top-state routing as keys
local top = self.stack and self.stack:top() local top = self.stack and self.stack:top()
if top and top.onGamepadPressed then if top and top.onGamepadPressed then
@@ -389,6 +408,8 @@ function Game:gamepadreleased(joystick, button)
end end
function Game:gamepadaxis(joystick, axis, value) 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) Input:gamepadaxis(joystick, axis, value)
end end
@@ -398,12 +419,12 @@ end
-- state is worse than asking the player to re-press. -- state is worse than asking the player to re-press.
function Game:focus(f) function Game:focus(f)
Input:reset() Input:reset()
TouchInput:reset() TouchControls:reset()
end end
function Game:visible(v) function Game:visible(v)
Input:reset() Input:reset()
TouchInput:reset() TouchControls:reset()
end end
-- A disconnected/dropped controller can't send the button-up for whatever -- A disconnected/dropped controller can't send the button-up for whatever
@@ -411,19 +432,19 @@ end
-- flags it owned. -- flags it owned.
function Game:joystickremoved(joystick) function Game:joystickremoved(joystick)
Input:reset() Input:reset()
TouchInput:reset() TouchControls:joystickremoved()
end end
function Game:touchpressed(id, x, y) function Game:touchpressed(id, x, y)
TouchInput:touchpressed(id, x, y) TouchControls:touchpressed(id, x, y)
end end
function Game:touchmoved(id, x, y) function Game:touchmoved(id, x, y)
TouchInput:touchmoved(id, x, y) TouchControls:touchmoved(id, x, y)
end end
function Game:touchreleased(id, x, y) function Game:touchreleased(id, x, y)
TouchInput:touchreleased(id, x, y) TouchControls:touchreleased(id, x, y)
end end
-- Point the loader's mod.save backing at this save's modData so per-mod -- 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 = {} self.pressQueue = {}
end 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) function Input:gamepadpressed(joystick, button)
local btn = self.padBindings[button] local btn = self.padBindings[button]
if btn then if btn then
+31 -1
View File
@@ -24,7 +24,31 @@ local FILTER_HIGHGAIN = { 0.4, 0.16, 0.064 }
local filterLevel = 0 local filterLevel = 0
local function applyVolume(src) local function applyVolume(src)
if src then pcall(src.setVolume, src, VOLUME * volumeScale) end if not src then return end
local vol = VOLUME * volumeScale
if Runtime.wantsHook("music.volume") then
local ctx = {
song = state.current,
mapSong = state.mapSong,
onBike = state.onBike,
surfing = state.surfing,
fading = state.fade ~= nil,
optionScale = volumeScale,
}
local ok, Game = pcall(require, "src.core.Game")
if ok and Game then
local ow = Game.overworld
if ow and ow.player then
ctx.x, ctx.y = ow.player.cellX, ow.player.cellY
ctx.mapId = ow.map and ow.map.id
ctx.tod = ow.tod
end
end
vol = Runtime.call("music.volume", function(v) return v end, vol, ctx)
vol = tonumber(vol) or (VOLUME * volumeScale)
if vol < 0 then vol = 0 end
end
pcall(src.setVolume, src, vol)
end end
-- Source:setFilter needs OpenAL EFX; the pcall degrades to unfiltered -- Source:setFilter needs OpenAL EFX; the pcall degrades to unfiltered
@@ -408,6 +432,12 @@ end
-- restores the map theme after a one-shot jingle -- restores the map theme after a one-shot jingle
function Music.update(data) function Music.update(data)
if state.chip then require("src.core.ChipAudio").update() end if state.chip then require("src.core.ChipAudio").update() end
-- distance / indoor muffling mods re-apply volume every frame while
-- subscribed; otherwise applyVolume only runs on song/option changes
if Runtime.wantsHook("music.volume") and not state.fade then
applyVolume(state.source)
applyVolume(state.loopSource)
end
-- volume ramp (Music.fadeOut): hold the current level for `control` -- volume ramp (Music.fadeOut): hold the current level for `control`
-- frames, then drop one level (FadeOutAudio decrements both rAUDVOL -- frames, then drop one level (FadeOutAudio decrements both rAUDVOL
-- nibbles when its counter reaches 0); at level 0 the music stops. -- nibbles when its counter reaches 0); at level 0 the music stops.
+51 -6
View File
@@ -552,6 +552,44 @@ function SaveData.writeSlot(version, slotId, saveTable)
return true return true
end end
-- Delete a registered slot: remove its main/.bak/.tmp files, drop it from the
-- options registry, and if it was active point active at another remaining
-- slot (or clear active when the list is empty). Returns true, or false +
-- an error string when the id is unknown / not registered.
function SaveData.deleteSlot(version, slotId)
version = version or GameVersion.get()
if not knownVersion(version) then return false, "unknown version" end
if type(slotId) ~= "string" or slotId == "" then
return false, "missing slot id"
end
local fs = persistFs(nil)
ensureVersionSlots(version, fs)
local opts = SaveData.loadOptions(fs)
opts.saveSlots = opts.saveSlots or {}
local reg = opts.saveSlots[version]
if not reg or not reg.list then return false, "slot not registered" end
local found, idx = false, nil
for i, id in ipairs(reg.list) do
if id == slotId then found = true; idx = i; break end
end
if not found then return false, "slot not registered" end
local main, bak, tmp = slotNames(version, slotId)
remove(fs, main)
remove(fs, bak)
remove(fs, tmp)
table.remove(reg.list, idx)
if reg.active == slotId then
reg.active = reg.list[1] -- may be nil when the list is now empty
end
opts.saveSlots[version] = reg
SaveData.saveOptions(opts, fs)
slotsChecked[version] = true
activeSlotCache[version] = reg.active or false
return true
end
-- Test seam: drop the process-global slot cache so a suite can exercise -- Test seam: drop the process-global slot cache so a suite can exercise
-- migration/resolution against a freshly injected filesystem. Unused by -- migration/resolution against a freshly injected filesystem. Unused by
-- the game, which resolves each version exactly once per boot. -- the game, which resolves each version exactly once per boot.
@@ -1117,9 +1155,16 @@ function SaveData.defaultHeal(boot)
end end
-- Post-credits home (issue #103). pokered left the player in HALL_OF_FAME -- Post-credits home (issue #103). pokered left the player in HALL_OF_FAME
-- after jp Init; this port places CONTINUE at the NewGameWarp bedroom and -- after jp Init; this port relocates CONTINUE instead, and retargets
-- retargets LAST_MAP exits (Red's house mats) at the heal-point town so -- LAST_MAP exits (Red's house mats) at the heal-point town so leaving the
-- leaving the house does not dump the player back at Indigo Plateau. -- house does not dump the player back at Indigo Plateau.
--
-- The landing spot is the blackout point, not the NewGameWarp bedroom:
-- HallOfFameResetEventsAndSaveScript sets wLastBlackoutMap := PALLET_TOWN,
-- so resuming puts the player outside the front door. Spawning them back
-- in the upstairs bedroom was a port-only detour (#253). Non-vanilla
-- spawns are unaffected -- defaultHeal returns boot's own start map for
-- those, which is what the bedroom branch resolved to anyway.
-- Marks postGameHomeOk so a later intentional HoF save is not relocated. -- Marks postGameHomeOk so a later intentional HoF save is not relocated.
function SaveData.applyPostGameHome(save, boot) function SaveData.applyPostGameHome(save, boot)
boot = type(boot) == "table" and boot or {} boot = type(boot) == "table" and boot or {}
@@ -1127,9 +1172,9 @@ function SaveData.applyPostGameHome(save, boot)
save.lastHeal = { map = heal.map, x = heal.x, y = heal.y } save.lastHeal = { map = heal.map, x = heal.x, y = heal.y }
save.lastOutdoor = { id = heal.map, x = heal.x, y = heal.y } save.lastOutdoor = { id = heal.map, x = heal.x, y = heal.y }
save.player = save.player or {} save.player = save.player or {}
save.player.map = boot.startMap or "REDS_HOUSE_2F" save.player.map = heal.map
save.player.x = boot.startX or 3 save.player.x = heal.x
save.player.y = boot.startY or 6 save.player.y = heal.y
save.player.facing = boot.startFacing or "down" save.player.facing = boot.startFacing or "down"
save.postGameHomeOk = true save.postGameHomeOk = true
return heal return heal
+134
View File
@@ -0,0 +1,134 @@
-- The engine's own player-facing text, made overridable.
--
-- Extracted dialogue already had a home: `Data.text`, keyed by pokered's
-- labels, which a mod reaches through `mod.content.text`. The strings the
-- engine *authors* had none. Battle messages, item results, menu labels
-- and the link-play screens were literals in Lua, so a translator could
-- reach two thirds of the game and no more (#186, #245).
--
-- Those literals stay where they are, wrapped in `S(...)`, and the English
-- source doubles as the catalog key:
--
-- self:say(S("But, it failed!"))
-- self:say(S("Wild %s\nappeared!", self.enemy.name))
--
-- Keying on the source rather than on an invented id is deliberate. It
-- keeps the English readable at the point it is used (the alternative
-- scatters a thousand `battle.it_failed` ids that have to be looked up to
-- review a diff), it needs no id registry to stay in sync, and an entry a
-- translation has not covered yet falls through to English instead of
-- rendering a raw id at the player. The cost is that editing an English
-- string orphans its translations; `tools/modkit.py translation --refresh`
-- reports those as changed keys rather than silently dropping them.
--
-- Same-source-different-meaning is the one case source keys cannot hold on
-- their own ("OFF" as a filter setting vs "OFF" as a toggle, which some
-- languages render differently). Those sites pass a context:
--
-- S("OFF", "options.musicFilter") -- key: "options.musicFilter|OFF"
--
-- A mod supplies the catalog through the `strings` registry:
--
-- mod.content.strings:override("But, it failed!", "Echec !")
--
-- With no mod loaded the catalog is empty and `S` is an identity function
-- guarded by one boolean, so a vanilla boot draws byte-identical text.
local Strings = {}
local catalog = nil -- Data.strings once a mod has put something in it
local missing = {} -- format-arity complaints, reported once each
-- Called from Game after the mod merge, and again on dev-mode hot reload.
-- Holding the table (not a copy) means a mod that registers late still
-- takes effect without a second load.
function Strings.load(data)
local t = data and data.strings
catalog = nil
if type(t) ~= "table" then return end
for _ in pairs(t) do
catalog = t
return
end
end
function Strings.active()
return catalog ~= nil
end
-- The lookup itself. `context` is optional and only disambiguates sources
-- that collide; the plain key is tried after it, so a translation that does
-- not care about the distinction can supply one entry for both.
function Strings.lookup(source, context)
if not catalog then return source end
if context then
local hit = catalog[context .. "|" .. source]
if type(hit) == "string" then return hit end
end
local hit = catalog[source]
if type(hit) == "string" then return hit end
return source
end
-- Count `%`-directives so a translation that drops or adds one is caught
-- here rather than as a mid-battle `string.format` error.
local function specifiers(s)
local n = 0
for spec in s:gmatch("%%(.)") do
if spec ~= "%" then n = n + 1 end
end
return n
end
-- S(source) -> translated source
-- S(source, ...) -> translated source, string.format'ed
-- S(source, context) -> context-disambiguated lookup, no formatting
--
-- The two-argument forms are told apart by whether the source carries any
-- format directives: a source with no `%s` cannot be formatting, so a lone
-- string second argument is a context.
function Strings.get(source, ...)
local argc = select("#", ...)
if argc == 0 then return Strings.lookup(source) end
local wants = specifiers(source)
if wants == 0 and argc == 1 and type((...)) == "string" then
return Strings.lookup(source, (...))
end
local text = Strings.lookup(source)
-- A translation with the wrong arity would raise inside string.format,
-- which in a battle means a crash the player cannot escape. Fall back to
-- the English source, which is known to match, and say so once.
if specifiers(text) ~= wants then
if not missing[source] then
missing[source] = true
require("src.core.Logger").warn(
"strings: translation of %q has %d format directives, source has %d"
.. " -- using the source", source, specifiers(text), wants)
end
text = source
end
local ok, out = pcall(string.format, text, ...)
if not ok then return source end
return out
end
-- A marker, not a lookup: returns its argument untouched.
--
-- Some templates are declared in a module-level table and formatted much
-- later (BattleState's charge-move lines, for one). Translating at the
-- declaration would freeze the English, because those tables are built at
-- require time and Strings.load has no catalog yet; the use site therefore
-- calls Strings(template, ...) and looks the source up then. That works at
-- runtime but leaves the literal invisible to the catalog generator, which
-- only sees what is spelled out at a call site. Wrapping the declaration in
-- Strings.source puts it back in the harvest while changing nothing at all
-- about when the lookup happens.
function Strings.source(text)
return text
end
setmetatable(Strings, { __call = function(_, ...) return Strings.get(...) end })
return Strings
+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
+267 -58
View File
@@ -1,4 +1,5 @@
local GameVersion = require("src.core.GameVersion") local GameVersion = require("src.core.GameVersion")
local Strings = require("src.core.Strings")
local RomImporter = {} local RomImporter = {}
RomImporter.__index = RomImporter RomImporter.__index = RomImporter
@@ -290,6 +291,39 @@ local function findPendingRom(ready)
return nil return nil
end end
-- Android SAF writes mod picks to picked_mod.zip; USB copies may use any
-- .zip basename at the save-dir root. preferAny=true also accepts those USB
-- copies (Choose / Import); focus only consumes the SAF basename so a random
-- leftover archive is never auto-installed on every refocus.
local function findPendingMod(preferAny)
local preferred = "picked_mod.zip"
if love.filesystem.getInfo(preferred, "file") then
return preferred
end
if not preferAny then return nil end
for _, name in ipairs(love.filesystem.getDirectoryItems("")) do
if name:lower():match("%.zip$") and love.filesystem.getInfo(name, "file") then
return name
end
end
return nil
end
-- Same pattern as findPendingMod for battery saves (picked_save.sav / *.sav).
local function findPendingSav(preferAny)
local preferred = "picked_save.sav"
if love.filesystem.getInfo(preferred, "file") then
return preferred
end
if not preferAny then return nil end
for _, name in ipairs(love.filesystem.getDirectoryItems("")) do
if name:lower():match("%.sav$") and love.filesystem.getInfo(name, "file") then
return name
end
end
return nil
end
local function chooseRom(promptName) local function chooseRom(promptName)
promptName = promptName or "Pokemon" promptName = promptName or "Pokemon"
local prompt = "Choose your " .. promptName .. " ROM" local prompt = "Choose your " .. promptName .. " ROM"
@@ -320,10 +354,10 @@ local function chooseRom(promptName)
end end
-- Open a native picker for a mod .zip (mirrors chooseRom's per-OS dialogs). -- Open a native picker for a mod .zip (mirrors chooseRom's per-OS dialogs).
-- Returns the chosen absolute path or nil. Android has no picker; the mods -- Returns the chosen absolute path or nil. Android uses love.system.pickFile
-- panel steers that case to the drag-drop hint instead. -- ("mod") instead -- see RomImporter:chooseMod.
local function chooseZip() local function chooseZip()
local prompt = "Choose a mod .zip" local prompt = Strings("Choose a mod .zip")
local platform = love.system.getOS() local platform = love.system.getOS()
if platform == "OS X" then if platform == "OS X" then
return commandOutput( return commandOutput(
@@ -351,10 +385,10 @@ local function chooseZip()
end end
-- Open a native picker for a raw .sav battery save (mirrors chooseZip's per-OS -- Open a native picker for a raw .sav battery save (mirrors chooseZip's per-OS
-- dialogs). Returns the chosen absolute path or nil. Android has no picker; -- dialogs). Returns the chosen absolute path or nil. Android uses
-- the SAVE FILES card steers that case to the drag-drop hint instead. -- love.system.pickFile("sav") instead -- see RomImporter:chooseSaveImport.
local function chooseSav() local function chooseSav()
local prompt = "Choose a .sav save file" local prompt = Strings("Choose a .sav save file")
local platform = love.system.getOS() local platform = love.system.getOS()
if platform == "OS X" then if platform == "OS X" then
return commandOutput( return commandOutput(
@@ -429,10 +463,16 @@ function RomImporter.new(onComplete, opts)
-- affordance (desktop love.system.openURL). -- affordance (desktop love.system.openURL).
saveNotice = {}, saveNotice = {},
-- MODS panel state (pass 3): mods is the cached LauncherMods.list() array -- MODS panel state (pass 3): mods is the cached LauncherMods.list() array
-- (refreshed lazily on first draw and after any toggle/install); modScroll -- (refreshed lazily on first draw and after any toggle/install/delete);
-- is the list scroll offset (px, clamped in draw); modNotice is the last -- modScroll is the list scroll offset (px, clamped in draw); modNotice is
-- install result { ok, text } shown as a line above the list. -- the last install/delete result { ok, text } shown as a line above the list.
mods = nil, modScroll = 0, modNotice = nil, mods = nil, modScroll = 0, modNotice = nil,
-- Android SAF: which game tab should receive the next picked_save.sav when
-- focus consumes it (set by chooseSaveImport before opening the picker).
androidPendingVersion = nil,
-- Android SAF create-document: which game's SAVE FILES card should show
-- "Save exported." when export_done.flag appears on focus.
androidPendingExportVersion = nil,
}, RomImporter) }, RomImporter)
for _, version in ipairs(GameVersion.ORDER) do for _, version in ipairs(GameVersion.ORDER) do
@@ -491,10 +531,40 @@ end
-- The system picker runs as a separate top activity, so LOVE's own -- The system picker runs as a separate top activity, so LOVE's own
-- love.focus/love.visible pause while it's up (see main.lua) -- once the -- love.focus/love.visible pause while it's up (see main.lua) -- once the
-- player returns here with a file picked, GameActivity has already copied -- player returns here with a file picked, GameActivity has already copied
-- it into the save directory, so a pending-ROM rescan on refocus picks it -- it into the save directory, so a pending-file rescan on refocus picks it
-- up without the player needing to tap the button again. -- up without the player needing to tap the button again. Mod and save SAF
-- drops (picked_mod.zip / picked_save.sav) are consumed first so a leftover
-- ROM pick cannot steal the focus path when both games are already ready.
function RomImporter:focus(f) function RomImporter:focus(f)
if not (f and self.android and self.workState ~= "working") then return end if not (f and self.android and self.workState ~= "working") then return end
-- SAF create-document finished: GameActivity wrote export_done.flag.
if love.filesystem.getInfo("export_done.flag", "file") then
love.filesystem.remove("export_done.flag")
love.filesystem.remove("pending_export.sav")
local version = self.androidPendingExportVersion or self:_savedropTarget()
self.androidPendingExportVersion = nil
self.saveNotice[version] = { ok = true, text = "Save exported." }
if self.tab == "mods" or self.tab == "yellow" then self.tab = version end
return
end
local modName = findPendingMod(false)
if modName then
self:_installMod(modName)
if self.modNotice and self.modNotice.ok then
love.filesystem.remove(modName)
end
return
end
local savName = findPendingSav(false)
if savName then
local version = self.androidPendingVersion or self:_savedropTarget()
self.androidPendingVersion = nil
self:_importSave(version, savName)
if self.saveNotice[version] and self.saveNotice[version].ok then
love.filesystem.remove(savName)
end
return
end
if self.ready.red and self.ready.blue then return end if self.ready.red and self.ready.blue then return end
local name, data = findPendingRom(self.ready) local name, data = findPendingRom(self.ready)
if name then self:startData(data, name) end if name then self:startData(data, name) end
@@ -672,13 +742,38 @@ function RomImporter:_installMod(source)
self.tab = "mods" self.tab = "mods"
end end
-- "Import mod .zip" button: open a native picker (desktop) and install the -- Remove an installed mod from the save-dir mods/ tree and refresh the panel.
-- pick. Android has no picker, so it points the player at the drop path. function RomImporter:_deleteMod(id)
if self.workState == "working" then return end
local LauncherMods = require("src.mods.LauncherMods")
local ok, res = LauncherMods.uninstall(id)
if ok then
self:_refreshMods()
self.modNotice = { ok = true, text = "Deleted " .. tostring(id) }
else
self.modNotice = { ok = false, text = tostring(res) }
end
end
-- "Import mod .zip" button: open a native picker and install the pick.
-- Android mirrors ROM import: scan for a pending .zip in the save dir (USB
-- or a fresh SAF drop), else love.system.pickFile("mod") -> picked_mod.zip
-- which focus/Choose consumes on return.
function RomImporter:chooseMod() function RomImporter:chooseMod()
if self.workState == "working" then return end if self.workState == "working" then return end
if self.android then if self.android then
self.modNotice = local name = findPendingMod(true)
{ ok = false, text = "Drop a mod .zip onto the window to install it." } if name then
self:_installMod(name)
if self.modNotice and self.modNotice.ok then
love.filesystem.remove(name)
end
return
end
if not love.system.pickFile("mod") then
self.modNotice = { ok = false,
text = "Could not open the file picker. Copy a mod .zip via USB." }
end
return return
end end
local path = chooseZip() local path = chooseZip()
@@ -722,13 +817,26 @@ function RomImporter:_importSave(version, source)
end end
end end
-- "Import save" button: open a native .sav picker (desktop) and import the pick. -- "Import save" button: open a native .sav picker and import the pick.
-- Android has no picker, so it points the player at the drop path. -- Android mirrors ROM / mod import via love.system.pickFile("sav").
function RomImporter:chooseSaveImport(version) function RomImporter:chooseSaveImport(version)
if self.workState == "working" then return end if self.workState == "working" then return end
if self.android then if self.android then
self.saveNotice[version] = local name = findPendingSav(true)
{ ok = false, text = "Drop a .sav onto the window to import it." } if name then
self.androidPendingVersion = version
self:_importSave(version, name)
if self.saveNotice[version] and self.saveNotice[version].ok then
love.filesystem.remove(name)
end
return
end
self.androidPendingVersion = version
if not love.system.pickFile("sav") then
self.androidPendingVersion = nil
self.saveNotice[version] = { ok = false,
text = "Could not open the file picker. Copy a .sav via USB." }
end
return return
end end
local path = chooseSav() local path = chooseSav()
@@ -736,16 +844,58 @@ function RomImporter:chooseSaveImport(version)
end end
-- "Export save" button: write the active slot back out to a raw .sav in the save -- "Export save" button: write the active slot back out to a raw .sav in the save
-- directory's exports/ folder and show the path (with an open-folder affordance) -- directory's exports/ folder. On desktop, show the path with an open-folder
-- on the SAVE FILES card. -- affordance. On Android, stage pending_export.sav and open the system
-- create-document picker (love.system.createFile) so the player can save to
-- Downloads / Drive / etc. -- the app-private exports/ path is not useful there.
function RomImporter:exportSave(version) function RomImporter:exportSave(version)
if self.workState == "working" then return end if self.workState == "working" then return end
local ok, res = require("src.import.SaveFileIO").exportActiveSlot(version) local ok, res = require("src.import.SaveFileIO").exportActiveSlot(version)
if ok then if not ok then
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("^(.*)[/\\][^/\\]+$") local dir = res:match("^(.*)[/\\][^/\\]+$")
self.saveNotice[version] = { ok = true, text = "Exported to " .. res, dir = dir } 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 else
self.saveNotice[version] = { ok = false, text = tostring(res) } self.saveNotice[version] = { ok = false, text = tostring(err) }
end end
end end
@@ -1277,7 +1427,7 @@ function RomImporter:draw()
self.updateButton = { x = rect.x, y = rect.y, width = rect.width, self.updateButton = { x = rect.x, y = rect.y, width = rect.width,
height = rect.height, action = "download" } height = rect.height, action = "download" }
message(upLatest and ("Update v" .. upLatest .. " available") message(upLatest and ("Update v" .. upLatest .. " available")
or "An update is available", rect.width) or Strings("An update is available"), rect.width)
elseif upStatus == "needs_full" then elseif upStatus == "needs_full" then
local rect = actionButton("Open releases") local rect = actionButton("Open releases")
self.updateButton = { x = rect.x, y = rect.y, width = rect.width, self.updateButton = { x = rect.x, y = rect.y, width = rect.width,
@@ -1427,9 +1577,17 @@ function RomImporter:mousepressed(x, y, button)
end end
return return
end end
-- SAVE SLOT rows. On desktop a press only ARMS a click: _updateSlotDrag -- SAVE SLOT rows / Delete. Delete is checked first so a tap on the Delete
-- commits it on release when the pointer did not move (a moved pointer scrolls -- label never also selects the row. On desktop a press only ARMS a click:
-- instead). Android has no reliable pointer polling, so it selects on press. -- _updateSlotDrag commits it on release when the pointer did not move (a
-- moved pointer scrolls instead). Android has no reliable pointer polling,
-- so it selects on press. Delete fires immediately (small fixed target).
for _, r in ipairs(self.slotDeleteRects or {}) do
if inside(r, x, y) then
self:_deleteSlot(self.panelVersion, r.id)
return
end
end
for _, r in ipairs(self.slotRects or {}) do for _, r in ipairs(self.slotRects or {}) do
if inside(r, x, y) then if inside(r, x, y) then
if self.android then if self.android then
@@ -1445,12 +1603,18 @@ function RomImporter:mousepressed(x, y, button)
self:_newSlot(self.panelVersion); return self:_newSlot(self.panelVersion); return
end end
-- Mods panel: the import button dispatches on press (fixed header, no scroll -- Mods panel: the import button dispatches on press (fixed header, no scroll
-- conflict); a toggle switch, which lives in the scrollable list, only ARMS a -- conflict); Delete fires immediately; a toggle switch, which lives in the
-- press so _updateSlotDrag can tell a click from a drag-scroll (Android, with -- scrollable list, only ARMS a press so _updateSlotDrag can tell a click from
-- no pointer polling, toggles on press). -- a drag-scroll (Android, with no pointer polling, toggles on press).
if inside(self.modImportRect, x, y) then if inside(self.modImportRect, x, y) then
self:chooseMod(); return self:chooseMod(); return
end end
for _, r in ipairs(self.modDeleteRects or {}) do
if inside(r, x, y) then
self:_deleteMod(r.id)
return
end
end
for _, r in ipairs(self.modRects or {}) do for _, r in ipairs(self.modRects or {}) do
if inside(r, x, y) then if inside(r, x, y) then
if self.android then if self.android then
@@ -1555,7 +1719,7 @@ function RomImporter:_playButton(x, y, w, h, gameName, ready, locked)
col(PAL.disabledInk, 0.3) col(PAL.disabledInk, 0.3)
love.graphics.rectangle("line", x, y, w, h, r, r) love.graphics.rectangle("line", x, y, w, h, r, r)
col(PAL.disabledInk) col(PAL.disabledInk)
local label = locked and "Coming soon" or "Import a ROM to play" local label = locked and "Coming soon" or Strings("Import a ROM to play")
printfB(label, x, y + (h - self.playFont:getHeight()) / 2, w, "center") printfB(label, x, y + (h - self.playFont:getHeight()) / 2, w, "center")
self.playButtonRect = nil self.playButtonRect = nil
end end
@@ -1567,13 +1731,13 @@ function RomImporter:_drawTabBar(x, y, w, h, chip)
local s, pulse = self._s, self.pulse local s, pulse = self._s, self.pulse
local tabs = { local tabs = {
{ id = "red", letter = "R", top = PAL.chipRedTop, bot = PAL.chipRedBot, { id = "red", letter = "R", top = PAL.chipRedTop, bot = PAL.chipRedBot,
under = PAL.red, label = "RED", ink = PAL.white }, under = PAL.red, label = Strings("RED"), ink = PAL.white },
{ id = "blue", letter = "B", top = PAL.chipBlueTop, bot = PAL.chipBlueBot, { id = "blue", letter = "B", top = PAL.chipBlueTop, bot = PAL.chipBlueBot,
under = PAL.blue, label = "BLUE", ink = PAL.white }, under = PAL.blue, label = Strings("BLUE"), ink = PAL.white },
{ id = "yellow", letter = "Y", top = PAL.chipGoldTop, bot = PAL.chipGoldBot, { id = "yellow", letter = "Y", top = PAL.chipGoldTop, bot = PAL.chipGoldBot,
under = PAL.gold, label = "YELLOW", ink = PAL.chipInkGold }, under = PAL.gold, label = Strings("YELLOW"), ink = PAL.chipInkGold },
{ id = "mods", mods = true, top = PAL.chipModTop, bot = PAL.chipModBot, { id = "mods", mods = true, top = PAL.chipModTop, bot = PAL.chipModBot,
under = PAL.modDot, label = "MODS" }, under = PAL.modDot, label = Strings("MODS") },
} }
local gap = 10 * s local gap = 10 * s
local r = 12 * s local r = 12 * s
@@ -1632,7 +1796,7 @@ function RomImporter:_drawTabBar(x, y, w, h, chip)
local ready = 0 local ready = 0
for _, v in ipairs(GameVersion.ORDER) do if self.ready[v] then ready = ready + 1 end end for _, v in ipairs(GameVersion.ORDER) do if self.ready[v] then ready = ready + 1 end end
love.graphics.setFont(self.readyFont) love.graphics.setFont(self.readyFont)
local label = ("%d of 3 ready"):format(ready) local label = Strings("%d of 3 ready", ready)
local lw = self.readyFont:getWidth(label) local lw = self.readyFont:getWidth(label)
if x + w - lw > cursorX + 8 * s then if x + w - lw > cursorX + 8 * s then
col(PAL.labelGray) col(PAL.labelGray)
@@ -1688,7 +1852,7 @@ function RomImporter:_drawGamePanel(version, x, y, w, h)
-- ROM card contents by state (rehomes the existing import flow) -- ROM card contents by state (rehomes the existing import flow)
local dropHint = self.android and "Copy the .gb via USB." local dropHint = self.android and "Copy the .gb via USB."
or "Or drop the .gb file here." or Strings("Or drop the .gb file here.")
local accent = locked and PAL.gold or (version == "red" and PAL.red or PAL.blue) local accent = locked and PAL.gold or (version == "red" and PAL.red or PAL.blue)
local romState, romDetail, romBtnLabel, romBtnEnabled, romProgress local romState, romDetail, romBtnLabel, romBtnEnabled, romProgress
if locked then if locked then
@@ -1703,12 +1867,12 @@ function RomImporter:_drawGamePanel(version, x, y, w, h)
romDetail = self.detail or "" romDetail = self.detail or ""
romProgress = self.progress or 0 romProgress = self.progress or 0
elseif ready then elseif ready then
romState = self.romName[version] or "ROM imported" romState = self.romName[version] or Strings("ROM imported")
romDetail = "Verified." romDetail = "Verified."
romBtnLabel, romBtnEnabled = "Re-import ROM", true romBtnLabel, romBtnEnabled = "Re-import ROM", true
elseif erroring then elseif erroring then
romState = "Import failed" romState = "Import failed"
romDetail = self.detail or "That ROM could not be imported." romDetail = self.detail or Strings("That ROM could not be imported.")
romBtnLabel, romBtnEnabled = "Import ROM", true romBtnLabel, romBtnEnabled = "Import ROM", true
elseif notice then elseif notice then
romState = "No ROM imported" romState = "No ROM imported"
@@ -1759,7 +1923,8 @@ function RomImporter:_drawGamePanel(version, x, y, w, h)
elseif locked then elseif locked then
sfHintText, sfHintCol = "Not available yet.", PAL.warning sfHintText, sfHintCol = "Not available yet.", PAL.warning
elseif self.android then elseif self.android then
sfHintText, sfHintCol = "Import a .sav, or drop one on the window.", PAL.warning sfHintText, sfHintCol =
"Import or export a .sav with the system file picker.", PAL.warning
else else
sfHintText, sfHintCol = sfHintText, sfHintCol =
"Import a .sav to a new slot, or export the active slot.", PAL.warning "Import a .sav to a new slot, or export the active slot.", PAL.warning
@@ -1836,7 +2001,7 @@ function RomImporter:_drawGamePanel(version, x, y, w, h)
if sfNotice and sfNotice.dir then if sfNotice and sfNotice.dir then
iy = iy + 4 * s iy = iy + 4 * s
love.graphics.setFont(self.hintFont) love.graphics.setFont(self.hintFont)
local label = "Open folder" local label = Strings("Open folder")
local lw = self.hintFont:getWidth(label) local lw = self.hintFont:getWidth(label)
local frect = { x = ix, y = iy, width = lw, height = self.hintFont:getHeight(), local frect = { x = ix, y = iy, width = lw, height = self.hintFont:getHeight(),
dir = sfNotice.dir } dir = sfNotice.dir }
@@ -1999,6 +2164,8 @@ function RomImporter:_drawSaveSlotPanel(version, x, y, w, h)
love.graphics.printf("No saves yet - start a new game or import one.", love.graphics.printf("No saves yet - start a new game or import one.",
rx + 12 * s, listTop + listH / 2 - self.hintFont:getHeight() / 2, rx + 12 * s, listTop + listH / 2 - self.hintFont:getHeight() / 2,
rw - 24 * s, "center") rw - 24 * s, "center")
self.slotRects = {}
self.slotDeleteRects = {}
elseif listH > 0 then elseif listH > 0 then
local nameH = self.slotNameFont:getHeight() local nameH = self.slotNameFont:getHeight()
local metaH = self.labelFont:getHeight() local metaH = self.labelFont:getHeight()
@@ -2017,6 +2184,7 @@ function RomImporter:_drawSaveSlotPanel(version, x, y, w, h)
self.slotScroll[version] = scroll self.slotScroll[version] = scroll
self.slotRects = {} self.slotRects = {}
self.slotDeleteRects = {}
love.graphics.setScissor(math.floor(rx), math.floor(listTop), love.graphics.setScissor(math.floor(rx), math.floor(listTop),
math.ceil(rw), math.ceil(listH)) math.ceil(rw), math.ceil(listH))
for i, slot in ipairs(slots) do for i, slot in ipairs(slots) do
@@ -2029,6 +2197,20 @@ function RomImporter:_drawSaveSlotPanel(version, x, y, w, h)
col(selected and PAL.green or PAL.cardBorder, selected and 0.9 or 0.22) col(selected and PAL.green or PAL.cardBorder, selected and 0.9 or 0.22)
love.graphics.rectangle("line", rx, ry, rw, rowH, rr, rr) love.graphics.rectangle("line", rx, ry, rw, rowH, rr, rr)
-- Delete label (bottom-right); reserve its width so name/meta don't overlap
love.graphics.setFont(self.hintFont)
local delText = "Delete"
local delW = self.hintFont:getWidth(delText)
local delH = self.hintFont:getHeight()
local delX = rx + rw - 12 * s - delW
local delY = ry + rowH - rowPadV - delH
local drect = { x = delX - 6 * s, y = delY - 4 * s,
width = delW + 12 * s, height = delH + 8 * s, id = slot.id }
local dhot = self:_hover(drect)
col(dhot and PAL.red or PAL.warning)
love.graphics.print(delText, delX, delY)
local rightReserve = delW + 18 * s
-- LOADED pill (top-right of the active row), then reserve its width -- LOADED pill (top-right of the active row), then reserve its width
local pillW = 0 local pillW = 0
if selected then if selected then
@@ -2047,21 +2229,20 @@ function RomImporter:_drawSaveSlotPanel(version, x, y, w, h)
love.graphics.setFont(self.slotNameFont) love.graphics.setFont(self.slotNameFont)
col(PAL.white) col(PAL.white)
local name = slot.name or "NEW GAME" local name = slot.name or Strings("NEW GAME")
printB(ellipsize(self.slotNameFont, name, rw - 24 * s - pillW), printB(ellipsize(self.slotNameFont, name, rw - 24 * s - math.max(pillW, rightReserve)),
rx + 12 * s, ry + rowPadV) rx + 12 * s, ry + rowPadV)
local metaTxt local metaTxt
if slot.exists and slot.meta then if slot.exists and slot.meta then
metaTxt = ("%d badges - %s - %d caught"):format( metaTxt = Strings("%d badges - %s - %d caught", slot.meta.badges or 0, slot.meta.timeText or "0:00",
slot.meta.badges or 0, slot.meta.timeText or "0:00",
slot.meta.dexCount or 0) slot.meta.dexCount or 0)
else else
metaTxt = "empty slot" metaTxt = "empty slot"
end end
love.graphics.setFont(self.labelFont) love.graphics.setFont(self.labelFont)
col(PAL.warning) col(PAL.warning)
love.graphics.print(ellipsize(self.labelFont, metaTxt, rw - 24 * s), love.graphics.print(ellipsize(self.labelFont, metaTxt, rw - 24 * s - rightReserve),
rx + 12 * s, ry + rowPadV + nameH + 4 * s) rx + 12 * s, ry + rowPadV + nameH + 4 * s)
-- clip the hit rect to the visible list band so a partly-scrolled row -- clip the hit rect to the visible list band so a partly-scrolled row
@@ -2072,6 +2253,12 @@ function RomImporter:_drawSaveSlotPanel(version, x, y, w, h)
self.slotRects[#self.slotRects + 1] = self.slotRects[#self.slotRects + 1] =
{ x = rx, y = vy, width = rw, height = vy2 - vy, id = slot.id } { x = rx, y = vy, width = rw, height = vy2 - vy, id = slot.id }
end end
local dvy = math.max(drect.y, listTop)
local dvy2 = math.min(drect.y + drect.height, listBottom)
if dvy2 > dvy then
self.slotDeleteRects[#self.slotDeleteRects + 1] =
{ x = drect.x, y = dvy, width = drect.width, height = dvy2 - dvy, id = slot.id }
end
end end
end end
love.graphics.setScissor() love.graphics.setScissor()
@@ -2152,7 +2339,7 @@ function RomImporter:_drawModsPanel(x, y, w, h)
for _, m in ipairs(mods) do if m.enabled then enabledCount = enabledCount + 1 end end for _, m in ipairs(mods) do if m.enabled then enabledCount = enabledCount + 1 end end
love.graphics.setFont(self.hintFont) love.graphics.setFont(self.hintFont)
col(PAL.warning) col(PAL.warning)
love.graphics.print(("%d of %d enabled"):format(enabledCount, #mods), love.graphics.print(Strings("%d of %d enabled", enabledCount, #mods),
x + nameW + 14 * s, y + (headerH - self.hintFont:getHeight()) / 2) x + nameW + 14 * s, y + (headerH - self.hintFont:getHeight()) / 2)
local btnLabel = "Import mod .zip" local btnLabel = "Import mod .zip"
@@ -2165,15 +2352,15 @@ function RomImporter:_drawModsPanel(x, y, w, h)
local top = y + headerH + 14 * s local top = y + headerH + 14 * s
-- notice line: the last install result, else the drag-drop hint -- notice line: the last install/delete result, else the platform hint
love.graphics.setFont(self.hintFont) love.graphics.setFont(self.hintFont)
if self.modNotice then if self.modNotice then
col(self.modNotice.ok and PAL.green or PAL.red) col(self.modNotice.ok and PAL.green or PAL.red)
love.graphics.printf(self.modNotice.text, x, top, w, "left") love.graphics.printf(self.modNotice.text, x, top, w, "left")
else else
col(PAL.warning) col(PAL.warning)
love.graphics.printf(self.android and "Copy a mod .zip via USB." love.graphics.printf(self.android and "Or copy a mod .zip via USB."
or "Or drop a mod .zip onto the window.", x, top, w, "left") or Strings("Or drop a mod .zip onto the window."), x, top, w, "left")
end end
top = top + self.hintFont:getHeight() + 12 * s top = top + self.hintFont:getHeight() + 12 * s
@@ -2187,20 +2374,25 @@ function RomImporter:_drawModsPanel(x, y, w, h)
dashedRoundRect(x, top, w, boxH, 14 * s, 7 * s, 5 * s) dashedRoundRect(x, top, w, boxH, 14 * s, 7 * s, 5 * s)
love.graphics.setFont(self.hintFont) love.graphics.setFont(self.hintFont)
col(PAL.warning) col(PAL.warning)
love.graphics.printf("No mods installed - drop a mod .zip here to add one.", local emptyHint = self.android
and "No mods installed - tap Import mod .zip to add one."
or Strings("No mods installed - drop a mod .zip here to add one.")
love.graphics.printf(emptyHint,
x + 16 * s, top + boxH / 2 - self.hintFont:getHeight() / 2, w - 32 * s, "center") x + 16 * s, top + boxH / 2 - self.hintFont:getHeight() / 2, w - 32 * s, "center")
self.modRects = {} self.modRects = {}
self.modDeleteRects = {}
self._modMax = 0 self._modMax = 0
return return
end end
-- card metrics (design: rounded 14, padding 14x16; toggle 56x28) -- card metrics (design: rounded 14, padding 14x16; toggle 56x28; Delete under)
local padH, padV = 16 * s, 14 * s local padH, padV = 16 * s, 14 * s
local cardGap, cardR = 10 * s, 14 * s local cardGap, cardR = 10 * s, 14 * s
local tw, th = 52 * s, 28 * s local tw, th = 52 * s, 28 * s
local innerW = w - 2 * padH local innerW = w - 2 * padH
local chipH = self.hintFont:getHeight() + 8 * s local chipH = self.hintFont:getHeight() + 8 * s
local clusterH = chipH + 6 * s + th -- status chip stacked over the toggle local delH = self.hintFont:getHeight()
local clusterH = chipH + 6 * s + th + 6 * s + delH
love.graphics.setFont(self.stateFont) love.graphics.setFont(self.stateFont)
local nameH = self.stateFont:getHeight() local nameH = self.stateFont:getHeight()
@@ -2210,7 +2402,8 @@ function RomImporter:_drawModsPanel(x, y, w, h)
for i, m in ipairs(mods) do for i, m in ipairs(mods) do
local chipText = modStatusChip(m.status) local chipText = modStatusChip(m.status)
local chipW = self.hintFont:getWidth(chipText) + 20 * s local chipW = self.hintFont:getWidth(chipText) + 20 * s
local clusterW = math.max(chipW, tw) local delW = self.hintFont:getWidth("Delete")
local clusterW = math.max(chipW, tw, delW)
local leftW = math.max(40 * s, innerW - clusterW - 14 * s) local leftW = math.max(40 * s, innerW - clusterW - 14 * s)
local descH = 0 local descH = 0
if m.description ~= "" then if m.description ~= "" then
@@ -2221,7 +2414,7 @@ function RomImporter:_drawModsPanel(x, y, w, h)
local contentH = padV * 2 + nameH + (descH > 0 and (6 * s + descH) or 0) local contentH = padV * 2 + nameH + (descH > 0 and (6 * s + descH) or 0)
local cardH = math.max(contentH, padV * 2 + clusterH) local cardH = math.max(contentH, padV * 2 + clusterH)
layout[i] = { h = cardH, leftW = leftW, clusterW = clusterW, layout[i] = { h = cardH, leftW = leftW, clusterW = clusterW,
chipText = chipText, chipW = chipW } chipText = chipText, chipW = chipW, delW = delW }
total = total + cardH total = total + cardH
end end
total = total + (#mods - 1) * cardGap total = total + (#mods - 1) * cardGap
@@ -2231,6 +2424,7 @@ function RomImporter:_drawModsPanel(x, y, w, h)
local scroll = clamp(self.modScroll or 0, 0, maxScroll) local scroll = clamp(self.modScroll or 0, 0, maxScroll)
self.modScroll = scroll self.modScroll = scroll
self.modRects = {} self.modRects = {}
self.modDeleteRects = {}
love.graphics.setScissor(math.floor(x), math.floor(top), love.graphics.setScissor(math.floor(x), math.floor(top),
math.ceil(w), math.ceil(listH)) math.ceil(w), math.ceil(listH))
@@ -2269,7 +2463,7 @@ function RomImporter:_drawModsPanel(x, y, w, h)
love.graphics.printf(m.description, nx, ny + nameH + 6 * s, L.leftW, "left") love.graphics.printf(m.description, nx, ny + nameH + 6 * s, L.leftW, "left")
end end
-- right cluster: status chip stacked over the toggle, vertically centred -- right cluster: status chip, toggle, Delete — vertically centred
local clusterX = x + w - padH - L.clusterW local clusterX = x + w - padH - L.clusterW
local clusterY = cy + (cardH - clusterH) / 2 local clusterY = cy + (cardH - clusterH) / 2
local _, chipColor = modStatusChip(m.status) local _, chipColor = modStatusChip(m.status)
@@ -2303,14 +2497,29 @@ function RomImporter:_drawModsPanel(x, y, w, h)
col(PAL.white) col(PAL.white)
love.graphics.circle("fill", kcx, ty + th / 2, kd / 2) love.graphics.circle("fill", kcx, ty + th / 2, kd / 2)
-- hit rect clipped to the visible list band (a partly-scrolled toggle is -- Delete under the toggle
-- only clickable where it actually shows) local delX = clusterX + (L.clusterW - L.delW) / 2
local delY = ty + th + 6 * s
local drect = { x = delX - 6 * s, y = delY - 2 * s,
width = L.delW + 12 * s, height = delH + 4 * s, id = m.id }
local dhot = self:_hover(drect)
love.graphics.setFont(self.hintFont)
col(dhot and PAL.red or PAL.warning)
love.graphics.print("Delete", delX, delY)
-- hit rects clipped to the visible list band
local vy = math.max(trect.y, top) local vy = math.max(trect.y, top)
local vy2 = math.min(trect.y + trect.height, top + listH) local vy2 = math.min(trect.y + trect.height, top + listH)
if vy2 > vy then if vy2 > vy then
self.modRects[#self.modRects + 1] = self.modRects[#self.modRects + 1] =
{ x = trect.x, y = vy, width = trect.width, height = vy2 - vy, id = m.id } { x = trect.x, y = vy, width = trect.width, height = vy2 - vy, id = m.id }
end end
local dvy = math.max(drect.y, top)
local dvy2 = math.min(drect.y + drect.height, top + listH)
if dvy2 > dvy then
self.modDeleteRects[#self.modDeleteRects + 1] =
{ x = drect.x, y = dvy, width = drect.width, height = dvy2 - dvy, id = m.id }
end
end end
cy = cy + cardH + cardGap cy = cy + cardH + cardGap
end end
+9 -1
View File
@@ -47,12 +47,20 @@ local function readSource(source)
return source return source
end end
local f, openErr = io.open(source, "rb") local f, openErr = io.open(source, "rb")
if not f then return nil, "could not read the save file: " .. tostring(openErr) end if f then
local data = f:read("*a") local data = f:read("*a")
f:close() f:close()
if type(data) ~= "string" then return nil, "the save file was empty" end if type(data) ~= "string" then return nil, "the save file was empty" end
return data return data
end 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 -- importToSlot(source, version) -> ok, slotIdOrErr
-- source: an absolute path, a LOVE DroppedFile, or raw 32768 bytes. On success -- source: an absolute path, a LOVE DroppedFile, or raw 32768 bytes. On success
+48 -46
View File
@@ -12,6 +12,7 @@
local Pokemon = require("src.pokemon.Pokemon") local Pokemon = require("src.pokemon.Pokemon")
local Flags = require("src.script.Flags") local Flags = require("src.script.Flags")
local Strings = require("src.core.Strings")
local ItemEffects = {} local ItemEffects = {}
@@ -119,8 +120,8 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
if battle and (VITAMINS[itemId] or STONES[itemId] or itemId == "PP_UP" if battle and (VITAMINS[itemId] or STONES[itemId] or itemId == "PP_UP"
or itemId == "RARE_CANDY" or itemId == "COIN_CASE" or itemId == "RARE_CANDY" or itemId == "COIN_CASE"
or (itemDef and itemDef.machine)) then or (itemDef and itemDef.machine)) then
return "failed", { "OAK: " .. save.player.name return "failed", { Strings("OAK: %s!\nThis isn't the\ntime to use that!",
.. "!\nThis isn't the\ntime to use that!" } save.player.name) }
end end
if BALLS[itemId] then if BALLS[itemId] then
@@ -138,11 +139,12 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
local mapId, npc = adjacentSleepingSnorlax(save, ow) local mapId, npc = adjacentSleepingSnorlax(save, ow)
if npc then if npc then
return "flute_wake", { data.text._PlayedFluteHadEffectText return "flute_wake", { data.text._PlayedFluteHadEffectText
or "{PLAYER} played the\nPOKé FLUTE." }, { mapId = mapId, npc = npc } or Strings("{PLAYER} played the\nPOKé FLUTE.") },
{ mapId = mapId, npc = npc }
end end
-- otherwise: play the tune, nothing happens (ItemUsePokeFlute's -- otherwise: play the tune, nothing happens (ItemUsePokeFlute's
-- PlayedFluteNoEffectText branch) -- PlayedFluteNoEffectText branch)
return "flute_field", { "Played the POKé\nFLUTE.\fNow, that's a\ncatchy tune!" } return "flute_field", { Strings("Played the POKé\nFLUTE.\fNow, that's a\ncatchy tune!") }
end end
local woke = false local woke = false
local function wake(mon) local function wake(mon)
@@ -157,24 +159,24 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
-- WakeUpEntireParty runs on the enemy's bench too -- WakeUpEntireParty runs on the enemy's bench too
for _, mon in ipairs(battle.enemyParty or {}) do wake(mon) end for _, mon in ipairs(battle.enemyParty or {}) do wake(mon) end
if not woke then if not woke then
return "failed", { "Played the POKé\nFLUTE.\fNow, that's a\ncatchy tune!" } return "failed", { Strings("Played the POKé\nFLUTE.\fNow, that's a\ncatchy tune!") }
end end
return "flute", { ("%s played the\nPOKé FLUTE."):format(save.player.name), return "flute", { Strings("%s played the\nPOKé FLUTE.", save.player.name),
"All sleeping\nPOKéMON woke up!" } Strings("All sleeping\nPOKéMON woke up!") }
end end
-- battle-only items -- battle-only items
if X_ITEMS[itemId] or itemId == "DIRE_HIT" or itemId == "GUARD_SPEC" if X_ITEMS[itemId] or itemId == "DIRE_HIT" or itemId == "GUARD_SPEC"
or itemId == "POKE_DOLL" then or itemId == "POKE_DOLL" then
if not battle then if not battle then
return "failed", { "OAK: " .. save.player.name .. "!\nThis isn't the\ntime to use that!" } return "failed", { Strings("OAK: %s!\nThis isn't the\ntime to use that!", save.player.name) }
end end
local b = battle.player local b = battle.player
if itemId == "X_ACCURACY" then if itemId == "X_ACCURACY" then
-- ItemUseXAccuracy sets USING_X_ACCURACY: moves never miss -- ItemUseXAccuracy sets USING_X_ACCURACY: moves never miss
-- (not an accuracy stage) -- (not an accuracy stage)
b.xAccuracy = true b.xAccuracy = true
return "consumed", { ("%s's\nhits will never\nmiss!"):format(b.name) } return "consumed", { Strings("%s's\nhits will never\nmiss!", b.name) }
end end
if X_ITEMS[itemId] then if X_ITEMS[itemId] then
local stat = X_ITEMS[itemId] local stat = X_ITEMS[itemId]
@@ -183,28 +185,28 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
-- effect, so at +6 it is still consumed and StatModifierUpEffect -- effect, so at +6 it is still consumed and StatModifierUpEffect
-- just prints "Nothing happened!" -- just prints "Nothing happened!"
if cur >= 6 then if cur >= 6 then
return "consumed", { "Nothing happened!" } return "consumed", { Strings("Nothing happened!") }
end end
b.stages[stat] = cur + 1 b.stages[stat] = cur + 1
return "consumed", { ("%s's\n%s rose!"):format(b.name, stat:upper()) } return "consumed", { Strings("%s's\n%s rose!", b.name, stat:upper()) }
end end
-- ItemUseDireHit/ItemUseGuardSpec always set the bit and consume -- ItemUseDireHit/ItemUseGuardSpec always set the bit and consume
-- the item, even when it is already active -- the item, even when it is already active
if itemId == "DIRE_HIT" then if itemId == "DIRE_HIT" then
b.focusEnergy = true b.focusEnergy = true
return "consumed", { ("%s's\ngetting pumped!"):format(b.name) } return "consumed", { Strings("%s's\ngetting pumped!", b.name) }
end end
if itemId == "GUARD_SPEC" then if itemId == "GUARD_SPEC" then
b.mist = true b.mist = true
return "consumed", { ("%s's\nprotected against\nstat changes!"):format(b.name) } return "consumed", { Strings("%s's\nprotected against\nstat changes!", b.name) }
end end
if itemId == "POKE_DOLL" then if itemId == "POKE_DOLL" then
if battle.kind ~= "wild" then if battle.kind ~= "wild" then
-- ItemUsePokeDoll jumps to ItemUseNotTime in trainer battles -- ItemUsePokeDoll jumps to ItemUseNotTime in trainer battles
return "failed", { "OAK: " .. save.player.name return "failed", { Strings(
.. "!\nThis isn't the\ntime to use that!" } "OAK: %s!\nThis isn't the\ntime to use that!", save.player.name) }
end end
return "consumed_escape", { "The wild POKéMON\nran away!" } return "consumed_escape", { Strings("The wild POKéMON\nran away!") }
end end
end end
@@ -213,7 +215,7 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
-- restore every move with no menu. -- restore every move with no menu.
if itemId == "ETHER" or itemId == "MAX_ETHER" if itemId == "ETHER" or itemId == "MAX_ETHER"
or itemId == "ELIXER" or itemId == "MAX_ELIXER" then or itemId == "ELIXER" or itemId == "MAX_ELIXER" then
if not target then return "failed", { "It won't have\nany effect." } end if not target then return "failed", { Strings("It won't have\nany effect.") } end
local restored = false local restored = false
local full = itemId == "MAX_ETHER" or itemId == "MAX_ELIXER" local full = itemId == "MAX_ETHER" or itemId == "MAX_ELIXER"
local allMoves = itemId == "ELIXER" or itemId == "MAX_ELIXER" local allMoves = itemId == "ELIXER" or itemId == "MAX_ELIXER"
@@ -235,9 +237,9 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
restored = mv and restore(mv) or false restored = mv and restore(mv) or false
end end
if not restored then if not restored then
return "failed", { "It won't have\nany effect." } return "failed", { Strings("It won't have\nany effect.") }
end end
return "consumed", { ("%s's PP\nwas restored!"):format(monName(data, target)) } return "consumed", { Strings("%s's PP\nwas restored!", monName(data, target)) }
end end
local heal = HEAL_AMOUNT[itemId] local heal = HEAL_AMOUNT[itemId]
@@ -250,17 +252,17 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
target.status = nil target.status = nil
cureActiveToxic(battle, target) cureActiveToxic(battle, target)
require("src.core.Sound").play(data, "Heal_Ailment") require("src.core.Sound").play(data, "Heal_Ailment")
return "consumed", { ("%s's\nstatus returned\nto normal!"):format(monName(data, target)) } return "consumed", { Strings("%s's\nstatus returned\nto normal!", monName(data, target)) }
end end
if not target or target.hp <= 0 or target.hp >= target.stats.hp then if not target or target.hp <= 0 or target.hp >= target.stats.hp then
return "failed", { "It won't have\nany effect." } return "failed", { Strings("It won't have\nany effect.") }
end end
if itemId == "MAX_POTION" or itemId == "FULL_RESTORE" then if itemId == "MAX_POTION" or itemId == "FULL_RESTORE" then
target.hp = target.stats.hp target.hp = target.stats.hp
else else
target.hp = math.min(target.stats.hp, target.hp + heal) target.hp = math.min(target.stats.hp, target.hp + heal)
end end
local msgs = { ("%s's HP\nwas restored!"):format(monName(data, target)) } local msgs = { Strings("%s's HP\nwas restored!", monName(data, target)) }
if itemId == "FULL_RESTORE" then if itemId == "FULL_RESTORE" then
target.status = nil target.status = nil
cureActiveToxic(battle, target) cureActiveToxic(battle, target)
@@ -272,27 +274,27 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
local cures = STATUS_HEAL[itemId] local cures = STATUS_HEAL[itemId]
if cures then if cures then
if not target or not target.status or not cures[target.status] then if not target or not target.status or not cures[target.status] then
return "failed", { "It won't have\nany effect." } return "failed", { Strings("It won't have\nany effect.") }
end end
target.status = nil target.status = nil
cureActiveToxic(battle, target) cureActiveToxic(battle, target)
require("src.core.Sound").play(data, "Heal_Ailment") require("src.core.Sound").play(data, "Heal_Ailment")
return "consumed", { ("%s's\nstatus returned\nto normal!"):format(monName(data, target)) } return "consumed", { Strings("%s's\nstatus returned\nto normal!", monName(data, target)) }
end end
if itemId == "REVIVE" or itemId == "MAX_REVIVE" then if itemId == "REVIVE" or itemId == "MAX_REVIVE" then
if not target or target.hp > 0 then if not target or target.hp > 0 then
return "failed", { "It won't have\nany effect." } return "failed", { Strings("It won't have\nany effect.") }
end end
target.status = nil target.status = nil
target.hp = itemId == "REVIVE" and math.floor(target.stats.hp / 2) or target.stats.hp target.hp = itemId == "REVIVE" and math.floor(target.stats.hp / 2) or target.stats.hp
require("src.core.Sound").play(data, "Heal_HP") require("src.core.Sound").play(data, "Heal_HP")
return "consumed", { ("%s\nis revitalized!"):format(monName(data, target)) } return "consumed", { Strings("%s\nis revitalized!", monName(data, target)) }
end end
if itemId == "RARE_CANDY" then if itemId == "RARE_CANDY" then
if not target or target.level >= 100 then if not target or target.level >= 100 then
return "failed", { "It won't have\nany effect." } return "failed", { Strings("It won't have\nany effect.") }
end end
local Growth = require("src.pokemon.Growth") local Growth = require("src.pokemon.Growth")
local Stats = require("src.pokemon.Stats") local Stats = require("src.pokemon.Stats")
@@ -302,56 +304,56 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
local old = target.stats local old = target.stats
target.stats = Stats.calc(speciesDef, target.level, target.dvs, target.statExp) target.stats = Stats.calc(speciesDef, target.level, target.dvs, target.statExp)
target.hp = math.min(target.stats.hp, target.hp + (target.stats.hp - old.hp)) target.hp = math.min(target.stats.hp, target.hp + (target.stats.hp - old.hp))
return "consumed", { ("%s grew\nto level %d!"):format(monName(data, target), target.level) }, return "consumed", { Strings("%s grew\nto level %d!", monName(data, target), target.level) },
{ leveledTo = target.level } { leveledTo = target.level }
end end
if STONES[itemId] then if STONES[itemId] then
if not target then return "failed", { "It won't have\nany effect." } end if not target then return "failed", { Strings("It won't have\nany effect.") } end
local speciesDef = data.pokemon[target.species] local speciesDef = data.pokemon[target.species]
for _, evo in ipairs(speciesDef.evolutions) do for _, evo in ipairs(speciesDef.evolutions) do
if evo.method == "ITEM" and evo.item == itemId then if evo.method == "ITEM" and evo.item == itemId then
return "consumed", nil, { evolveTo = evo.species } return "consumed", nil, { evolveTo = evo.species }
end end
end end
return "failed", { "It won't have\nany effect." } return "failed", { Strings("It won't have\nany effect.") }
end end
-- vitamins: +2560 stat exp, refused at 25600+ (ItemUseVitamin, -- vitamins: +2560 stat exp, refused at 25600+ (ItemUseVitamin,
-- engine/items/item_effects.asm) -- engine/items/item_effects.asm)
local vitaminStat = VITAMINS[itemId] local vitaminStat = VITAMINS[itemId]
if vitaminStat then if vitaminStat then
if not target then return "failed", { "It won't have\nany effect." } end if not target then return "failed", { Strings("It won't have\nany effect.") } end
target.statExp = target.statExp or {} target.statExp = target.statExp or {}
local cur = target.statExp[vitaminStat] or 0 local cur = target.statExp[vitaminStat] or 0
if cur >= 25600 then if cur >= 25600 then
return "failed", { "It won't have\nany effect." } return "failed", { Strings("It won't have\nany effect.") }
end end
target.statExp[vitaminStat] = math.min(65535, cur + 2560) target.statExp[vitaminStat] = math.min(65535, cur + 2560)
local Stats = require("src.pokemon.Stats") local Stats = require("src.pokemon.Stats")
target.stats = Stats.calc(data.pokemon[target.species], target.level, target.stats = Stats.calc(data.pokemon[target.species], target.level,
target.dvs, target.statExp) target.dvs, target.statExp)
target.hp = math.min(target.hp, target.stats.hp) target.hp = math.min(target.hp, target.stats.hp)
return "consumed", { ("%s's %s\nrose!"):format(monName(data, target), return "consumed", { Strings("%s's %s\nrose!", monName(data, target),
vitaminStat == "hp" and "HP" or vitaminStat:upper()) } vitaminStat == "hp" and "HP" or vitaminStat:upper()) }
end end
-- PP UP boosts the move the player picked (ItemUsePPUp's move menu) -- PP UP boosts the move the player picked (ItemUsePPUp's move menu)
if itemId == "PP_UP" then if itemId == "PP_UP" then
if not target then return "failed", { "It won't have\nany effect." } end if not target then return "failed", { Strings("It won't have\nany effect.") } end
local mv = target.moves[moveIndex or 1] local mv = target.moves[moveIndex or 1]
local mdef = mv and data.moves[mv.id] local mdef = mv and data.moves[mv.id]
if mdef and (mv.ppUps or 0) < 3 then if mdef and (mv.ppUps or 0) < 3 then
mv.ppUps = (mv.ppUps or 0) + 1 mv.ppUps = (mv.ppUps or 0) + 1
-- each PP UP adds maxPP/5 uses on top of the base maximum -- each PP UP adds maxPP/5 uses on top of the base maximum
mv.pp = mv.pp + math.floor(mdef.pp / 5) mv.pp = mv.pp + math.floor(mdef.pp / 5)
return "consumed", { ("%s's PP\nincreased!"):format(mdef.name) } return "consumed", { Strings("%s's PP\nincreased!", mdef.name) }
end end
return "failed", { "It won't have\nany effect." } return "failed", { Strings("It won't have\nany effect.") }
end end
if itemDef and itemDef.machine then if itemDef and itemDef.machine then
if not target then return "failed", { "It won't have\nany effect." } end if not target then return "failed", { Strings("It won't have\nany effect.") } end
local speciesDef = data.pokemon[target.species] local speciesDef = data.pokemon[target.species]
local ok = false local ok = false
for _, m in ipairs(speciesDef.tmhm) do for _, m in ipairs(speciesDef.tmhm) do
@@ -362,11 +364,11 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
-- plays SFX_DENIED before MonCannotLearnMachineMoveText (the generic -- plays SFX_DENIED before MonCannotLearnMachineMoveText (the generic
-- ItemUseNotTime/NoCyclingAllowedHere paths are silent) -- ItemUseNotTime/NoCyclingAllowedHere paths are silent)
require("src.core.Sound").play(data, "Denied") require("src.core.Sound").play(data, "Denied")
return "failed", { ("%s can't\nlearn that move!"):format(monName(data, target)) } return "failed", { Strings("%s can't\nlearn that move!", monName(data, target)) }
end end
for _, mv in ipairs(target.moves) do for _, mv in ipairs(target.moves) do
if mv.id == itemDef.machine.move then if mv.id == itemDef.machine.move then
return "failed", { "It knows that\nmove already!" } return "failed", { Strings("It knows that\nmove already!") }
end end
end end
-- HMs are never consumed; TMs are single-use -- HMs are never consumed; TMs are single-use
@@ -375,14 +377,14 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
if itemId == "OLD_ROD" or itemId == "GOOD_ROD" or itemId == "SUPER_ROD" then if itemId == "OLD_ROD" or itemId == "GOOD_ROD" or itemId == "SUPER_ROD" then
if battle then if battle then
return "failed", { "OAK: " .. save.player.name .. "!\nThis isn't the\ntime to use that!" } return "failed", { Strings("OAK: %s!\nThis isn't the\ntime to use that!", save.player.name) }
end end
return "fish", itemId return "fish", itemId
end end
if itemId == "BICYCLE" then if itemId == "BICYCLE" then
if battle then if battle then
return "failed", { "OAK: " .. save.player.name .. "!\nThis isn't the\ntime to use that!" } return "failed", { Strings("OAK: %s!\nThis isn't the\ntime to use that!", save.player.name) }
end end
return "bicycle" return "bicycle"
end end
@@ -392,26 +394,26 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
end end
if itemId == "TOWN_MAP" then if itemId == "TOWN_MAP" then
if battle then if battle then
return "failed", { "OAK: " .. save.player.name .. "!\nThis isn't the\ntime to use that!" } return "failed", { Strings("OAK: %s!\nThis isn't the\ntime to use that!", save.player.name) }
end end
return "townmap" return "townmap"
end end
if itemId == "ITEMFINDER" then if itemId == "ITEMFINDER" then
if battle then if battle then
return "failed", { "OAK: " .. save.player.name .. "!\nThis isn't the\ntime to use that!" } return "failed", { Strings("OAK: %s!\nThis isn't the\ntime to use that!", save.player.name) }
end end
return "itemfinder" return "itemfinder"
end end
if itemId == "COIN_CASE" then if itemId == "COIN_CASE" then
return "failed", { ("Coin count:\n%d"):format(save.coins or 0) } return "failed", { Strings("Coin count:\n%d", save.coins or 0) }
end end
if itemId == "REPEL" or itemId == "SUPER_REPEL" or itemId == "MAX_REPEL" then if itemId == "REPEL" or itemId == "SUPER_REPEL" or itemId == "MAX_REPEL" then
local steps = itemId == "REPEL" and 100 or itemId == "SUPER_REPEL" and 200 or 250 local steps = itemId == "REPEL" and 100 or itemId == "SUPER_REPEL" and 200 or 250
save.repelSteps = steps save.repelSteps = steps
return "consumed", { ("%s used\n%s!"):format(save.player.name, name) } return "consumed", { Strings("%s used\n%s!", save.player.name, name) }
end end
return "failed", { "OAK: " .. save.player.name .. "!\nThis isn't the\ntime to use that!" } return "failed", { Strings("OAK: %s!\nThis isn't the\ntime to use that!", save.player.name) }
end end
return ItemEffects return ItemEffects
+25 -24
View File
@@ -21,6 +21,7 @@ local Logger = require("src.core.Logger")
local Protocol = require("src.link.Protocol") local Protocol = require("src.link.Protocol")
local Runtime = require("src.mods.Runtime") local Runtime = require("src.mods.Runtime")
local TurnOrder = require("src.battle.TurnOrder") local TurnOrder = require("src.battle.TurnOrder")
local Strings = require("src.core.Strings")
local LinkBattle = {} local LinkBattle = {}
@@ -177,18 +178,18 @@ function LinkBattle.new(game, net, opts)
local theirName = opts.theirName or "FOE" local theirName = opts.theirName or "FOE"
if not Handshake.battleAllowed(opts.verdict) then if not Handshake.battleAllowed(opts.verdict) then
return nil, "Link battle needs\nthe same mods on\nboth games." return nil, Strings("Link battle needs\nthe same mods on\nboth games.")
end end
-- both parties pass through the same pack->unpack clamp on both -- both parties pass through the same pack->unpack clamp on both
-- machines, so the copies are identical everywhere -- machines, so the copies are identical everywhere
local unpackOpts = { strict = opts.strict or false, forceLevel = opts.forceLevel } local unpackOpts = { strict = opts.strict or false, forceLevel = opts.forceLevel }
local myParty, myErr = unpackParty(game, opts.myParty, unpackOpts, function(p) local myParty, myErr = unpackParty(game, opts.myParty, unpackOpts, function(p)
return ("Your %s can't\nbattle on the\nother game."):format(tostring(p.species)) return Strings("Your %s can't\nbattle on the\nother game.", tostring(p.species))
end) end)
if not myParty then return nil, myErr end if not myParty then return nil, myErr end
local theirParty, theirErr = unpackParty(game, opts.theirParty, unpackOpts, function(p, why) local theirParty, theirErr = unpackParty(game, opts.theirParty, unpackOpts, function(p, why)
return ("Their %s isn't\nin this game.\n(%s)"):format(tostring(p.species), tostring(why)) return Strings("Their %s isn't\nin this game.\n(%s)", tostring(p.species), tostring(why))
end) end)
if not theirParty then return nil, theirErr end if not theirParty then return nil, theirErr end
if #myParty == 0 or #theirParty == 0 then if #myParty == 0 or #theirParty == 0 then
@@ -232,7 +233,7 @@ function LinkBattle.new(game, net, opts)
self.enemyParty = theirParty self.enemyParty = theirParty
self.playerParty = myParty -- intro ball row uses the clamped copies self.playerParty = myParty -- intro ball row uses the clamped copies
self.opponentName = theirName self.opponentName = theirName
self.introText = ("%s wants\nto battle!"):format(theirName) self.introText = Strings("%s wants\nto battle!", theirName)
self.remoteHashes = {} self.remoteHashes = {}
self.localHashes = {} self.localHashes = {}
self.remoteParts = {} self.remoteParts = {}
@@ -292,7 +293,7 @@ function LinkBattle.new(game, net, opts)
battle = s, side = s.sides[2], battler = s.enemy, previous = previous, battle = s, side = s.sides[2], battler = s.enemy, previous = previous,
}) })
s.enemySendingOut = true s.enemySendingOut = true
s:sayNext(("%s sent\nout %s!"):format(theirName, s.enemy.name)) s:sayNext(Strings("%s sent\nout %s!", theirName, s.enemy.name))
s:actNext(function() s:actNext(function()
s.enemySendingOut = false s.enemySendingOut = false
s:startGrowIn(s.enemy) s:startGrowIn(s.enemy)
@@ -315,8 +316,9 @@ function LinkBattle.new(game, net, opts)
tostring(turn), component, tostring(localH), tostring(remoteH)) tostring(turn), component, tostring(localH), tostring(remoteH))
Runtime.emit("link.desync", { turn = turn, component = component, Runtime.emit("link.desync", { turn = turn, component = component,
localHash = localH, remoteHash = remoteH }) localHash = localH, remoteHash = remoteH })
endAsDraw(s, ("Link desync!\n%s differs.\fAre both games\nrunning the same\nmods?") endAsDraw(s, Strings(
:format(component)) "Link desync!\n%s differs.\fAre both games\nrunning the same\nmods?",
component))
end end
-- a verified turn stays recorded: consuming it here left a finished -- a verified turn stays recorded: consuming it here left a finished
@@ -349,7 +351,7 @@ function LinkBattle.new(game, net, opts)
local function resolveLockstep(s, myMsg, theirMsg) local function resolveLockstep(s, myMsg, theirMsg)
if myMsg.kind == "run" or theirMsg.kind == "run" then if myMsg.kind == "run" or theirMsg.kind == "run" then
local who = myMsg.kind == "run" and game.save.player.name or theirName local who = myMsg.kind == "run" and game.save.player.name or theirName
endAsDraw(s, ("%s ran from\nthe battle!"):format(who)) endAsDraw(s, Strings("%s ran from\nthe battle!", who))
return return
end end
s.phase = "messages" s.phase = "messages"
@@ -479,9 +481,9 @@ function LinkBattle.new(game, net, opts)
party = myParty, party = myParty,
onSwitch = function(mon) onSwitch = function(mon)
if mon == s.player.mon then if mon == s.player.mon then
s:say(("%s is\nalready out!"):format(s.player.name)) s:say(Strings("%s is\nalready out!", s.player.name))
elseif mon.hp <= 0 then elseif mon.hp <= 0 then
s:say("There's no will\nto fight!") s:say(Strings("There's no will\nto fight!"))
else else
s:resolveSwitch(mon) s:resolveSwitch(mon)
end end
@@ -491,7 +493,7 @@ function LinkBattle.new(game, net, opts)
end end
self.openItems = function(s) self.openItems = function(s)
s:say("Items can't be\nused in a link\nbattle!") s:say(Strings("Items can't be\nused in a link\nbattle!"))
s.phase = "messages" s.phase = "messages"
s.afterQueue = "menu" s.afterQueue = "menu"
end end
@@ -509,7 +511,7 @@ function LinkBattle.new(game, net, opts)
return return
end end
end end
s:sayNext(("%s is out of\nPOKéMON!\f%s wins!"):format(game.save.player.name, s:sayNext(Strings("%s is out of\nPOKéMON!\f%s wins!", game.save.player.name,
theirName)) theirName))
s.result = "lose" s.result = "lose"
s.afterQueue = "finish" s.afterQueue = "finish"
@@ -522,7 +524,7 @@ function LinkBattle.new(game, net, opts)
return return
end end
end end
s:sayNext(("%s is out of\nPOKéMON!\f%s wins!"):format(theirName, s:sayNext(Strings("%s is out of\nPOKéMON!\f%s wins!", theirName,
game.save.player.name)) game.save.player.name))
s.result = "win" s.result = "win"
s.afterQueue = "finish" s.afterQueue = "finish"
@@ -543,13 +545,13 @@ function LinkBattle.new(game, net, opts)
-- only a draw if our own simulation hasn't already decided -- only a draw if our own simulation hasn't already decided
-- (the winner's bye can arrive while we're still animating) -- (the winner's bye can arrive while we're still animating)
if not s.result then if not s.result then
endAsDraw(s, ("%s left the\nbattle."):format(theirName)) endAsDraw(s, Strings("%s left the\nbattle.", theirName))
end end
elseif msg.type == "forfeit" then elseif msg.type == "forfeit" then
-- the peer's own shot clock ran out; unlike a mutual RUN/desync -- the peer's own shot clock ran out; unlike a mutual RUN/desync
-- draw, this has a definite winner (us) -- draw, this has a definite winner (us)
if not s.result then if not s.result then
endWithResult(s, "win", ("%s ran out of\ntime!"):format(theirName)) endWithResult(s, "win", Strings("%s ran out of\ntime!", theirName))
end end
else else
-- a tournament control message (bracket_update, the next -- a tournament control message (bracket_update, the next
@@ -582,7 +584,7 @@ function LinkBattle.new(game, net, opts)
if s.turnClock <= 0 then if s.turnClock <= 0 then
s.turnClockActive = false s.turnClockActive = false
send({ type = "forfeit" }) send({ type = "forfeit" })
endWithResult(s, "lose", "Time's up! You\nforfeit the match.") endWithResult(s, "lose", Strings("Time's up! You\nforfeit the match."))
end end
elseif opts.turnLimit then elseif opts.turnLimit then
s.turnClockActive = false s.turnClockActive = false
@@ -643,17 +645,16 @@ function LinkBattle.newSpectator(game, net, opts)
local guestName = opts.guestName or "GUEST" local guestName = opts.guestName or "GUEST"
if not Handshake.battleAllowed(opts.verdict) then if not Handshake.battleAllowed(opts.verdict) then
return nil, "Link battle needs\nthe same mods on\nboth games." return nil, Strings("Link battle needs\nthe same mods on\nboth games.")
end end
local unpackOpts = { strict = opts.strict or false, forceLevel = opts.forceLevel } local unpackOpts = { strict = opts.strict or false, forceLevel = opts.forceLevel }
local hostParty, hostErr = unpackParty(game, opts.hostParty, unpackOpts, function(p) local hostParty, hostErr = unpackParty(game, opts.hostParty, unpackOpts, function(p)
return ("%s's %s can't\nbattle on this\ngame."):format(hostName, tostring(p.species)) return Strings("%s's %s can't\nbattle on this\ngame.", hostName, tostring(p.species))
end) end)
if not hostParty then return nil, hostErr end if not hostParty then return nil, hostErr end
local guestParty, guestErr = unpackParty(game, opts.guestParty, unpackOpts, function(p, why) local guestParty, guestErr = unpackParty(game, opts.guestParty, unpackOpts, function(p, why)
return ("%s's %s can't\nbattle on this\ngame.\n(%s)"):format( return Strings("%s's %s can't\nbattle on this\ngame.\n(%s)", guestName, tostring(p.species), tostring(why))
guestName, tostring(p.species), tostring(why))
end) end)
if not guestParty then return nil, guestErr end if not guestParty then return nil, guestErr end
if #hostParty == 0 or #guestParty == 0 then if #hostParty == 0 or #guestParty == 0 then
@@ -672,7 +673,7 @@ function LinkBattle.newSpectator(game, net, opts)
self.enemyParty = guestParty self.enemyParty = guestParty
self.playerParty = hostParty self.playerParty = hostParty
self.opponentName = guestName self.opponentName = guestName
self.introText = ("%s vs %s!"):format(hostName, guestName) self.introText = Strings("%s vs %s!", hostName, guestName)
local function orderMove(action) local function orderMove(action)
if action and action.id then return game.data.moves[action.id] end if action and action.id then return game.data.moves[action.id] end
@@ -712,7 +713,7 @@ function LinkBattle.newSpectator(game, net, opts)
battle = s, side = s.sides[2], battler = s.enemy, previous = previous, battle = s, side = s.sides[2], battler = s.enemy, previous = previous,
}) })
s.enemySendingOut = true s.enemySendingOut = true
s:sayNext(("%s sent\nout %s!"):format(guestName, s.enemy.name)) s:sayNext(Strings("%s sent\nout %s!", guestName, s.enemy.name))
s:actNext(function() s:actNext(function()
s.enemySendingOut = false s.enemySendingOut = false
s:startGrowIn(s.enemy) s:startGrowIn(s.enemy)
@@ -789,7 +790,7 @@ function LinkBattle.newSpectator(game, net, opts)
return return
end end
end end
s:sayNext(("%s is out of\nPOKéMON!\f%s wins!"):format(hostName, guestName)) s:sayNext(Strings("%s is out of\nPOKéMON!\f%s wins!", hostName, guestName))
s.result = "guestWin" s.result = "guestWin"
s.afterQueue = "finish" s.afterQueue = "finish"
end end
@@ -801,7 +802,7 @@ function LinkBattle.newSpectator(game, net, opts)
return return
end end
end end
s:sayNext(("%s is out of\nPOKéMON!\f%s wins!"):format(guestName, hostName)) s:sayNext(Strings("%s is out of\nPOKéMON!\f%s wins!", guestName, hostName))
s.result = "hostWin" s.result = "hostWin"
s.afterQueue = "finish" s.afterQueue = "finish"
end end
+67 -47
View File
@@ -11,6 +11,7 @@ local Protocol = require("src.link.Protocol")
local Runtime = require("src.mods.Runtime") local Runtime = require("src.mods.Runtime")
local Screens = require("src.ui.Screens") local Screens = require("src.ui.Screens")
local TextBox = require("src.render.TextBox") local TextBox = require("src.render.TextBox")
local Strings = require("src.core.Strings")
local LinkState = {} local LinkState = {}
LinkState.__index = LinkState LinkState.__index = LinkState
@@ -31,7 +32,12 @@ local function indexOf(list, value)
end end
local function levelForWire(v) 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 end
local function forceLevelLabel(v) local function forceLevelLabel(v)
@@ -85,7 +91,7 @@ function LinkState.newJoinOnline(game, code)
self.stage = "onlineJoining" self.stage = "onlineJoining"
else else
self.stage = "menu" -- exitWith below needs a real stage to unwind from self.stage = "menu" -- exitWith below needs a real stage to unwind from
self:exitWith("Link error:\n" .. (self.net.error or "?")) self:exitWith(Strings("Link error:\n%s", self.net.error or "?"))
end end
return self return self
end end
@@ -169,7 +175,7 @@ function LinkState:update(dt)
if self.net then if self.net then
self.net:update() self.net:update()
if self.net.error and not PRE_CONNECT_STAGES[self.stage] then if self.net.error and not PRE_CONNECT_STAGES[self.stage] then
self:exitWith("Link error:\n" .. self.net.error:sub(1, 60)) self:exitWith(Strings("Link error:\n%s", self.net.error:sub(1, 60)))
return return
end end
-- the peer vanished without a bye (only once the inbox is drained, -- the peer vanished without a bye (only once the inbox is drained,
@@ -178,7 +184,7 @@ function LinkState:update(dt)
and not PRE_CONNECT_STAGES[self.stage] and self.stage ~= "addrEntry" and not PRE_CONNECT_STAGES[self.stage] and self.stage ~= "addrEntry"
and self.stage ~= "codeEntry" and self.stage ~= "notice" and self.stage ~= "codeEntry" and self.stage ~= "notice"
and self.stage ~= "battleRunning" then and self.stage ~= "battleRunning" then
self:exitWith("The link was\nbroken.") self:exitWith(Strings("The link was\nbroken."))
return return
end end
end end
@@ -196,7 +202,7 @@ function LinkState:update(dt)
self.index = 1 self.index = 1
elseif self.index == 2 or self.index == 3 then elseif self.index == 2 or self.index == 3 then
if not Handshake.onlineAllowed(self.game) then if not Handshake.onlineAllowed(self.game) then
self:exitWith("Online play needs\nno mods enabled.\fDisable them in\nSTART > MODS.") self:exitWith(Strings("Online play needs\nno mods enabled.\fDisable them in\nSTART > MODS."))
return return
end end
if self.index == 2 then if self.index == 2 then
@@ -222,7 +228,7 @@ function LinkState:update(dt)
if self.net:host() then if self.net:host() then
self.stage = "hosting" self.stage = "hosting"
else else
self:exitWith("Link error:\n" .. (self.net.error or "?")) self:exitWith(Strings("Link error:\n%s", self.net.error or "?"))
end end
else else
self.stage = "addrEntry" self.stage = "addrEntry"
@@ -241,7 +247,7 @@ function LinkState:update(dt)
if self.net:hostOnline() then if self.net:hostOnline() then
self.stage = "onlineHosting" self.stage = "onlineHosting"
else else
self:exitWith("Link error:\n" .. (self.net.error or "?")) self:exitWith(Strings("Link error:\n%s", self.net.error or "?"))
end end
else else
self.stage = "codeEntry" self.stage = "codeEntry"
@@ -279,7 +285,7 @@ function LinkState:update(dt)
if self.net:joinOnline(nil, code) then if self.net:joinOnline(nil, code) then
self.stage = "onlineJoining" self.stage = "onlineJoining"
else else
self:exitWith("Link error:\n" .. (self.net.error or "?")) self:exitWith(Strings("Link error:\n%s", self.net.error or "?"))
end end
end end
@@ -318,7 +324,7 @@ function LinkState:update(dt)
if self.net:join(table.concat(octets, ".")) then if self.net:join(table.concat(octets, ".")) then
self.stage = "joining" self.stage = "joining"
else else
self:exitWith("Link error:\n" .. (self.net.error or "?")) self:exitWith(Strings("Link error:\n%s", self.net.error or "?"))
end end
end end
@@ -428,7 +434,7 @@ function LinkState:update(dt)
end end
if not battle then if not battle then
self.net:send({ type = "bye" }) self.net:send({ type = "bye" })
self:exitWith(why or "Link battle\ncan't start.", "error") self:exitWith(why or Strings("Link battle\ncan't start."), "error")
return return
end end
self.game.stack:push(battle) self.game.stack:push(battle)
@@ -486,13 +492,21 @@ function LinkState:updateTrade(input)
local t = self.trade local t = self.trade
if t.stage == "cancelled" then if t.stage == "cancelled" then
self:exitWith(t.error and ("The trade stopped:\n%s."):format(t.error) self:exitWith(t.error and Strings("The trade stopped:\n%s.", t.error)
or "The trade was\ncancelled.") or Strings("The trade was\ncancelled."))
return return
end end
if t.stage == "done" then if t.stage == "done" then
local sent = t.party[t.myPick] local sent = t.party[t.myPick]
local received, evoTo = t:apply(self.game) 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 local name = received.nickname or self.game.data.pokemon[received.species].name
Runtime.emit("link.ended", { reason = "done" }) Runtime.emit("link.ended", { reason = "done" })
self.net:close() self.net:close()
@@ -507,10 +521,16 @@ function LinkState:updateTrade(input)
enemyOtId = received.otId, enemyOtId = received.otId,
onDone = function() onDone = function()
game.stack:push(TextBox.new(game, game.stack:push(TextBox.new(game,
("Trade completed!\f%s received\n%s!"):format(game.save.player.name, name), Strings("Trade completed!\f%s received\n%s!", game.save.player.name, name),
function() function()
if evoTo then if evoTo then
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)) end))
end, end,
@@ -528,7 +548,7 @@ function LinkState:updateTrade(input)
-- the trade) -- B is dead after that, matching the A branch's own -- the trade) -- B is dead after that, matching the A branch's own
-- self.confirmed == nil guard -- self.confirmed == nil guard
self.net:send({ type = "bye" }) self.net:send({ type = "bye" })
self:exitWith("The trade was\ncancelled.") self:exitWith(Strings("The trade was\ncancelled."))
elseif t.stage == "picking" and input:wasPressed("a") then elseif t.stage == "picking" and input:wasPressed("a") then
if t:canPick(self.index) then if t:canPick(self.index) then
self.net:send(t:pick(self.index)) self.net:send(t:pick(self.index))
@@ -555,30 +575,30 @@ end
function LinkState:draw() function LinkState:draw()
if self.stage == "menu" then if self.stage == "menu" then
drawTitle("BOIS CLUB LIVE") drawTitle("BOIS CLUB LIVE")
Font.draw("LINK CABLE (LAN)", 32, 44) Font.draw(Strings("LINK CABLE (LAN)"), 32, 44)
Font.draw("ONLINE MATCH", 32, 60) Font.draw(Strings("ONLINE MATCH"), 32, 60)
Font.draw("TOURNAMENT", 32, 76) Font.draw(Strings("TOURNAMENT"), 32, 76)
Font.drawCode(CURSOR, 24, 44 + (self.index - 1) * 16) Font.drawCode(CURSOR, 24, 44 + (self.index - 1) * 16)
elseif self.stage == "lanMenu" then elseif self.stage == "lanMenu" then
drawTitle("LINK CABLE (LAN)") drawTitle("LINK CABLE (LAN)")
Font.draw("HOST A GAME", 32, 48) Font.draw(Strings("HOST A GAME"), 32, 48)
Font.draw("JOIN A GAME", 32, 68) Font.draw(Strings("JOIN A GAME"), 32, 68)
Font.drawCode(CURSOR, 24, self.index == 1 and 48 or 68) Font.drawCode(CURSOR, 24, self.index == 1 and 48 or 68)
Font.draw("UDP port " .. Net.defaultPort(), 8, 128) Font.draw(Strings("UDP port %s", Net.defaultPort()), 8, 128)
elseif self.stage == "onlineMenu" then elseif self.stage == "onlineMenu" then
drawTitle("ONLINE MATCH") drawTitle("ONLINE MATCH")
Font.draw("HOST ONLINE", 32, 48) Font.draw(Strings("HOST ONLINE"), 32, 48)
Font.draw("JOIN ONLINE", 32, 68) Font.draw(Strings("JOIN ONLINE"), 32, 68)
Font.drawCode(CURSOR, 24, self.index == 1 and 48 or 68) Font.drawCode(CURSOR, 24, self.index == 1 and 48 or 68)
elseif self.stage == "onlineHosting" then elseif self.stage == "onlineHosting" then
drawTitle("HOSTING ONLINE") drawTitle("HOSTING ONLINE")
Font.draw("Tell your friend", 16, 40) Font.draw(Strings("Tell your friend"), 16, 40)
Font.draw("the code:", 16, 52) Font.draw(Strings("the code:"), 16, 52)
Font.draw(self.net.code or "??????", 32, 68) Font.draw(self.net.code or "??????", 32, 68)
Font.draw("Waiting for join...", 8, 96) Font.draw(Strings("Waiting for join..."), 8, 96)
elseif self.stage == "codeEntry" then elseif self.stage == "codeEntry" then
drawTitle("ENTER CODE") drawTitle("ENTER CODE")
@@ -590,18 +610,18 @@ function LinkState:draw()
Font.drawCode(0xEE, x, 76) -- ▼ under the active slot Font.drawCode(0xEE, x, 76) -- ▼ under the active slot
end end
end end
Font.draw("A: connect B: back", 8, 128) Font.draw(Strings("A: connect B: back"), 8, 128)
elseif self.stage == "onlineJoining" then elseif self.stage == "onlineJoining" then
drawTitle("CONNECTING...") drawTitle("CONNECTING...")
Font.draw("Calling...", 8, 56) Font.draw(Strings("Calling..."), 8, 56)
Font.draw(self.net.target or "", 8, 72) Font.draw(self.net.target or "", 8, 72)
elseif self.stage == "hosting" then elseif self.stage == "hosting" then
drawTitle("HOSTING") drawTitle("HOSTING")
Font.draw("Friend joins at:", 16, 48) Font.draw(Strings("Friend joins at:"), 16, 48)
Font.draw(self.net.address or "?", 16, 64) Font.draw(self.net.address or "?", 16, 64)
Font.draw("Waiting for join...", 8, 96) Font.draw(Strings("Waiting for join..."), 8, 96)
elseif self.stage == "addrEntry" then elseif self.stage == "addrEntry" then
drawTitle("ENTER HOST ADDRESS") drawTitle("ENTER HOST ADDRESS")
@@ -616,34 +636,34 @@ function LinkState:draw()
for octet = 1, 3 do for octet = 1, 3 do
Font.draw(".", 16 + octet * 32 - 8, 64) Font.draw(".", 16 + octet * 32 - 8, 64)
end end
Font.draw("Port: " .. Net.defaultPort(), 16, 96) Font.draw(Strings("Port: %s", Net.defaultPort()), 16, 96)
Font.draw("A: connect B: back", 8, 128) Font.draw(Strings("A: connect B: back"), 8, 128)
elseif self.stage == "joining" then elseif self.stage == "joining" then
drawTitle("JOINING...") drawTitle("JOINING...")
Font.draw("Calling...", 8, 56) Font.draw(Strings("Calling..."), 8, 56)
Font.draw(self.net.target or "", 8, 72) Font.draw(self.net.target or "", 8, 72)
elseif self.stage == "modeSelect" then elseif self.stage == "modeSelect" then
drawTitle("CONNECTED!") drawTitle("CONNECTED!")
Font.draw("TRADE", 32, 48) Font.draw(Strings("TRADE"), 32, 48)
Font.draw("BATTLE", 32, 68) Font.draw(Strings("BATTLE"), 32, 68)
Font.drawCode(CURSOR, 24, self.index == 1 and 48 or 68) Font.drawCode(CURSOR, 24, self.index == 1 and 48 or 68)
elseif self.stage == "battleOptions" then elseif self.stage == "battleOptions" then
drawTitle("BATTLE OPTIONS") drawTitle("BATTLE OPTIONS")
Font.draw("LEVELS:", 16, 56) Font.draw(Strings("LEVELS:"), 16, 56)
Font.draw(forceLevelLabel(self.levelChoice or ANY), 88, 56) Font.draw(forceLevelLabel(self.levelChoice or ANY), 88, 56)
Font.draw("A: continue B: back", 8, 128) Font.draw(Strings("A: continue B: back"), 8, 128)
elseif self.stage == "waitMode" or self.stage == "waitHello" then elseif self.stage == "waitMode" or self.stage == "waitHello" then
drawTitle("CONNECTED!") drawTitle("CONNECTED!")
if self.stage == "waitHello" then if self.stage == "waitHello" then
Font.draw("Checking the", 16, 56) Font.draw(Strings("Checking the"), 16, 56)
Font.draw("other game...", 16, 72) Font.draw(Strings("other game..."), 16, 72)
else else
Font.draw("Waiting for the", 16, 56) Font.draw(Strings("Waiting for the"), 16, 56)
Font.draw("host to choose...", 16, 72) Font.draw(Strings("host to choose..."), 16, 72)
end end
elseif self.stage == "notice" then elseif self.stage == "notice" then
@@ -652,12 +672,12 @@ function LinkState:draw()
if i > 8 then break end -- what fits above the prompt row if i > 8 then break end -- what fits above the prompt row
Font.draw(line, 8, 24 + (i - 1) * 12) Font.draw(line, 8, 24 + (i - 1) * 12)
end end
Font.draw(self.noticeExits and "A: back" or "A: trade anyway", 8, 128) Font.draw(self.noticeExits and "A: back" or Strings("A: trade anyway"), 8, 128)
elseif self.stage == "trade" then elseif self.stage == "trade" then
drawTitle("TRADE") drawTitle("TRADE")
local t = self.trade local t = self.trade
Font.draw("YOURS", 8, 20) Font.draw(Strings("YOURS"), 8, 20)
for i, mon in ipairs(self.game.save.party) do for i, mon in ipairs(self.game.save.party) do
local def = self.game.data.pokemon[mon.species] local def = self.game.data.pokemon[mon.species]
local label = (mon.nickname or def.name):sub(1, 8) local label = (mon.nickname or def.name):sub(1, 8)
@@ -665,7 +685,7 @@ function LinkState:draw()
Font.draw(label, 16, 20 + i * 12) Font.draw(label, 16, 20 + i * 12)
if i == self.index then Font.drawCode(CURSOR, 8, 20 + i * 12) end if i == self.index then Font.drawCode(CURSOR, 8, 20 + i * 12) end
end end
Font.draw("THEIRS", 84, 20) Font.draw(Strings("THEIRS"), 84, 20)
for i, mon in ipairs(t.theirParty or {}) do for i, mon in ipairs(t.theirParty or {}) do
local def = self.game.data.pokemon[mon.species] local def = self.game.data.pokemon[mon.species]
Font.draw((mon.nickname or def.name):sub(1, 8), 92, 20 + i * 12) Font.draw((mon.nickname or def.name):sub(1, 8), 92, 20 + i * 12)
@@ -676,16 +696,16 @@ function LinkState:draw()
elseif t.stage == "waitParty" then hint = "Exchanging data..." elseif t.stage == "waitParty" then hint = "Exchanging data..."
elseif t.stage == "picking" then elseif t.stage == "picking" then
hint = t:canPick(self.index) and "Pick one to trade" hint = t:canPick(self.index) and "Pick one to trade"
or "X: not on theirs" or Strings("X: not on theirs")
elseif t.stage == "waitPick" then hint = "Waiting for them..." elseif t.stage == "waitPick" then hint = "Waiting for them..."
elseif t.stage == "confirming" then elseif t.stage == "confirming" then
hint = self.confirmed and "Waiting..." or "A: trade B: cancel" hint = self.confirmed and "Waiting..." or Strings("A: trade B: cancel")
end end
Font.draw(hint or "", 8, 132) Font.draw(hint or "", 8, 132)
elseif self.stage == "battleWait" or self.stage == "battleRunning" then elseif self.stage == "battleWait" or self.stage == "battleRunning" then
drawTitle("LINK BATTLE") drawTitle("LINK BATTLE")
Font.draw("Exchanging data...", 16, 64) Font.draw(Strings("Exchanging data..."), 16, 64)
end end
love.graphics.setColor(1, 1, 1, 1) love.graphics.setColor(1, 1, 1, 1)
end end
+13 -9
View File
@@ -26,6 +26,7 @@
local Json = require("src.link.Json") local Json = require("src.link.Json")
local Logger = require("src.core.Logger") local Logger = require("src.core.Logger")
local Strings = require("src.core.Strings")
local hasEnet, enet = pcall(require, "enet") local hasEnet, enet = pcall(require, "enet")
if not hasEnet then enet = nil end if not hasEnet then enet = nil end
@@ -173,7 +174,7 @@ function Net:connectTCP(addr)
tcp:settimeout(5) tcp:settimeout(5)
local ok, err = tcp:connect(host, port) local ok, err = tcp:connect(host, port)
if not ok then if not ok then
self.error = ("can't reach relay %s:%d\n(%s)"):format(host, port, tostring(err)) self.error = Strings("can't reach relay %s:%d\n(%s)", host, port, tostring(err))
return false return false
end end
tcp:settimeout(0) tcp:settimeout(0)
@@ -236,11 +237,14 @@ local function handleGenericRelayControl(self, msg)
self.paired = true self.paired = true
return true return true
elseif msg.type == "join_error" then elseif msg.type == "join_error" then
self.error = ({ self.error = Strings(({
not_found = "That code wasn't\nfound.", not_found = Strings.source("That code wasn't\nfound."),
full = "That game already\nhas two players.", full = Strings.source("That game already\nhas two players."),
expired = "That code has\nexpired.", expired = Strings.source("That code has\nexpired."),
})[msg.reason] or ("Couldn't join:\n%s"):format(tostring(msg.reason)) })[msg.reason] or "")
if self.error == "" then
self.error = Strings("Couldn't join:\n%s", tostring(msg.reason))
end
self.closed = true self.closed = true
return true return true
elseif msg.type == "peer_gone" then elseif msg.type == "peer_gone" then
@@ -328,7 +332,7 @@ function Net:update()
-- an unreachable join target surfaces as a service error -- an unreachable join target surfaces as a service error
-- (ICMP unreachable on the connected UDP socket) -- (ICMP unreachable on the connected UDP socket)
if self.mode == "joining" and not self.paired then if self.mode == "joining" and not self.paired then
self.error = ("no answer from\n%s"):format(self.target or "the host") self.error = Strings("no answer from\n%s", self.target or "the host")
else else
self.error = tostring(event) self.error = tostring(event)
end end
@@ -358,14 +362,14 @@ function Net:update()
self.closed = true self.closed = true
if not self.paired then if not self.paired then
self.error = self.error or self.error = self.error or
("no answer from\n%s"):format(self.target or "the host") Strings("no answer from\n%s", self.target or "the host")
end end
end end
end end
end end
if self.mode == "joining" and not self.paired if self.mode == "joining" and not self.paired
and self.joinDeadline and now() > self.joinDeadline then and self.joinDeadline and now() > self.joinDeadline then
self.error = ("no answer from\n%s"):format(self.target or "the host") self.error = Strings("no answer from\n%s", self.target or "the host")
self.closed = true self.closed = true
pcall(function() self.peer:disconnect_now() end) pcall(function() self.peer:disconnect_now() end)
end end
+30 -4
View File
@@ -43,7 +43,12 @@ Protocol.plainCopy = plainCopy
-- serialize a mon instance for the wire (plain data only). ppUps rides -- serialize a mon instance for the wire (plain data only). ppUps rides
-- along because the real cable transmitted it and its absence silently -- 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) function Protocol.packMon(mon)
local moves = {} local moves = {}
for _, mv in ipairs(mon.moves) do for _, mv in ipairs(mon.moves) do
@@ -59,6 +64,8 @@ function Protocol.packMon(mon)
dvs = mon.dvs, dvs = mon.dvs,
statExp = mon.statExp, statExp = mon.statExp,
moves = moves, moves = moves,
ot = mon.ot,
otId = mon.otId,
extra = plainCopy(mon.extra), extra = plainCopy(mon.extra),
} }
end end
@@ -71,6 +78,14 @@ function Protocol.unpackMon(data, packed, opts)
local Stats = require("src.pokemon.Stats") local Stats = require("src.pokemon.Stats")
local Growth = require("src.pokemon.Growth") local Growth = require("src.pokemon.Growth")
local strict = opts and opts.strict 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] local def = data.pokemon[packed.species]
if not def then if not def then
if strict then return nil, "unknown POKéMON" end 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 -- 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 -- Lv12 and a Lv100 party can battle on equal footing. Both sides pass
-- the identical forceLevel for a given match, so this stays symmetric. -- the identical forceLevel for a given match, so this stays symmetric.
if opts and opts.forceLevel then if forceLevel then
level = math.max(2, math.min(100, math.floor(opts.forceLevel))) level = math.max(2, math.min(100, math.floor(forceLevel)))
end end
local dvs = {} local dvs = {}
for _, k in ipairs({ "hp", "attack", "defense", "speed", "special" }) do 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) -- current HP/status (a different level's numbers, possibly mid-fight)
-- isn't meaningful anymore -- auto-level starts everyone full and fresh, -- isn't meaningful anymore -- auto-level starts everyone full and fresh,
-- same as a standardized tournament format would -- same as a standardized tournament format would
local forced = opts and opts.forceLevel local forced = forceLevel
local hp = forced and stats.hp local hp = forced and stats.hp
or math.max(0, math.min(stats.hp, math.floor(packed.hp or 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 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 { return {
species = packed.species, species = packed.species,
level = level, level = level,
@@ -129,6 +153,8 @@ function Protocol.unpackMon(data, packed, opts)
hp = hp, hp = hp,
status = status, status = status,
nickname = packed.nickname, nickname = packed.nickname,
ot = ot,
otId = otId,
moves = moves, moves = moves,
-- a namespace whose mod this install lacks survives untouched, so the -- a namespace whose mod this install lacks survives untouched, so the
-- mon keeps it for the trip home -- 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 Runtime = require("src.mods.Runtime")
local Sound = require("src.core.Sound") local Sound = require("src.core.Sound")
local TextBox = require("src.render.TextBox") local TextBox = require("src.render.TextBox")
local Strings = require("src.core.Strings")
local Tournament = {} local Tournament = {}
Tournament.__index = Tournament Tournament.__index = Tournament
@@ -57,7 +58,12 @@ local function levelLabel(v)
end end
local function levelForWire(v) 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 end
local function forceLevelLabel(v) local function forceLevelLabel(v)
@@ -123,7 +129,7 @@ end
function Tournament:startHosting() function Tournament:startHosting()
self.net = Net.new() self.net = Net.new()
if not self.net:connectTCP(Net.defaultRelayAddress()) then if not self.net:connectTCP(Net.defaultRelayAddress()) then
self:exitWith("Link error:\n" .. (self.net.error or "?")) self:exitWith(Strings("Link error:\n%s", self.net.error or "?"))
return return
end end
local size, minL, maxL = partyStats(self.game.save.party) local size, minL, maxL = partyStats(self.game.save.party)
@@ -146,7 +152,7 @@ end
function Tournament:startJoining(code) function Tournament:startJoining(code)
self.net = Net.new() self.net = Net.new()
if not self.net:connectTCP(Net.defaultRelayAddress()) then if not self.net:connectTCP(Net.defaultRelayAddress()) then
self:exitWith("Link error:\n" .. (self.net.error or "?")) self:exitWith(Strings("Link error:\n%s", self.net.error or "?"))
return return
end end
local size, minL, maxL = partyStats(self.game.save.party) local size, minL, maxL = partyStats(self.game.save.party)
@@ -166,9 +172,9 @@ end
-- ------------------------------------------------------------------- -- -------------------------------------------------------------------
local JOIN_ERROR_TEXT = { local JOIN_ERROR_TEXT = {
not_found = "That code wasn't\nfound.", not_found = Strings.source("That code wasn't\nfound."),
already_started = "That tournament\nhas already begun.", already_started = Strings.source("That tournament\nhas already begun."),
expired = "That code has\nexpired.", expired = Strings.source("That code has\nexpired."),
} }
function Tournament:handleMessage(msg) function Tournament:handleMessage(msg)
@@ -193,17 +199,17 @@ function Tournament:handleMessage(msg)
end end
elseif msg.type == "tournament_host_error" then elseif msg.type == "tournament_host_error" then
if msg.reason == "party_ineligible" then if msg.reason == "party_ineligible" then
self:exitWith(("Can't host:\nneed %d Pokemon\nLv %s-%s."):format( self:exitWith(Strings("Can't host:\nneed %d Pokemon\nLv %s-%s.", msg.requiredPartySize, levelLabel(msg.minLevel), levelLabel(msg.maxLevel)))
msg.requiredPartySize, levelLabel(msg.minLevel), levelLabel(msg.maxLevel)))
else else
self:exitWith("Couldn't host\nthat tournament.") self:exitWith(Strings("Couldn't host\nthat tournament."))
end end
elseif msg.type == "tournament_join_error" then elseif msg.type == "tournament_join_error" then
if msg.reason == "party_ineligible" then if msg.reason == "party_ineligible" then
self:exitWith(("Your party needs\n%d Pokemon, Lv\n%s-%s."):format( self:exitWith(Strings("Your party needs\n%d Pokemon, Lv\n%s-%s.", msg.requiredPartySize, levelLabel(msg.minLevel), levelLabel(msg.maxLevel)))
msg.requiredPartySize, levelLabel(msg.minLevel), levelLabel(msg.maxLevel)))
else else
self:exitWith(JOIN_ERROR_TEXT[msg.reason] or "Couldn't join\nthat tournament.") self:exitWith(JOIN_ERROR_TEXT[msg.reason]
and Strings(JOIN_ERROR_TEXT[msg.reason])
or Strings("Couldn't join\nthat tournament."))
end end
elseif msg.type == "tournament_roster" then elseif msg.type == "tournament_roster" then
self.roster = msg.players self.roster = msg.players
@@ -276,7 +282,7 @@ function Tournament:beginMatchBattle()
-- shouldn't happen (both sides already passed the online-play mods -- shouldn't happen (both sides already passed the online-play mods
-- gate), but a mismatched engine/build is still possible -- bail out -- gate), but a mismatched engine/build is still possible -- bail out
-- of just this match rather than crash the tournament -- of just this match rather than crash the tournament
self:exitWith("Link error:\nversion mismatch\nwith opponent.") self:exitWith(Strings("Link error:\nversion mismatch\nwith opponent."))
return return
end end
self.linkSeed = self.isHost and love.math.random(1, 2 ^ 30) or nil self.linkSeed = self.isHost and love.math.random(1, 2 ^ 30) or nil
@@ -346,11 +352,11 @@ function Tournament:update(dt)
self.net:update() self.net:update()
if self.net.error and self.stage ~= "menu" and self.stage ~= "hostSettings" if self.net.error and self.stage ~= "menu" and self.stage ~= "hostSettings"
and self.stage ~= "codeEntry" then and self.stage ~= "codeEntry" then
self:exitWith("Link error:\n" .. self.net.error:sub(1, 60)) self:exitWith(Strings("Link error:\n%s", self.net.error:sub(1, 60)))
return return
end end
if self.net.closed and self.stage ~= "done" then if self.net.closed and self.stage ~= "done" then
self:exitWith("The tournament\nconnection was\nlost.") self:exitWith(Strings("The tournament\nconnection was\nlost."))
return return
end end
end end
@@ -376,7 +382,7 @@ function Tournament:update(dt)
and LinkBattle.newHost(self.game, self.net, self.pendingBattleOpts) and LinkBattle.newHost(self.game, self.net, self.pendingBattleOpts)
or LinkBattle.newGuest(self.game, self.net, self.pendingBattleOpts) or LinkBattle.newGuest(self.game, self.net, self.pendingBattleOpts)
if not battle then if not battle then
self:exitWith(why or "Link battle\ncan't start.") self:exitWith(why or Strings("Link battle\ncan't start."))
return return
end end
-- anything after `party` in this same batch belongs to the -- anything after `party` in this same batch belongs to the
@@ -413,7 +419,7 @@ function Tournament:update(dt)
forceLevel = levelForWire(self.settings.forceLevel), forceLevel = levelForWire(self.settings.forceLevel),
}) })
if not battle then if not battle then
self:exitWith(why or "Can't watch this\nmatch.") self:exitWith(why or Strings("Can't watch this\nmatch."))
return return
end end
for j = #msgs, i + 1, -1 do for j = #msgs, i + 1, -1 do
@@ -536,8 +542,8 @@ local SETTINGS_LABELS = { "POKEMON", "MIN LV", "MAX LV", "TIMER", "LEVELS", "PLA
function Tournament:draw() function Tournament:draw()
if self.stage == "menu" then if self.stage == "menu" then
drawTitle("TOURNAMENT") drawTitle("TOURNAMENT")
Font.draw("HOST", 32, 48) Font.draw(Strings("HOST"), 32, 48)
Font.draw("JOIN", 32, 68) Font.draw(Strings("JOIN"), 32, 68)
Font.drawCode(CURSOR, 24, self.index == 1 and 48 or 68) Font.drawCode(CURSOR, 24, self.index == 1 and 48 or 68)
elseif self.stage == "hostSettings" then elseif self.stage == "hostSettings" then
@@ -556,7 +562,7 @@ function Tournament:draw()
Font.draw(values[i], 96, y) Font.draw(values[i], 96, y)
if i == self.settingsIndex then Font.drawCode(CURSOR, 8, y) end if i == self.settingsIndex then Font.drawCode(CURSOR, 8, y) end
end end
Font.draw("START: create", 8, 128) Font.draw(Strings("START: create"), 8, 128)
elseif self.stage == "codeEntry" then elseif self.stage == "codeEntry" then
drawTitle("ENTER CODE") drawTitle("ENTER CODE")
@@ -568,28 +574,27 @@ function Tournament:draw()
Font.drawCode(0xEE, x, 76) Font.drawCode(0xEE, x, 76)
end end
end end
Font.draw("A: join B: back", 8, 128) Font.draw(Strings("A: join B: back"), 8, 128)
elseif self.stage == "registering" then elseif self.stage == "registering" then
drawTitle("CONNECTING...") drawTitle("CONNECTING...")
Font.draw("B: cancel", 8, 128) Font.draw(Strings("B: cancel"), 8, 128)
elseif self.stage == "bracket" or self.stage == "matchHello" elseif self.stage == "bracket" or self.stage == "matchHello"
or self.stage == "matchWaitParty" or self.stage == "spectateWait" then or self.stage == "matchWaitParty" or self.stage == "spectateWait" then
drawTitle(("TOURNAMENT %s"):format(self.code or "??????")) drawTitle(Strings("TOURNAMENT %s", self.code or "??????"))
if self.bracket then if self.bracket then
local y = 20 local y = 20
for _, round in ipairs(self.bracket.rounds) do for _, round in ipairs(self.bracket.rounds) do
Font.draw(("ROUND %d"):format(round.round), 8, y) Font.draw(Strings("ROUND %d", round.round), 8, y)
y = y + 10 y = y + 10
for _, m in ipairs(round.matches) do for _, m in ipairs(round.matches) do
local line local line
if m.bye then if m.bye then
line = ("%s (bye)"):format(m.a or m.b or "?") line = Strings("%s (bye)", m.a or m.b or "?")
else else
local mark = m.state == "live" and "*" or (m.winner and "" or "") local mark = m.state == "live" and "*" or (m.winner and "" or "")
line = ("%s%s vs %s%s"):format( line = Strings("%s%s vs %s%s", m.winner == m.a and ">" or " ", m.a or "?",
m.winner == m.a and ">" or " ", m.a or "?",
m.b or "?", m.winner == m.b and "<" or (mark == "*" and " *" or "")) m.b or "?", m.winner == m.b and "<" or (mark == "*" and " *" or ""))
end end
Font.draw(line, 12, y) Font.draw(line, 12, y)
@@ -599,11 +604,11 @@ function Tournament:draw()
end end
else else
if self.participating == false then if self.participating == false then
Font.draw("(organizing --", 16, 32) Font.draw(Strings("(organizing --"), 16, 32)
Font.draw("not playing)", 16, 42) Font.draw(Strings("not playing)"), 16, 42)
end end
Font.draw("Waiting for", 16, 48) Font.draw(Strings("Waiting for"), 16, 48)
Font.draw("players to join:", 16, 60) Font.draw(Strings("players to join:"), 16, 60)
local y = 60 local y = 60
for i, name in ipairs(self.roster) do for i, name in ipairs(self.roster) do
y = 60 + i * 10 y = 60 + i * 10
@@ -615,18 +620,18 @@ function Tournament:draw()
end end
end end
if self.isCreator and #self.roster >= 2 and not self.bracket then if self.isCreator and #self.roster >= 2 and not self.bracket then
Font.draw("A: START B: cancel", 8, 132) Font.draw(Strings("A: START B: cancel"), 8, 132)
else else
Font.draw("B: cancel", 8, 132) Font.draw(Strings("B: cancel"), 8, 132)
end end
elseif self.stage == "done" then elseif self.stage == "done" then
drawTitle("TOURNAMENT OVER") drawTitle("TOURNAMENT OVER")
if self.champion then if self.champion then
Font.draw(("%s is the"):format(self.champion), 16, 56) Font.draw(Strings("%s is the", self.champion), 16, 56)
Font.draw("champion!", 16, 68) Font.draw(Strings("champion!"), 16, 68)
end end
Font.draw("A: continue", 8, 128) Font.draw(Strings("A: continue"), 8, 128)
end end
love.graphics.setColor(1, 1, 1, 1) love.graphics.setColor(1, 1, 1, 1)
end end
+34 -3
View File
@@ -3,11 +3,12 @@
-- manifests only. The full loader (src/mods/Loader.lua) still owns the real -- manifests only. The full loader (src/mods/Loader.lua) still owns the real
-- load at boot; this reads the same options.mods enable-state the loader -- load at boot; this reads the same options.mods enable-state the loader
-- writes, derives per-mod status with the pure ManagerState.resolveToggle, -- writes, derives per-mod status with the pure ManagerState.resolveToggle,
-- and installs a dropped/chosen .zip into the save-dir "mods/<id>/" tree. -- installs a dropped/chosen .zip into the save-dir "mods/<id>/" tree, and
-- uninstalls a mod by removing that tree + clearing options.mods[id].
-- --
-- Split in two: the pure derivation (deriveList, locateRoot) has no love and -- Split in two: the pure derivation (deriveList, locateRoot) has no love and
-- no filesystem, so the engine tier can table-drive it; the discovery and -- no filesystem, so the engine tier can table-drive it; the discovery,
-- install paths reach for love.filesystem and SaveData. -- install, and uninstall paths reach for love.filesystem and SaveData.
local Manifest = require("src.mods.Manifest") local Manifest = require("src.mods.Manifest")
local ManagerState = require("src.mods.ManagerState") local ManagerState = require("src.mods.ManagerState")
@@ -332,4 +333,34 @@ function LauncherMods.installZip(source)
return true, manifest.id return true, manifest.id
end end
-- uninstall(id) -> true | nil, errString
-- Removes mods/<id>/ from the save directory and clears options.mods[id] so the
-- loader and in-game manager no longer see it. Rejects unknown / missing ids.
-- Does not touch other mods' enable state.
function LauncherMods.uninstall(id)
if type(id) ~= "string" or id == "" then
return nil, "missing mod id"
end
if id:find("[/\\]") or id == "." or id == ".." then
return nil, "invalid mod id"
end
if not (love and love.filesystem) then
return nil, "mod uninstall needs LOVE"
end
local fs = love.filesystem
local dest = "mods/" .. id
if not fs.getInfo(dest) then
return nil, "mod '" .. id .. "' is not installed"
end
removeTree(dest)
-- Drop the enable flag so a reinstall of the same id starts from the
-- loader's default (enabled) rather than a stale false.
local options = SaveData.loadOptions()
if options.mods and options.mods[id] ~= nil then
options.mods[id] = nil
SaveData.saveOptions(options)
end
return true
end
return LauncherMods return LauncherMods
+1
View File
@@ -68,6 +68,7 @@ local devShim = { installed = false, permissions = {}, warned = {}, depth = 0 }
local SUPPORTED_REQUIRES = { local SUPPORTED_REQUIRES = {
["src.mods.Semver"] = true, ["src.mods.Semver"] = true,
["src.audio.ChipAsm"] = true, ["src.audio.ChipAsm"] = true,
["src.pokemon.Stats"] = true, -- Stats.isShiny / calc for indicator mods
} }
local function scanRequire(name) local function scanRequire(name)
+21 -20
View File
@@ -10,6 +10,7 @@ local Semver = require("src.mods.Semver")
local Version = require("src.core.Version") local Version = require("src.core.Version")
local Theme = require("src.ui.Theme") local Theme = require("src.ui.Theme")
local OptionRows = require("src.ui.OptionRows") local OptionRows = require("src.ui.OptionRows")
local Strings = require("src.core.Strings")
local ManagerState = {} local ManagerState = {}
ManagerState.__index = ManagerState ManagerState.__index = ManagerState
@@ -269,7 +270,7 @@ function ManagerState:modRows()
end end
end end
if #rows == 0 then if #rows == 0 then
rows[1] = { header = true, label = "NO MODS INSTALLED" } rows[1] = { header = true, label = Strings("NO MODS INSTALLED") }
end end
return rows return rows
end end
@@ -281,7 +282,7 @@ function ManagerState:profileRows()
rows[#rows + 1] = { profile = p, label = p.name, rows[#rows + 1] = { profile = p, label = p.name,
glyph = opts.activeProfile == p.name and GLYPH.errored or " " } glyph = opts.activeProfile == p.name and GLYPH.errored or " " }
end end
rows[#rows + 1] = { saveAs = true, label = "SAVE CURRENT AS.." } rows[#rows + 1] = { saveAs = true, label = Strings("SAVE CURRENT AS..") }
rows[#rows + 1] = { adhoc = true, rows[#rows + 1] = { adhoc = true,
label = opts.activeProfile and "[AD-HOC]" or "[AD-HOC] (LIVE)" } label = opts.activeProfile and "[AD-HOC]" or "[AD-HOC] (LIVE)" }
return rows return rows
@@ -316,30 +317,30 @@ function ManagerState:detailRows(m)
rows[#rows + 1] = { label = m.enabled and "DISABLE" or "ENABLE", rows[#rows + 1] = { label = m.enabled and "DISABLE" or "ENABLE",
action = function() self:beginToggle(m) end } action = function() self:beginToggle(m) end }
if self:schemaFor(m) then if self:schemaFor(m) then
rows[#rows + 1] = { label = "OPTIONS..", rows[#rows + 1] = { label = Strings("OPTIONS.."),
action = function() self:openOptions(m) end } action = function() self:openOptions(m) end }
end end
if m.permissions and #m.permissions > 0 then if m.permissions and #m.permissions > 0 then
rows[#rows + 1] = { label = "PERMISSIONS..", rows[#rows + 1] = { label = Strings("PERMISSIONS.."),
action = function() self:goTo("permissions") end } action = function() self:goTo("permissions") end }
end end
if m.error then if m.error then
rows[#rows + 1] = { label = "VIEW ERROR..", rows[#rows + 1] = { label = Strings("VIEW ERROR.."),
action = function() self:goTo("errors") end } action = function() self:goTo("errors") end }
end end
rows[#rows + 1] = { label = "BACK", action = function() self:goBack() end } rows[#rows + 1] = { label = Strings("BACK"), action = function() self:goBack() end }
return rows return rows
end end
function ManagerState:applyRows() function ManagerState:applyRows()
local rows = {} local rows = {}
rows[#rows + 1] = { label = "APPLY & RESTART", action = function() rows[#rows + 1] = { label = Strings("APPLY & RESTART"), action = function()
self:openConfirm({ "RESTART NOW?" }, function() self:restartGame() end) self:openConfirm({ "RESTART NOW?" }, function() self:restartGame() end)
end } end }
rows[#rows + 1] = { label = "DISCARD CHANGES", action = function() rows[#rows + 1] = { label = Strings("DISCARD CHANGES"), action = function()
self:discardChanges() self:discardChanges()
end } end }
rows[#rows + 1] = { label = "BACK", action = function() self:goBack() end } rows[#rows + 1] = { label = Strings("BACK"), action = function() self:goBack() end }
return rows return rows
end end
@@ -352,7 +353,7 @@ function ManagerState:permissionRows(m)
label = info and info.text or name } label = info and info.text or name }
end end
if #rows == 0 then if #rows == 0 then
rows[1] = { inert = true, label = "DATA & API ONLY" } rows[1] = { inert = true, label = Strings("DATA & API ONLY") }
end end
return rows return rows
end end
@@ -564,7 +565,7 @@ function ManagerState:openCascade(r, m, want)
else else
local dep = self.byId[r.alsoDisable[1]] local dep = self.byId[r.alsoDisable[1]]
lines[#lines + 1] = (dep and dep.name or r.alsoDisable[1]) .. " NEEDS THIS." lines[#lines + 1] = (dep and dep.name or r.alsoDisable[1]) .. " NEEDS THIS."
lines[#lines + 1] = #r.alsoDisable > 1 and "DISABLE ALL?" or "DISABLE BOTH?" lines[#lines + 1] = #r.alsoDisable > 1 and "DISABLE ALL?" or Strings("DISABLE BOTH?")
end end
self.overlay = { kind = "confirm", lines = lines, index = 1, self.overlay = { kind = "confirm", lines = lines, index = 1,
onYes = function() self:commitToggle(r.apply) end } onYes = function() self:commitToggle(r.apply) end }
@@ -700,7 +701,7 @@ end
function ManagerState:saveCurrentAs() function ManagerState:saveCurrentAs()
local NamingScreen = require("src.ui.NamingScreen") local NamingScreen = require("src.ui.NamingScreen")
self.game.stack:push(NamingScreen.new(self.game, { self.game.stack:push(NamingScreen.new(self.game, {
title = "PROFILE NAME?", title = Strings("PROFILE NAME?"),
maxLen = 10, maxLen = 10,
onDone = function(name) onDone = function(name)
local opts = self:optionsTable() local opts = self:optionsTable()
@@ -726,7 +727,7 @@ end
function ManagerState:renameProfile(p) function ManagerState:renameProfile(p)
local NamingScreen = require("src.ui.NamingScreen") local NamingScreen = require("src.ui.NamingScreen")
self.game.stack:push(NamingScreen.new(self.game, { self.game.stack:push(NamingScreen.new(self.game, {
title = "RENAME?", title = Strings("RENAME?"),
maxLen = 10, maxLen = 10,
default = p.name, default = p.name,
onDone = function(name) onDone = function(name)
@@ -886,7 +887,7 @@ function ManagerState:buildOptionRows(m, schema)
end } end }
end end
end end
rows[#rows + 1] = { id = "__reset", label = "RESET DEFAULTS", rows[#rows + 1] = { id = "__reset", label = Strings("RESET DEFAULTS"),
value = function() return "" end, value = function() return "" end,
activate = function() activate = function()
for _, row in ipairs(schema) do for _, row in ipairs(schema) do
@@ -1047,7 +1048,7 @@ function ManagerState:drawApply()
y = y + 1 y = y + 1
end end
if #staged == 0 then if #staged == 0 then
Font.draw(Runtime.safeMode and "SAFE MODE" or "NO CHANGES", 16, y * 8) Font.draw(Runtime.safeMode and "SAFE MODE" or Strings("NO CHANGES"), 16, y * 8)
y = y + 1 y = y + 1
end end
local rows = self:rowsForScreen() local rows = self:rowsForScreen()
@@ -1076,12 +1077,12 @@ function ManagerState:drawOverlay()
end end
if overlay.kind == "confirm" then if overlay.kind == "confirm" then
local yesY = ty + #lines + 1 local yesY = ty + #lines + 1
Font.draw("YES", 5 * 8, yesY * 8) Font.draw(Strings("YES"), 5 * 8, yesY * 8)
Font.draw("NO", 5 * 8, (yesY + 1) * 8) Font.draw(Strings("NO"), 5 * 8, (yesY + 1) * 8)
Font.drawCode(Theme.cursor, 4 * 8, Font.drawCode(Theme.cursor, 4 * 8,
(overlay.index == 1 and yesY or yesY + 1) * 8) (overlay.index == 1 and yesY or yesY + 1) * 8)
else else
Font.draw("A:OK", 5 * 8, (ty + #lines + 1) * 8) Font.draw(Strings("A:OK"), 5 * 8, (ty + #lines + 1) * 8)
end end
end end
@@ -1090,7 +1091,7 @@ function ManagerState:draw()
OptionRows.draw(self.game, self.optionRows or {}, self.cursor, OptionRows.draw(self.game, self.optionRows or {}, self.cursor,
self.scroll or 0) self.scroll or 0)
love.graphics.setColor(0, 0, 0, 1) love.graphics.setColor(0, 0, 0, 1)
Font.draw(self.notice or "B:DONE (NO RESTART)", 8, 136) Font.draw(self.notice or Strings("B:DONE (NO RESTART)"), 8, 136)
love.graphics.setColor(1, 1, 1, 1) love.graphics.setColor(1, 1, 1, 1)
if self.overlay then self:drawOverlay() end if self.overlay then self:drawOverlay() end
return return
@@ -1099,7 +1100,7 @@ function ManagerState:draw()
love.graphics.rectangle("fill", 0, 0, 160, 144) love.graphics.rectangle("fill", 0, 0, 160, 144)
love.graphics.setColor(1, 1, 1, 1) love.graphics.setColor(1, 1, 1, 1)
Font.drawBox(0, 0, 20, 18) Font.drawBox(0, 0, 20, 18)
Font.draw(self.banner or "MOD MANAGER", 16, 8) Font.draw(self.banner or Strings("MOD MANAGER"), 16, 8)
if self.screen == "list" then if self.screen == "list" then
self:drawList() self:drawList()
elseif self.screen == "detail" then elseif self.screen == "detail" then
+31 -2
View File
@@ -584,6 +584,17 @@ R.text = {
example = 'mod.content.text:override("_PalletTownText1", "HELLO!")', example = 'mod.content.text:override("_PalletTownText1", "HELLO!")',
} }
-- The engine's own authored text, the half of the game `text` does not
-- cover (src/core/Strings.lua). The id is the English source string, so a
-- translation supplies one entry per literal the engine draws and anything
-- it has not reached yet keeps rendering in English. Ids that collide in
-- English but not elsewhere carry a "context|source" prefix.
R.strings = {
semantics = "record", target = "strings",
value = f.str,
example = 'mod.content.strings:override("But, it failed!", "Echec !")',
}
-- the self-contained bytecode blob ChipAsm.song/sfx emit (13.1 shape 2); -- the self-contained bytecode blob ChipAsm.song/sfx emit (13.1 shape 2);
-- shared by every namespace that plays a chip program -- shared by every namespace that plays a chip program
local chipProgram = f.rec{ local chipProgram = f.rec{
@@ -918,8 +929,20 @@ R.battle_sprite_scales = {
-- 1 = native pixels; the drawn size relative to the pic's own pixels -- 1 = native pixels; the drawn size relative to the pic's own pixels
scale = f.numRange(0.25, 4.0), scale = f.numRange(0.25, 4.0),
}, },
example = 'mod.content.battle_sprite_scales:register("abra_back", ' .. example = [[
'{ path = "assets/generated/battle/back/abrab.png", scale = 1.5 })', mod.content.battle_sprite_scales:register("abra_back", {
path = "assets/generated/battle/back/abrab.png",
scale = 1.5,
})]],
notes = [[
Scales one battle pic by its asset path, overriding the species-level
`battleScaleFront`/`battleScaleBack` (see [pokemon](#pokemon)) for that
image. The only way to scale a pic that is not species-keyed, like the
player's trainer back sprite. Resolution order at draw time:
image-level, then species-level, then the defaults (1x front, 2x back).
The pic stays grounded at any scale: player feet stay flush on the
text-box top, the enemy pic stays bottom-pinned in its slot, and the
scale composes with the send-out grow animation.]],
} }
-- ------- progression -- ------- progression
@@ -1146,6 +1169,12 @@ R.field = {
name = f.opt(f.str) })), name = f.opt(f.str) })),
nest = f.opt(f.any) }, nest = f.opt(f.any) },
flyOrder = f.list(f.str), flyOrder = f.list(f.str),
-- the player's own trainer art (FieldDefaults.PLAYER_PICS): the battle
-- back pic, the catch tutorial's old man, and the front pic the intro,
-- trainer card and Hall of Fame share. Every key is optional so a
-- conversion can replace one pic and inherit the rest.
playerPics = f.rec{
back = f.opt(f.str), demoBack = f.opt(f.str), front = f.opt(f.str) },
-- the new-game and boot config a total conversion replaces -- the new-game and boot config a total conversion replaces
boot = f.rec{ boot = f.rec{
startMap = f.opt(f.str), startX = f.opt(f.int(0)), startY = f.opt(f.int(0)), startMap = f.opt(f.str), startX = f.opt(f.int(0)), startY = f.opt(f.int(0)),
+63 -7
View File
@@ -14,6 +14,7 @@ local Runtime = require("src.mods.Runtime")
local Screens = require("src.ui.Screens") local Screens = require("src.ui.Screens")
local Stats = require("src.pokemon.Stats") local Stats = require("src.pokemon.Stats")
local TextBox = require("src.render.TextBox") local TextBox = require("src.render.TextBox")
local Strings = require("src.core.Strings")
local Evolution = {} local Evolution = {}
@@ -23,7 +24,7 @@ Evolution.METHODS = {
return trigger.kind == "levelup" and mon.level >= (evo.level or 0) return trigger.kind == "levelup" and mon.level >= (evo.level or 0)
end, end,
describe = function(evo) describe = function(evo)
return ("Level %d"):format(evo.level or 0) return Strings("Level %d", evo.level or 0)
end, end,
}, },
ITEM = { ITEM = {
@@ -106,21 +107,67 @@ function Evolution.apply(game, mon, newSpecies, via)
}) })
end 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. -- Play the evolution movie (flashing forms), then apply + text.
-- Headless (no real graphics) falls back to the plain text flow. -- Headless (no real graphics) falls back to the plain text flow.
function Evolution.evolve(game, mon, newSpecies, onDone, via) function Evolution.evolve(game, mon, newSpecies, onDone, via)
if love.image and love.image.newImageData then 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 return
end end
Music.play(game.data, Music.special(game.data, "evolution")) Music.play(game.data, Music.special(game.data, "evolution"))
local oldName = mon.nickname or game.data.pokemon[mon.species].name local oldName = mon.nickname or game.data.pokemon[mon.species].name
Evolution.apply(game, mon, newSpecies, via) Evolution.apply(game, mon, newSpecies, via)
local msg = ("What?\n%s is\nevolving!\fCongratulations!\nYour %s\nevolved into\n%s!") local msg = Strings("What?\n%s is\nevolving!\fCongratulations!\nYour %s\nevolved into\n%s!",
:format(oldName, oldName, game.data.pokemon[newSpecies].name) oldName, oldName, game.data.pokemon[newSpecies].name)
game.stack:push(TextBox.new(game, msg, function() game.stack:push(TextBox.new(game, msg, function()
Music.restoreMap(game.data) Music.restoreMap(game.data)
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))
end end
@@ -138,15 +185,24 @@ function Evolution.request(game, mon, trigger, onDone)
return species return species
end end
-- After-battle hook: evolve everyone who qualifies (queued one at a time). -- After-battle hook: evolve mons that leveled this battle and still
function Evolution.checkParty(game, onDone) -- 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 = {} local pending = {}
if leveledUp then
for _, mon in ipairs(game.save.party) do for _, mon in ipairs(game.save.party) do
if leveledUp[mon] then
local target, evo = Evolution.pendingFor(game, mon, { kind = "levelup" }) local target, evo = Evolution.pendingFor(game, mon, { kind = "levelup" })
if target then if target then
table.insert(pending, { mon = mon, to = target, via = evo and evo.method }) table.insert(pending, { mon = mon, to = target, via = evo and evo.method })
end end
end end
end
end
local i = 0 local i = 0
local function nextOne() local function nextOne()
i = i + 1 i = i + 1
+97
View File
@@ -0,0 +1,97 @@
-- Runtime Pokémon art resolution. Content registries freeze after load,
-- so a mod that lets the player pick an alternate skin mid-session cannot
-- patch pokemon.spriteFront / icons.bySpecies. These helpers are the
-- sanctioned seam: every battle pic and party icon load goes through
-- pokemon.sprite / pokemon.icon, which stay live for the whole process.
--
-- playerPath is the same seam for the player's own trainer art, whose
-- vanilla paths are data (field.playerPics) rather than a species record.
local Runtime = require("src.mods.Runtime")
local FieldDefaults = require("src.world.FieldDefaults")
local Sprites = {}
local function samePath(path) return path end
-- Resolve a battle / menu front or back pic path for `species`.
-- side: "front" | "back"
-- opts.mon: the live mon when available (per-instance skins)
-- opts.kind: "battle" | "summary" | "dex" | "evolution" | "hof" | "trade"
-- | "title" | "oak" | "credits" (informational for wrappers)
-- Returns path, trueColor.
function Sprites.path(data, species, side, opts)
opts = opts or {}
local def = data and data.pokemon and data.pokemon[species]
if not def then return nil, false end
local path = side == "back" and def.spriteBack or def.spriteFront
local ctx = {
species = species,
side = side == "back" and "back" or "front",
kind = opts.kind or "battle",
mon = opts.mon,
trueColor = def.trueColor and true or false,
data = data,
}
if path and Runtime.wantsHook("pokemon.sprite") then
local hooked = Runtime.call("pokemon.sprite", samePath, path, ctx)
if type(hooked) == "string" and hooked ~= "" then path = hooked end
end
return path, ctx.trueColor and true or false
end
-- Resolve the player's own trainer pic path.
-- side: "back" (the battle intro pic) | "front" (intro / card / Hall of Fame)
-- opts.kind: "battle" | "intro" | "trainer_card" | "hof"
-- opts.demo: the catch tutorial, where the old man fights in the player's
-- place and stands in for the back pic
-- opts.battle: the live battle, for kind == "battle"
-- Returns path, trueColor.
function Sprites.playerPath(data, side, opts)
opts = opts or {}
side = side == "back" and "back" or "front"
-- one key per pic, so a conversion can replace the back and inherit the
-- rest; fieldValue folds data.field over FieldDefaults per key
local key = side == "front" and "front"
or (opts.demo and "demoBack" or "back")
local path = FieldDefaults.fieldValue(data, "playerPics", key)
local ctx = {
side = side,
kind = opts.kind or "battle",
demo = opts.demo and true or false,
battle = opts.battle,
trueColor = false,
data = data,
}
if path and Runtime.wantsHook("player.sprite") then
local hooked = Runtime.call("player.sprite", samePath, path, ctx)
if type(hooked) == "string" and hooked ~= "" then path = hooked end
end
return path, ctx.trueColor and true or false
end
-- Resolve a party-menu icon image path for `mon`.
-- vanillaPath is the path PartyMenu already picked from icons.bySpecies /
-- def.icon / icons.byDex; the hook may replace it.
-- Returns path (possibly nil).
function Sprites.iconPath(data, mon, vanillaPath, opts)
opts = opts or {}
if not vanillaPath and not Runtime.wantsHook("pokemon.icon") then
return vanillaPath
end
local species = mon and mon.species
local ctx = {
species = species,
mon = mon,
name = opts.name,
data = data,
kind = "icon",
}
if not Runtime.wantsHook("pokemon.icon") then return vanillaPath end
local hooked = Runtime.call("pokemon.icon", samePath, vanillaPath, ctx)
if type(hooked) == "string" and hooked ~= "" then return hooked end
if hooked == nil or hooked == false then return nil end
return vanillaPath
end
return Sprites
+16
View File
@@ -57,4 +57,20 @@ function Stats.applyStage(value, stage)
return math.max(1, math.min(999, v)) return math.max(1, math.min(999, v))
end end
-- Gen 2 shiny formula applied to Gen 1 DVs (the RBY "virtual shiny"):
-- Defense/Speed/Special DV == 10 and Attack DV is even-high
-- (2, 3, 6, 7, 10, 11, 14, or 15). Used by shiny-indicator mods.
local SHINY_ATK = {
[2] = true, [3] = true, [6] = true, [7] = true,
[10] = true, [11] = true, [14] = true, [15] = true,
}
function Stats.isShiny(dvs)
if type(dvs) ~= "table" then return false end
return (dvs.defense or 0) == 10
and (dvs.speed or 0) == 10
and (dvs.special or 0) == 10
and SHINY_ATK[dvs.attack or 0] == true
end
return Stats return Stats
+69 -20
View File
@@ -102,35 +102,84 @@ local function pageFor(code)
return nil return nil
end end
local SPACE = 0x7F
-- Segment text into glyph spans: `{ from, to, code }` byte ranges, one per
-- drawn glyph, code nil when the charmap has nothing. A span is a whole
-- charmap sequence, so a multi-byte char ("é", "♂") and an ASCII ligature
-- ("<PK>", "'d") are each one glyph.
--
-- Every caller that *measures* or *cuts* text walks these instead of bytes.
-- "POKéMON" is 8 bytes and 7 glyphs: measuring it as 8 wraps lines that fit
-- (25 vanilla lines did), and cutting at byte 8 splits the é into two
-- invalid bytes that both draw as spaces. That distinction is the whole
-- reason a non-English font can be shipped as a mod (#186, #245).
--
-- Safe before Font.load: with no charmap it falls back to UTF-8 lead-byte
-- boundaries, which is all a headless paginate needs.
function Font.split(text)
local spans = {}
local i, n = 1, #text
while i <= n do
local span
local candidates = state and state.byFirstByte[text:byte(i)]
if candidates then
for _, entry in ipairs(candidates) do
local len = #entry.seq
if text:sub(i, i + len - 1) == entry.seq then
span = { from = i, to = i + len - 1, code = entry.code }
break
end
end
end
if not span then
-- Nothing matched. Still keep a UTF-8 sequence whole, so a cut never
-- lands mid-character even for a glyph we cannot draw.
local last = i
if text:byte(i) >= 0xC0 then
local k = i + 1
while k <= n do
local b = text:byte(k)
if b < 0x80 or b > 0xBF then break end
last, k = k, k + 1
end
end
span = { from = i, to = last }
end
spans[#spans + 1] = span
i = span.to + 1
end
return spans
end
-- How many leading spans fit in `budget` pixels. Advances come from each
-- glyph's own page, so a variable-width page measures correctly.
function Font.spansFitting(spans, budget)
local used, fit = 0, 0
for _, span in ipairs(spans) do
used = used + Font.advanceOf(span.code or SPACE)
if used > budget then break end
fit = fit + 1
end
return fit
end
-- Convert a text string into a list of glyph codes. Unknown characters -- Convert a text string into a list of glyph codes. Unknown characters
-- render as space (and are reported once). -- render as space (and are reported once).
local reported = {} local reported = {}
function Font.encode(text) function Font.encode(text)
local codes = {} local codes = {}
local i = 1 for _, span in ipairs(Font.split(text)) do
while i <= #text do local code = span.code
local candidates = state.byFirstByte[text:byte(i)] if not code then
local matched = false local ch = text:sub(span.from, span.to)
if candidates then if not reported[ch] and text:byte(span.from) >= 32 then
for _, entry in ipairs(candidates) do
local n = #entry.seq
if text:sub(i, i + n - 1) == entry.seq then
codes[#codes + 1] = entry.code
i = i + n
matched = true
break
end
end
end
if not matched then
local ch = text:sub(i, i)
if not reported[ch] and ch:byte() >= 32 then
reported[ch] = true reported[ch] = true
require("src.core.Logger").warn("font: no glyph for %q", ch) require("src.core.Logger").warn("font: no glyph for %q", ch)
end end
codes[#codes + 1] = 0x7F -- space code = SPACE
i = i + 1
end end
codes[#codes + 1] = code
end end
return codes return codes
end end
+13 -1
View File
@@ -77,7 +77,17 @@ end
-- one-pixel sliver. The fill is tinted with the SGB bar palettes at -- one-pixel sliver. The fill is tinted with the SGB bar palettes at
-- GetHealthBarColor's thresholds (>= 27 px green, >= 10 yellow, else -- GetHealthBarColor's thresholds (>= 27 px green, >= 10 yellow, else
-- red). -- 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 local x, y = tx * 8, ty * 8
HudTiles.tile(0x71, x, y) HudTiles.tile(0x71, x, y)
HudTiles.tile(0x62, x + 8, y) HudTiles.tile(0x62, x + 8, y)
@@ -86,6 +96,7 @@ function HudTiles.drawHPBar(data, tx, ty, mon, barType)
px = math.max(1, math.floor(mon.hp * 48 / mon.stats.hp)) px = math.max(1, math.floor(mon.hp * 48 / mon.stats.hp))
end end
local tint local tint
if not grayFill then
local PaletteFX = require("src.render.PaletteFX") local PaletteFX = require("src.render.PaletteFX")
local name = px >= 27 and "GREENBAR" or px >= 10 and "YELLOWBAR" or "REDBAR" local name = px >= 27 and "GREENBAR" or px >= 10 and "YELLOWBAR" or "REDBAR"
local colors = PaletteFX.pal(data, name) local colors = PaletteFX.pal(data, name)
@@ -96,6 +107,7 @@ function HudTiles.drawHPBar(data, tx, ty, mon, barType)
tint = { math.min(1, c[1] / 170), math.min(1, c[2] / 170), tint = { math.min(1, c[1] / 170), math.min(1, c[2] / 170),
math.min(1, c[3] / 170), 1 } math.min(1, c[3] / 170), 1 }
end end
end
for i = 0, 5 do for i = 0, 5 do
local seg = math.min(8, math.max(0, px - i * 8)) local seg = math.min(8, math.max(0, px - i * 8))
HudTiles.tile(seg >= 8 and 0x6B or 0x63 + seg, x + 16 + i * 8, y, tint) HudTiles.tile(seg >= 8 and 0x6B or 0x63 + seg, x + 16 + i * 8, y, tint)
+44 -17
View File
@@ -50,14 +50,23 @@ PaletteFX.GBC_OBJ = {
} }
-- OG BLUE: Pokemon Blue's Game Boy Color boot-ROM auto-palette. Same -- 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 -- one-global-pair scheme as OG RED (Blue also ships no CGB code), but the boot
-- boot ROM colorizes the background blue instead of red -- so "OG RED" for a -- ROM gives Blue its OWN entry rather than a recolored Red: a light-blue/blue
-- Blue playthrough is white -> light blue -> dark blue -> black, mirroring -- BACKGROUND and -- unlike Red -- a PINK object palette (OBP0). Values from
-- GBC_BG channel-for-channel so the blue reads at the same brightness. The -- Bulbapedia's "List of color palettes ... Generation I" GBC boot-ROM table
-- OBJ (sprite) palette stays the same green, matching how Red and Blue share -- (BG 0x63A5FF/0x0000FF, OBJ 0xFF8484/0x943A3A), confirmed against a Gambatte
-- the green-character look on a Game Boy Color. -- 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 = { 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 -- The active game's OG boot-ROM background palette: blue for a Blue
@@ -69,6 +78,16 @@ function PaletteFX.ogBg()
return PaletteFX.GBC_BG return PaletteFX.GBC_BG
end 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 } local INV_MAP = { [0] = 3, [1] = 2, [2] = 1, [3] = 0 }
function PaletteFX.shader() function PaletteFX.shader()
@@ -183,17 +202,25 @@ function PaletteFX.usesGbcPack(mode)
end end
-- Whether the active mode bakes a per-OBJ palette onto overworld sprites -- 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 -- (the OBP bake + post-zone redraw path). OG RED and SGB both do: characters
-- GBC boot-ROM green object palette (PaletteFX.GBC_OBJ) so the player and -- wear the GBC boot-ROM object palette (PaletteFX.ogObj -- green over Red's red
-- NPCs stay green over the red background, exactly like Pokemon Red on a -- background, pink over Blue's blue background), so the player and NPCs carry a
-- Game Boy Color. SGB mode deliberately does NOT: an SGB OBJ carries no -- fixed object color instead of tinting with whatever region palette their
-- palette of its own, so the characters tint with the whole-map region -- feet stand over. On real hardware a sprite is an OBJ colored by an OBJ
-- palette along with the terrain (the Super Game Boy never colored Pokemon -- palette (color/sprites.asm ColorOverworldSprite), distinct from the BG it
-- Red's sprites separately -- baking a per-sprite palette there was the -- overlaps -- so Red's cap must stay green in tall grass, not turn the ROUTE
-- "reds coloring on the player/NPCs" bug). RED++ colors sprites through -- palette's light-blue (issue #150: SGB region-tinting sent the cap to shade-2
-- the usesGbcPack() path in SpriteRenderer instead. -- = 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) function PaletteFX.usesSpriteObp(mode)
return (mode or PaletteFX.mode) == "ogred" mode = mode or PaletteFX.mode
return mode == "ogred" or mode == "gbc"
end end
-- ------- post-zone sprite redraw (GBC mode) -- ------- 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 Tilt = require("src.render.Tilt")
local PaletteFX = require("src.render.PaletteFX") local PaletteFX = require("src.render.PaletteFX")
local Pipelines = require("src.render.Pipelines") local Pipelines = require("src.render.Pipelines")
local PixelCanvas = require("src.render.PixelCanvas")
local Runtime = require("src.mods.Runtime")
local Renderer = {} local Renderer = {}
@@ -39,32 +41,49 @@ end
-- endFrame composites the padded canvas back with a matching offset. -- endFrame composites the padded canvas back with a matching offset.
Renderer.UPRIGHT_MARGIN = 160 Renderer.UPRIGHT_MARGIN = 160
-- LOVE units + framebuffer pixels + the live unit→pixel ratio. -- LOVE units + framebuffer pixels + per-axis unit→pixel ratios.
-- Android's DisplayMetrics.density (love.graphics.getDPIScale) is often -- Android's DisplayMetrics.density is often non-integer (1.5, 2.75, …).
-- non-integer (1.5, 2.75, …). Integer scaling in units then maps each GB -- Integer scaling in units then maps each GB pixel to a fractional number of
-- pixel to a fractional number of framebuffer pixels → shimmer, uneven / -- framebuffer pixels → shimmer, uneven / non-square "pixels", and movement
-- non-square "pixels", and movement judder (issue #87). Always derive the -- judder (issue #87). Always derive the crisp integer scale from the
-- crisp integer scale from the drawable pixel size; draw with (pixels/dpi) -- *drawable* pixel size (the window framebuffer we present into -- not a
-- so the GPU lands on whole framebuffer pixels. Desktop dpi=1 is unchanged. -- 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 function displayMetrics()
local ww, wh = love.graphics.getDimensions() local ww, wh = love.graphics.getDimensions()
local pw, ph = ww, wh local pw, ph = ww, wh
if love.graphics.getPixelDimensions then if love.graphics.getPixelDimensions then
pw, ph = love.graphics.getPixelDimensions() pw, ph = love.graphics.getPixelDimensions()
end end
local dpi = 1 local dpiX, dpiY = 1, 1
if ww > 0 and pw > 0 then if ww > 0 and pw > 0 then dpiX = pw / ww end
dpi = pw / ww if wh > 0 and ph > 0 then dpiY = ph / wh end
elseif love.graphics.getDPIScale then -- No pixel API (headless / old stub): fall back to getDPIScale, else 1.
dpi = love.graphics.getDPIScale() 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 end
if not dpi or dpi < 1e-6 then dpi = 1 end if dpiX < 1e-6 then dpiX = 1 end
return ww, wh, pw, ph, dpi if dpiY < 1e-6 then dpiY = 1 end
return ww, wh, pw, ph, dpiX, dpiY
end end
function Renderer:init() function Renderer:init()
self.canvas = love.graphics.newCanvas(self.WIDTH, self.HEIGHT) -- 160x144 real pixels, never DPI-scaled: see src/render/PixelCanvas.lua
self.canvas:setFilter("nearest", "nearest") -- (#208). Every canvas below is sized in framebuffer pixels for the same
-- reason -- worldViewSize() already works in drawable pixels.
self.canvas = PixelCanvas.new(self.WIDTH, self.HEIGHT, "nearest")
self.worldCanvas = nil self.worldCanvas = nil
self.worldActive = false self.worldActive = false
-- tilt mode only: a transparent overlay canvas the size of the world -- tilt mode only: a transparent overlay canvas the size of the world
@@ -93,21 +112,43 @@ end
-- Integer framebuffer pixels per GB pixel that fit the window. Zoom / -- Integer framebuffer pixels per GB pixel that fit the window. Zoom /
-- GBCFX / callers treat this as the crisp scale; endFrame converts to LOVE -- 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() function Renderer:fitScale()
local _, _, pw, ph = displayMetrics() local _, _, pw, ph = displayMetrics()
return math.max(1, math.floor(math.min(pw / self.WIDTH, ph / self.HEIGHT))) return math.max(1, math.floor(math.min(pw / self.WIDTH, ph / self.HEIGHT)))
end end
-- world-pass canvas size in world pixels: enough to fill the window at s'. -- LOVE-unit draw scales endFrame uses for the UI blit: integer framebuffer
-- In tilt mode the canvas grows (both dimensions, by Tilt.viewGrowth) so -- scale (fitScale) divided by each axis's unit→pixel factor, so a GB pixel
-- the projected ground plane still covers the whole window with no -- lands on fitScale() whole PHYSICAL pixels on both axes once LOVE applies
-- background peeking at the receded top/bottom corners; flat mode returns -- its projection (fitScale() == drawScaleX() * dpiX == drawScaleY() * dpiY).
-- exactly today's size (growth factor is 1 when tilt is inactive). -- 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() function Renderer:worldViewSize()
local ww, wh, _, _, dpi = displayMetrics() local _, _, pw, ph = displayMetrics()
local s = Zoom.scale(self:fitScale()) / dpi local sp = Zoom.scale(self:fitScale())
local vw, vh = Zoom.fillViewSize(s, ww, wh) local vw, vh = math.ceil(pw / sp), math.ceil(ph / sp)
-- Even sizes keep Camera:follow on integer pixels (viewW/2 is integral), -- Even sizes keep Camera:follow on integer pixels (viewW/2 is integral),
-- so unfloored FX/sprite math cannot phase-shimmer against the tile layer. -- so unfloored FX/sprite math cannot phase-shimmer against the tile layer.
if vw % 2 ~= 0 then vw = vw + 1 end 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 -- Black 8x8 (scaled) blocks cascading outward from the classic GB letterbox
-- into the surrounding window, matching BattleTransition wipe progress. -- into the surrounding window, matching BattleTransition wipe progress.
-- Tiles that sit entirely inside the 160x144 square are left to the OG wipe. -- 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 if not prog or prog <= 0 then return end
local TILE = 8 * S Sy = Sy or Sx
if TILE < 1 then TILE = 1 end local TILE_W, TILE_H = 8 * Sx, 8 * Sy
local cols = math.ceil(ww / TILE) if TILE_W < 1 then TILE_W = 1 end
local rows = math.ceil(wh / TILE) 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 cx, cy = ox + vpw / 2, oy + vph / 2
local order = {} local order = {}
for row = 0, rows - 1 do for row = 0, rows - 1 do
for col = 0, cols - 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 -- 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 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 / 2 - cx), local dist = math.max(math.abs(x + TILE_W / 2 - cx),
math.abs(y + TILE / 2 - cy)) math.abs(y + TILE_H / 2 - cy))
order[#order + 1] = { x, y, dist } order[#order + 1] = { x, y, dist }
end end
end end
@@ -176,7 +220,7 @@ function Renderer:drawBattleCascade(prog, ww, wh, ox, oy, vpw, vph, S)
love.graphics.setScissor(0, 0, ww, wh) love.graphics.setScissor(0, 0, ww, wh)
for i = 1, n do for i = 1, n do
local t = order[i] 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 end
love.graphics.setScissor() love.graphics.setScissor()
love.graphics.setColor(1, 1, 1, 1) 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 -- the view size every frame, so without this the superseded canvases
-- pile up in VRAM until a GC finalizer happens to run -- pile up in VRAM until a GC finalizer happens to run
if self.worldCanvas and self.worldCanvas.release then self.worldCanvas:release() end if self.worldCanvas and self.worldCanvas.release then self.worldCanvas:release() end
self.worldCanvas = love.graphics.newCanvas(vw, vh) self.worldCanvas = PixelCanvas.new(vw, vh, "nearest")
self.worldCanvas:setFilter("nearest", "nearest")
end end
self.worldActive = true self.worldActive = true
PaletteFX.setPass("world") PaletteFX.setPass("world")
@@ -219,8 +262,7 @@ function Renderer:beginUprightPass()
if not self.uprightCanvas or self.uprightCanvas:getWidth() ~= cw if not self.uprightCanvas or self.uprightCanvas:getWidth() ~= cw
or self.uprightCanvas:getHeight() ~= ch then or self.uprightCanvas:getHeight() ~= ch then
if self.uprightCanvas and self.uprightCanvas.release then self.uprightCanvas:release() end if self.uprightCanvas and self.uprightCanvas.release then self.uprightCanvas:release() end
self.uprightCanvas = love.graphics.newCanvas(cw, ch) self.uprightCanvas = PixelCanvas.new(cw, ch, "nearest")
self.uprightCanvas:setFilter("nearest", "nearest")
end end
self.uprightActive = true self.uprightActive = true
PaletteFX.setPass(nil) PaletteFX.setPass(nil)
@@ -299,10 +341,11 @@ end
-- into (nil = default framebuffer; presentCanvas when CRT is on). -- into (nil = default framebuffer; presentCanvas when CRT is on).
-- Returns true on success; false (no shader/mesh) tells endFrame to fall -- Returns true on success; false (no shader/mesh) tells endFrame to fall
-- back to the flat blit unchanged. -- 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 shader = self:tiltShader()
local mesh = self:tiltMesh() local mesh = self:tiltMesh()
if not (shader and mesh) then return false end if not (shader and mesh) then return false end
sy = sy or sx
local wvw = self.worldCanvas:getWidth() local wvw = self.worldCanvas:getWidth()
local wvh = self.worldCanvas:getHeight() 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 if not self.tiltCanvas or self.tiltCanvas:getWidth() ~= wvw
or self.tiltCanvas:getHeight() ~= wvh then or self.tiltCanvas:getHeight() ~= wvh then
if self.tiltCanvas and self.tiltCanvas.release then self.tiltCanvas:release() end if self.tiltCanvas and self.tiltCanvas.release then self.tiltCanvas:release() end
self.tiltCanvas = love.graphics.newCanvas(wvw, wvh) self.tiltCanvas = PixelCanvas.new(wvw, wvh, "linear")
self.tiltCanvas:setFilter("linear", "linear")
end end
love.graphics.setCanvas(self.tiltCanvas) love.graphics.setCanvas(self.tiltCanvas)
@@ -351,7 +393,7 @@ function Renderer:drawTiltedWorld(zoneList, s, wox, woy, target)
mesh:setVertices(Tilt.meshCorners(wvw, wvh)) mesh:setVertices(Tilt.meshCorners(wvw, wvh))
love.graphics.push() love.graphics.push()
love.graphics.translate(wox, woy) love.graphics.translate(wox, woy)
love.graphics.scale(s, s) love.graphics.scale(sx, sy)
love.graphics.setColor(1, 1, 1, 1) love.graphics.setColor(1, 1, 1, 1)
love.graphics.setShader(shader) love.graphics.setShader(shader)
love.graphics.draw(mesh) love.graphics.draw(mesh)
@@ -393,14 +435,15 @@ end
-- presented through the GBC FX shader as a final pass. -- presented through the GBC FX shader as a final pass.
function Renderer:endFrame(zones, worldZones) function Renderer:endFrame(zones, worldZones)
love.graphics.setCanvas() love.graphics.setCanvas()
local ww, wh, pw, ph, dpi = displayMetrics() local ww, wh, pw, ph, dpiX, dpiY = displayMetrics()
-- Sp = integer framebuffer pixels per GB pixel; S = LOVE-unit draw scale. -- Sp = integer framebuffer pixels per GB pixel;
-- Sx/Sy = LOVE-unit draw scales (may differ when dpiX ≠ dpiY).
local Sp = self:fitScale() local Sp = self:fitScale()
local S = Sp / dpi local Sx, Sy = Sp / dpiX, Sp / dpiY
local vpw, vph = self.WIDTH * S, self.HEIGHT * S local vpw, vph = self.WIDTH * Sx, self.HEIGHT * Sy
-- Snap the letterbox origin to a framebuffer pixel, then convert to units. -- Snap the letterbox origin to a framebuffer pixel, then convert to units.
local ox = math.floor((pw - self.WIDTH * Sp) / 2) / dpi local ox = math.floor((pw - self.WIDTH * Sp) / 2) / dpiX
local oy = math.floor((ph - self.HEIGHT * Sp) / 2) / dpi local oy = math.floor((ph - self.HEIGHT * Sp) / 2) / dpiY
local GBCFX = require("src.render.GBCFX") local GBCFX = require("src.render.GBCFX")
-- Forced mono/Classic modes still need a whole-screen zone when a state -- Forced mono/Classic modes still need a whole-screen zone when a state
-- exposes no SGB packets (raw DMG canvas), so sendColors can remap. -- exposes no SGB packets (raw DMG canvas), so sendColors can remap.
@@ -422,6 +465,14 @@ function Renderer:endFrame(zones, worldZones)
if needPresent then if needPresent then
if not self.presentCanvas or self.presentCanvas:getWidth() ~= ww if not self.presentCanvas or self.presentCanvas:getWidth() ~= ww
or self.presentCanvas:getHeight() ~= wh then or self.presentCanvas:getHeight() ~= wh then
-- The one canvas NOT built through PixelCanvas: it is sized in LOVE
-- units and blitted back at unit scale 1 (and handed to mod present
-- passes as ww x wh), so it has to keep the screen's DPI scale for its
-- texture to cover the framebuffer. Everything composited into it is
-- already native-resolution now, so #208's fractional source is gone;
-- what remains here is the dpiX vs dpiY truncation gap (well under 1%,
-- one seam across the window) that a single scalar dpiscale cannot
-- express.
self.presentCanvas = love.graphics.newCanvas(ww, wh) self.presentCanvas = love.graphics.newCanvas(ww, wh)
self.presentCanvas:setFilter("linear", "linear") self.presentCanvas:setFilter("linear", "linear")
end end
@@ -444,15 +495,27 @@ function Renderer:endFrame(zones, worldZones)
love.graphics.setColor(clearR, clearG, clearB, 1) love.graphics.setColor(clearR, clearG, clearB, 1)
love.graphics.rectangle("fill", 0, 0, ww, wh) love.graphics.rectangle("fill", 0, 0, ww, wh)
love.graphics.setColor(1, 1, 1, 1) love.graphics.setColor(1, 1, 1, 1)
-- render.letterbox: SGB borders / custom void art in the bars around the
-- 160x144 (or world) blit. Drawn after the clear and before the game
-- canvas so the playfield sits on top of the border.
if Runtime.wantsHook("render.letterbox") then
Runtime.call("render.letterbox", function() end, {
ww = ww, wh = wh, pw = pw, ph = ph,
ox = ox, oy = oy, vpw = vpw, vph = vph,
scale = Sp, dpiX = dpiX, dpiY = dpiY,
worldActive = self.worldActive and true or false,
})
end
-- blit `canvas` at `scale` (LOVE units) into origin (bx, by), scissored to -- blit `canvas` at (sx, sy) LOVE-unit scales into origin (bx, by),
-- the (boxX, boxY, boxW, boxH) screen rect. zoneScale converts zone -- scissored to the (boxX, boxY, boxW, boxH) screen rect. zoneSx/zoneSy
-- coords (canvas-space) into screen units. -- convert zone coords (canvas-space) into screen units.
local function blit(canvas, scale, zoneList, zoneScale, bx, by, boxX, boxY, boxW, boxH) 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 local shader = zoneList and zoneList[1] and PaletteFX.shader() or nil
if not shader then if not shader then
love.graphics.setScissor(boxX, boxY, boxW, boxH) 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() love.graphics.setScissor()
return return
end end
@@ -468,10 +531,10 @@ function Renderer:endFrame(zones, worldZones)
love.graphics.setShader(not plain and shader or nil) love.graphics.setShader(not plain and shader or nil)
end end
if not plain then PaletteFX.sendColors(shader, z.colors) end if not plain then PaletteFX.sendColors(shader, z.colors) end
if scissorClamped(bx + z.x * zoneScale, by + z.y * zoneScale, if scissorClamped(bx + z.x * zoneSx, by + z.y * zoneSy,
z.w * zoneScale, z.h * zoneScale, z.w * zoneSx, z.h * zoneSy,
boxX, boxY, boxW, boxH) then 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
end end
love.graphics.setScissor() love.graphics.setScissor()
@@ -486,7 +549,7 @@ function Renderer:endFrame(zones, worldZones)
-- runs, so dialogs, menus and the HUD sit on top as usual. -- runs, so dialogs, menus and the HUD sit on top as usual.
love.graphics.setColor(1, 1, 1, 1) love.graphics.setColor(1, 1, 1, 1)
love.graphics.setScissor(0, 0, ww, wh) 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() love.graphics.setScissor()
-- the screen-space overlays the flat path draws over its composite -- the screen-space overlays the flat path draws over its composite
local fade = self.worldFadeAlpha local fade = self.worldFadeAlpha
@@ -496,15 +559,15 @@ function Renderer:endFrame(zones, worldZones)
love.graphics.setColor(1, 1, 1, 1) love.graphics.setColor(1, 1, 1, 1)
end end
if self.battleCascadeProg then 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
elseif self.worldActive then elseif self.worldActive then
local sp = Zoom.scale(Sp) local sp = Zoom.scale(Sp)
local s = sp / dpi local sx, sy = sp / dpiX, sp / dpiY
local wvw = self.worldCanvas:getWidth() local wvw = self.worldCanvas:getWidth()
local wvh = self.worldCanvas:getHeight() local wvh = self.worldCanvas:getHeight()
local wox = math.floor((pw - wvw * sp) / 2) / dpi local wox = math.floor((pw - wvw * sp) / 2) / dpiX
local woy = math.floor((ph - wvh * sp) / 2) / dpi local woy = math.floor((ph - wvh * sp) / 2) / dpiY
-- Tilt mode projects the ground world pass through the perspective mesh -- Tilt mode projects the ground world pass through the perspective mesh
-- (SGB zones baked in beforehand -- see drawTiltedWorld -- so no zone -- (SGB zones baked in beforehand -- see drawTiltedWorld -- so no zone
-- scissoring here). drawTiltedWorld returns false when tilt is off or -- 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 -- falls through to the flat blit, keeping the flat frame byte-for-byte
-- identical to today. -- identical to today.
local projected = 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 not projected then
if worldZones 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 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 end
-- OBP-baked overworld sprites replay on top of the zone pass (GBC -- OBP-baked overworld sprites replay on top of the zone pass (GBC
-- mode per-object coloring; see PaletteFX.markSpriteRedraw). Grass -- mode per-object coloring; see PaletteFX.markSpriteRedraw). Grass
@@ -538,11 +601,11 @@ function Renderer:endFrame(zones, worldZones)
end end
if wanted then PaletteFX.sendColors(wanted, r.colors) end if wanted then PaletteFX.sendColors(wanted, r.colors) end
if r.quad then if r.quad then
love.graphics.draw(r.image, r.quad, wox + r.x * s, woy + r.y * s, love.graphics.draw(r.image, r.quad, wox + r.x * sx, woy + r.y * sy,
0, s * r.sx, s) 0, sx * r.sx, sy)
else else
love.graphics.draw(r.image, wox + r.x * s, woy + r.y * s, love.graphics.draw(r.image, wox + r.x * sx, woy + r.y * sy,
0, s * r.sx, s) 0, sx * r.sx, sy)
end end
end end
if activeShader then love.graphics.setShader() end if activeShader then love.graphics.setShader() end
@@ -559,7 +622,7 @@ function Renderer:endFrame(zones, worldZones)
local M = self.UPRIGHT_MARGIN local M = self.UPRIGHT_MARGIN
love.graphics.setColor(1, 1, 1, 1) love.graphics.setColor(1, 1, 1, 1)
love.graphics.setScissor(0, 0, ww, wh) 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() love.graphics.setScissor()
end end
-- Screen-space warp fade (Transition) over the full world composite so -- 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 -- Battle transition: cascade black blocks into the area outside the
-- classic 160x144 wipe square (world still shows through until filled). -- classic 160x144 wipe square (world still shows through until filled).
if self.battleCascadeProg then 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
end end
-- UI stays in the classic centered GB letterbox -- 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 if present then
love.graphics.setCanvas() love.graphics.setCanvas()
@@ -589,7 +652,7 @@ function Renderer:endFrame(zones, worldZones)
-- grid itself. Each pass hands back a canvas; with none registered -- grid itself. Each pass hands back a canvas; with none registered
-- this returns `present` unchanged and the frame is byte-identical. -- this returns `present` unchanged and the frame is byte-identical.
local composed = Pipelines.present(present, 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 if GBCFX.active() then
-- shader grid/shadow math is in framebuffer pixels -- shader grid/shadow math is in framebuffer pixels
GBCFX.present(composed, Sp) GBCFX.present(composed, Sp)
+11 -6
View File
@@ -105,7 +105,10 @@ function SpriteRenderer:resolveImage()
local colors, group = PaletteFX.spriteObp(self.def, self.seed) local colors, group = PaletteFX.spriteObp(self.def, self.seed)
if colors then return getObpImage(self.def.image, colors, group) end if colors then return getObpImage(self.def.image, colors, group) end
elseif PaletteFX.usesSpriteObp() then 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 end
return self.image return self.image
end end
@@ -141,11 +144,13 @@ function SpriteRenderer:draw(px, py, camX, camY, facing, walkPhase, stepFlip)
image = getObpImage(self.def.image, colors, group) image = getObpImage(self.def.image, colors, group)
end end
elseif PaletteFX.usesSpriteObp() and PaletteFX.spriteRedrawPassActive() then elseif PaletteFX.usesSpriteObp() and PaletteFX.spriteRedrawPassActive() then
-- OG RED (GBC boot-ROM look): every OBJ wears the one global green -- OG RED (GBC boot-ROM look): every OBJ wears the one global object
-- object palette. The red BG zone shader still runs over the world -- palette -- green over Red's red background, pink over Blue's blue
-- canvas, so the baked sprite is queued for a post-zone redraw -- background (PaletteFX.ogObj, #155). The BG zone shader still runs over
-- (PaletteFX.markSpriteRedraw) that restores its green pixels on top. -- the world canvas, so the baked sprite is queued for a post-zone redraw
image = getObpImage(self.def.image, PaletteFX.GBC_OBJ, "gbcobj") -- (PaletteFX.markSpriteRedraw) that restores its object-colored pixels on
-- top.
image = getObpImage(self.def.image, PaletteFX.ogObj())
redraw = true redraw = true
end end
-- single-frame sprites (item balls, fossils...) have one fixed pose; -- 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. -- pokered ContText waits for A/B + ▼ before scrolling that line in.
function TextBox.paginate(text, maxCols) function TextBox.paginate(text, maxCols)
maxCols = maxCols or (Theme.textBox and Theme.textBox.maxCols) or MAX_COLS maxCols = maxCols or (Theme.textBox and Theme.textBox.maxCols) or MAX_COLS
-- maxCols is a column count, so the budget is that many vanilla 8px
-- cells. Measuring in pixels rather than columns is what lets a mod's
-- variable-advance page wrap correctly (#186).
local budget = maxCols * 8
local pages = {} local pages = {}
local contBefore = {} local contBefore = {}
-- Soft-wrap on glyph boundaries, never byte boundaries: a line is over
-- budget by what it *draws*, and the cut falls between glyphs so a
-- multi-byte char is never torn in half.
local function pushLine(lines, conts, line, wait) local function pushLine(lines, conts, line, wait)
while #line > maxCols do while true do
local cut = maxCols local spans = Font.split(line)
for i = maxCols, 1, -1 do local fit = Font.spansFitting(spans, budget)
if line:sub(i, i) == " " then cut = i break end if fit >= #spans then break end
-- a glyph wider than the whole box still has to advance by one
fit = math.max(fit, 1)
local cut = spans[fit].to
for i = fit, 1, -1 do
if line:sub(spans[i].from, spans[i].to) == " " then
cut = spans[i].to
break
end
end end
table.insert(lines, line:sub(1, cut)) table.insert(lines, line:sub(1, cut))
table.insert(conts, wait) table.insert(conts, wait)
@@ -167,6 +182,18 @@ function TextBox:update(dt)
if self.autoSrc and self.autoSrc.isPlaying and self.autoSrc:isPlaying() then if self.autoSrc and self.autoSrc.isPlaying and self.autoSrc:isPlaying() then
return -- the cry is still sounding (WaitForSoundToFinish) return -- the cry is still sounding (WaitForSoundToFinish)
end end
-- auto.wait: the pet-NPC cries (PewterNidoranHouseNidoranText,
-- ViridianNicknameHouseSpearowText) have nothing queued behind the
-- cry, so DisplayTextID's trailing WaitForTextScrollButtonPress still
-- runs once it is over -- their maps enable auto text box drawing,
-- which zeroes wDoNotWaitForButtonPressAfterDisplayingText
-- (home/window.asm AutoTextBoxDrawingCommon). Drop the auto gate and
-- hand the box to the plain A/B path, which also starts the blinking
-- arrow, instead of popping it (#247, #251).
if self.auto.wait then
self.auto = nil
return
end
self.autoTimer = self.autoTimer + 1 self.autoTimer = self.autoTimer + 1
local delay = self.auto.delay or 3 local delay = self.auto.delay or 3
-- auto.onOverlap: fired once when the delay elapses but before the -- auto.onOverlap: fired once when the delay elapses but before the
+4 -1
View File
@@ -542,7 +542,10 @@ end
local function bakeBorderFill(self, block) local function bakeBorderFill(self, block)
local border = self.map.tileset.blocks[block + 1] local border = self.map.tileset.blocks[block + 1]
if not border then return end if not border then return end
local canvas = love.graphics.newCanvas(32, 32) -- 32x32 real pixels: a DPI-scaled canvas would bake the border block at a
-- fractional texel size and the repeat-wrapped image would then tile at
-- non-square pixels (#208, see src/render/PixelCanvas.lua)
local canvas = require("src.render.PixelCanvas").new(32, 32)
love.graphics.push("all") love.graphics.push("all")
love.graphics.setCanvas(canvas) love.graphics.setCanvas(canvas)
love.graphics.clear(1, 1, 1, 1) love.graphics.clear(1, 1, 1, 1)
+26 -7
View File
@@ -4,19 +4,38 @@
-- zoom. Persisted as save.options.zoom (default 0 = FIT). -- zoom. Persisted as save.options.zoom (default 0 = FIT).
-- Spec: docs/new-features.md (survey zoom) -- Spec: docs/new-features.md (survey zoom)
local Runtime = require("src.mods.Runtime")
local Zoom = {} local Zoom = {}
Zoom.offset = 0 Zoom.offset = 0
-- effective integer scale s' in [1, 2*S] -- legal offset range for a given fit scale (vanilla: survey at 1 px/world
function Zoom.scale(S) -- through 2× fit). zoom.range may widen or shrink the window.
return math.max(1, math.min(2 * S, S + Zoom.offset))
end
-- legal offset range for a given fit scale
function Zoom.offsetRange(S) function Zoom.offsetRange(S)
S = math.max(1, math.floor(tonumber(S) or 1)) S = math.max(1, math.floor(tonumber(S) or 1))
return 1 - S, S local lo, hi = 1 - S, S
if Runtime.wantsHook("zoom.range") then
lo, hi = Runtime.call("zoom.range", function(a, b) return a, b end, lo, hi, S)
lo = math.floor(tonumber(lo) or (1 - S))
hi = math.floor(tonumber(hi) or S)
if lo > hi then lo, hi = hi, lo end
end
return lo, hi
end
-- effective scale s' = S + offset, clamped to the (possibly modded) range.
-- Vanilla stays in [1, 2*S]. A zoom.range wrapper that lowers `lo` below
-- 1-S permits sub-1 survey scales so the whole region can fit on screen.
function Zoom.scale(S)
local lo, hi = Zoom.offsetRange(S)
local s = S + Zoom.offset
local minScale = S + lo
local maxScale = math.max(minScale, S + hi)
if s < minScale then s = minScale end
if s > maxScale then s = maxScale end
if s < 0.25 then s = 0.25 end
return s
end end
function Zoom.clampOffset(offset, S) function Zoom.clampOffset(offset, S)
+29 -14
View File
@@ -10,6 +10,7 @@ local Flags = require("src.script.Flags")
local Logger = require("src.core.Logger") local Logger = require("src.core.Logger")
local Screens = require("src.ui.Screens") local Screens = require("src.ui.Screens")
local TextBox = require("src.render.TextBox") local TextBox = require("src.render.TextBox")
local Strings = require("src.core.Strings")
local Commands = {} local Commands = {}
@@ -74,6 +75,9 @@ end
-- <PROMPT> of its own; the box only closes once WaitForSoundToFinish's poll -- <PROMPT> of its own; the box only closes once WaitForSoundToFinish's poll
-- loop sees the cry channel go quiet, so it's a no-button-wait auto text -- loop sees the cry channel go quiet, so it's a no-button-wait auto text
-- gated on the cry, not a button-wait one -- see TextBox's opts.auto. -- gated on the cry, not a button-wait one -- see TextBox's opts.auto.
-- play_cry's waitForButton form keeps that cry gate but hands the box back
-- to the A/B path once the cry is over -- see TextBox's opts.auto.wait
-- (#247, #251).
function Commands.show_text(ctx, textId, subs) function Commands.show_text(ctx, textId, subs)
local text = ctx.game.data.text[textId] local text = ctx.game.data.text[textId]
if not text and ctx.overworld then if not text and ctx.overworld then
@@ -100,10 +104,12 @@ function Commands.show_text(ctx, textId, subs)
local opts local opts
if ctx.pendingCry then if ctx.pendingCry then
local species = ctx.pendingCry local species = ctx.pendingCry
ctx.pendingCry = nil local waitForButton = ctx.pendingCryWait
ctx.pendingCry, ctx.pendingCryWait = nil, nil
-- delay 0: WaitForSoundToFinish has no trailing Delay3 of its own
opts = { auto = { sound = function() opts = { auto = { sound = function()
return require("src.core.Sound").playCry(ctx.game.data, species) return require("src.core.Sound").playCry(ctx.game.data, species)
end, delay = 0 } } -- WaitForSoundToFinish has no trailing Delay3 of its own end, delay = 0, wait = waitForButton } }
end end
-- text_opts armed the next box: auto = true is the plain no-button-wait -- text_opts armed the next box: auto = true is the plain no-button-wait
-- form, overlap folds under auto, everything else passes through -- form, overlap folds under auto, everything else passes through
@@ -189,7 +195,7 @@ function Commands.give_item(ctx, itemId, count, gotText)
-- skips the received text entirely when AddItemToInventory refuses) -- skips the received text entirely when AddItemToInventory refuses)
if not require("src.inventory.Bag").add(ctx.save, itemId, count or 1) then if not require("src.inventory.Bag").add(ctx.save, itemId, count or 1) then
Commands.show_text(ctx, ctx.game.data.text Commands.show_text(ctx, ctx.game.data.text
and ctx.game.data.text._BagFullText or "You can't carry\nany more items!") and ctx.game.data.text._BagFullText or Strings("You can't carry\nany more items!"))
return math.huge return math.huge
end end
local def = ctx.game.data.items[itemId] local def = ctx.game.data.items[itemId]
@@ -202,7 +208,7 @@ function Commands.give_item(ctx, itemId, count, gotText)
(def and def.keyItem) and "Get_Key_Item" or "Get_Item1") (def and def.keyItem) and "Get_Key_Item" or "Get_Item1")
if gotText ~= false then if gotText ~= false then
Commands.show_text(ctx, gotText Commands.show_text(ctx, gotText
or "{PLAYER} got\n" .. ctx.game.stringBuffer .. "!") or Strings("{PLAYER} got\n%s!", ctx.game.stringBuffer))
end end
end end
@@ -469,18 +475,26 @@ function Commands.play_once(ctx, songId)
runner:yield() runner:yield()
end end
-- play_cry <species>: PlayCry (home/audio.asm). The text_asm bodies that -- play_cry <species> [waitForButton]: PlayCry (home/audio.asm). The
-- use it run text_far (a no-button-wait "...@" string) -> PlayCry -> -- text_asm bodies that use it run text_far (a no-button-wait "...@" string)
-- WaitForSoundToFinish, all within the same text ID -- the cry only starts -- -> PlayCry -> WaitForSoundToFinish, all within the same text ID -- the cry
-- once the box has finished typing, and the box then auto-closes (no A -- only starts once the box has finished typing, and the box then auto-closes
-- press) the instant the cry finishes, rather than firing immediately -- (no A press) the instant the cry finishes, rather than firing immediately
-- alongside the typewriter effect. Script rows run strictly in order, so -- alongside the typewriter effect. Script rows run strictly in order, so
-- this stashes the species on ctx for the show_text row that always -- this stashes the species on ctx for the show_text row that always
-- immediately follows it (Power Plant Zapdos, Seafoam Articuno, Victory -- immediately follows it (Power Plant Zapdos, Seafoam Articuno, Victory
-- Road Moltres, Cerulean Cave Mewtwo battle text) to play once its box is -- Road Moltres, Cerulean Cave Mewtwo battle text) to play once its box is
-- done typing (see show_text's opts.auto). Headless-safe no-op there. -- done typing (see show_text's opts.auto). Headless-safe no-op there.
function Commands.play_cry(ctx, species) --
-- waitForButton is the pet-NPC form (scripts/PewterNidoranHouse.asm and
-- scripts/ViridianNicknameHouse.asm: the text prints, then PlayCry,
-- WaitForSoundToFinish, TextScriptEnd). Nothing is queued behind the cry
-- there, so DisplayTextID's trailing WaitForTextScrollButtonPress still
-- runs and the box holds until A/B like any other NPC line instead of
-- popping itself into a static battle (#247, #251).
function Commands.play_cry(ctx, species, waitForButton)
ctx.pendingCry = species ctx.pendingCry = species
ctx.pendingCryWait = waitForButton or nil
end end
-- check_battle_result <r1> [r2 ...]: lastCheck = the last scripted -- check_battle_result <r1> [r2 ...]: lastCheck = the last scripted
@@ -517,7 +531,7 @@ local function askNickname(ctx, mon)
if ctx.game.data.text and ctx.game.data.text._DoYouWantToNicknameText then if ctx.game.data.text and ctx.game.data.text._DoYouWantToNicknameText then
Commands.show_text(ctx, "_DoYouWantToNicknameText", { RAM = name }) Commands.show_text(ctx, "_DoYouWantToNicknameText", { RAM = name })
else else
Commands.show_text(ctx, ("Do you want to\ngive a nickname\nto %s?"):format(name)) Commands.show_text(ctx, Strings("Do you want to\ngive a nickname\nto %s?", name))
end end
local ChoiceBox = require("src.ui.ChoiceBox") local ChoiceBox = require("src.ui.ChoiceBox")
ctx.game.stack:push(ChoiceBox.new(ctx.game, function(yes) ctx.game.stack:push(ChoiceBox.new(ctx.game, function(yes)
@@ -527,7 +541,7 @@ local function askNickname(ctx, mon)
return return
end end
Screens.push(ctx.game, "NamingScreen", { Screens.push(ctx.game, "NamingScreen", {
title = "NICKNAME?", maxLen = 10, title = Strings("NICKNAME?"), maxLen = 10,
onDone = function(nick) onDone = function(nick)
if nick and #nick > 0 then mon.nickname = nick end if nick and #nick > 0 then mon.nickname = nick end
ctx.lastCheck = success ctx.lastCheck = success
@@ -598,8 +612,9 @@ function Commands.give_pokemon(ctx, species, level)
local TextBox = require("src.render.TextBox") local TextBox = require("src.render.TextBox")
local raw = ctx.game.data.text and ctx.game.data.text._SentToBoxText local raw = ctx.game.data.text and ctx.game.data.text._SentToBoxText
ctx.game.stack:push(TextBox.new(ctx.game, raw or ( ctx.game.stack:push(TextBox.new(ctx.game, raw or (
"There's no more\nroom for POKéMON!\v" .. name Strings(
.. " was\vsent to POKéMON\vBOX " .. boxNum .. " on PC!"), "There's no more\nroom for POKéMON!\v%s was\vsent to POKéMON\vBOX %s on PC!",
name, boxNum)),
function() end)) function() end))
end end
end end
+2 -1
View File
@@ -15,6 +15,7 @@
local Data = require("src.core.Data") local Data = require("src.core.Data")
local Logger = require("src.core.Logger") local Logger = require("src.core.Logger")
local Runtime = require("src.mods.Runtime") local Runtime = require("src.mods.Runtime")
local Strings = require("src.core.Strings")
local MapScripts = {} local MapScripts = {}
@@ -268,7 +269,7 @@ function MapScripts.validateContribution(contribution, lookup)
end end
end end
if type(contribution) ~= "table" then if type(contribution) ~= "table" then
return { "contribution is not a table" } return { Strings("contribution is not a table") }
end end
for textConst, script in pairs(contribution.talk or {}) do for textConst, script in pairs(contribution.talk or {}) do
if type(script) == "table" then collect("talk." .. textConst, script) end if type(script) == "table" then collect("talk." .. textConst, script) end
+2 -1
View File
@@ -13,6 +13,7 @@
local Commands = require("src.script.Commands") local Commands = require("src.script.Commands")
local Logger = require("src.core.Logger") local Logger = require("src.core.Logger")
local Runtime = require("src.mods.Runtime") local Runtime = require("src.mods.Runtime")
local Strings = require("src.core.Strings")
local unpack = table.unpack or unpack -- LuaJIT (LÖVE) compatibility local unpack = table.unpack or unpack -- LuaJIT (LÖVE) compatibility
@@ -195,7 +196,7 @@ function ScriptRunner:resume(...)
if not ok then if not ok then
local source = self.ctx and self.ctx.source local source = self.ctx and self.ctx.source
local where = source local where = source
and (" [%s %s.%s]"):format(tostring(source.modId or "engine"), and Strings(" [%s %s.%s]", tostring(source.modId or "engine"),
tostring(source.mapId or "?"), tostring(source.hook or "?")) tostring(source.mapId or "?"), tostring(source.hook or "?"))
or "" or ""
Logger.error("script error%s: %s", where, tostring(err)) Logger.error("script error%s: %s", where, tostring(err))
+49 -27
View File
@@ -9,6 +9,7 @@ local TextBox = require("src.render.TextBox")
local BagMenu = {} local BagMenu = {}
local Bag = require("src.inventory.Bag") local Bag = require("src.inventory.Bag")
local Strings = require("src.core.Strings")
-- acquisition order like wBagItems (Bag.order), not alphabetical -- acquisition order like wBagItems (Bag.order), not alphabetical
local function buildItems(game) local function buildItems(game)
@@ -108,13 +109,13 @@ local function useOn(game, battle, id, target, list, moveIndex)
if game.save.onBike then if game.save.onBike then
game.save.onBike = false game.save.onBike = false
Music.playMap(game.data, ow and ow.map.id, false) Music.playMap(game.data, ow and ow.map.id, false)
showMessages(game, { save_name(game) .. " got off\nthe BICYCLE." }) showMessages(game, { Strings("%s got off\nthe BICYCLE.", save_name(game)) })
elseif bikeAllowed() then elseif bikeAllowed() then
game.save.onBike = true game.save.onBike = true
Music.playMap(game.data, ow.map.id, true) Music.playMap(game.data, ow.map.id, true)
showMessages(game, { save_name(game) .. " got on\nthe BICYCLE!" }) showMessages(game, { Strings("%s got on\nthe BICYCLE!", save_name(game)) })
else else
showMessages(game, { "No cycling\nallowed here." }) showMessages(game, { Strings("No cycling\nallowed here.") })
end end
return return
end end
@@ -130,13 +131,14 @@ local function useOn(game, battle, id, target, list, moveIndex)
return return
end end
end end
showMessages(game, { "No good! It's not\neven near water." }) showMessages(game, { Strings("No good! It's not\neven near water.") })
return return
end end
if result == "ball" then if result == "ball" then
if not battle then if not battle then
showMessages(game, { "OAK: " .. game.save.player.name .. "!\nThis isn't the\ntime to use that!" }) showMessages(game, { Strings("OAK: %s!\nThis isn't the\ntime to use that!",
game.save.player.name) })
return return
end end
consume(game, id) consume(game, id)
@@ -151,7 +153,7 @@ local function useOn(game, battle, id, target, list, moveIndex)
local function teach() local function teach()
if #target.moves < 4 then if #target.moves < 4 then
table.insert(target.moves, { id = moveId, pp = mdef.pp }) table.insert(target.moves, { id = moveId, pp = mdef.pp })
showMessages(game, { ("%s learned\n%s!"):format(target.nickname or showMessages(game, { Strings("%s learned\n%s!", target.nickname or
game.data.pokemon[target.species].name, mdef.name) }) game.data.pokemon[target.species].name, mdef.name) })
if result == "learn" then consume(game, id) end if result == "learn" then consume(game, id) end
else else
@@ -172,7 +174,7 @@ local function useOn(game, battle, id, target, list, moveIndex)
require("src.ui.Screens").push(game, "TownMap") require("src.ui.Screens").push(game, "TownMap")
end) end)
if not ok then if not ok then
showMessages(game, { "The TOWN MAP is\nunreadable here." }) showMessages(game, { Strings("The TOWN MAP is\nunreadable here.") })
end end
return return
end end
@@ -184,10 +186,10 @@ local function useOn(game, battle, id, target, list, moveIndex)
local t = game.data.text local t = game.data.text
if ow and ow:hasHiddenItemLeft() then if ow and ow:hasHiddenItemLeft() then
showMessages(game, { t._ItemfinderFoundItemText showMessages(game, { t._ItemfinderFoundItemText
or "Yes! ITEMFINDER\nindicates there's\nan item nearby." }) or Strings("Yes! ITEMFINDER\nindicates there's\nan item nearby.") })
else else
showMessages(game, { t._ItemfinderFoundNothingText showMessages(game, { t._ItemfinderFoundNothingText
or "Nope! ITEMFINDER\nisn't responding." }) or Strings("Nope! ITEMFINDER\nisn't responding.") })
end end
return return
end end
@@ -214,14 +216,14 @@ local function useOn(game, battle, id, target, list, moveIndex)
and ow.map.id ~= "AGATHAS_ROOM" then and ow.map.id ~= "AGATHAS_ROOM" then
list:close() list:close()
consume(game, id) consume(game, id)
require("src.core.Sound").play(game.data, "Teleport_Exit1") -- LeaveMapAnim spin-up + SFX_TELEPORT_EXIT_1, a fade, then land OUTSIDE
ow.player.surfing = false -- the last Pokémon Center town door like Fly (#196), via the shared
-- EnterMapAnim on arrival (BIT_ESCAPE_WARP / special warp path); -- departure helper -- the same path Dig/Teleport take from the party menu
-- blackouts omit arrive="teleport" (HandleBlackOut has no LeaveMapAnim) ow:beginTeleportOut()
ow:warpToHealPoint(nil, { arrive = "teleport" })
else else
showMessages(game, { "OAK: " .. game.save.player.name showMessages(game, { Strings(
.. "!\nThis isn't the\ntime to use that!" }) "OAK: %s!\nThis isn't the\ntime to use that!",
game.save.player.name) })
end end
return return
end end
@@ -265,7 +267,7 @@ local function useOn(game, battle, id, target, list, moveIndex)
if #target.moves < 4 then if #target.moves < 4 then
table.insert(target.moves, { id = moveId, pp = mdef.pp }) table.insert(target.moves, { id = moveId, pp = mdef.pp })
local name = target.nickname or def.name local name = target.nickname or def.name
showMessages(game, { ("%s learned\n%s!"):format(name, mdef.name) }, showMessages(game, { Strings("%s learned\n%s!", name, mdef.name) },
nextStep) nextStep)
else else
require("src.ui.Screens").push(game, "MoveLearnMenu", require("src.ui.Screens").push(game, "MoveLearnMenu",
@@ -303,7 +305,8 @@ local function pickTargetAndUse(game, battle, id, list)
-- the ETHERs and PP UP open the move menu after picking a mon -- the ETHERs and PP UP open the move menu after picking a mon
-- (ItemUsePPRestore / ItemUsePPUp); the ELIXERs hit every move -- (ItemUsePPRestore / ItemUsePPUp); the ELIXERs hit every move
local wantsMove = id == "ETHER" or id == "MAX_ETHER" or id == "PP_UP" 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, pickOnly = true,
onSwitch = function(mon) onSwitch = function(mon)
if not wantsMove then if not wantsMove then
@@ -326,7 +329,17 @@ local function pickTargetAndUse(game, battle, id, list)
end, end,
})) }))
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 end
local function useItem(game, battle, id, list) 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 moveDef = game.data.moves[def.machine.move]
local moveName = moveDef and moveDef.name or def.machine.move local moveName = moveDef and moveDef.name or def.machine.move
local booted = def.machine.kind == "HM" local booted = def.machine.kind == "HM"
and "Booted up an HM!" or "Booted up a TM!" and "Booted up an HM!" or Strings("Booted up a TM!")
showMessages(game, { booted, ("It contained\n%s!"):format(moveName) }, showMessages(game, { booted, Strings("It contained\n%s!", moveName) },
function() pickTargetAndUse(game, battle, id, list) end) function() pickTargetAndUse(game, battle, id, list) end)
return return
end end
@@ -360,7 +373,10 @@ function BagMenu.new(game, opts)
local battle = opts.battle local battle = opts.battle
local list local list
list = ListMenu.new(game, "ITEMS", buildItems(game), { list = ListMenu.new(game, "ITEMS", buildItems(game), {
kind = "bag",
footer = ("¥%d"):format(game.save.money), footer = ("¥%d"):format(game.save.money),
-- B returns to the start menu when the bag was opened from it
onCancel = opts.onCancel,
-- SELECT reorders items like the original bag (swap_items.asm) -- SELECT reorders items like the original bag (swap_items.asm)
onSelectKey = function(item, l) onSelectKey = function(item, l)
if not item then return end if not item then return end
@@ -389,17 +405,23 @@ function BagMenu.new(game, opts)
useItem(game, battle, id, list) useItem(game, battle, id, list)
return return
end end
-- USE / TOSS submenu (the original's item options) -- USE / TOSS submenu (the original's item options).
-- data/text_boxes.asm USE_TOSS_MENU_TEMPLATE: box (13,10)-(19,14),
-- text at (15,11); start_sub_menus.asm then sets wTopMenuItemY/X to
-- 11/14 for the cursor. Menu's own geometry reproduces all of that
-- from the box alone, so this needs opts rather than a change to the
-- shared Menu. The old 12/10/8/6 box was a column too wide and a row
-- too tall, which left the labels stranded near its top edge (#284).
local Menu = require("src.ui.Menu") local Menu = require("src.ui.Menu")
game.stack:push(Menu.new(game, { game.stack:push(Menu.new(game, {
{ label = "USE", onSelect = function() { label = Strings("USE"), onSelect = function()
useItem(game, battle, id, list) useItem(game, battle, id, list)
end }, end },
{ label = "TOSS", onSelect = function() { label = Strings("TOSS"), onSelect = function()
-- KeyItemFlags + HMs decide tossability (not price: -- KeyItemFlags + HMs decide tossability (not price:
-- MOON STONE is price 0 but tossable) -- MOON STONE is price 0 but tossable)
if not def or def.keyItem or id:find("^HM_") then if not def or def.keyItem or id:find("^HM_") then
showMessages(game, { "That's too impor-\ntant to toss!" }) showMessages(game, { Strings("That's too impor-\ntant to toss!") })
return return
end end
local QuantityBox = require("src.ui.QuantityBox") local QuantityBox = require("src.ui.QuantityBox")
@@ -413,12 +435,12 @@ function BagMenu.new(game, opts)
Bag.remove(game.save, id, qty) Bag.remove(game.save, id, qty)
list.items = buildItems(game) list.items = buildItems(game)
list.index = math.min(list.index, math.max(1, #list.items)) list.index = math.min(list.index, math.max(1, #list.items))
showMessages(game, { ("Threw away\n%s."):format(def and def.name or id) }) showMessages(game, { Strings("Threw away\n%s.", def and def.name or id) })
end)) end))
end, end,
})) }))
end }, end },
}, { tx = 12, ty = 10, tw = 8, th = 6 })) }, { tx = 13, ty = 10, tw = 7, th = 5 }))
end, end,
}) })
return list return list
+5 -2
View File
@@ -7,6 +7,7 @@
local Font = require("src.render.Font") local Font = require("src.render.Font")
local ListMenu = require("src.ui.ListMenu") local ListMenu = require("src.ui.ListMenu")
local Input = require("src.core.Input") local Input = require("src.core.Input")
local Strings = require("src.core.Strings")
local BindingsMenu = setmetatable({}, { __index = ListMenu }) local BindingsMenu = setmetatable({}, { __index = ListMenu })
BindingsMenu.__index = BindingsMenu BindingsMenu.__index = BindingsMenu
@@ -55,7 +56,9 @@ function BindingsMenu.new(game)
and game.save.options.bindings and game.save.options.bindings
local items = {} local items = {}
for i, def in ipairs(BUTTONS) do for i, def in ipairs(BUTTONS) do
items[i] = { label = def.label, -- translated here, not in ROWS: that table is built at require
-- time, before Strings.load has a catalog to look in
items[i] = { label = Strings(def.label),
right = boundRight(overlay, def), button = def } right = boundRight(overlay, def), button = def }
end end
local self = setmetatable(ListMenu.new(game, "CONTROLS", items, {}), local self = setmetatable(ListMenu.new(game, "CONTROLS", items, {}),
@@ -111,7 +114,7 @@ function BindingsMenu:draw()
if self.capture then if self.capture then
Font.drawBox(1, 6, 18, 4) Font.drawBox(1, 6, 18, 4)
love.graphics.setColor(0, 0, 0, 1) love.graphics.setColor(0, 0, 0, 1)
Font.draw("PRESS A BUTTON", 24, 60) Font.draw(Strings("PRESS A BUTTON"), 24, 60)
love.graphics.setColor(1, 1, 1, 1) love.graphics.setColor(1, 1, 1, 1)
end end
end end
+26 -25
View File
@@ -8,12 +8,13 @@ local ListMenu = require("src.ui.ListMenu")
local Menu = require("src.ui.Menu") local Menu = require("src.ui.Menu")
local Party = require("src.pokemon.Party") local Party = require("src.pokemon.Party")
local TextBox = require("src.render.TextBox") local TextBox = require("src.render.TextBox")
local Strings = require("src.core.Strings")
local BoxMenu = {} local BoxMenu = {}
local function monLabel(game, mon) local function monLabel(game, mon)
local def = game.data.pokemon[mon.species] local def = game.data.pokemon[mon.species]
return ("%s :L%d"):format(mon.nickname or def.name, mon.level) return Strings("%s :L%d", mon.nickname or def.name, mon.level)
end end
local function monName(game, mon) local function monName(game, mon)
@@ -28,13 +29,13 @@ local function monSubmenu(game, action, mon, onAction)
game.stack:push(Menu.new(game, { game.stack:push(Menu.new(game, {
{ label = action, onSelect = onAction }, { label = action, onSelect = onAction },
{ {
label = "STATS", label = Strings("STATS"),
keepOpen = true, keepOpen = true,
onSelect = function() onSelect = function()
require("src.ui.Screens").push(game, "SummaryMenu", mon) require("src.ui.Screens").push(game, "SummaryMenu", mon)
end, end,
}, },
{ label = "CANCEL" }, { label = Strings("CANCEL") },
}, { tx = 9, ty = 10, tw = 11, th = 8, noSound = true })) }, { tx = 9, ty = 10, tw = 11, th = 8, noSound = true }))
end end
@@ -50,12 +51,12 @@ local function withdraw(game)
local t = game.data.text local t = game.data.text
if #box == 0 then if #box == 0 then
game.stack:push(TextBox.new(game, t._NoMonText game.stack:push(TextBox.new(game, t._NoMonText
or "What? There are\nno POKéMON here!")) or Strings("What? There are\nno POKéMON here!")))
return return
end end
if #game.save.party >= Party.MAX then if #game.save.party >= Party.MAX then
game.stack:push(TextBox.new(game, t._CantTakeMonText game.stack:push(TextBox.new(game, t._CantTakeMonText
or "You can't take\nany more POKéMON.\fDeposit POKéMON\nfirst.")) or Strings("You can't take\nany more POKéMON.\fDeposit POKéMON\nfirst.")))
return return
end end
local items = {} local items = {}
@@ -63,7 +64,7 @@ local function withdraw(game)
table.insert(items, { label = monLabel(game, mon), value = i }) table.insert(items, { label = monLabel(game, mon), value = i })
end end
game.stack:push(ListMenu.new(game, game.stack:push(ListMenu.new(game,
("BOX %d (WITHDRAW)"):format(game.save.currentBox), items, { Strings("BOX %d (WITHDRAW)", game.save.currentBox), items, {
onChoose = function(item, list) onChoose = function(item, list)
local mon = box[item.value] local mon = box[item.value]
if not mon then return end if not mon then return end
@@ -78,7 +79,7 @@ local function withdraw(game)
game.stringBuffer = name game.stringBuffer = name
require("src.core.Sound").playCry(game.data, mon.species) require("src.core.Sound").playCry(game.data, mon.species)
afterTransfer(game, list, t._MonIsTakenOutText afterTransfer(game, list, t._MonIsTakenOutText
or (name .. " is\ntaken out.\vGot " .. name .. ".")) or Strings("%s is\ntaken out.\vGot %s.", name, name))
end) end)
end, end,
})) }))
@@ -88,13 +89,13 @@ local function deposit(game)
local t = game.data.text local t = game.data.text
if #game.save.party <= 1 then if #game.save.party <= 1 then
game.stack:push(TextBox.new(game, t._CantDepositLastMonText game.stack:push(TextBox.new(game, t._CantDepositLastMonText
or "You can't deposit\nthe last POKéMON!")) or Strings("You can't deposit\nthe last POKéMON!")))
return return
end end
local box = Boxes.active(game.save) local box = Boxes.active(game.save)
if #box >= Boxes.CAPACITY then if #box >= Boxes.CAPACITY then
game.stack:push(TextBox.new(game, t._BoxFullText game.stack:push(TextBox.new(game, t._BoxFullText
or "Oops! This Box is\nfull of POKéMON.")) or Strings("Oops! This Box is\nfull of POKéMON.")))
return return
end end
local items = {} local items = {}
@@ -107,12 +108,12 @@ local function deposit(game)
if not mon then return end if not mon then return end
monSubmenu(game, "DEPOSIT", mon, function() monSubmenu(game, "DEPOSIT", mon, function()
if #game.save.party <= 1 then if #game.save.party <= 1 then
list.footer = "You need at least\none POKéMON!" list.footer = Strings("You need at least\none POKéMON!")
return return
end end
local active = Boxes.active(game.save) local active = Boxes.active(game.save)
if #active >= Boxes.CAPACITY then if #active >= Boxes.CAPACITY then
list.footer = ("BOX %d is full!"):format(game.save.currentBox) list.footer = Strings("BOX %d is full!", game.save.currentBox)
return return
end end
table.remove(game.save.party, item.value) table.remove(game.save.party, item.value)
@@ -122,7 +123,7 @@ local function deposit(game)
game.boxNumString = tostring(game.save.currentBox) game.boxNumString = tostring(game.save.currentBox)
require("src.core.Sound").playCry(game.data, mon.species) require("src.core.Sound").playCry(game.data, mon.species)
afterTransfer(game, list, t._MonWasStoredText afterTransfer(game, list, t._MonWasStoredText
or (name .. " was\nstored in Box " .. game.boxNumString .. ".")) or Strings("%s was\nstored in Box %s.", name, game.boxNumString))
end) end)
end, end,
})) }))
@@ -135,7 +136,7 @@ local function release(game)
local t = game.data.text local t = game.data.text
if #box == 0 then if #box == 0 then
game.stack:push(TextBox.new(game, t._NoMonText game.stack:push(TextBox.new(game, t._NoMonText
or "What? There are\nno POKéMON here!")) or Strings("What? There are\nno POKéMON here!")))
return return
end end
local items = {} local items = {}
@@ -143,20 +144,20 @@ local function release(game)
table.insert(items, { label = monLabel(game, mon), value = i }) table.insert(items, { label = monLabel(game, mon), value = i })
end end
game.stack:push(ListMenu.new(game, game.stack:push(ListMenu.new(game,
("BOX %d (RELEASE)"):format(game.save.currentBox), items, { Strings("BOX %d (RELEASE)", game.save.currentBox), items, {
onChoose = function(_, list) onChoose = function(_, list)
local mon = box[list.index] local mon = box[list.index]
if not mon then return end if not mon then return end
local name = monName(game, mon) local name = monName(game, mon)
local ChoiceBox = require("src.ui.ChoiceBox") local ChoiceBox = require("src.ui.ChoiceBox")
game.stack:push(TextBox.new(game, game.stack:push(TextBox.new(game,
"Once released,\n" .. name .. " is\ngone forever. OK?", function() Strings("Once released,\n%s is\ngone forever. OK?", name), function()
game.stack:push(ChoiceBox.new(game, function(yes) game.stack:push(ChoiceBox.new(game, function(yes)
if not yes then return end if not yes then return end
table.remove(box, list.index) table.remove(box, list.index)
require("src.core.Sound").playCry(game.data, mon.species) require("src.core.Sound").playCry(game.data, mon.species)
game.stack:push(TextBox.new(game, game.stack:push(TextBox.new(game,
("%s was\nreleased outside.\fBye %s!"):format(name, name))) Strings("%s was\nreleased outside.\fBye %s!", name, name)))
list:removeCurrent() list:removeCurrent()
end, { defaultNo = true, noSound = true })) end, { defaultNo = true, noSound = true }))
end)) end))
@@ -170,7 +171,7 @@ local function changeBox(game)
for i = 1, Boxes.COUNT do for i = 1, Boxes.COUNT do
local mark = i == game.save.currentBox and "*" or " " local mark = i == game.save.currentBox and "*" or " "
table.insert(items, { table.insert(items, {
label = ("%sBOX %2d"):format(mark, i), label = Strings("%sBOX %2d", mark, i),
right = ("%d/%d"):format(#boxes[i], Boxes.CAPACITY), right = ("%d/%d"):format(#boxes[i], Boxes.CAPACITY),
value = i, value = i,
}) })
@@ -181,7 +182,7 @@ local function changeBox(game)
-- BOX, data will be saved. OK?"); declining aborts the change -- BOX, data will be saved. OK?"); declining aborts the change
local ChoiceBox = require("src.ui.ChoiceBox") local ChoiceBox = require("src.ui.ChoiceBox")
game.stack:push(TextBox.new(game, game.stack:push(TextBox.new(game,
"When you change a\nPOKéMON BOX, data\nwill be saved. OK?", function() Strings("When you change a\nPOKéMON BOX, data\nwill be saved. OK?"), function()
game.stack:push(ChoiceBox.new(game, function(yes) game.stack:push(ChoiceBox.new(game, function(yes)
if not yes then return end if not yes then return end
game.save.currentBox = item.value game.save.currentBox = item.value
@@ -197,10 +198,10 @@ end
local function drawChrome(game) local function drawChrome(game)
Font.drawBox(0, 12, 20, 6) Font.drawBox(0, 12, 20, 6)
love.graphics.setColor(0, 0, 0, 1) love.graphics.setColor(0, 0, 0, 1)
Font.draw("What?", 8, 112) Font.draw(Strings("What?"), 8, 112)
Font.drawBox(9, 14, 11, 4) Font.drawBox(9, 14, 11, 4)
love.graphics.setColor(0, 0, 0, 1) love.graphics.setColor(0, 0, 0, 1)
Font.draw("BOX No.", 80, 128) Font.draw(Strings("BOX No."), 80, 128)
local n = game.save.currentBox or 1 local n = game.save.currentBox or 1
if n >= 10 then if n >= 10 then
Font.draw(tostring(n), 136, 128) Font.draw(tostring(n), 136, 128)
@@ -217,15 +218,15 @@ function BoxMenu.new(game)
-- full interior (cursor col + label). keepOpen so WITHDRAW/DEPOSIT/ -- full interior (cursor col + label). keepOpen so WITHDRAW/DEPOSIT/
-- RELEASE/CHANGE BOX leave this menu underneath (jp BillsPCMenu). -- RELEASE/CHANGE BOX leave this menu underneath (jp BillsPCMenu).
local menu = Menu.new(game, { local menu = Menu.new(game, {
{ label = "WITHDRAW <PK><MN>", keepOpen = true, { label = Strings("WITHDRAW <PK><MN>"), keepOpen = true,
onSelect = function() withdraw(game) end }, onSelect = function() withdraw(game) end },
{ label = "DEPOSIT <PK><MN>", keepOpen = true, { label = Strings("DEPOSIT <PK><MN>"), keepOpen = true,
onSelect = function() deposit(game) end }, onSelect = function() deposit(game) end },
{ label = "RELEASE <PK><MN>", keepOpen = true, { label = Strings("RELEASE <PK><MN>"), keepOpen = true,
onSelect = function() release(game) end }, onSelect = function() release(game) end },
{ label = "CHANGE BOX", keepOpen = true, { label = Strings("CHANGE BOX"), keepOpen = true,
onSelect = function() changeBox(game) end }, onSelect = function() changeBox(game) end },
{ label = "SEE YA!" }, { label = Strings("SEE YA!") },
-- Bill's PC runs silent end to end (BIT_NO_MENU_BUTTON_SOUND, -- Bill's PC runs silent end to end (BIT_NO_MENU_BUTTON_SOUND,
-- engine/menus/pokemon_pc.asm) -- engine/menus/pokemon_pc.asm)
}, { tx = 0, ty = 0, tw = 14, th = 12, noSound = true }) }, { tx = 0, ty = 0, tw = 14, th = 12, noSound = true })
+3 -2
View File
@@ -2,6 +2,7 @@
local Font = require("src.render.Font") local Font = require("src.render.Font")
local Theme = require("src.ui.Theme") local Theme = require("src.ui.Theme")
local Strings = require("src.core.Strings")
local ChoiceBox = {} local ChoiceBox = {}
ChoiceBox.__index = ChoiceBox ChoiceBox.__index = ChoiceBox
@@ -46,8 +47,8 @@ function ChoiceBox:draw()
local tx, ty, tw, th = self.tx, self.ty, self.tw, self.th local tx, ty, tw, th = self.tx, self.ty, self.tw, self.th
Font.drawBox(tx, ty, tw, th) Font.drawBox(tx, ty, tw, th)
love.graphics.setColor(0, 0, 0, 1) love.graphics.setColor(0, 0, 0, 1)
Font.draw("YES", (tx + 2) * 8, (ty + 1) * 8) Font.draw(Strings("YES"), (tx + 2) * 8, (ty + 1) * 8)
Font.draw("NO", (tx + 2) * 8, (ty + 3) * 8) Font.draw(Strings("NO"), (tx + 2) * 8, (ty + 3) * 8)
Font.drawCode(Theme.cursor, (tx + 1) * 8, Font.drawCode(Theme.cursor, (tx + 1) * 8,
(ty + (self.index == 1 and 1 or 3)) * 8) (ty + (self.index == 1 and 1 or 3)) * 8)
love.graphics.setColor(1, 1, 1, 1) love.graphics.setColor(1, 1, 1, 1)
+9 -7
View File
@@ -31,6 +31,7 @@
local Font = require("src.render.Font") local Font = require("src.render.Font")
local Music = require("src.core.Music") local Music = require("src.core.Music")
local Strings = require("src.core.Strings")
local Credits = {} local Credits = {}
Credits.__index = Credits Credits.__index = Credits
@@ -140,8 +141,9 @@ function Credits:enter()
end end
function Credits:monSprite(species) function Credits:monSprite(species)
local def = self.game.data.pokemon and self.game.data.pokemon[species] local path = require("src.pokemon.Sprites").path(
return silhouette(def and def.spriteFront) self.game.data, species, "front", { kind = "credits" })
return silhouette(path)
end end
-- advance to the next CreditsOrder screen (Credits .nextCreditsScreen); -- advance to the next CreditsOrder screen (Credits .nextCreditsScreen);
@@ -260,14 +262,14 @@ function Credits:drawCopyright(xoff)
love.graphics.draw(self.gfImg, xoff + 80, 88) love.graphics.draw(self.gfImg, xoff + 80, 88)
else else
love.graphics.setColor(0, 0, 0, 1) love.graphics.setColor(0, 0, 0, 1)
Font.draw("GAME FREAK", xoff + 80, 88) Font.draw(Strings("GAME FREAK"), xoff + 80, 88)
love.graphics.setColor(1, 1, 1, 1) love.graphics.setColor(1, 1, 1, 1)
end end
else else
love.graphics.setColor(0, 0, 0, 1) love.graphics.setColor(0, 0, 0, 1)
Font.draw("Nintendo", xoff + 80, 56) Font.draw(Strings("Nintendo"), xoff + 80, 56)
Font.draw("Creatures inc.", xoff + 80, 72) Font.draw(Strings("Creatures inc."), xoff + 80, 72)
Font.draw("GAME FREAK inc.", xoff + 16, 88) Font.draw(Strings("GAME FREAK inc."), xoff + 16, 88)
love.graphics.setColor(1, 1, 1, 1) love.graphics.setColor(1, 1, 1, 1)
end end
end end
@@ -297,7 +299,7 @@ function Credits:drawTheEnd()
end end
else else
love.graphics.setColor(0, 0, 0, 1) love.graphics.setColor(0, 0, 0, 1)
Font.draw((te and te.display) or "T H E E N D", 32, 64) Font.draw((te and te.display) or Strings("T H E E N D"), 32, 64)
love.graphics.setColor(1, 1, 1, 1) love.graphics.setColor(1, 1, 1, 1)
end end
end end
+7 -4
View File
@@ -8,6 +8,7 @@
-- without permanently marking the mon owned. -- without permanently marking the mon owned.
local Font = require("src.render.Font") local Font = require("src.render.Font")
local Strings = require("src.core.Strings")
local DexEntryMenu = {} local DexEntryMenu = {}
DexEntryMenu.__index = DexEntryMenu DexEntryMenu.__index = DexEntryMenu
@@ -34,7 +35,9 @@ function DexEntryMenu.new(game, speciesOrOpts)
local species, forceOwned = resolveArgs(speciesOrOpts) local species, forceOwned = resolveArgs(speciesOrOpts)
local self = setmetatable({ game = game, forceOwned = forceOwned }, DexEntryMenu) local self = setmetatable({ game = game, forceOwned = forceOwned }, DexEntryMenu)
self.def = game.data.pokemon[species] self.def = game.data.pokemon[species]
local ok, img = pcall(love.graphics.newImage, self.def.spriteFront) local path = require("src.pokemon.Sprites").path(game.data, species, "front",
{ kind = "dex" })
local ok, img = path and pcall(love.graphics.newImage, path)
self.sprite = ok and img or nil self.sprite = ok and img or nil
require("src.core.Sound").playCry(game.data, species) require("src.core.Sound").playCry(game.data, species)
return self return self
@@ -74,8 +77,8 @@ function DexEntryMenu:draw()
-- feet/inches use the dex screen's /″ glyphs ("HT ???″" in -- feet/inches use the dex screen's /″ glyphs ("HT ???″" in
-- pokedex.asm; the tiles come from gfx/pokedex/pokedex.png via -- pokedex.asm; the tiles come from gfx/pokedex/pokedex.png via
-- engine/gfx/load_pokedex_tiles.asm) -- engine/gfx/load_pokedex_tiles.asm)
Font.draw(("HT %d%02d″"):format(e.heightFt, e.heightIn or 0), 72, 44) Font.draw(Strings("HT %d%02d″", e.heightFt, e.heightIn or 0), 72, 44)
Font.draw(("WT %.1flb"):format((e.weight or 0) / 10), 72, 54) Font.draw(Strings("WT %.1flb", (e.weight or 0) / 10), 72, 54)
end end
local text = owned and e.text and self.game.data.text[e.text] or nil local text = owned and e.text and self.game.data.text[e.text] or nil
local y = 72 local y = 72
@@ -86,7 +89,7 @@ function DexEntryMenu:draw()
y = y + 10 y = y + 10
end end
else else
Font.draw("Data unknown.", 8, y) Font.draw(Strings("Data unknown."), 8, y)
end end
love.graphics.setColor(1, 1, 1, 1) love.graphics.setColor(1, 1, 1, 1)
end end
+62 -21
View File
@@ -1,11 +1,17 @@
-- The evolution movie (engine/movie/evolution.asm): the mon's pic -- The evolution movie (engine/movie/evolution.asm): the mon's pic
-- flashes back and forth with the evolved form, speeding up, then the -- flashes back and forth with the evolved form, speeding up, then the
-- new form appears with its cry and the congratulations text. -- new form appears with its cry and the congratulations text.
-- B during the flash cancels ("Huh? ... stopped evolving!"? -- Gen 1 -- pokered engine/movie/evolution.asm (Evolution_CheckForCancel) polls the
-- has no cancel; the flash always completes). -- 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 Font = require("src.render.Font")
local Music = require("src.core.Music") local Music = require("src.core.Music")
local Strings = require("src.core.Strings")
local EvolutionState = {} local EvolutionState = {}
EvolutionState.__index = EvolutionState EvolutionState.__index = EvolutionState
@@ -14,7 +20,10 @@ EvolutionState.isOpaque = true
-- SGB: SetPal_PokemonWholeScreen for the mon on display -- SGB: SetPal_PokemonWholeScreen for the mon on display
function EvolutionState:sgbPalettes(game) function EvolutionState:sgbPalettes(game)
local P = require("src.render.PaletteFX") 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) local c = P.monPal(game.data, species)
if c then return { P.whole(c) } end if c then return { P.whole(c) } end
return P.wholeNamed(game.data, "MEWMON") return P.wholeNamed(game.data, "MEWMON")
@@ -22,24 +31,33 @@ end
local FLASH_FRAMES = 220 local FLASH_FRAMES = 220
local function frontSprite(game, species) local function frontSprite(game, species, mon)
local def = game.data.pokemon[species] local path = require("src.pokemon.Sprites").path(game.data, species, "front",
if not (def and def.spriteFront) then return nil end { mon = mon, kind = "evolution" })
local ok, img = pcall(love.graphics.newImage, def.spriteFront) if not path then return nil end
local ok, img = pcall(love.graphics.newImage, path)
return ok and img or nil return ok and img or nil
end end
function EvolutionState.new(game, mon, newSpecies, onDone) function EvolutionState.new(game, mon, newSpecies, onDone, via)
local self = setmetatable({}, EvolutionState) local self = setmetatable({}, EvolutionState)
self.game = game self.game = game
self.mon = mon self.mon = mon
self.newSpecies = newSpecies self.newSpecies = newSpecies
self.onDone = onDone 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.oldName = mon.nickname or game.data.pokemon[mon.species].name
self.oldSprite = frontSprite(game, mon.species) self.oldSprite = frontSprite(game, mon.species, mon)
self.newSprite = frontSprite(game, newSpecies) self.newSprite = frontSprite(game, newSpecies, mon)
self.t = 0 self.t = 0
self.done = false self.done = false
self.canceled = false
Music.play(game.data, Music.special(game.data, "evolution")) Music.play(game.data, Music.special(game.data, "evolution"))
return self return self
end end
@@ -47,22 +65,44 @@ end
function EvolutionState:update(dt) function EvolutionState:update(dt)
self.t = self.t + 1 self.t = self.t + 1
if self.done then return end if self.done then return end
if self.t >= FLASH_FRAMES then
self.done = true
local game = self.game local game = self.game
local Evolution = require("src.pokemon.Evolution") -- evos_moves.asm EvolveMon: each flash iteration polls hJoyHeld and, for
Evolution.apply(game, self.mon, self.newSpecies) -- a cancelable evolution, aborts when B is held -- the mon keeps its
require("src.core.Sound").playCry(game.data, self.newSpecies) -- species (Evolution.apply never runs) and _StoppedEvolvingText prints.
if self.cancelable and game.input:isDown("b") then
self.done = true
self.canceled = true
local TextBox = require("src.render.TextBox") 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, game.stack:push(TextBox.new(game,
("Congratulations!\nYour %s\nevolved into\n%s!") Strings("Huh? %s\nstopped evolving!", self.oldName),
:format(self.oldName, newName),
function() function()
Music.restoreMap(game.data) Music.restoreMap(game.data)
game.stack:pop() -- the evolution screen itself game.stack:pop() -- the evolution screen itself
if self.onDone then self.onDone() end if self.onDone then self.onDone() end
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
end end
@@ -73,7 +113,8 @@ function EvolutionState:draw()
-- accelerating flash between the two forms -- accelerating flash between the two forms
local sprite local sprite
if self.done then 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 else
local period = math.max(4, 28 - math.floor(self.t / 40) * 6) local period = math.max(4, 28 - math.floor(self.t / 40) * 6)
local showNew = math.floor(self.t / period) % 2 == 1 local showNew = math.floor(self.t / period) % 2 == 1
@@ -86,9 +127,9 @@ function EvolutionState:draw()
love.graphics.setColor(0, 0, 0, 1) love.graphics.setColor(0, 0, 0, 1)
if not self.done then if not self.done then
Font.draw("What?", 8, 104) Font.draw(Strings("What?"), 8, 104)
Font.draw(self.oldName .. " is", 8, 114) Font.draw(self.oldName .. " is", 8, 114)
Font.draw("evolving!", 8, 124) Font.draw(Strings("evolving!"), 8, 124)
end end
love.graphics.setColor(1, 1, 1, 1) love.graphics.setColor(1, 1, 1, 1)
end end
+6 -2
View File
@@ -11,9 +11,13 @@ function FlyMenu.new(game)
local visited = game.save.visited or {} local visited = game.save.visited or {}
local seen = {} local seen = {}
for _, mapId in ipairs(game.data.field.flyOrder or {}) do 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] 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 seen[mapId] = true
table.insert(items, { table.insert(items, {
value = mapId, value = mapId,
+19 -13
View File
@@ -7,10 +7,12 @@
-- name/time/money boxes plus the dex rating. Plays Music_HallOfFame -- name/time/money boxes plus the dex rating. Plays Music_HallOfFame
-- when the audio data has it. Calls onDone() after popping itself. -- when the audio data has it. Calls onDone() after popping itself.
local Assets = require("src.render.Assets")
local Font = require("src.render.Font") local Font = require("src.render.Font")
local Music = require("src.core.Music") local Music = require("src.core.Music")
local Sound = require("src.core.Sound") local Sound = require("src.core.Sound")
local TypeChart = require("src.battle.TypeChart") local TypeChart = require("src.battle.TypeChart")
local Strings = require("src.core.Strings")
local HallOfFame = {} local HallOfFame = {}
HallOfFame.__index = HallOfFame HallOfFame.__index = HallOfFame
@@ -47,9 +49,11 @@ local FADE_FRAMES = 20
-- HoFPrintTextAndDelay after each dex line -- HoFPrintTextAndDelay after each dex line
local DEX_HOLD = 120 local DEX_HOLD = 120
-- through Assets.resolve so an enabled mod's overrides/ shadows these the
-- same way it shadows every other generated asset
local function tryImage(path) local function tryImage(path)
if not path then return nil end if not path then return nil end
local ok, img = pcall(love.graphics.newImage, path) local ok, img = pcall(love.graphics.newImage, Assets.resolve(path))
return ok and img or nil return ok and img or nil
end end
@@ -79,7 +83,8 @@ function HallOfFame.new(game, onDone)
self.timer = 0 self.timer = 0
self.phase = "mons" self.phase = "mons"
self.sprites = {} -- species -> image or false self.sprites = {} -- species -> image or false
self.playerPic = tryImage("assets/generated/trainer_card/red.png") self.playerPic = tryImage(require("src.pokemon.Sprites").playerPath(
game.data, "front", { kind = "hof" }))
self.scrollX = PIC_X self.scrollX = PIC_X
self.showHofBanner = false self.showHofBanner = false
self.fade = 0 self.fade = 0
@@ -118,8 +123,9 @@ end
function HallOfFame:spriteFor(species) function HallOfFame:spriteFor(species)
local cached = self.sprites[species] local cached = self.sprites[species]
if cached == nil then if cached == nil then
local def = self.game.data.pokemon[species] local path = require("src.pokemon.Sprites").path(
cached = tryImage(def and def.spriteFront) or false self.game.data, species, "front", { kind = "hof" })
cached = tryImage(path) or false
self.sprites[species] = cached self.sprites[species] = cached
end end
return cached or nil return cached or nil
@@ -205,13 +211,13 @@ function HallOfFame:drawMonInfo(mon)
love.graphics.setColor(0, 0, 0, 1) love.graphics.setColor(0, 0, 0, 1)
local name = mon.nickname or (def and def.name) or mon.species local name = mon.nickname or (def and def.name) or mon.species
Font.draw(name, 1 * 8, 4 * 8) Font.draw(name, 1 * 8, 4 * 8)
Font.draw("LEVEL/", 2 * 8, 6 * 8) Font.draw(Strings("LEVEL/"), 2 * 8, 6 * 8)
Font.draw("TYPE1/", 2 * 8, 7 * 8) Font.draw(Strings("TYPE1/"), 2 * 8, 7 * 8)
local t1 = def and def.types and def.types[1] local t1 = def and def.types and def.types[1]
local t2 = def and def.types and def.types[2] local t2 = def and def.types and def.types[2]
local dual = t2 and t2 ~= t1 local dual = t2 and t2 ~= t1
if dual then if dual then
Font.draw("TYPE2/", 2 * 8, 8 * 8) Font.draw(Strings("TYPE2/"), 2 * 8, 8 * 8)
end end
-- PrintLevelCommon at (8,7): bare level digits (no <LV> tile here) -- PrintLevelCommon at (8,7): bare level digits (no <LV> tile here)
Font.draw(tostring(mon.level), 8 * 8, 7 * 8) Font.draw(tostring(mon.level), 8 * 8, 7 * 8)
@@ -228,7 +234,7 @@ end
function HallOfFame:drawHofBanner() function HallOfFame:drawHofBanner()
Font.drawBox(2, 13, 16, 5) Font.drawBox(2, 13, 16, 5)
love.graphics.setColor(0, 0, 0, 1) love.graphics.setColor(0, 0, 0, 1)
Font.draw("HALL OF FAME", 4 * 8, 15 * 8) Font.draw(Strings("HALL OF FAME"), 4 * 8, 15 * 8)
end end
function HallOfFame:drawPic(img) function HallOfFame:drawPic(img)
@@ -248,11 +254,11 @@ function HallOfFame:drawPlayerStats()
-- play time / money box: TextBoxBorder (0,4) b=6,c=10 → drawBox(0,4,12,8) -- play time / money box: TextBoxBorder (0,4) b=6,c=10 → drawBox(0,4,12,8)
Font.drawBox(0, 4, 12, 8) Font.drawBox(0, 4, 12, 8)
love.graphics.setColor(0, 0, 0, 1) love.graphics.setColor(0, 0, 0, 1)
Font.draw("PLAY TIME", 1 * 8, 6 * 8) Font.draw(Strings("PLAY TIME"), 1 * 8, 6 * 8)
local t = math.floor(save.playTime or 0) local t = math.floor(save.playTime or 0)
Font.draw(("%3d:%02d"):format(math.floor(t / 3600), math.floor(t / 60) % 60), Font.draw(("%3d:%02d"):format(math.floor(t / 3600), math.floor(t / 60) % 60),
5 * 8, 7 * 8) 5 * 8, 7 * 8)
Font.draw("MONEY", 1 * 8, 9 * 8) Font.draw(Strings("MONEY"), 1 * 8, 9 * 8)
-- PrintBCDNumber with MONEY_SIGN; port uses ¥ like TrainerCard -- PrintBCDNumber with MONEY_SIGN; port uses ¥ like TrainerCard
Font.draw(("¥%d"):format(save.money or 0), 4 * 8, 10 * 8) Font.draw(("¥%d"):format(save.money or 0), 4 * 8, 10 * 8)
end end
@@ -265,16 +271,16 @@ function HallOfFame:drawDexBox(kind)
if kind == "seen" then if kind == "seen" then
local seen, owned = self:dexSeenOwned() local seen, owned = self:dexSeenOwned()
local seenOwned = text._DexSeenOwnedText local seenOwned = text._DexSeenOwnedText
or "POKéDEX Seen:{NUM:wDexRatingNumMonsSeen, 1, 3}\n Owned:{NUM:wDexRatingNumMonsOwned, 1, 3}" or Strings("POKéDEX Seen:{NUM:wDexRatingNumMonsSeen, 1, 3}\n Owned:{NUM:wDexRatingNumMonsOwned, 1, 3}")
seenOwned = seenOwned seenOwned = seenOwned
:gsub("{NUM:wDexRatingNumMonsSeen[^}]*}", tostring(seen)) :gsub("{NUM:wDexRatingNumMonsSeen[^}]*}", tostring(seen))
:gsub("{NUM:wDexRatingNumMonsOwned[^}]*}", tostring(owned)) :gsub("{NUM:wDexRatingNumMonsOwned[^}]*}", tostring(owned))
drawTextBlock(seenOwned, 1 * 8, 14 * 8, 17 * 8) drawTextBlock(seenOwned, 1 * 8, 14 * 8, 17 * 8)
else else
local _, owned = self:dexSeenOwned() local _, owned = self:dexSeenOwned()
local ratingHeader = (text._DexRatingText or "POKéDEX Rating{COLON}"):gsub("{COLON}", ":") local ratingHeader = (text._DexRatingText or Strings("POKéDEX Rating{COLON}")):gsub("{COLON}", ":")
Font.draw(ratingHeader, 1 * 8, 14 * 8) Font.draw(ratingHeader, 1 * 8, 14 * 8)
local rating = text[dexRatingKey(owned)] or "Keep it up!" local rating = text[dexRatingKey(owned)] or Strings("Keep it up!")
drawTextBlock(rating, 1 * 8, 15 * 8, 17 * 8) drawTextBlock(rating, 1 * 8, 15 * 8, 17 * 8)
end end
end end
+4 -3
View File
@@ -27,6 +27,7 @@
local Font = require("src.render.Font") local Font = require("src.render.Font")
local Music = require("src.core.Music") local Music = require("src.core.Music")
local Sound = require("src.core.Sound") local Sound = require("src.core.Sound")
local Strings = require("src.core.Strings")
local IntroMovie = {} local IntroMovie = {}
IntroMovie.__index = IntroMovie IntroMovie.__index = IntroMovie
@@ -287,7 +288,7 @@ function IntroMovie:drawSplash()
end end
-- custom studio name (replaces the GAME FREAK splash text) -- custom studio name (replaces the GAME FREAK splash text)
love.graphics.setColor(0, 0, 0, dim and 0.35 or 1) love.graphics.setColor(0, 0, 0, dim and 0.35 or 1)
local card = self.studio.card or "bois club games" local card = self.studio.card or Strings("bois club games")
Font.draw(card, (160 - #card * 8) / 2, TEXT_Y) Font.draw(card, (160 - #card * 8) / 2, TEXT_Y)
love.graphics.setColor(1, 1, 1, 1) love.graphics.setColor(1, 1, 1, 1)
end end
@@ -348,7 +349,7 @@ function IntroMovie:drawFight()
if not gengar and not nido then if not gengar and not nido then
love.graphics.setColor(0, 0, 0, 1) love.graphics.setColor(0, 0, 0, 1)
Font.draw("GENGAR VS NIDORINO", (160 - 18 * 8) / 2, 64) Font.draw(Strings("GENGAR VS NIDORINO"), (160 - 18 * 8) / 2, 64)
love.graphics.setColor(1, 1, 1, 1) love.graphics.setColor(1, 1, 1, 1)
end end
drawBars() drawBars()
@@ -366,7 +367,7 @@ function IntroMovie:draw()
-- custom boot card (replaces the Nintendo / GAME FREAK copyright -- custom boot card (replaces the Nintendo / GAME FREAK copyright
-- card; no (c) glyph in the charmap, keep it ASCII-safe) -- card; no (c) glyph in the charmap, keep it ASCII-safe)
love.graphics.setColor(0, 0, 0, 1) love.graphics.setColor(0, 0, 0, 1)
local credit = self.studio.credit or "bois club" local credit = self.studio.credit or Strings("bois club")
Font.draw("2026", (160 - 4 * 8) / 2, 48) Font.draw("2026", (160 - 4 * 8) / 2, 48)
Font.draw(credit, (160 - #credit * 8) / 2, 64) Font.draw(credit, (160 - #credit * 8) / 2, 64)
Font.draw("bryanthaboi", (160 - 11 * 8) / 2, 80) Font.draw("bryanthaboi", (160 - 11 * 8) / 2, 80)
+102 -9
View File
@@ -3,7 +3,9 @@
-- box and the Pokédex. -- box and the Pokédex.
local Font = require("src.render.Font") local Font = require("src.render.Font")
local Runtime = require("src.mods.Runtime")
local Theme = require("src.ui.Theme") local Theme = require("src.ui.Theme")
local Strings = require("src.core.Strings")
local ListMenu = {} local ListMenu = {}
ListMenu.__index = ListMenu ListMenu.__index = ListMenu
@@ -15,9 +17,37 @@ function ListMenu:sgbPalettes(game)
end end
local ROWS = 7 local ROWS = 7
-- frames to wait before key-repeat kicks in, then between repeats
local REPEAT_DELAY = 16
local REPEAT_RATE = 4
-- ui.list_menu identity: unhooked opts pass through unchanged
local function sameOpts(opts) return opts end
function ListMenu.new(game, title, items, opts) function ListMenu.new(game, title, items, opts)
opts = opts or {} opts = opts or {}
-- bag / shop / dex / generic: mods may enable wrap, pageJump, keyRepeat
if Runtime.wantsHook("ui.list_menu") then
local hooked = Runtime.call("ui.list_menu", sameOpts, {
wrap = opts.wrap,
pageJump = opts.pageJump,
keyRepeat = opts.keyRepeat,
repeatDelay = opts.repeatDelay,
repeatRate = opts.repeatRate,
}, {
game = game,
title = title,
kind = opts.kind or title,
itemCount = items and #items or 0,
})
if type(hooked) == "table" then
if hooked.wrap ~= nil then opts.wrap = hooked.wrap end
if hooked.pageJump ~= nil then opts.pageJump = hooked.pageJump end
if hooked.keyRepeat ~= nil then opts.keyRepeat = hooked.keyRepeat end
if hooked.repeatDelay ~= nil then opts.repeatDelay = hooked.repeatDelay end
if hooked.repeatRate ~= nil then opts.repeatRate = hooked.repeatRate end
end
end
local self = setmetatable({}, ListMenu) local self = setmetatable({}, ListMenu)
self.game = game self.game = game
self.title = title self.title = title
@@ -28,6 +58,12 @@ function ListMenu.new(game, title, items, opts)
self.onCancel = opts.onCancel self.onCancel = opts.onCancel
self.footer = opts.footer self.footer = opts.footer
self.pageJump = opts.pageJump -- Left/Right move a page at a time self.pageJump = opts.pageJump -- Left/Right move a page at a time
self.wrap = opts.wrap -- Up on first / Down on last wraps
self.keyRepeat = opts.keyRepeat -- hold Up/Down (and pageJump L/R) to scroll
self.repeatDelay = opts.repeatDelay or REPEAT_DELAY
self.repeatRate = opts.repeatRate or REPEAT_RATE
self.holdDir = nil
self.holdFrames = 0
self.onSelectKey = opts.onSelectKey -- SELECT pressed on an item self.onSelectKey = opts.onSelectKey -- SELECT pressed on an item
-- scripted mode (the old man tutorial): update() runs the script -- scripted mode (the old man tutorial): update() runs the script
-- every frame INSTEAD of reading input -- DisplayListMenuID's old-man -- every frame INSTEAD of reading input -- DisplayListMenuID's old-man
@@ -46,6 +82,42 @@ function ListMenu.new(game, title, items, opts)
return self return self
end end
local function moveIndex(self, delta)
local n = #self.items
if n == 0 then return end
local next = self.index + delta
if self.wrap then
next = ((next - 1) % n) + 1
else
next = math.max(1, math.min(n, next))
end
self.index = next
end
local function syncScroll(self)
if self.index - self.scroll > self.rows then
self.scroll = self.index - self.rows
end
if self.index - self.scroll < 1 then self.scroll = self.index - 1 end
end
-- edge press or key-repeat tick for a held direction
local function navPressed(self, dir)
if dir == "up" then
moveIndex(self, -1)
elseif dir == "down" then
moveIndex(self, 1)
elseif dir == "left" and self.pageJump then
moveIndex(self, -self.rows)
elseif dir == "right" and self.pageJump then
moveIndex(self, self.rows)
else
return false
end
syncScroll(self)
return true
end
function ListMenu:update(dt) function ListMenu:update(dt)
if self.script then if self.script then
self.script(self) self.script(self)
@@ -59,14 +131,20 @@ function ListMenu:update(dt)
end end
return return
end end
local moved = false
if input:wasPressed("up") then if input:wasPressed("up") then
self.index = math.max(1, self.index - 1) moved = navPressed(self, "up")
self.holdDir, self.holdFrames = "up", 0
elseif input:wasPressed("down") then elseif input:wasPressed("down") then
self.index = math.min(#self.items, self.index + 1) moved = navPressed(self, "down")
self.holdDir, self.holdFrames = "down", 0
elseif self.pageJump and input:wasPressed("left") then elseif self.pageJump and input:wasPressed("left") then
self.index = math.max(1, self.index - self.rows) moved = navPressed(self, "left")
self.holdDir, self.holdFrames = "left", 0
elseif self.pageJump and input:wasPressed("right") then elseif self.pageJump and input:wasPressed("right") then
self.index = math.min(#self.items, self.index + self.rows) moved = navPressed(self, "right")
self.holdDir, self.holdFrames = "right", 0
elseif self.onSelectKey and input:wasPressed("select") then elseif self.onSelectKey and input:wasPressed("select") then
self.onSelectKey(self.items[self.index], self) self.onSelectKey(self.items[self.index], self)
elseif input:wasPressed("b") then elseif input:wasPressed("b") then
@@ -80,10 +158,22 @@ function ListMenu:update(dt)
end end
return return
end end
if self.index - self.scroll > self.rows then
self.scroll = self.index - self.rows -- hold-to-scroll (opt-in via ui.list_menu keyRepeat)
if self.keyRepeat then
local dir = self.holdDir
if dir and input:isDown(dir) then
self.holdFrames = self.holdFrames + 1
local afterDelay = self.holdFrames - self.repeatDelay
if afterDelay >= 0 and afterDelay % self.repeatRate == 0 then
navPressed(self, dir)
end end
if self.index - self.scroll < 1 then self.scroll = self.index - 1 end else
self.holdDir, self.holdFrames = nil, 0
end
end
if not moved then syncScroll(self) end
end end
-- remove current item (e.g. consumed); keeps cursor valid -- remove current item (e.g. consumed); keeps cursor valid
@@ -103,7 +193,7 @@ function ListMenu:draw()
love.graphics.setColor(0, 0, 0, 1) love.graphics.setColor(0, 0, 0, 1)
Font.draw(self.title, 8, 4) Font.draw(self.title, 8, 4)
if #self.items == 0 then if #self.items == 0 then
Font.draw("Nothing here.", 16, 64) Font.draw(Strings("Nothing here."), 16, 64)
end end
for row = 1, self.rows do for row = 1, self.rows do
local i = self.scroll + row local i = self.scroll + row
@@ -112,7 +202,10 @@ function ListMenu:draw()
local y = 8 + row * 16 local y = 8 + row * 16
Font.draw(item.label, 16, y) Font.draw(item.label, 16, y)
if item.ball then -- the Pokédex owned-ball marker tile if item.ball then -- the Pokédex owned-ball marker tile
local bx = 16 + (#item.label + 1) * 8 + 3 -- one blank glyph after the name, measured in glyph advances rather
-- than bytes: NIDORAN♂/♀ carry a multi-byte charmap entry, so
-- `#item.label` overcounted by 2 and pushed their ball 16px right (#285)
local bx = 16 + Font.width(item.label) + 8 + 3
local by = y + 3 local by = y + 3
love.graphics.circle("fill", bx, by, 3.5) love.graphics.circle("fill", bx, by, 3.5)
love.graphics.setColor(1, 1, 1, 1) love.graphics.setColor(1, 1, 1, 1)

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