Compare commits

...

49 Commits

Author SHA1 Message Date
bryanthaboi 0ca1defb66 Merge pull request #341 from bryanthaboi/dev
fix internet, fix the mf grass in advanced
2026-07-28 15:16:09 -04:00
bryanthaboi 370ddea0b8 fix the mf grass 2026-07-28 15:14:48 -04:00
bryanthaboi a03f69926e bazinga 2026-07-28 15:01:59 -04:00
bryanthaboi 04ece522d7 anbernic in build and disable gbcfx on it 2026-07-28 14:04:49 -04:00
bryanthaboi 5a48a61f2e anbernic included in next release 2026-07-28 12:49:04 -04:00
bryanthaboi fd66a04686 Merge pull request #338 from bryanthaboi/dev
squashing all bugs known to man
2026-07-28 12:24:13 -04:00
bryanthaboi b69f8c27c9 Merge pull request #332 from johnjohto/fix-hof-dex-rating
Run the HoF dex rating through the standard text box (#314)
2026-07-28 12:19:43 -04:00
bryanthaboi 66f0326492 Merge pull request #333 from johnjohto/fix-midstep-buttons
Gate overworld buttons on the completed step (#286)
2026-07-28 12:19:31 -04:00
bryanthaboi dbab4a2116 Merge pull request #334 from johnjohto/fix-export-fidelity
Export fidelity: name padding, wOptions, as-caught catchRate (#206)
2026-07-28 12:19:17 -04:00
bryanthaboi 3e8da9b232 Merge pull request #335 from johnjohto/feat-save-slot-names
Nameable save slots in the launcher (#205)
2026-07-28 12:19:05 -04:00
bryanthaboi 223202f230 Merge pull request #336 from GFlorio/fix-linux-portable
Fix portable mode on the Linux AppImage release
2026-07-28 12:18:48 -04:00
bryanthaboi 3078c20e1d Merge pull request #337 from bryanthaboi/squashing-bugs-jul-28
bug squash an additional features
2026-07-28 12:18:17 -04:00
bryanthaboi 617a87a47b anbernic 2026-07-28 12:14:58 -04:00
bryanthaboi 8539a6b268 launcher editor and widescreen battle 2026-07-28 12:00:15 -04:00
bryanthaboi f9f38d161f CLOSES #223, CLOSES #233, CLOSES #236, CLOSES #240, CLOSES #241, CLOSES #249, CLOSES #252, CLOSES #255, CLOSES #257, CLOSES #258, CLOSES #263, CLOSES #265, CLOSES #274, CLOSES #275, CLOSES #276, CLOSES #279, CLOSES #280, CLOSES #282, CLOSES #283, CLOSES #287, CLOSES #291, CLOSES #292, CLOSES #293, CLOSES #301, CLOSES #304, CLOSES #315, CLOSES #316, CLOSES #317, CLOSES #321, CLOSES #322, CLOSES #330 2026-07-28 10:29:05 -04:00
Gabriel Florio 8b85cda84c get proper appDir in the AppImage linux release 2026-07-28 10:58:42 -03:00
johnjohto 1deba07106 Nameable save slots in the launcher (#205)
The reporter labels runs by abusing the in-game player name; give slots
a real label instead.  SaveData.renameSlot persists a trimmed label in
the options registry (options.saveSlots[version].names) -- never in the
save file, so renaming needs no save rewrite and an empty slot can be
labeled too -- listSlots rows carry it as `label`, and deleteSlot
drops it with the slot.

In the launcher, right-clicking a slot row opens an inline rename modal
(Enter commits, Esc cancels, empty clears; 24 whole-codepoint cap via
local UTF-8 helpers, since plain luajit has no utf8 library).  The row
title shows the label over the player name; badges/time/caught stay on
the meta line.  Desktop-only: touch has no secondary button.  main.lua
now forwards love.textinput to the importer while it is up.

Backend covered by a new renameSlot block in tests/engine/save_slots.lua
(78/78); docs/launcher.md's registry section documents the label.
2026-07-28 09:25:55 -04:00
johnjohto c24c7cbcb7 Export fidelity: name padding, wOptions, as-caught catchRate (#206)
Auditing the reporter's PKHeX screenshots against the emitted bytes
turned up three fidelity bugs in the .sav export:

- Name fields on a templateless export stayed zero-filled after the
  $50 terminator; every real save the naming screen wrote $50-pads
  the tail, and the zero tail is what PKHeX rendered as "JOHN{}".
  encodeName now pads zero tail bytes with $50 (nonzero template
  bytes still survive untouched).
- wOptions was never written, dropping text speed / battle style /
  battle effects on export.  Templateless exports now pack it from
  save.options (the recomp's textSpeed 1/3/5 are pokered's exact
  values); with a template the cartridge's own byte still wins.
- The party/box catch-rate byte was always re-derived from the
  current species, but Gen1 freezes it at catch time (evolution does
  not update it), which is why PKHeX demanded "a preevolution catch
  rate".  Pokemon.new now stamps the as-caught catchRate and
  Evolution.apply's in-place mutation preserves it.

The reported emulator crash itself is not addressed here: the encoder
is byte-identical to the reporter's version, and a byte-level audit
(offsets vs Bulbapedia's save map, all three checksums, a real
gameplay save round-trip, pokered's LoadMapData regenerating every
zeroed cache) shows the current export is structurally valid.
2026-07-28 09:00:07 -04:00
johnjohto c2334f2f91 Gate overworld buttons on the completed step (#286)
OverworldLoop (home/overworld.asm) jumps straight to .moveAhead while
wWalkCounter is nonzero: JoypadOverworld -- the START check, the A
check, and direction initiation -- only ever runs while the player
stands on a tile, and a button pressed mid-step is simply never seen.
The port ran handleInput() every frame regardless, so a mid-step
A/START pushed its TextBox/StartMenu right there and froze Red between
tiles, mid-animation (the Nurse Joy run-up in the report).

Gate handleInput on player.moving like the original.  The port-invented
wall-bonk SFX cooldown is hoisted above the gate so it keeps ticking on
held-direction frames mid-step exactly as before.

Adds tests/parity_midstep_buttons.lua: mid-step and last-frame A/START
presses are swallowed and the step completes, and both buttons work
again once the player stands on the tile.
2026-07-28 08:07:17 -04:00
johnjohto bfac5fe327 Run the HoF dex rating through the standard text box (#314)
HoFDisplayPlayerStats prints its three dex texts (seen/owned, the
POKéDEX Rating: header, the tier line) through HoFPrintTextAndDelay ->
PrintText, the standard two-row bottom box, each followed by 120
DelayFrames.  The port hand-drew a 6-row box and flattened the tier
text's cont (\v) rows into plain newlines, so a 3+ row rating painted
its third row over the box's bottom border and dropped the rest.

Push the three texts as a chain of auto-advancing TextBox states
instead: the cont rows scroll inside the two-row box with the original's
A/B wait (_ContText -> ManualTextScroll), and each box closes itself
after the 120-frame hold.

Adds tests/parity_hof_rating.lua: box order/content and the auto-close
hold are asserted over a full headless induction.
2026-07-28 07:32:35 -04:00
bryanthaboi 3b1032cc63 CLOSES #303, CLOSES #307, CLOSES #314, CLOSES #318 2026-07-28 06:16:21 -04:00
bryanthaboi 72f83760b7 Merge pull request #328 from johnjohto/fix-fieldmove-whiteflash
Run the field-move white blink under its text, not after (#320)
2026-07-28 05:10:06 -04:00
bryanthaboi 63a179b77a Merge pull request #326 from johnjohto/fix-windows-picker-unicode
Fix launcher crash on non-ASCII picked filenames (#325)
2026-07-28 05:09:51 -04:00
bryanthaboi 37ba5c3879 Merge pull request #323 from johnjohto/fix-shake-name-dupe
Stop HUD names ghosting during the battle window shake (#295)
2026-07-28 05:09:21 -04:00
bryanthaboi 00f86ca192 Merge pull request #324 from johnjohto/fix-oakspeech-strings-gate
Route the Oak speech fallback texts through Strings.source
2026-07-28 05:08:59 -04:00
johnjohto b0868e7571 Run the field-move white blink under its text, not after (#320)
The vanilla surf/strength flow closed the menu, showed the overworld,
then fired a solid-white blink on the empty map, and set the surfing
sprite while the player was still on land.  pokered's
GBPalWhiteOutWithDelay3 runs while the text is still up, so the blink
reads as a text flash.

The blink now sits under the textbox on the stack (only the top state
updates, so it holds its frames until the text closes), and surfing
applies only when the step onto the water happens.  Same reorder for
the party-menu STRENGTH texts.  The parity surf tests now dismiss the
got-on text before asserting the mount, matching the new order.
2026-07-27 22:42:47 -04:00
johnjohto 9ec3ff26d8 Fix launcher crash on non-ASCII picked filenames (#325)
The Windows pickers shell out to PowerShell, which writes the chosen
path in the console's OEM codepage (CP437 on en-US): a file named
"Pokémon ...zip" came back as Pok\x82mon.  The import then failed and
the error notice carrying those bytes hard-crashed the mods panel's
UTF-8-validating text draw.

- All three Windows picker scripts (ROM, mod, save) now force
  [Console]::OutputEncoding to UTF-8, so returned paths and any notice
  built from them are valid.
- The mod picker also copies the pick to a plain-ASCII temp name and
  returns that, so a non-ASCII filename actually imports instead of
  failing the io.open (Windows io.open needs ANSI bytes).
2026-07-27 22:08:57 -04:00
johnjohto 726e1d4ec5 Route the Oak speech fallback texts through Strings.source
The intro API upgrade left six player-visible literals in OakSpeech's
FALLBACKS table outside the Strings routing, so the strings-coverage
gate failed on dev. Mark them with Strings.source, the catalog idiom
for declaration-site literals, and the gate is green again.
2026-07-27 20:46:06 -04:00
johnjohto 5b3c5f5da8 Stop HUD names ghosting during the battle window shake (#295)
The zone pass composited two copies of the baked canvas on shake
frames: a base copy and the offset copy. The canvas holds window-layer
content (HUD names, the text box), so the vacated strip showed a full
second copy of the enemy name. Draw only the shifted copy and fill the
strip blank, like the hardware revealing empty BG.
2026-07-27 20:44:09 -04:00
bryanthaboi c45f6be8f2 Update README.md 2026-07-27 20:36:30 -04:00
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
bryanthaboi f6b29e6caa evee fix and some other junk 2026-07-25 15:21:49 -04:00
335 changed files with 34387 additions and 4147 deletions
+14 -1
View File
@@ -25,7 +25,9 @@ body:
options:
- macOS
- Windows
- Both
- Linux
- Android
- Multiple platforms
validations:
required: true
@@ -38,6 +40,17 @@ body:
validations:
required: true
- type: input
id: discord
attributes:
label: Discord username (optional)
description: >
So maintainers can ping you on Discord if they need a quick follow-up.
Leave blank if you'd rather keep everything on GitHub.
placeholder: yourname
validations:
required: false
- type: input
id: location
attributes:
+34 -19
View File
@@ -1,35 +1,53 @@
name: Feature request
description: Ask for something new, or a change to how something already works.
description: Ask for something new in the engine, launcher, or platform — not a content/gameplay mod.
labels: ["enhancement"]
body:
- type: markdown
attributes:
value: |
Use this for **engine / launcher / platform** work (ports, video options, save
tooling, networking, mod API seams, docs).
If what you want is a gameplay, cosmetic, audio, or QoL change that a Lua mod
could ship — running shoes, alternate sprites, day/night, shiny indicators,
Gen 2-like battle toggles, soundtrack packs — open a
**[Mod request](https://github.com/bryanthaboi/gen1recomp/issues/new?template=mod_request.yml)**
instead.
"Can we add X" on its own is hard to act on. Say what you want, why you want it,
and how you picture it working. A request with real detail is one someone can
actually build without guessing at what you meant.
and how you picture it working.
- type: input
id: summary
attributes:
label: One line summary
description: What you want, in a sentence.
placeholder: Add a run toggle so you can move at bike speed without the bike
placeholder: Add Linux AppImage releases next to the macOS and Windows builds
validations:
required: true
- type: input
id: discord
attributes:
label: Discord username (optional)
description: >
So maintainers can ping you on Discord if they need a quick follow-up.
Leave blank if you'd rather keep everything on GitHub.
placeholder: yourname
validations:
required: false
- type: textarea
id: what
attributes:
label: What do you want
description: >
Describe it properly. What is it, where does it live in the game, what does the
player see or do. If it changes something that already exists, say what it does today
and what it should do instead.
Describe it properly. What is it, where does it live (launcher, options,
engine), what does the player see or do. If it changes something that already
exists, say what it does today and what it should do instead.
placeholder: |
A hold-to-run button, the way later Pokemon games do it. Hold B while moving on
foot and the player moves at bike speed. Release and you go back to walking.
Doesn't work indoors where the bike doesn't work, doesn't work in battles or menus.
Ship a Linux AppImage on each release, same version as the macOS/Windows builds,
with the same save folder layout and mod discovery path.
validations:
required: true
@@ -41,9 +59,8 @@ body:
What's annoying or missing right now. What does this fix. If it's just because you
think it would be fun, say that, it's a real answer.
placeholder: |
Backtracking across routes you've already cleared is slow, and the bike is a menu
dive away and doesn't work in a lot of places. Running would cut a lot of dead time
without making the game easier.
LÖVE already runs on Linux; without a packaged build, players have to assemble
it themselves and miss release notes / update checks.
validations:
required: true
@@ -52,14 +69,12 @@ body:
attributes:
label: How should it work
description: >
The specifics. Which button, which menu, what happens in the edge cases. If you don't
The specifics. Which menu, what happens in the edge cases. If you don't
know, say what you'd expect as a player and leave the rest open.
placeholder: |
- Hold B on the overworld to run
- Same speed as the bike
- Disabled anywhere the bike is disabled
- Sprite should probably need a running animation, not sure what that takes
- No effect on encounter rates
- GitHub Releases asset next to the .dmg / .exe
- Same options.lua / mods/ layout as desktop
- Documented in the README install section
validations:
required: true
+115
View File
@@ -0,0 +1,115 @@
name: Mod request
description: Ask for a gameplay, cosmetic, audio, or QoL change that belongs as a Lua mod.
labels: ["mod request"]
body:
- type: markdown
attributes:
value: |
This tracker is for ideas that should ship as **mods**, not as core engine
features — alternate sprites, running shoes, day/night, shiny indicators,
soundtrack packs, Gen 2-like battle toggles, map cosmetics, bag QoL, etc.
The engine already exposes a lot of this through registries and hooks
([modding wiki](https://github.com/bryanthaboi/gen1recomp/wiki)).
Use a **Feature request** instead for launcher / ports / video options /
networking / save tooling / new API seams.
- type: input
id: summary
attributes:
label: One line summary
description: What the mod should do, in a sentence.
placeholder: Hold B to run at bike speed on the overworld
validations:
required: true
- type: input
id: discord
attributes:
label: Discord username (optional)
description: >
So maintainers or mod authors can ping you on Discord if they pick this up.
Leave blank if you'd rather keep everything on GitHub.
placeholder: yourname
validations:
required: false
- type: textarea
id: what
attributes:
label: What should the mod do
description: >
Describe the player-facing behavior. What changes, where, what does the
player see or press. If it toggles from Options or a START-menu entry, say so.
placeholder: |
Hold B while walking outdoors to move at bike speed. Release to walk again.
Same places the bike is allowed; no effect in battles or menus.
validations:
required: true
- type: textarea
id: why
attributes:
label: Why is this worth doing as a mod
description: >
Why optional/modded rather than a core option. Who wants it on, who wants
vanilla left alone.
placeholder: |
Great for replaying and backtracking, but some people want a strict Gen 1
pace. A mod (or an opt-in mod option) keeps both camps happy.
validations:
required: true
- type: textarea
id: how
attributes:
label: How should it work
description: >
Buttons, menus, edge cases, whether it needs new art/audio. If you know a
hook or registry that fits (movement.speed, pokemon.sprite, rulesets, …),
mention it — otherwise leave it open.
placeholder: |
- Hold B on the overworld
- Same step timing as the bike
- Disabled where the bike is disabled
- Prefer hooks:wrap("movement.speed") if that still fits
validations:
required: true
- type: dropdown
id: vanilla
attributes:
label: With the mod off, is vanilla unchanged
options:
- Yes — parity when disabled
- No — it would replace something always-on
- Not sure
validations:
required: true
- type: dropdown
id: category
attributes:
label: Best-fit mod category
description: Same taxonomy as example mods (BALANCE, GRAPHICS, AUDIO, …).
options:
- GAMEPLAY / QoL
- GRAPHICS
- AUDIO
- BALANCE / ruleset
- CONTENT (maps, encounters, trainers)
- UI / TOOL
- TOTAL_CONVERSION-ish
- Not sure
validations:
required: true
- type: textarea
id: extra
attributes:
label: Anything else
description: >
Reference screenshots, other games/hacks that do it, related issues, or
"I'd like to try writing this myself." Leave blank if nothing comes to mind.
validations:
required: false
+3 -1
View File
@@ -14,7 +14,9 @@ name: ci
on:
push:
branches: [main]
# Integration branch + release branch. PRs already run via pull_request
# (any base); this list is only for post-merge push runs.
branches: [dev, main]
pull_request:
# a force-push while CI is mid-run should cancel the stale run, not queue
+20 -2
View File
@@ -1,7 +1,8 @@
name: Release
# Builds the macOS, Windows, and Linux desktop apps plus an Android APK
# on the self-hosted Mac runner, and publishes them as a GitHub Release.
# Builds the macOS, Windows, and Linux desktop apps, an Android APK, and the
# Anbernic RG34XXSP (Stock OS 64-bit MOD / PortMaster) port on the self-hosted
# Mac runner, and publishes them as a GitHub Release.
#
# Versioning:
# - First ever release is 0.1.0.
@@ -10,6 +11,9 @@ name: Release
# - To force a specific version, either:
# * run this workflow manually (Actions tab) and type it into "version", or
# * put "[release X.Y.Z]" anywhere in the commit message.
#
# Branch model: day-to-day work merges to `dev`. Releases stay on `main` only
# so promoting `dev` -> `main` is the ship gate that cuts a build.
on:
push:
@@ -166,6 +170,13 @@ jobs:
set -euo pipefail
scripts/build_android.sh --version "${{ steps.ver.outputs.version }}"
- name: Build Anbernic RG34XXSP port
run: |
set -euo pipefail
# Self-contained aarch64 PortMaster-style pack; pulls the LÖVE 11.5
# runtime from PortMaster-GUI, so it needs no signing/notarization.
./build-rg34xxsp.sh --version "${{ steps.ver.outputs.version }}"
- name: Notarize & staple macOS app
run: |
set -euo pipefail
@@ -215,6 +226,12 @@ jobs:
[ -n "$apk" ] || { echo "::error::no Android APK found under dist/android/debug"; exit 1; }
cp "$apk" "$outdir/gen1recomp-${v}-android.apk"
# Anbernic handheld port (suffix names the CFW it targets, so a
# future RG35XX/other-CFW pack can ship alongside it).
rg34="dist/rg34xxsp/gen1recomp-rg34xxsp-stockos64-mod.zip"
[ -f "$rg34" ] || { echo "::error::$rg34 not found (expected from ./build-rg34xxsp.sh)"; exit 1; }
cp "$rg34" "$outdir/gen1recomp-${v}-rg34xxsp-stockos64-mod.zip"
# Platform-independent update payload, built alongside the desktop
# apps above (same game.love that gets fused into each of them).
love_file=".bazinga/work/game.love"
@@ -280,6 +297,7 @@ jobs:
"dist/release/gen1recomp-${v}-windows.zip" \
"dist/release/gen1recomp-${v}-linux.zip" \
"dist/release/gen1recomp-${v}-android.apk" \
"dist/release/gen1recomp-${v}-rg34xxsp-stockos64-mod.zip" \
"dist/release/gen1recomp-${v}.love" \
"dist/release/sha256sums.txt"
+84 -39
View File
@@ -1,13 +1,13 @@
# Pokemon Gen 1 Recompilation Project
# Gen1Recomp
A native LÖVE2D recreation of Pokemon Red and Blue. The engine and map
A native LÖVE2D recreation of Poke Red and Blue. The engine and map
behavior are hand-written Lua; game data and graphics are decoded from a ROM
supplied by the player.
SUPPORT AND ANNOUNCEMENTS: [Discord](https://bois.icu)
This project does not include a ROM, emulate the Game Boy, transpile assembly,
or download a disassembly. A canonical US Pokemon Red or Blue ROM is the only
or download a disassembly. A canonical US Poke Red or Blue ROM is the only
game content input.
The ROM is verified, used during import, and then released from memory. It is
@@ -33,46 +33,82 @@ audio channel programs copied out of the verified ROM.
## Controls
| Action | Keyboard | Controller |
|--------|----------|------------|
| Move | Arrow keys / WASD | D-pad / left stick |
| A | Z / Enter / Space | A |
| B | X / Backspace | B |
| Start | Escape | Start |
| Select | Tab / Shift | Back / Select |
| Action | Keyboard | Controller |
| ------ | ----------------- | ------------------ |
| Move | Arrow keys / WASD | D-pad / left stick |
| A | Z / Enter / Space | A |
| B | X / Backspace | B |
| Start | Escape | Start |
| Select | Tab / Shift | Back / Select |
Rebind any of these in-game under **OPTIONS → CONTROLS**. Controllers are
supported out of the box.
### Hotkeys
| Key | What it does |
|-----|----------------|
| `-` / `=` | Zoom out / in (overworld; also mouse wheel) |
| `2` | Cycle COLORS |
| `3` | Cycle TILT (free-roam overworld) |
| `4` | Cycle ZOOM through every level (free-roam overworld) |
| `5` | Cycle GBC FX |
| `F1` | Save |
| `F2` | Load |
| `F10` | Open / close the mod manager |
| Key | What it does |
| --------- | ---------------------------------------------------- |
| `-` / `=` | Zoom out / in (overworld; also mouse wheel) |
| `2` | Cycle COLORS |
| `3` | Cycle TILT (free-roam overworld) |
| `4` | Cycle ZOOM through every level (free-roam overworld) |
| `5` | Cycle GBC FX |
| `F1` | Save |
| `F2` | Load |
| `F10` | Open / close the mod manager |
COLORS, TILT, ZOOM, GBC FX, and VOID FILL are also in the Options menu
and persist in `options.lua`.
### Rulesets
**OPTIONS → RULESET** picks which set of Gen 1 battle behaviors to run.
Both rulesets share the same damage formulas; they differ only in whether
the original's quirks are kept. The setting persists in `options.lua`, and
mods can register their own.
`gen1_faithful` is the default and reproduces the original cartridge,
famous bugs included:
| Rule | Behavior |
| --------------------------- | ----------------------------------------------------- |
| `oneIn256Miss` | A 100%-accurate move still misses on a roll of 255 |
| `critUsesBaseSpeed` | Crit rate reads base speed, not the current stat |
| `critIgnoresStages` | Crit rate ignores stat stages |
| `focusEnergyBug` | FOCUS ENERGY quarters the crit rate instead of x4 |
| `enemyUnlimitedPP` | Enemies never spend PP, so they never Struggle |
| `hyperBeamSkipRechargeOnKO` | HYPER BEAM skips its recharge when the target faints |
| `randMin` / `randMax` | Damage random factor 217-255 |
`modern_clean` keeps the formulas but removes the notorious quirks:
| Rule | Behavior |
| --------------------------- | ----------------------------------------------------- |
| `oneIn256Miss` | Off: a 100%-accurate move always hits |
| `critUsesBaseSpeed` | Unchanged: crit rate still reads base speed |
| `critIgnoresStages` | Off: stat stages count toward the crit rate |
| `focusEnergyBug` | Off: FOCUS ENERGY raises the crit rate as intended |
| `enemyUnlimitedPP` | Off: enemies deplete PP and Struggle when empty |
| `hyperBeamSkipRechargeOnKO` | Off: HYPER BEAM always recharges, like Gen 2+ |
| `randMin` / `randMax` | Damage random factor 217-255, same as faithful |
## Running From Source
Requires LÖVE 11.x. Place a Red or Blue ROM in the project folder and
double-click `Play-Mac.command` or `Play-Windows.bat`, or run:
```sh
scripts/setup.sh --rom "/path/to/Pokemon Red.gb" # or Pokemon Blue.gb
scripts/setup.sh --rom "/path/to/Poke Red.gb" # or Poke Blue.gb
scripts/run.sh
```
then `love .` for later launches. Windows PowerShell scripts, the optional
developer data build, test suites, and cache management are covered in
[Developer Setup](https://github.com/bryanthaboi/pokemon-gen1-recomp-project/wiki/Guide-Developer-Setup).
[Developer Setup](https://github.com/bryanthaboi/gen1recomp/wiki/Guide-Developer-Setup).
## Portable Mode
@@ -80,7 +116,7 @@ By default the game keeps your save, options, and the private ROM-derived
data cache in your OS's normal per-user app data folder. To keep everything
next to the game instead (handy for a USB stick or portable drive you carry
between computers), drop an empty file named `portable.txt` next to the app
(next to `PokemonRed.app`/`.exe`, or next to `main.lua`/`conf.lua` when
(next to `gen1recomp.app`/`.exe`, or next to `main.lua`/`conf.lua` when
running from source), then launch the game. Portable mode is desktop-only
(Windows, Linux, macOS); it has no effect on Android or iOS, where the app
runs from a read-only package.
@@ -88,14 +124,21 @@ runs from a read-only package.
With `portable.txt` present:
- `save.lua`, `save.lua.bak`, and `options.lua` are read from and written to
that same folder instead of the OS save directory.
that same folder instead of the OS save directory.
- A ROM import writes the generated `data/generated` and `assets/generated`
cache straight into that folder too (nothing is left in the OS save
directory), so a later launch reuses it without asking for the ROM again
even on a different computer, as long as the same folder comes along.
cache straight into that folder too (nothing is left in the OS save
directory), so a later launch reuses it without asking for the ROM again
even on a different computer, as long as the same folder comes along.
- Deleting `portable.txt` switches back to the normal OS save directory; nothing
already written to either location is touched automatically, so copy files
over yourself if you want to carry existing progress across the switch.
already written to either location is touched automatically, so copy files
over yourself if you want to carry existing progress across the switch.
## Handhelds
A PortMaster-style port for the **Anbernic RG34XXSP** on Stock OS 64-bit MOD
ships with every release as `gen1recomp-*-rg34xxsp-stockos64-mod.zip`.
Install steps, controls, and troubleshooting live in
[docs/anbernic-rg34xxsp.md](docs/anbernic-rg34xxsp.md).
## Modding
@@ -103,36 +146,38 @@ The game ships a native mod platform: content registries, events and hooks,
per-mod saves and options, and an in-game manager. The full modding book —
getting started, a twelve-rung tutorial ladder, a cookbook, and the generated
reference — lives on the
[project wiki](https://github.com/bryanthaboi/pokemon-gen1-recomp-project/wiki).
[project wiki](https://github.com/bryanthaboi/gen1recomp/wiki).
Shipped example mods, one per kind of author, live in [`mods/`](mods/).
Shipped example mods, one per kind of author, live in `[mods/](mods/)`.
## Bugs and Ideas
Found a bug? A warp dropping you somewhere it shouldn't, a battle doing math
that looks wrong, text in the wrong box, anything that does not match the
original game.
[Open a bug report](https://github.com/bryanthaboi/pokemon-gen1-recomp-project/issues/new?template=bug_report.yml).
[Open a bug report](https://github.com/bryanthaboi/gen1recomp/issues/new?template=bug_report.yml).
Attach a screenshot if you can. It saves a lot of back and forth, and if you
can't get one, the form asks you to describe what you saw instead.
Thought of a feature that could be good, or a way to improve one that already
exists?
[Open a feature request](https://github.com/bryanthaboi/pokemon-gen1-recomp-project/issues/new?template=feature_request.yml).
[Open a feature request](https://github.com/bryanthaboi/gen1recomp/issues/new?template=feature_request.yml).
Say what you want, why it is worth doing, and how you picture it working. A
request with real detail is one that can actually get built.
## More
- [Link play](https://github.com/bryanthaboi/pokemon-gen1-recomp-project/wiki/Guide-Link-Play)
— START > LINK connects two copies directly over UDP.
- [Save editor](https://github.com/bryanthaboi/pokemon-gen1-recomp-project/wiki/Guide-Save-Editor)
— edit party, boxes, items, events, and Pokédex flags outside the game.
- [Link play](https://github.com/bryanthaboi/gen1recomp/wiki/Guide-Link-Play)
— START > LINK connects two copies directly over UDP.
- [Save editor](https://github.com/bryanthaboi/gen1recomp/wiki/Guide-Save-Editor)
— edit party, boxes, items, events, and Pokédex flags outside the game.
- `docs/architecture.md` — runtime details;
`docs/behavior-porting-notes.md` — formula provenance.
`docs/behavior-porting-notes.md` — formula provenance.
## Special Thanks
This project would not be possible without [pret](https://github.com/pret) >
the pret band of decompiling maniacs > and their
[pokered](https://github.com/pret/pokered) disassembly.
[pokered](https://github.com/pret/pokered) disassembly.
+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

+333
View File
@@ -0,0 +1,333 @@
#!/usr/bin/env bash
# Build a PortMaster-style aarch64 port of gen1recomp for Anbernic RG34XXSP
# on Stock OS 64-bit MOD (H700; cbepx-me StockOS MOD / official V1.0.6 base
# + PortMaster).
#
# The stock Anbernic firmware resolves ports relative to the launch script
# (roms/PORTS/...), not PortMaster's /$directory/ports/... path. This pack
# uses SHDIR-relative paths and bundles the LÖVE 11.5 aarch64 runtime so the
# device does not need a separate love_11.5 runtime download on first launch.
#
# Usage:
# ./build-rg34xxsp.sh [--version X.Y.Z]
#
# Output:
# dist/rg34xxsp/gen1recomp-rg34xxsp-stockos64-mod.zip
#
# Install on device:
# 1. Flash / run Stock OS 64-bit MOD with PortMaster installed.
# 2. Unzip into the SD card's roms/PORTS/ folder so you have:
# roms/PORTS/Gen1recomp.sh
# roms/PORTS/gen1recomp/...
# 3. Copy a legal US Red or Blue .gb into roms/PORTS/gen1recomp/lovegame/
# 4. Launch "Gen1recomp" from the Ports list; press Choose ROM (scans that
# folder when zenity is missing).
set -euo pipefail
ROOT="$(cd "$(dirname "$0")" && pwd)"
HERE="$ROOT/.bazinga"
CACHE="$HERE/cache/rg34xxsp"
WORK="$HERE/work/rg34xxsp"
DIST="$ROOT/dist/rg34xxsp"
APP_NAME="gen1recomp"
# Artifact suffix matches the CFW this port targets (Anbernic H700 Stock OS
# 64-bit MOD for RG34XXSP). Release uploads stage it as
# gen1recomp-<ver>-rg34xxsp-stockos64-mod.zip.
ARTIFACT_SUFFIX="rg34xxsp-stockos64-mod"
PORT_DIR_NAME="gen1recomp"
LAUNCHER_NAME="Gen1recomp.sh"
LOVE_VERSION="11.5"
VERSION="$(git -C "$ROOT" rev-parse --short HEAD 2>/dev/null || echo dev)"
# Official PortMaster LÖVE 11.5 aarch64 runtime (small love stub + liblove).
PM_RUNTIME_BASE="https://raw.githubusercontent.com/PortsMaster/PortMaster-GUI/main/PortMaster/runtimes/love_${LOVE_VERSION}"
say() { printf '\033[1;32m==>\033[0m %s\n' "$*"; }
warn() { printf '\033[1;33mwarn:\033[0m %s\n' "$*" >&2; }
fail() { printf '\033[1;31merror:\033[0m %s\n' "$*" >&2; exit 1; }
while [ $# -gt 0 ]; do
case "$1" in
--version) VERSION="$2"; shift ;;
-h|--help)
sed -n '2,24p' "$0"
exit 0
;;
*) fail "unknown argument: $1" ;;
esac
shift
done
command -v curl >/dev/null || fail "curl is required"
command -v zip >/dev/null || fail "zip is required"
command -v unzip >/dev/null || fail "unzip is required"
mkdir -p "$CACHE" "$WORK" "$DIST"
download() {
local url="$1" dest="$2"
if [ -f "$dest" ] && [ -s "$dest" ]; then
return 0
fi
say "downloading $(basename "$dest")"
curl -fL --progress-bar "$url" -o "$dest.tmp" \
|| fail "download failed: $url"
mv "$dest.tmp" "$dest"
}
# --------------------------------------------------------------- game tree
# Unpacked directory (not a .love zip) so the player can drop a .gb next to
# main.lua and RomImporter can scan it without zenity/kdialog.
say "staging lovegame/"
GAME_SRC="$WORK/lovegame"
rm -rf "$GAME_SRC"
mkdir -p "$GAME_SRC"
# Same payload as scripts/build.sh's game.love — never ship ROM-derived cache.
# tools/save-editor is part of that payload: the launcher's Edit button on a
# save row opens it in-process (main.lua).
(cd "$ROOT" && zip -q -9 -r "$WORK/game-payload.zip" \
main.lua conf.lua src data assets tools/save-editor \
tools/rom_manifest.json tools/rom_manifest_blue.json \
-x '*.DS_Store' 'data/generated/*' 'assets/generated/*')
if unzip -Z1 "$WORK/game-payload.zip" \
| grep -Eq '^(data|assets)/generated/[^/]+|^(data|assets)/generated/.+/'; then
fail "payload unexpectedly contains generated ROM data"
fi
unzip -q "$WORK/game-payload.zip" -d "$GAME_SRC"
rm -f "$WORK/game-payload.zip"
# Stamp release version into the staged tree only (never the working tree).
if printf '%s' "$VERSION" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$'; then
say "stamping engine version $VERSION"
sed -E "s/(engine[[:space:]]*=[[:space:]]*\")[^\"]*(\")/\1$VERSION\2/" \
"$ROOT/src/core/Version.lua" > "$GAME_SRC/src/core/Version.lua"
version_re="$(printf '%s' "$VERSION" | sed 's/\./\\./g')"
grep -Eq "engine[[:space:]]*=[[:space:]]*\"$version_re\"" \
"$GAME_SRC/src/core/Version.lua" \
|| fail "version stamp failed"
else
say "version '$VERSION' is not X.Y.Z — shipping default engine (no stamp)"
fi
# Portable marker: saves + ROM cache live next to the game on the SD card.
: > "$GAME_SRC/portable.txt"
# --------------------------------------------------------------- love runtime
say "fetching LÖVE $LOVE_VERSION aarch64 runtime"
LOVE_BIN="$CACHE/love.aarch64"
LOVE_LIB="$CACHE/liblove-11.5.so"
LUAJIT_LIB="$CACHE/libluajit-5.1.so.2"
MODPLUG_LIB="$CACHE/libmodplug.so.1"
OGG_LIB="$CACHE/libogg.so.0"
download "$PM_RUNTIME_BASE/love.aarch64" "$LOVE_BIN"
download "$PM_RUNTIME_BASE/libs.aarch64/liblove-11.5.so" "$LOVE_LIB"
download "$PM_RUNTIME_BASE/libs.aarch64/libluajit-5.1.so.2" "$LUAJIT_LIB"
download "$PM_RUNTIME_BASE/libs.aarch64/libmodplug.so.1" "$MODPLUG_LIB"
download "$PM_RUNTIME_BASE/libs.aarch64/libogg.so.0" "$OGG_LIB"
# Sanity: love stub must be an aarch64 ELF.
file "$LOVE_BIN" | grep -qi 'aarch64\|ARM aarch64' \
|| fail "love.aarch64 does not look like an aarch64 ELF (got: $(file "$LOVE_BIN"))"
# --------------------------------------------------------------- port tree
say "assembling port package"
PORT_ROOT="$WORK/port"
rm -rf "$PORT_ROOT"
mkdir -p "$PORT_ROOT/$PORT_DIR_NAME/bin" \
"$PORT_ROOT/$PORT_DIR_NAME/libs.aarch64" \
"$PORT_ROOT/$PORT_DIR_NAME/licenses" \
"$PORT_ROOT/$PORT_DIR_NAME/conf"
cp -R "$GAME_SRC" "$PORT_ROOT/$PORT_DIR_NAME/lovegame"
cp "$LOVE_BIN" "$PORT_ROOT/$PORT_DIR_NAME/bin/love.aarch64"
chmod +x "$PORT_ROOT/$PORT_DIR_NAME/bin/love.aarch64"
cp "$LOVE_LIB" "$LUAJIT_LIB" "$MODPLUG_LIB" "$OGG_LIB" \
"$PORT_ROOT/$PORT_DIR_NAME/libs.aarch64/"
# Drop a short license pointer for the bundled LÖVE bits.
cat > "$PORT_ROOT/$PORT_DIR_NAME/licenses/LICENSE.love2d.txt" <<'EOF'
This port bundles the LÖVE 11.5 aarch64 runtime from PortMaster
(https://github.com/PortsMaster/PortMaster-GUI). LÖVE is zlib-licensed;
see https://love2d.org/ for full terms.
EOF
# --------------------------------------------------------------- launcher
# Stock OS fix: resolve GAMEDIR from this script's directory (SHDIR), not
# PortMaster's $directory variable — Anbernic uses roms/PORTS with different
# casing and mount points (mmc / sdcard / TF1 / TF2).
cat > "$PORT_ROOT/$LAUNCHER_NAME" <<'EOF'
#!/bin/bash
# gen1recomp — Anbernic RG34XXSP stock OS / PortMaster launcher
# Uses SHDIR-relative paths so stock firmware finds the game folder.
export HOME="${HOME:-/root}"
XDG_DATA_HOME="${XDG_DATA_HOME:-$HOME/.local/share}"
if [ -d "/opt/system/Tools/PortMaster/" ]; then
controlfolder="/opt/system/Tools/PortMaster"
elif [ -d "/opt/tools/PortMaster/" ]; then
controlfolder="/opt/tools/PortMaster"
elif [ -d "$XDG_DATA_HOME/PortMaster/" ]; then
controlfolder="$XDG_DATA_HOME/PortMaster"
elif [ -d "/mnt/mmc/Roms/PORTS/PortMaster" ]; then
controlfolder="/mnt/mmc/Roms/PORTS/PortMaster"
elif [ -d "/mnt/sdcard/Roms/PORTS/PortMaster" ]; then
controlfolder="/mnt/sdcard/Roms/PORTS/PortMaster"
elif [ -d "/roms/ports/PortMaster" ]; then
controlfolder="/roms/ports/PortMaster"
else
controlfolder="/roms/PORTS/PortMaster"
fi
SHDIR="$(cd "$(dirname "$0")" && pwd)"
# shellcheck disable=SC1090
source "$controlfolder/control.txt"
get_controls
[ -f "${controlfolder}/mod_${CFW_NAME}.txt" ] && source "${controlfolder}/mod_${CFW_NAME}.txt"
GAMEDIR="$SHDIR/gen1recomp"
CONFDIR="$GAMEDIR/conf"
mkdir -p "$CONFDIR"
cd "$GAMEDIR" || exit 1
> "$GAMEDIR/log.txt" && exec > >(tee "$GAMEDIR/log.txt") 2>&1
export XDG_DATA_HOME="$CONFDIR"
export XDG_CONFIG_HOME="$CONFDIR"
export LD_LIBRARY_PATH="$GAMEDIR/libs.aarch64:${LD_LIBRARY_PATH:-}"
export SDL_GAMECONTROLLERCONFIG="${sdl_controllerconfig:-}"
# Mali / H700: prefer GLES where available
export LOVE_GRAPHICS_USE_OPENGLES="${LOVE_GRAPHICS_USE_OPENGLES:-1}"
# Same GPU class as a phone, but getOS() here says "Linux", so the Android
# gate (issue #136) would not fire on its own: refuse GBC FX explicitly.
# Hides the OPTIONS row, pins the level to OFF, and heals a level already
# persisted in options.lua.
export POKEPORT_GBCFX="${POKEPORT_GBCFX:-0}"
$ESUDO chmod a+x ./bin/love.aarch64 2>/dev/null || chmod a+x ./bin/love.aarch64
$ESUDO chmod 666 /dev/uinput 2>/dev/null || true
if [ -n "${GPTOKEYB:-}" ]; then
$GPTOKEYB "love.aarch64" &
fi
if type pm_platform_helper >/dev/null 2>&1; then
pm_platform_helper "$GAMEDIR/bin/love.aarch64"
fi
./bin/love.aarch64 "$GAMEDIR/lovegame"
if type pm_finish >/dev/null 2>&1; then
pm_finish
else
if [ -n "${ESUDO:-}" ]; then
$ESUDO kill -9 $(pidof gptokeyb) 2>/dev/null || true
else
kill -9 $(pidof gptokeyb) 2>/dev/null || true
fi
fi
EOF
chmod +x "$PORT_ROOT/$LAUNCHER_NAME"
# --------------------------------------------------------------- metadata
cat > "$PORT_ROOT/port.json" <<EOF
{
"version": 2,
"name": "gen1recomp.zip",
"items": [
"$LAUNCHER_NAME",
"$PORT_DIR_NAME"
],
"items_opt": null,
"attr": {
"title": "gen1recomp",
"desc": "Native LÖVE2D recreation of Pokemon Red and Blue. Supply your own legal US Red or Blue ROM.",
"inst": "Requires Anbernic RG34XXSP Stock OS 64-bit MOD with PortMaster. Copy a canonical US Red or Blue .gb into gen1recomp/lovegame/, then launch and press Choose ROM.",
"genres": ["adventure", "rpg"],
"porter": ["gen1recomp"],
"image": {},
"rtr": true,
"runtime": null,
"reqs": [],
"arch": ["aarch64"]
}
}
EOF
cat > "$PORT_ROOT/gameinfo.xml" <<EOF
<?xml version="1.0" encoding="utf-8"?>
<gameList>
<game>
<path>./$LAUNCHER_NAME</path>
<name>gen1recomp</name>
<desc>Native LÖVE2D recreation of Pokemon Red and Blue. Requires your own legal US Red or Blue ROM.</desc>
<releasedate>20250101T000000</releasedate>
<developer>the bois club</developer>
<publisher>the bois club</publisher>
<genre>RPG</genre>
</game>
</gameList>
EOF
cat > "$PORT_ROOT/README.md" <<'EOF'
## gen1recomp (RG34XXSP / Stock OS 64-bit MOD)
Native LÖVE 11.5 port of gen1recomp for Anbernic RG34XXSP on
**Stock OS 64-bit MOD** (H700, PortMaster).
### Install
1. Use **Stock OS 64-bit MOD** with PortMaster installed (TF1).
2. Unzip so `Gen1recomp.sh` and the `gen1recomp/` folder sit in `roms/PORTS/`.
3. Copy a legal US Pokemon Red or Blue `.gb` into `gen1recomp/lovegame/`.
4. Refresh Ports / restart EmulationStation and launch **Gen1recomp**.
### Controls (launcher)
| Input | Action |
|--|--|
| D-pad / left stick | Move cursor |
| A | Click |
| L1 / R1 | Switch tabs |
| Right stick | Scroll lists |
| Start / Select | Play or Choose ROM |
In-game controls use the normal PortMaster / SDL pad map (rebind under OPTIONS → CONTROLS).
### Display options
GBC FX is disabled on this device (the H700's Mali GPU compiles that present
pass and then shows a black frame), so the OPTIONS row is hidden. COLORS,
TILT, ZOOM, VOID FILL and MAX FPS all work. To try it anyway, launch with
`POKEPORT_GBCFX=1`.
### First run
Stock OS has no zenity file picker. Put the `.gb` in `lovegame/`, then press
**Choose ROM** — the game scans that folder. After import, the ROM-derived
cache and saves stay beside the game (`portable.txt`).
Canonical SHA-1 (1 MiB US carts only):
- Red: `ea9bcae617fdf159b045185467ae58b2e4a48b9a`
- Blue: `d7037c83e1ae5b39bde3c30787637ba1d4c48ce2`
### Thanks
LÖVE runtime binaries from [PortMaster](https://portmaster.games/).
Stock OS 64-bit MOD: [cbepx-me](https://github.com/cbepx-me/Anbernic-H700-RG-xx-StockOS-Modification).
EOF
# --------------------------------------------------------------- zip
ZIP_OUT="$DIST/$APP_NAME-$ARTIFACT_SUFFIX.zip"
rm -f "$ZIP_OUT" "$DIST/$APP_NAME-rg34xxsp.zip"
say "packing $ZIP_OUT"
(cd "$PORT_ROOT" && zip -q -9 -r "$ZIP_OUT" \
"$LAUNCHER_NAME" "$PORT_DIR_NAME" port.json gameinfo.xml README.md)
say "done."
say "artifact: $ZIP_OUT ($(du -h "$ZIP_OUT" | cut -f1))"
say "copy onto the RG34XXSP SD card under roms/PORTS/ (Stock OS 64-bit MOD), then drop your .gb into gen1recomp/lovegame/"
+9 -3
View File
@@ -13,7 +13,12 @@ function love.conf(t)
_G.POKEPORT_DEV_MODE = developer
if editor then
t.identity = os.getenv("POKEPORT_IDENTITY") or "pokemon-love2d-editor"
-- Same identity as the game, deliberately: the editor edits the game's
-- saves and reads the game's ROM cache, both of which live under this
-- folder. A private editor identity would point love.filesystem at an
-- empty directory in a packaged build, so `--editor` could not find
-- data/generated at all (SaveIO.defaultPath already assumed this name).
t.identity = os.getenv("POKEPORT_IDENTITY") or "pokemon-love2d"
t.window.title = "Pokemon Save Editor"
t.window.width = 1280
t.window.height = 800
@@ -47,8 +52,9 @@ function love.conf(t)
-- freely to portrait or landscape), otherwise locked to the window's w/h
-- aspect. So a non-resizable tall window forced portrait; resizable lets
-- the game follow the device. The renderer letterboxes the 160x144
-- viewport into whatever size results, and touch input is gesture-based,
-- so both orientations just work. iOS follows the Info.plist orientations
-- viewport into whatever size results, and the on-screen touch controls
-- re-lay themselves out from the new window size, so both orientations
-- just work. iOS follows the Info.plist orientations
-- (see mobile/ios/overlays/love-ios.plist, now portrait + landscape).
t.window.resizable = true
-- Starting size is a tall portrait hint; the OS resizes to the real
+10 -2
View File
@@ -1,13 +1,21 @@
-- pokered/scripts/PewterNidoranHouse.asm
-- PewterNidoranHouseNidoranText: text_far _PewterNidoranHouseNidoranText, then
-- text_asm plays the NIDORAN_M cry (PlayCry/WaitForSoundToFinish) before ending.
-- No sound-effect command exists in Commands.lua, so we port the dialogue line
-- only; the cry SFX is cosmetic and has no gameplay effect.
-- play_cry only stashes ctx.pendingCry for the very next show_text row, so it
-- has to sit immediately before that row (src/script/Commands.lua); the cry
-- then sounds once the box has typed out, matching the asm's print-then-cry
-- order. Its waitForButton form keeps DisplayTextID's trailing
-- WaitForTextScrollButtonPress -- PewterNidoranHouse_Script is
-- `jp EnableAutoTextBoxDrawing`, which zeroes
-- wDoNotWaitForButtonPressAfterDisplayingText -- so the box still holds until
-- A/B. The cry row was missing, leaving the NIDORAN silent (#247).
return {
PEWTER_NIDORAN_HOUSE = {
talk = {
TEXT_PEWTERNIDORANHOUSE_NIDORAN = {
{ "face_player" },
-- text_asm: ld a, NIDORAN_M / call PlayCry
{ "play_cry", "NIDORAN_M", true },
{ "show_text", "_PewterNidoranHouseNidoranText" },
},
},
+22
View File
@@ -28,7 +28,29 @@ local function badgeGuard(badge, passFlag)
}
end
-- Route23SetVictoryRoadBoulders (pokered scripts/Route23.asm:8): every entry
-- to Route 23 resets the Victory Road boulder puzzle behind you. The 2F/3F
-- switch events clear (so those barriers are closed again on the next floor
-- load) and the boulder that fell through 3F's hole goes back upstairs:
-- ShowObject TOGGLE_VICTORY_ROAD_3F_BOULDER / HideObject
-- TOGGLE_VICTORY_ROAD_2F_BOULDER, which are VICTORYROAD3F_BOULDER4 and
-- VICTORYROAD2F_BOULDER3 (data/maps/toggleable_objects.asm). 1F's event is
-- reset by the Indigo Plateau lobby and by Victory Road 2F, not here. This
-- is what makes the puzzle "completely reset" on a return trip (#258).
-- onEnter is the port's BIT_CUR_MAP_LOADED_2 equivalent: setMap runs it on
-- every map entry, connection crossings included, like EnterMap.
M.ROUTE_23 = {
onEnter = function(game, ow)
local f = game.save.flags
f.EVENT_VICTORY_ROAD_2_BOULDER_ON_SWITCH1 = nil
f.EVENT_VICTORY_ROAD_2_BOULDER_ON_SWITCH2 = nil
f.EVENT_VICTORY_ROAD_3_BOULDER_ON_SWITCH1 = nil
f.EVENT_VICTORY_ROAD_3_BOULDER_ON_SWITCH2 = nil
local Commands = require("src.script.Commands")
local ctx = { save = game.save, overworld = ow, game = game }
Commands.show_object(ctx, "VICTORY_ROAD_3F", "VICTORYROAD3F_BOULDER4")
Commands.hide_object(ctx, "VICTORY_ROAD_2F", "VICTORYROAD2F_BOULDER3")
end,
talk = {
-- Route23Guard1Text: EventFlagBit ..., EVENT_PASSED_EARTHBADGE_CHECK
-- -> wWhichBadge = EARTHBADGE
+7 -3
View File
@@ -59,13 +59,17 @@ M.VIRIDIAN_CITY = {
-- ViridianCityYoungster2CaterpieAndWeedleDescriptionText are
-- defined without a leading underscore in pokered/text/ViridianCity.asm
-- and aren't present in data/generated/text.lua, so we fall back to
-- the literal strings from pokered.
-- the literal strings from pokered. Those fallbacks have to carry the
-- extractor's markers, not plain newlines: line -> \n, cont -> \v,
-- para -> \f. Spelling cont/para as \n and \n\n put all six lines on
-- one page with nothing to wait on, so the whole speech scrolled past
-- without a button press (#250).
TEXT_VIRIDIANCITY_YOUNGSTER2 = function(game, ow, npc, done)
local t = text(game)
ask(game, t._ViridianCityYoungster2YouWantToKnowAboutText
or "You want to know\nabout the 2 kinds\nof caterpillar\nPOKéMON?", function(yes)
or "You want to know\nabout the 2 kinds\vof caterpillar\vPOKéMON?", function(yes)
if yes then
push(game, "CATERPIE has no\npoison, but\nWEEDLE does.\n\nWatch out for its\nPOISON STING!", done)
push(game, "CATERPIE has no\npoison, but\vWEEDLE does.\fWatch out for its\nPOISON STING!", done)
else
push(game, "Oh, OK then!", done)
end
@@ -3,12 +3,16 @@
return {
VIRIDIAN_NICKNAME_HOUSE = {
talk = {
-- ViridianNicknameHouseSpearowText: text_asm that shows the text
-- then plays a SPEAROW cry (PlayCry/WaitForSoundToFinish). The
-- cry playback isn't in the talk-script command vocabulary, so
-- only the flavor line is ported here.
-- ViridianNicknameHouseSpearowText: text_asm that PrintTexts the
-- line, then plays the SPEAROW cry (PlayCry/WaitForSoundToFinish)
-- before TextScriptEnd. play_cry only arms the show_text row that
-- follows it, so the cry sounds once the box has typed out; its
-- waitForButton form keeps DisplayTextID's trailing
-- WaitForTextScrollButtonPress, so the box then holds until A/B like
-- any other NPC line rather than popping itself (#251).
TEXT_VIRIDIANNICKNAMEHOUSE_SPEAROW = {
{ "show_text", "_ViridianNicknameHouseSpearowText" },
{ "play_cry", "SPEAROW", true }, -- 1 PlayCry
{ "show_text", "_ViridianNicknameHouseSpearowText" }, -- 2 PrintText
},
},
},
+59 -34
View File
@@ -6,7 +6,8 @@
-- takes it ("I'll take this one, then!") and both balls disappear.
-- Source: scripts/OaksLab.asm OaksLabCharmanderPokeBallText /
-- OaksLabRivalTakePokeBallScript.
-- * Rival (object 1): before starter -> "gramps isn't around"; with
-- * Rival (object 1): before starter -> "go ahead and choose" once Oak
-- has walked you in, else "gramps isn't around" (#218); with
-- starter -> taunt + battle OPP_RIVAL1 with the counter-pick party
-- (player Bulbasaur -> rival Charmander etc., parties 1/2/3 =
-- Squirtle/Bulbasaur/Charmander in data/trainers/parties.asm);
@@ -162,36 +163,45 @@ return {
starterBall("_OaksLabYouWantBulbasaurText", "BULBASAUR", "EVENT_CHOSE_BULBASAUR",
"OAKSLAB_BULBASAUR_POKE_BALL", 6, "OAKSLAB_CHARMANDER_POKE_BALL"),
-- Talking to the rival only ever prints a line: the lab battle is a
-- coordinate trigger (OaksLabRivalChallengesPlayerScript, wYCoord == 6;
-- see onStep below), never a talk action. The handler used to fall
-- through from the "looks stronger" taunt straight into start_battle,
-- so talking to Blue at the table launched the rival fight before the
-- player ever stepped onto the trigger (#219). scripts/OaksLab.asm
-- OaksLabText8 branches text only:
-- * got starter, not yet battled -> _OaksLabRivalMyPokemonLooksStronger
-- * already battled -> _OaksLabRivalFedUpWithWaitingText (a Route 22
-- line; normally unreachable since the rival is hidden after the
-- lab battle in OaksLabRivalEndBattleScript)
-- * no starter yet -> the FOLLOWED_OAK pre-starter fork (#218)
TEXT_OAKSLAB_RIVAL = {
{ "face_player" }, -- 1
{ "check_flag", "EVENT_GOT_STARTER" }, -- 2
{ "jump_if_false", 21 }, -- 3
{ "check_flag", "EVENT_BATTLED_RIVAL_IN_OAKS_LAB" }, -- 4
{ "jump_if_true", 19 }, -- 5
{ "show_text", "_OaksLabRivalMyPokemonLooksStrongerText" }, -- 6
{ "check_flag", "EVENT_CHOSE_BULBASAUR" }, -- 7
{ "jump_if_false", 11 }, -- 8
{ "start_battle", "trainer", "OPP_RIVAL1", 3 }, -- 9 Charmander
{ "jump", 16 }, -- 10
{ "check_flag", "EVENT_CHOSE_SQUIRTLE" }, -- 11
{ "jump_if_false", 15 }, -- 12
{ "start_battle", "trainer", "OPP_RIVAL1", 2 }, -- 13 Bulbasaur
{ "jump", 16 }, -- 14
{ "start_battle", "trainer", "OPP_RIVAL1", 1 }, -- 15 Squirtle
-- OaksLabRivalEndBattleScript: HealParty + flag, then exit either way
{ "heal_party" }, -- 16
{ "set_flag", "EVENT_BATTLED_RIVAL_IN_OAKS_LAB" }, -- 17
{ "jump", 23 }, -- 18
{ "show_text", "_OaksLabRivalFedUpWithWaitingText" }, -- 19
{ "jump", "end" }, -- 20
{ "show_text", "_OaksLabRivalGrampsIsntAroundText" }, -- 21
{ "jump", "end" }, -- 22
-- win: sulk text then exit; loss: Rival1WinText already played in
-- battle (HandlePlayerBlackOut), so skip straight to the walk-out
{ "jump_if_false", 25 }, -- 23
{ "show_text", "_OaksLabRivalIPickedTheWrongPokemonText" }, -- 24
{ "move_npc_to", 1, 4, 11 }, -- 25
{ "hide_object", "OAKS_LAB", "OAKSLAB_RIVAL" }, -- 26
{ "face_player" },
{ "check_flag", "EVENT_GOT_STARTER" },
{ "jump_if_false", "pre_starter" },
{ "check_flag", "EVENT_BATTLED_RIVAL_IN_OAKS_LAB" },
{ "jump_if_true", "after_battle" },
-- has a starter, has not fought yet: just the taunt. The battle is
-- the coordinate trigger in onStep, not this talk (#219)
{ "show_text", "_OaksLabRivalMyPokemonLooksStrongerText" },
{ "jump", "end" },
{ "label", "after_battle" },
{ "show_text", "_OaksLabRivalFedUpWithWaitingText" },
{ "jump", "end" },
-- pre-starter fork (scripts/OaksLab.asm OaksLabText8 rival handler):
-- Oak has already escorted you in (three balls on the table) -> he
-- waves you on to choose; not yet escorted (very early game) -> the
-- "Gramps isn't around" line. #218
{ "label", "pre_starter" },
{ "check_flag", "EVENT_FOLLOWED_OAK_INTO_LAB" },
{ "jump_if_false", "gramps_gone" },
{ "show_text", "_OaksLabRivalGoAheadAndChooseText" },
{ "jump", "end" },
{ "label", "gramps_gone" },
{ "show_text", "_OaksLabRivalGrampsIsntAroundText" },
},
},
@@ -213,9 +223,16 @@ return {
-- OaksLabScript8 / OaksLabRivalChallenge)
onStep = function(game, ow, x, y)
local flags = game.save.flags
-- Oak blocks the exit mats (4,11)/(5,11) until you take a starter
-- Oak stops you leaving without a starter at the bookshelf row (cell
-- y == 6): this is the same wYCoord == 6 coordinate script the rival
-- challenge just below uses (scripts/OaksLab.asm, both gated on
-- EVENT_GOT_STARTER), so Oak halts you level with the shelves, not one
-- corridor length later on the exit mat. At y>=6 only the x=4,5
-- corridor is walkable and the up-push keeps the player above y=7, so
-- this only ever fires at y=6 in the corridor -- matching the rival
-- trigger's shape. (#232)
if flags.EVENT_FOLLOWED_OAK_INTO_LAB and not flags.EVENT_GOT_STARTER
and y == 11 and (x == 4 or x == 5) then
and y >= 6 then
ow.runner:run({
{ "show_text", "_OaksLabOakDontGoAwayYetText" },
{ "move_player", "up", 1 },
@@ -254,10 +271,18 @@ return {
-- OaksLabRivalEndBattleScript: heal + flag on win or loss; no blackout
table.insert(rows, { "heal_party" })
table.insert(rows, { "set_flag", "EVENT_BATTLED_RIVAL_IN_OAKS_LAB" })
-- win: sulk text then exit; loss jumps to the walk-out (taunt was
-- already shown in-battle via Rival1WinText)
-- OaksLabRivalEndBattleScript: on WIN, print the "picked the wrong
-- POKéMON!" gloat, then BOTH win and loss print the shared exit line
-- _OaksLabRivalSmellYouLaterText ("OK! I'll make my POKéMON fight to
-- toughen it up!\012<PLAYER>! Gramps! Smell you later!") before Blue
-- marches out. A loss skips only the gloat (that taunt was already
-- shown in-battle via Rival1WinText), never the exit line (#231). The
-- jump_if_false convergence point is the exit line: base+6 indexes the
-- SmellYouLater row below, so WIN falls IPicked -> SmellYouLater and
-- LOSS jumps straight to SmellYouLater (both then walk-out + hide).
table.insert(rows, { "jump_if_false", base + 6 })
table.insert(rows, { "show_text", "_OaksLabRivalIPickedTheWrongPokemonText" })
table.insert(rows, { "show_text", "_OaksLabRivalSmellYouLaterText" })
table.insert(rows, { "move_npc_to", 1, 4, 11 })
table.insert(rows, { "hide_object", "OAKS_LAB", "OAKSLAB_RIVAL" })
ow.runner:run(rows, { npc = rival })
+152 -16
View File
@@ -16,6 +16,14 @@ M.VIRIDIAN_MART = {
-- StartSimulatingJoypadStates walks the player to the counter
-- (.PlayerMovement: PAD_LEFT 1, PAD_UP 2, door (3,7) -> counter (2,5))
-- and ViridianMartOaksParcelScript hands the parcel over. The player
-- never presses A.
--
-- The RLE list is consumed back to front: DecodeRLEList fills forward
-- from wSimulatedJoypadStatesEnd, the script seeds wSimulatedJoypadStates-
-- Index with the count, and home/overworld.asm decrements that index
-- before each read. So PAD_LEFT 1, PAD_UP 2 actually plays up, up, left,
-- and the walk ends facing the clerk. Replaying the list front to back
-- put the player at the counter facing up instead (#235). The player
-- never presses A. This matters beyond convenience: the parcel gates
-- Oak's Pokedex and the old man clearing Route 2, so vanilla guarantees
-- it on entry rather than letting you walk out without it.
@@ -28,8 +36,8 @@ M.VIRIDIAN_MART = {
if not f.EVENT_GOT_STARTER then return end
ow:queueScript({
{ "show_text", "_ViridianMartClerkYouCameFromPalletTownText" },
{ "move_player", "left", 1 },
{ "move_player", "up", 2 },
{ "move_player", "left", 1 },
-- the quest text's last page is "{PLAYER} got\nOAK's PARCEL!"
{ "give_item", "OAKS_PARCEL", 1, "_ViridianMartClerkParcelQuestText" },
{ "set_flag", "EVENT_GOT_OAKS_PARCEL" },
@@ -86,6 +94,27 @@ M.VIRIDIAN_CITY = {
{ "show_text", "_ViridianCityOldManTimeIsMoneyText" }, -- 8 (9 = end)
},
},
-- Re-apply the Pokedex old-man swap for a save that already holds the flag
-- but was never standing here when it fired: a vanilla .sav imported
-- through src/save_convert, whose codec does not model pokered's
-- wToggleableObjectFlags array, so save.objectToggles arrives empty and
-- both objects fall back to their compiled-in defaults -- the sleeper ON
-- (data/maps/toggleable_objects.asm toggleable_objects_for VIRIDIAN_CITY),
-- still lying across the north path (#234).
-- OaksLabOakGivesPokedexScript (scripts/OaksLab.asm) sets EVENT_GOT_POKEDEX
-- and, with no branch in between, runs HideObject TOGGLE_LYING_OLD_MAN /
-- ShowObject TOGGLE_OLD_MAN, so that one flag settles both toggles exactly.
-- Same shape as M.OAKS_LAB.onEnter in data/scripts/oaks_lab.lua, which
-- re-applies the same script's other two HideObjects on entry (#106).
onEnter = function(game, ow)
if not (game.save.flags and game.save.flags.EVENT_GOT_POKEDEX) then
return
end
local Commands = require("src.script.Commands")
local ctx = { save = game.save, game = game, overworld = ow }
Commands.hide_object(ctx, "VIRIDIAN_CITY", "VIRIDIANCITY_OLD_MAN_SLEEPY")
Commands.show_object(ctx, "VIRIDIAN_CITY", "VIRIDIANCITY_OLD_MAN")
end,
-- ViridianCityCheckGotPokedexScript: the north corridor is gated on
-- EVENT_GOT_POKEDEX, NOT on the sleeper being hidden, and it triggers
-- on exactly one cell -- (19,9), the gap east of the sleeper (18,9)
@@ -395,7 +424,93 @@ M.SS_ANNE_CAPTAINS_ROOM = {
-- MrFujisHouse.asm; the teleport back to his house is a warp)
-- -------------------------------------------------------------------
-- scripts/PokemonTower7F.asm: after each Rocket loses,
-- PokemonTower7FEndBattleScript shows its EndBattle text (EndTrainerBattle),
-- prints its AfterBattle text (DisplayTextID), then
-- PokemonTower7FRocketLeaveMovementScript walks the grunt off toward the
-- (9,16) stairs (MoveSprite) and PokemonTower7FHideNPCScript despawns it
-- (HideObject). Without this the beaten grunts stood in the corridor
-- forever (#200).
local POKEMON_TOWER_7F_ROCKETS = {
{ index = 1, name = "POKEMONTOWER7F_ROCKET1",
beat = "EVENT_BEAT_POKEMONTOWER_7_TRAINER_0",
after = "_PokemonTower7FRocket1AfterBattleText" },
{ index = 2, name = "POKEMONTOWER7F_ROCKET2",
beat = "EVENT_BEAT_POKEMONTOWER_7_TRAINER_1",
after = "_PokemonTower7FRocket2AfterBattleText" },
{ index = 3, name = "POKEMONTOWER7F_ROCKET3",
beat = "EVENT_BEAT_POKEMONTOWER_7_TRAINER_2",
after = "_PokemonTower7FRocket3AfterBattleText" },
}
-- PokemonTower7FNPCCoordMovementTable, keyed by the PLAYER's tile ("x,y")
-- at the engagement (both the talk and the sight walk-up leave the player on
-- the tile the vanilla table indexes). Each list is that entry's
-- NPC_MOVEMENT_* exit, applied from the grunt's current tile. The exact
-- tables are one per grunt (dbmapcoord bytes decoded back to x,y).
local POKEMON_TOWER_7F_EXITS = {
[1] = { -- ROCKET1 @ (9,11), faces RIGHT
["9,12"] = { "right", "down", "down", "down", "down", "down", "left" },
["10,11"] = { "down", "right", "down", "down", "down", "down" },
["11,11"] = { "down", "down", "down", "down", "down" },
["12,11"] = { "down", "down", "down", "down", "down" },
},
[2] = { -- ROCKET2 @ (12,9), faces LEFT
["12,10"] = { "left", "down", "down", "down", "down", "down", "down" },
["11,9"] = { "down", "down", "down", "left", "down", "down" },
["10,9"] = { "down", "down", "down", "down", "down" },
["9,9"] = { "down", "down", "down", "down", "down" },
},
[3] = { -- ROCKET3 @ (9,7), faces RIGHT
["9,8"] = { "right", "down", "down", "down", "down", "down", "down" },
["10,7"] = { "down", "down", "down", "down", "down" },
["11,7"] = { "down", "down", "down", "down", "down" },
["12,7"] = { "down", "down", "down", "down", "down" },
},
}
-- The vanilla table only lists the sight/talk tiles the map geometry allows;
-- a plain down-walk keeps any other engagement tile (e.g. talking from the
-- side) safe -- it still clears the corridor and despawns the grunt.
local POKEMON_TOWER_7F_EXIT_FALLBACK =
{ "down", "down", "down", "down", "down" }
M.POKEMON_TOWER_7F = {
-- Repair saves that beat a grunt before the exit walk was ported: in
-- vanilla HideObject persists (wMissableObjectFlags), so a beaten grunt is
-- still gone on re-entry. hide_object below writes objectToggles for new
-- wins; this hides any grunt whose BEAT flag is already set.
onEnter = function(game, ow)
local Commands = require("src.script.Commands")
local ctx = { game = game, save = game.save, overworld = ow }
for _, r in ipairs(POKEMON_TOWER_7F_ROCKETS) do
if game.save.flags[r.beat] then
Commands.hide_object(ctx, "POKEMON_TOWER_7F", r.name)
end
end
end,
-- runVictoryHook fires after every trainer win on the map (both the sight
-- and talk paths route through engageTrainer -> checkVictoryRewards), so
-- this walks off whichever grunt was just beaten but is still standing.
-- The AfterBattle text + walk queue UNDER the EndBattle box engageTrainer
-- pushes right after, giving the vanilla order: EndBattle, AfterBattle,
-- walk, despawn.
onVictory = function(game, ow)
if ow.runner:isRunning() then return end
for _, r in ipairs(POKEMON_TOWER_7F_ROCKETS) do
local npc = ow:npcByIndex(r.index)
if npc and game.save.flags[r.beat] then
local key = ow.player.cellX .. "," .. ow.player.cellY
local dirs = (POKEMON_TOWER_7F_EXITS[r.index] or {})[key]
or POKEMON_TOWER_7F_EXIT_FALLBACK
ow.runner:run({
{ "show_text", r.after }, -- DisplayTextID
{ "walk_npc", r.index, dirs }, -- MoveSprite
{ "hide_object", "POKEMON_TOWER_7F", r.name }, -- HideObject
}, { npc = npc })
return
end
end
end,
talk = {
TEXT_POKEMONTOWER7F_MR_FUJI = {
{ "face_player" }, -- 1
@@ -625,11 +740,20 @@ local function boulderAt(ow, x, y)
return npc and npc.def.sprite == "SPRITE_BOULDER" and npc or nil
end
-- Each barrier is stamped BOTH ways on entry. pokered only ever stamps the
-- OPEN block (ReplaceTileBlock edits the loaded block map, which the next map
-- load throws away, so a fresh entry always shows the .blk's closed barrier),
-- but OverworldState:replaceBlock writes through Map:setBlock into the SHARED
-- map record in Game.data, so the port has to put the closed block back
-- itself or a solved barrier stays open for the rest of the session (#258).
-- The closed ids are the shipped .blk bytes: VictoryRoad1F.blk (4,6) = $25,
-- VictoryRoad2F.blk (3,4) = $37 and (11,7) = $25, VictoryRoad3F.blk
-- (3,5) = $25. Same both-ways pattern as the card-key doors in
-- OverworldState:setMap.
M.VICTORY_ROAD_1F = {
onEnter = function(game, ow)
if game.save.flags.EVENT_VICTORY_ROAD_1_BOULDER_ON_SWITCH then
ow:replaceBlock(4, 6, 0x1D)
end
ow:replaceBlock(4, 6,
game.save.flags.EVENT_VICTORY_ROAD_1_BOULDER_ON_SWITCH and 0x1D or 0x25)
end,
onBoulderMoved = function(game, ow, npc)
if npc.cellX == 17 and npc.cellY == 13
@@ -642,12 +766,14 @@ M.VICTORY_ROAD_1F = {
M.VICTORY_ROAD_2F = {
onEnter = function(game, ow)
if game.save.flags.EVENT_VICTORY_ROAD_2_BOULDER_ON_SWITCH1 then
ow:replaceBlock(3, 4, 0x15)
end
if game.save.flags.EVENT_VICTORY_ROAD_2_BOULDER_ON_SWITCH2 then
ow:replaceBlock(11, 7, 0x1D)
end
-- VictoryRoad2FResetBoulderEventScript (scripts/VictoryRoad2F.asm:19):
-- entering 2F clears the 1F switch event, so 1F's barrier is closed
-- again the next time you climb down (#258)
game.save.flags.EVENT_VICTORY_ROAD_1_BOULDER_ON_SWITCH = nil
ow:replaceBlock(3, 4,
game.save.flags.EVENT_VICTORY_ROAD_2_BOULDER_ON_SWITCH1 and 0x15 or 0x37)
ow:replaceBlock(11, 7,
game.save.flags.EVENT_VICTORY_ROAD_2_BOULDER_ON_SWITCH2 and 0x1D or 0x25)
end,
onBoulderMoved = function(game, ow, npc)
if npc.cellX == 1 and npc.cellY == 16
@@ -665,9 +791,8 @@ M.VICTORY_ROAD_2F = {
M.VICTORY_ROAD_3F = {
onEnter = function(game, ow)
if game.save.flags.EVENT_VICTORY_ROAD_3_BOULDER_ON_SWITCH1 then
ow:replaceBlock(3, 5, 0x1D)
end
ow:replaceBlock(3, 5,
game.save.flags.EVENT_VICTORY_ROAD_3_BOULDER_ON_SWITCH1 and 0x1D or 0x25)
end,
onBoulderMoved = function(game, ow, npc)
if npc.cellX == 3 and npc.cellY == 5
@@ -675,12 +800,23 @@ M.VICTORY_ROAD_3F = {
game.save.flags.EVENT_VICTORY_ROAD_3_BOULDER_ON_SWITCH1 = true
ow:replaceBlock(3, 5, 0x1D)
end
-- the hole at (23,15): the boulder drops to 2F next to switch 2
if npc.cellX == 23 and npc.cellY == 15 then
-- the hole at (23,15): the boulder drops to 2F next to switch 2.
-- VictoryRoad3FDefaultScript .handle_hole gates the swap on
-- CheckAndSetEvent EVENT_VICTORY_ROAD_3_BOULDER_ON_SWITCH2, then
-- HideObject TOGGLE_VICTORY_ROAD_3F_BOULDER / ShowObject
-- TOGGLE_VICTORY_ROAD_2F_BOULDER. Those two toggles are
-- VICTORYROAD3F_BOULDER4 and VICTORYROAD2F_BOULDER3
-- (data/maps/toggleable_objects.asm); the old "VICTORYROAD2F_BOULDER"
-- matched no object_event name, so the toggle landed under a key
-- objectVisible never reads: harmless only while nothing hid that
-- boulder, and fatal once Route 23's reset does (#258).
if npc.cellX == 23 and npc.cellY == 15
and not game.save.flags.EVENT_VICTORY_ROAD_3_BOULDER_ON_SWITCH2 then
game.save.flags.EVENT_VICTORY_ROAD_3_BOULDER_ON_SWITCH2 = true
local Commands = require("src.script.Commands")
local ctx = { save = game.save, overworld = ow, game = game }
Commands.hide_object(ctx, "VICTORY_ROAD_3F", npc.def.name)
Commands.show_object(ctx, "VICTORY_ROAD_2F", "VICTORYROAD2F_BOULDER")
Commands.show_object(ctx, "VICTORY_ROAD_2F", "VICTORYROAD2F_BOULDER3")
end
end,
-- scripts/VictoryRoad3F.asm VictoryRoad3FDefaultScript: the same hole
+5 -3
View File
@@ -528,7 +528,9 @@ M.MT_MOON_B2F = {
}
-- The ticket clerk (scripts/Museum1F.asm Museum1FScientist1Text):
-- Y50, once; declining at the rope walks you back out.
-- Y50, once. Declining at the rope shoves the player one tile SOUTH back off
-- the exhibit rope they crossed heading north (#151); the museum floor has no
-- ledges, so a plain scriptMove("down",1) is the correct primitive.
local function museumClerk(game, ow, done, onDecline)
local TextBox = require("src.render.TextBox")
local ChoiceBox = require("src.ui.ChoiceBox")
@@ -563,7 +565,7 @@ M.MUSEUM_1F = {
if y == 4 and (x == 9 or x == 10)
and not game.save.flags.EVENT_BOUGHT_MUSEUM_TICKET then
museumClerk(game, ow, nil, function()
ow:scriptMove(ow.player, "right", 1)
ow:scriptMove(ow.player, "down", 1)
end)
return true
end
@@ -759,7 +761,7 @@ M.CINNABAR_LAB_FOSSIL_ROOM = {
-- #118: do not raise mon.level until a paid retrieve (pokered reverts
-- wDayCareMonBoxLevel on .leaveMonInDayCare). Fold pending steps into
-- mon.exp once and clear them so a second talk cannot re-apply the same
-- walk. Fill {RAM:wNameBuffer}/{RAM:wDayCareMonName}/{NUM:...} here
-- walk. Fill {RAM:wNameBuffer}/{RAM:wDayCareMonName}/{NUM:...} here --
-- TextBox.TOKENS.RAM only knows wStringBuffer.
-- -------------------------------------------------------------------
+63 -32
View File
@@ -300,7 +300,7 @@ local function elevator(elevatorMapId, keyGate, preFrames)
-- Rocket Hideout: without LIFT_KEY the panel only prints the need-
-- a-key line (scripts/RocketHideoutElevator.asm). Exit warps are
-- still seeded above so walking out returns to the entry floor
-- instead of the car's ROM default (B1F) #90 / #105.
-- instead of the car's ROM default (B1F) -- #90 / #105.
if keyGate and not game.save.inventory[keyGate.item] then
local TextBox = require("src.render.TextBox")
game.stack:push(TextBox.new(game,
@@ -524,8 +524,17 @@ M.GAME_CORNER = {
game.data.text._GameCornerRocketAfterBattleText
or "Our hideout might\nbe discovered! I\nbetter tell BOSS!",
function()
hideRocket()
done()
-- #198: GameCornerRocketExitScript (scripts/GameCorner.asm)
-- ApplyMovementData walks the grunt one tile UP into the poster
-- (the hideout's secret entrance at 9,4) before HideObject, so
-- he leaves the floor rather than popping out of existence on
-- (9,5). scriptMove locks player input (#scriptMoves>0) and
-- ignores collision, so we despawn + unfreeze (done) only once
-- the step lands.
ow:scriptMove(npc, "up", 1, function()
hideRocket()
done()
end)
end))
end)
end,
@@ -606,40 +615,62 @@ local function activePrizes()
return require("src.core.GameVersion").isBlue() and BLUE_PRIZES or RED_PRIZES
end
-- Prize counters (engine/menus/prize_menu.asm CeladonPrizeMenu; the prize
-- list itself is data/events/prizes.asm, prize_mon_levels.asm). Gen1 gates
-- the prize window on the COIN CASE: it does IsItemInBag COIN_CASE first, and
-- with no case prints RequireCoinCaseText and returns without ever opening a
-- window; only with the case does it print ExchangeCoinsForPrizesText and then
-- show the prizes. #194: the port used to open the window unconditionally and
-- skip both text boxes.
local function prizeCounter(game, ow, npc, done)
local ListMenu = require("src.ui.ListMenu")
local Commands = require("src.script.Commands")
local items = {}
for _, p in ipairs(activePrizes()) do
local label
if p.kind == "mon" then
label = ("%s L%d"):format(game.data.pokemon[p.species].name, p.level)
else
label = game.data.items[p.item].name
end
table.insert(items, { label = label, right = tostring(p.cost), value = p })
local TextBox = require("src.render.TextBox")
local t = game.data.text
-- IsItemInBag COIN_CASE: without the case, deny and open no window
-- (COIN_CASE is a numeric count in save.inventory, nil when absent).
if not game.save.inventory.COIN_CASE then
game.stack:push(TextBox.new(game,
t._RequireCoinCaseText or "A COIN CASE is\nrequired!", done))
return
end
local list
list = ListMenu.new(game, "PRIZES (COINS)", items, {
footer = ("COINS %d"):format(game.save.coins or 0),
onChoose = function(item)
local p = item.value
if (game.save.coins or 0) < p.cost then
list.footer = "Not enough coins!"
return
-- ExchangeCoinsForPrizesText plays before the prize window opens.
game.stack:push(TextBox.new(game,
t._ExchangeCoinsForPrizesText or "We exchange your\ncoins for prizes.",
function()
local items = {}
for _, p in ipairs(activePrizes()) do
local label
if p.kind == "mon" then
label = ("%s L%d"):format(game.data.pokemon[p.species].name, p.level)
else
label = game.data.items[p.item].name
end
table.insert(items,
{ label = label, right = tostring(p.cost), value = p })
end
game.save.coins = game.save.coins - p.cost
if p.kind == "mon" then
Commands.give_pokemon({ save = game.save, game = game },
p.species, p.level)
else
game.save.inventory[p.item] = (game.save.inventory[p.item] or 0) + 1
end
list.footer = ("Got it! COINS %d"):format(game.save.coins)
end,
onCancel = done,
})
game.stack:push(list)
local list
list = ListMenu.new(game, "PRIZES (COINS)", items, {
footer = ("COINS %d"):format(game.save.coins or 0),
onChoose = function(item)
local p = item.value
if (game.save.coins or 0) < p.cost then
list.footer = "Not enough coins!"
return
end
game.save.coins = game.save.coins - p.cost
if p.kind == "mon" then
Commands.give_pokemon({ save = game.save, game = game },
p.species, p.level)
else
game.save.inventory[p.item] = (game.save.inventory[p.item] or 0) + 1
end
list.footer = ("Got it! COINS %d"):format(game.save.coins)
end,
onCancel = done,
})
game.stack:push(list)
end))
end
M.GAME_CORNER_PRIZE_ROOM = {
+26 -2
View File
@@ -134,12 +134,19 @@ M.MT_MOON_POKECENTER = {
-- Karate Master, take HITMONLEE or HITMONCHAN (the other disappears)
-- -------------------------------------------------------------------
-- ownBall/otherBall keep their spots for signature symmetry; only ownBall
-- is ever hidden (Gen1 removes just the chosen ball).
local function dojoBall(species, ownBall, otherBall, askKey)
return function(game, ow, npc, done)
local t = text(game)
local flags = game.save.flags
-- Already took one prize: the OTHER ball is still on the mat, so
-- talking to it now gives the "Better not get greedy..." refusal
-- (FightingDojo.asm .gotThePokemon / _FightingDojoBetterNotGetGreedyText),
-- not a silent no-op (#197).
if flags.EVENT_GOT_HITMONLEE or flags.EVENT_GOT_HITMONCHAN then
done()
push(game, t._FightingDojoBetterNotGetGreedyText
or "Better not get\ngreedy...", done)
return
end
if not flags.EVENT_BEAT_KARATE_MASTER then
@@ -153,8 +160,10 @@ local function dojoBall(species, ownBall, otherBall, askKey)
local Commands = require("src.script.Commands")
local ctx = { save = game.save, game = game, overworld = ow }
Commands.give_pokemon(ctx, species, 30)
-- Hide ONLY the chosen ball; the other stays (FightingDojo.asm hides
-- just the picked object's index) and routes to the greedy line above
-- when talked to (#197).
Commands.hide_object(ctx, "FIGHTING_DOJO", ownBall)
Commands.hide_object(ctx, "FIGHTING_DOJO", otherBall)
push(game, ("%s got\n%s!"):format(game.save.player.name, species), done)
end)
end
@@ -171,6 +180,21 @@ M.FIGHTING_DOJO = {
"FIGHTINGDOJO_HITMONLEE_POKE_BALL",
"_FightingDojoHitmonchanPokeBallText"),
},
-- The two dojo posters on the north wall (FightingDojo.asm bg_events at
-- the top of the room, cells (4,0)/(5,0) directly above the prize balls)
-- print _EnemiesOnEverySideText. The map extractor dropped the dojo
-- bg_events (signs = {}), so wire the poster read here, the same
-- facing-up + coord shape the mansion switches use. Reachable only once
-- a claimed prize frees the ball cell below the poster (#197).
onInteract = function(game, ow, fx, fy)
if ow.player.facing ~= "up" then return false end
if fy == 0 and (fx == 4 or fx == 5) then
push(game, text(game)._EnemiesOnEverySideText
or "Enemies on every\nside!")
return true
end
return false
end,
}
-- -------------------------------------------------------------------
+160 -22
View File
@@ -196,14 +196,24 @@ local function inCoords(coords, x, y)
return false
end
-- coordinate block: show a line and push the player back one step
-- coordinate block: show a line and shove the player back one step. pokered
-- shoves with a SIMULATED JOYPAD press (scripts/ViridianCity.asm ViridianCity-
-- CheckGymOpenScript), which runs the normal overworld step pipeline including
-- HandleLedges (engine/overworld/ledges.asm), so the shove must HOP a ledge
-- when one sits in front: the tile below the Viridian Gym door is a down-ledge
-- (#151). Gates with no ledge in front (Cinnabar gym lock at (18,4)) get
-- checkLedgeHop == false and fall back to the plain forced step, unchanged.
local function stepGate(opts)
return function(game, ow, x, y)
if not inCoords(opts.coords, x, y) then return false end
if not opts.blocked(game) then return false end
require("src.core.Sound").play(game.data, "Denied")
push(game, text(game)[opts.text] or opts.fallback,
function() ow:scriptMove(ow.player, opts.push, 1) end)
push(game, text(game)[opts.text] or opts.fallback, function()
ow.player.facing = opts.push
if not ow:checkLedgeHop(opts.push) then
ow:scriptMove(ow.player, opts.push, 1)
end
end)
return true
end
end
@@ -283,7 +293,11 @@ pewterEscort.guySteps = {
"right", "right", "right",
}
-- Walk home: reverse of guySteps with opposite facings (gym → spawn).
-- Reverse of guySteps with opposite facings, i.e. the gym-to-spawn
-- mirror of RLEList_PewterGymGuy. Kept as the documented inverse that
-- tests/parity_pewter_escort.lua checks; the youngster does NOT walk it
-- home any more, because its first step is LEFT through the player
-- parked on (11,18) (#241). See walkHome below.
do
local opp = { up = "down", down = "up", left = "right", right = "left" }
local ret = {}
@@ -361,20 +375,44 @@ local function pewterGymEscort(game, ow)
local head = plan.guyHeadStart
-- After the walk: face the player, restore map music, "Go take on
-- BROCK", then retrace RLEList_PewterGymGuy back to his spawn (35,16).
-- (pokered teleports him via MovementData_PewterGymGuyExit; we walk
-- the same route home instead. Brock victory still HideObject's him.)
-- BROCK", then MovementData_PewterGymGuyExit -- five steps RIGHT out of
-- (12,18), the cell PewterCityYoungsterShowsPlayerGymScript pins him to
-- with SetSpritePosition1 (hSpriteMapXCoord 16 / hSpriteMapYCoord 22,
-- minus the +4 border offset object_event coords carry per
-- macros/scripts/maps.asm) and exactly where the escort leaves him.
-- That lands him on (17,18), the last walkable cell before the fence at
-- (18,18) and one column past the screen edge with the player parked on
-- (11,18). PewterCityHideYoungsterScript then HideObject's him and
-- PewterCityResetYoungsterScript's SetSpritePosition2 + ShowObject put
-- him back on his object_event spawn (35,16) facing DOWN, which is the
-- snap below (the vanish/reappear is off screen, same as the original).
--
-- The old code retraced guyReturnSteps instead, whose first step is
-- LEFT into (11,18) -- the cell the player is standing on -- and
-- scriptMove is a pure tween with no entity test, so he walked straight
-- through Red (#241). There is no honest route home: (17,18) is a
-- dead-end pocket ((18,18) fence, (17,17) wall), which is precisely why
-- the original teleports. guyReturnSteps stays as the documented
-- mirror of RLEList_PewterGymGuy that parity_pewter_escort asserts.
-- Brock victory still HideObject's him.
local function walkHome()
if not guy then return end
local ret = pewterEscort.guyReturnSteps
local i = 0
local function tick()
i = i + 1
if not ret[i] then
if i > 5 then
-- SetSpritePosition2: same field writes as Commands.place_npc,
-- including the target clear -- a stale targetX/targetY would
-- leave OverworldState:npcAtCell reserving (17,18) forever and
-- silently wall the player out of that pocket.
guy.cellX, guy.cellY = 35, 16
guy.px, guy.py = 35 * 16, 16 * 16
guy.moving = false
guy.targetX, guy.targetY = nil, nil
guy.facing = "down"
return
end
ow:scriptMove(guy, ret[i], 1, tick)
ow:scriptMove(guy, "right", 1, tick)
end
tick()
end
@@ -458,23 +496,48 @@ end
-- Gym) and again in Viridian Gym; we derive the same windows from the
-- surrounding story flags so old saves work too.
-- Rival1Exit → Viridian (right/down); Rival2Exit → League (left).
-- Both exit lists are keyed on wSavedCoordIndex -- WHICH entry of
-- Route22DefaultScript.Route22RivalBattleCoords the player matched -- not
-- on where the rival ended up. CheckCoords (home/map_objects.asm:107)
-- zeroes wCoordIndex and `inc [hl]` BEFORE each compare, so the first
-- entry reports 1: index 1 is (29,4), index 2 is (29,5).
-- Route22Rival1AfterBattleScript (Route22.asm:175) takes
-- ...ExitMovementData1 on index 1 and ...Data2 otherwise. From the top
-- tile the rival stands BELOW the player on (29,5) and leaves east along
-- row 5; from the bottom tile he stands LEFT of him on (28,5) and has to
-- step UP to row 4 to get around him. The port had the two branches
-- swapped, so the top tile started the walk with UP out of (28,4) into
-- the cliff cell (28,3), which is not walkable (#236).
local function route22ExitDirs(n, py)
if n == 2 then
if py == 5 then return { "left", "left", "left", "left" } end
-- Route22Rival2ExitMovementData1 falls through into ...Data2: LEFT x4
-- from (29,5), LEFT x3 from (28,5), both back onto the spawn (25,5).
if py == 4 then return { "left", "left", "left", "left" } end
return { "left", "left", "left" }
end
if py == 5 then
-- Route22Rival1ExitMovementData1: (29,5) -> (31,5) -> (31,10)
if py == 4 then
return { "right", "right", "down", "down", "down", "down", "down" }
end
-- Route22Rival1ExitMovementData2: (28,5) -> (28,4) -> (31,4) -> (31,10)
return { "up", "right", "right", "right",
"down", "down", "down", "down", "down", "down" }
end
local function route22Scene(n, objIndex, objName, oppClass, baseParty, beatFlag, py)
-- Route22MoveRivalRightScript (Route22.asm:39) walks him RIGHT along his
-- own row from the object_event spawn (25,5): the full four-RIGHT
-- Route22RivalMovementData on coord index 1 (player on (29,4)), so he
-- stops BELOW the player on (29,5); `inc de` drops one RIGHT on index 2
-- (player on (29,5)), so he stops LEFT of him on (28,5). He never
-- leaves row 5. Route22Rival{1,2}StartBattleScript (Route22.asm:110)
-- then faces him UP on index 1 and RIGHT otherwise (#236).
local rx = (py == 4) and 29 or 28
local rivalFacing = (py == 4) and "up" or "right"
return {
{ "show_object", "ROUTE_22", objName }, -- 1
{ "move_npc_to", objIndex, 28, py }, -- 2
{ "face_object", objIndex, "right" }, -- 3
{ "move_npc_to", objIndex, rx, 5 }, -- 2
{ "face_object", objIndex, rivalFacing }, -- 3
{ "show_text", "_Route22RivalBeforeBattleText" .. n }, -- 4
{ "rival_battle", oppClass, baseParty }, -- 5
{ "jump_if_false", 11 }, -- 6
@@ -486,20 +549,25 @@ local function route22Scene(n, objIndex, objName, oppClass, baseParty, beatFlag,
}
end
-- Route22Rival{1,2}StartBattleScript turns the player toward him too:
-- PLAYER_DIR_DOWN on coord index 1 (the (29,4) tile, rival below him),
-- and the PLAYER_DIR_LEFT Route22DefaultScript already set on index 2
-- (the (29,5) tile, rival to his left) (#236).
M.ROUTE_22 = {
onStep = function(game, ow, x, y)
if not inCoords({ { 29, 4 }, { 29, 5 } }, x, y) then return false end
local f = game.save.flags
local playerFacing = (y == 4) and "down" or "left"
if f.EVENT_GOT_POKEDEX and not f.EVENT_BEAT_BROCK
and not f.EVENT_BEAT_ROUTE22_RIVAL_1ST_BATTLE then
return runAmbush(game, ow,
route22Scene(1, 1, "ROUTE22_RIVAL1", "OPP_RIVAL1", 4,
"EVENT_BEAT_ROUTE22_RIVAL_1ST_BATTLE", y), "left")
"EVENT_BEAT_ROUTE22_RIVAL_1ST_BATTLE", y), playerFacing)
end
if f.EVENT_BEAT_GIOVANNI and not f.EVENT_BEAT_ROUTE22_RIVAL_2ND_BATTLE then
return runAmbush(game, ow,
route22Scene(2, 2, "ROUTE22_RIVAL2", "OPP_RIVAL2", 10,
"EVENT_BEAT_ROUTE22_RIVAL_2ND_BATTLE", y), "left")
"EVENT_BEAT_ROUTE22_RIVAL_2ND_BATTLE", y), playerFacing)
end
return false
end,
@@ -535,7 +603,7 @@ end
-- scripts/CeruleanCity.asm CeruleanCityRocketText: fight the thief,
-- then he returns TM28 (DIG) and hurries off. CeruleanHideRocket
-- (CeruleanCity_2.asm) is GBFadeOutToBlack → Show GUARD1 / Hide GUARD2 /
-- Hide ROCKET → GBFadeInFromBlack not a bare hide_object.
-- Hide ROCKET → GBFadeInFromBlack -- not a bare hide_object.
local rocketRows = {
{ "face_player" }, -- 1
{ "check_flag", "EVENT_GOT_TM28" }, -- 2
@@ -554,7 +622,7 @@ local rocketRows = {
{ "fade", "out" }, -- 15 GBFadeOutToBlack
-- CeruleanHideRocket while black: GUARD1 (28,12) appears, GUARD2
-- (27,12) and the ROCKET go. GUARD2 blocks the trashed-house south
-- door neighbour the swap reconnects the city (Bill's ticket does
-- door neighbour -- the swap reconnects the city (Bill's ticket does
-- the same in story.lua; either route is enough).
{ "show_object", "CERULEAN_CITY", "CERULEANCITY_GUARD1" }, -- 16
{ "hide_object", "CERULEAN_CITY", "CERULEANCITY_GUARD2" }, -- 17
@@ -618,13 +686,83 @@ M.MUSEUM_1F = {
}
-- The Pewter Center's singing JIGGLYPUFF (scripts/PewterPokecenter.asm
-- plays MUSIC_JIGGLYPUFF_SONG, then the map theme resumes)
-- PewterPokecenterJigglypuffText). The text_asm sets
-- wDoNotWaitForButtonPressAfterDisplayingText before PrintText, so the box
-- prints with no A prompt and the script keeps running underneath it:
-- SFX_STOP_ALL_MUSIC, DelayFrames 32, PlayMusic MUSIC_JIGGLYPUFF_SONG,
-- then .spinMovementLoop writes the next of DOWN -> LEFT -> UP -> RIGHT (a
-- clockwise turn) every 24 frames for as long as the song's CHAN1/CHAN2
-- still sound, DelayFrames 48, PlayDefaultMusic, TextScriptEnd. Only that
-- last step closes the box, so the whole dance plays out unskippably
-- (#249). Music.playOnce's pendingRestore stands in for both the channel
-- poll and PlayDefaultMusic, so the Center's theme comes back the frame
-- the song ends rather than 48 frames later.
local JIGGLYPUFF_SPIN = { "down", "left", "up", "right" }
local JIGGLYPUFF_SILENCE, JIGGLYPUFF_STEP, JIGGLYPUFF_TAIL = 32, 24, 48
-- Built as a TextBox `auto` table: auto.sound fires the frame the last
-- page has typed out (PrintText returning), and auto.tick then runs once
-- per frame while the gate it returns still reads as playing.
local function jigglypuffDance(game, npc)
local Music = require("src.core.Music")
-- .findMatchingFacingDirectionLoop: the rotation picks up at the entry
-- matching the sprite's current facing (showMapText has just turned it
-- toward the player), and the first write is that same facing, so the
-- first visible quarter turn lands 24 frames in
local step = 1
for i, dir in ipairs(JIGGLYPUFF_SPIN) do
if npc and npc.facing == dir then step = i end
end
local frames, phase = 0, "silence"
return {
sound = function()
Music.stop() -- SFX_STOP_ALL_MUSIC
return { isPlaying = function() return phase ~= "done" end }
end,
tick = function()
frames = frames + 1
if phase == "silence" then
if frames < JIGGLYPUFF_SILENCE then return end
frames = 0
if Music.playOnce(game.data, "Music_JigglypuffSong") then
phase = "spin"
else
-- no song def (or headless): Music.stop above already took the
-- map theme down and nothing armed pendingRestore, so put it
-- back by hand and fall through to the tail rather than hold
-- the box on a poll that would never clear
Music.restoreMap(game.data)
phase = "tail"
end
return
end
if phase == "spin" then
if frames < JIGGLYPUFF_STEP then return end
frames = 0
-- the loop tests the channels after the delay and before the next
-- write, so a song that just ended costs no extra quarter turn
if not Music.oneShotPlaying() then
phase = "tail"
return
end
step = step % #JIGGLYPUFF_SPIN + 1
if npc then npc.facing = JIGGLYPUFF_SPIN[step] end
return
end
if frames >= JIGGLYPUFF_TAIL then phase = "done" end
end,
}
end
M.PEWTER_POKECENTER = {
talk = {
TEXT_PEWTERPOKECENTER_JIGGLYPUFF = function(game, ow, npc, done)
require("src.core.Music").playOnce(game.data, "Music_JigglypuffSong")
push(game, text(game)._PewterPokecenterJigglypuffText
or "JIGGLYPUFF: Puu\npupuu!", done)
-- not the local push() helper: this box needs opts.auto, which is
-- also what suppresses the blinking arrow and the A dismissal
local TextBox = require("src.render.TextBox")
game.stack:push(TextBox.new(game,
text(game)._PewterPokecenterJigglypuffText or "JIGGLYPUFF: Puu\npupuu!",
done, { auto = jigglypuffDance(game, npc) }))
end,
},
}
+6 -3
View File
@@ -16,7 +16,7 @@
-- `dialogue` is the end-battle + post-battle text chain each gym leader
-- runs (SaveEndBattleTextPointers then the map's *PostBattle / ReceiveTM
-- script). Leaders are not def_trainers entries, so engageTrainer has
-- no header.won checkVictoryRewards shows this chain instead of a
-- no header.won -- checkVictoryRewards shows this chain instead of a
-- synthetic "received badge/TM" stub.
local function range(prefix, first, last)
@@ -114,9 +114,12 @@ return {
-- Fighting Dojo Karate Master (scripts/FightingDojo.asm
-- FightingDojoKarateMasterPostBattleScript sets EVENT_BEAT_KARATE_MASTER,
-- which gates the HITMONLEE/HITMONCHAN gift). OPP_BLACKBELT party 1 is
-- only him (data/maps/objects/FightingDojo.asm).
-- only him (data/maps/objects/FightingDojo.asm). `dialogue` is the
-- concede + prize offer he speaks after the win (his header supplies the
-- "Hwa! Arrgh! Beaten!" won line first; #197).
["OPP_BLACKBELT#1"] = { flag = "EVENT_BEAT_KARATE_MASTER",
deactivate = range("EVENT_BEAT_FIGHTING_DOJO_TRAINER_", 0, 3) },
deactivate = range("EVENT_BEAT_FIGHTING_DOJO_TRAINER_", 0, 3),
dialogue = { "_FightingDojoKarateMasterIWillGiveYouAPokemonText" } },
-- Elite Four progress flags (their rooms' door logic isn't ported, but
-- the flags make the Hall of Fame checkable)
+91
View File
@@ -0,0 +1,91 @@
# Anbernic RG34XXSP (Stock OS 64-bit MOD)
Download `gen1recomp-*-rg34xxsp-stockos64-mod.zip` from
[Releases](https://github.com/bryanthaboi/gen1recomp/releases). This build
targets **Stock OS 64-bit MOD** on the RG34XXSP with PortMaster installed
(TF1).
## Install
1. Unzip the release on your computer. You get `Gen1recomp.sh` and a
`gen1recomp/` folder.
2. Copy **both** onto the SD card under **`Roms/PORTS/`** so the layout is:
```
Roms/PORTS/Gen1recomp.sh
Roms/PORTS/gen1recomp/
```
On the device that path is `/mnt/mmc/Roms/PORTS/`. Keep the launcher and
the `gen1recomp/` folder as siblings — do not nest the `.sh` inside the
folder.
3. Put your legal US Red and/or Blue `.gb` files inside the game folder:
```
Roms/PORTS/gen1recomp/lovegame/
```
Example: `Roms/PORTS/gen1recomp/lovegame/Pokemon - Red Version.gb`
4. Eject the card, boot the handheld, open **Ports → Gen1recomp**.
5. On the launcher, move the cursor with the D-pad or left stick, press **A**
to click. Choose the Red or Blue tab, then **Choose ROM** — with no file
picker on stock OS, that scans `lovegame/` for the `.gb` you dropped in.
The port ships with `portable.txt` already in place, so after import the
saves and ROM-derived cache stay next to the game on the SD card. See
[Portable Mode](../README.md#portable-mode) for what that means.
Only the canonical 1 MiB US carts import:
- Red: `ea9bcae617fdf159b045185467ae58b2e4a48b9a`
- Blue: `d7037c83e1ae5b39bde3c30787637ba1d4c48ce2`
## Launcher controls
| Input | Action |
| ------------------ | ------------------ |
| D-pad / left stick | Move cursor |
| A | Click |
| L1 / R1 | Switch tabs |
| Right stick | Scroll lists |
| Start / Select | Play or Choose ROM |
In-game controls use the normal PortMaster / SDL pad map, rebindable under
**OPTIONS → CONTROLS**.
## Notes
**GBC FX is off on this device.** The launcher exports `POKEPORT_GBCFX=0`,
which hides the GBC FX row from OPTIONS, pins the level to OFF, and clears a
level carried over in an `options.lua` from another machine. The H700's Mali
GPU is in the same class as the phone GPUs that compile that present pass and
then show a black frame (issue #136), and `love.system.getOS()` reports
`"Linux"` here, so the Android gate would not have caught it. Every other
display option — COLORS, TILT, ZOOM, VOID FILL, MAX FPS — works normally. If
your device turns out to handle the pass, launch with `POKEPORT_GBCFX=1` to
put the row back.
The pack bundles the LÖVE 11.5 aarch64 runtime from
[PortMaster](https://portmaster.games/), so the device does not need a
separate `love_11.5` runtime download on first launch. The launcher resolves
paths relative to its own directory rather than PortMaster's `$directory`,
because stock Anbernic firmware runs ports out of `roms/PORTS/` with its own
casing and mount points.
If a launch fails, `Roms/PORTS/gen1recomp/log.txt` holds the output of the
last run.
## Building the port
Release runs build this automatically (see `.github/workflows/release.yml`).
To build it by hand:
```sh
./build-rg34xxsp.sh --version 0.1.0 # -> dist/rg34xxsp/gen1recomp-rg34xxsp-stockos64-mod.zip
```
`install-rg34xxsp.sh` copies that pack straight onto a mounted SD card for
local testing.
Stock OS 64-bit MOD comes from
[cbepx-me](https://github.com/cbepx-me/Anbernic-H700-RG-xx-StockOS-Modification).
+1 -1
View File
@@ -59,7 +59,7 @@ the same core data and graphics into the source tree for verification.
| | `src/battle/Experience.lua`, `Catching.lua`, `TrainerAI.lua` | exp/levels, Gen 1 catch algorithm, AI |
| | `src/battle/rulesets/` | `gen1_faithful` (default) vs `modern_clean` |
| ui | `src/ui/*` | start menu, generic menu, yes/no box, party/bag lists |
| | `tools/save-editor/` | Standalone save editor (`love . --editor`) |
| | `tools/save-editor/` | Save editor: shipped in every build, opened from the launcher's Edit button or standalone with `love . --editor` |
## Map scripts
+56 -15
View File
@@ -5,19 +5,39 @@ that runs before `Game:load`. Besides ROM import (see the file's own header)
it hosts a tabbed shell covering per-game save slots and a mod manager. This
file documents the runtime model; the visual spec lives separately.
## Android multi-ROM import
## Android multi-ROM / mod / save import
On Android, `love.system.pickFile()` opens the Storage Access Framework
picker (`GameActivity.showRomFilePicker`); the chosen file is copied to
`picked_rom.gb` in the app save directory. `RomImporter` then imports on
refocus / Choose via `findPendingRom`: only a 1 MiB `.gb` whose SHA-1 maps
to a version that is **not** yet ready counts as pending. A leftover
`picked_rom.gb` from Red therefore cannot block Blue's Choose (issue #167).
After a successful import the consumed save-dir `.gb` is removed.
On Android, `love.system.pickFile([kind])` opens the Storage Access Framework
picker (`GameActivity.showFilePicker`); the chosen file is copied into the app
save directory as:
| `kind` | Destination |
| --- | --- |
| nil / `"rom"` | `picked_rom.gb` (open) |
| `"mod"` | `picked_mod.zip` (open) |
| `"sav"` / `"save"` | `picked_save.sav` (open) |
Export uses a separate API: `love.system.createFile(suggestedName)`
`GameActivity.showCreateDocument` (`ACTION_CREATE_DOCUMENT`), which copies
staged `pending_export.sav` to the user-chosen URI and writes `export_done.flag`
for the launcher to acknowledge on refocus.
`RomImporter` then imports on refocus / Choose:
- **ROMs** via `findPendingRom`: only a 1 MiB `.gb` whose SHA-1 maps to a
version that is **not** yet ready counts as pending. A leftover
`picked_rom.gb` from Red therefore cannot block Blue's Choose (issue #167).
- **Mods** via `findPendingMod`: Prefer `picked_mod.zip`, or (on Choose) any
other `.zip` at the save-dir root (USB copy).
- **Saves** via `findPendingSav`: Prefer `picked_save.sav`, or (on Choose) any
other `.sav` at the save-dir root.
After a successful import the consumed save-dir file is removed.
**Manual check (device/emulator):** import Red → switch to Blue → Choose →
system file picker must appear (not a silent Red re-extract) → pick Blue →
Blue becomes ready beside Red.
Blue becomes ready beside Red. On the MODS tab, Import mod .zip must open the
same system picker and install the chosen archive on return.
## Tab structure
@@ -49,6 +69,15 @@ keeps working unchanged.
- **Registry.** The ordered slot list and which one is active persist in
`options.lua` (via the existing `SaveData.loadOptions`/`saveOptions`):
`options.saveSlots = { [version] = { list = {"slot1", ...}, active = "slot1" } }`.
Custom slot labels (#205) live alongside them in the same registry:
`options.saveSlots[version].names = { slot1 = "Nuzlocke" }`, written by
`SaveData.renameSlot` (trimmed; an empty label clears it) and surfaced on
each `listSlots` row as `label` (the launcher row shows `label`, falling
back to the player name). `deleteSlot` drops the label with the slot.
Renaming never touches the save file, so an empty slot can be labeled.
On desktop, right-clicking a slot row opens the inline rename modal
(Enter commits, Esc cancels); touch has no secondary button, so the
affordance is desktop-only.
- **Active slot resolution.** `saveNames(version)`, the function every
existing caller (`TitleState` hasSave/load/save, recovery order) already
goes through, now resolves the *active* slot instead of a fixed flat name.
@@ -78,6 +107,10 @@ The launcher-facing API:
- `SaveData.createSlot(version)` -> new slot id, registered but with **no
save file written**. An empty slot means the title screen offers NEW GAME
only, which needs no further changes.
- `SaveData.deleteSlot(version, slotId)` removes the slot's
main/`.bak`/`.tmp` files, drops it from the registry, and if it was active
points active at another remaining slot (or clears active when the list is
empty). The launcher's SAVE SLOT panel Delete control calls this.
## Launcher mod manager
@@ -114,6 +147,9 @@ before `Game:load`, so **it never loads a mod's entry chunk**; only
temp first (mount only reaches save-dir-relative paths), the same way
`RomImporter` handles a dropped ROM. A failed copy rolls its partial tree
back, and every path unmounts and clears the staged temp file.
- `LauncherMods.uninstall(id)` removes `mods/<id>/` and clears
`options.mods[id]` so a later reinstall starts from the loader's default
(enabled). The mods panel Delete control calls this and re-derives the list.
## Import / Export save
@@ -122,9 +158,10 @@ through `src/import/SaveFileIO.lua`, which sits on top of
`src/save_convert/SaveConvert.lua` and the slot API in `SaveData`.
- **Import save** is live once the game's ROM is imported (playable). It opens
a native `.sav` picker (`chooseSav`, the per-OS dialogs mirror `chooseZip`;
Android has no picker and shows a drop hint). `SaveFileIO.importToSlot`
reads the bytes (an absolute path, a dropped LOVE file, or raw bytes),
a native `.sav` picker (`chooseSav` on desktop; on Android,
`love.system.pickFile("sav")``picked_save.sav`, same SAF path as ROMs).
`SaveFileIO.importToSlot` reads the bytes (an absolute path, a save-dir
relative name, a dropped LOVE file, or raw bytes),
guards the 32768-byte size, runs `SaveConvert.importSav` (which also rejects
a bad main-data checksum), then registers a fresh slot (`SaveData.createSlot`),
writes it (`SaveData.writeSlot`), and makes it active (`SaveData.setActiveSlot`).
@@ -136,9 +173,13 @@ through `src/import/SaveFileIO.lua`, which sits on top of
slot, encodes it back with `SaveConvert.exportSav` (a slot never keeps
`rawImport`, so this is a zero-filled template export, which is valid), and
writes `exports/gen1recomp-<version>-<slotId>.sav` in the save directory
(`love.filesystem.createDirectory("exports")`). It returns the absolute path
(`love.filesystem.getSaveDirectory()`), which the notice line shows with a
desktop "Open folder" affordance (`love.system.openURL("file://" .. dir)`).
(`love.filesystem.createDirectory("exports")`). On desktop it returns the
absolute path (`love.filesystem.getSaveDirectory()`), which the notice line
shows with an "Open folder" affordance (`love.system.openURL("file://" .. dir)`).
On Android the bytes are also staged as `pending_export.sav` and
`love.system.createFile(suggestedName)` opens `ACTION_CREATE_DOCUMENT` so the
player can save to Downloads / Drive / etc.; on return `export_done.flag`
makes focus show "Save exported."
- **Drag-drop.** `filedropped` routes a `.sav` to the import path for the
currently active game tab; when a non-game tab (mods, or the locked yellow
placeholder) is showing it defaults to red, the always-present first game
+6 -6
View File
@@ -1,21 +1,21 @@
# Native modding
The modding book lives on the
[project wiki](https://github.com/bryanthaboi/pokemon-gen1-recomp-project/wiki).
[project wiki](https://github.com/bryanthaboi/gen1recomp/wiki).
- [Getting started](https://github.com/bryanthaboi/pokemon-gen1-recomp-project/wiki/Getting-Started)
- [Getting started](https://github.com/bryanthaboi/gen1recomp/wiki/Getting-Started)
— install a mod, write a first one, enable and disable it.
- [Tutorials](https://github.com/bryanthaboi/pokemon-gen1-recomp-project/wiki/Tutorials)
- [Tutorials](https://github.com/bryanthaboi/gen1recomp/wiki/Tutorials)
— twelve dependency-ordered rungs, each a runnable mod.
- [Cookbook](https://github.com/bryanthaboi/pokemon-gen1-recomp-project/wiki/Cookbook)
- [Cookbook](https://github.com/bryanthaboi/gen1recomp/wiki/Cookbook)
— task-sized recipes.
- [Registry reference](https://github.com/bryanthaboi/pokemon-gen1-recomp-project/wiki/Reference-Registries)
- [Registry reference](https://github.com/bryanthaboi/gen1recomp/wiki/Reference-Registries)
— every registry, generated from `src/mods/Schemas.lua`.
Regenerate the reference straight into a wiki checkout:
```sh
luajit tools/gen_registry_docs.lua ../pokemon-gen1-recomp-project.wiki
luajit tools/gen_registry_docs.lua ../gen1recomp.wiki
```
## Rendering pipelines
+165 -7
View File
@@ -86,7 +86,7 @@ Game Boy equivalent:
## Colors mode
The `2` key (and the Options menu COLORS row) cycles the display mode
through **OG RED → SGB → RED++ → OG → OG INV → SGB INV → CLASSIC → OG RED**.
through **OG RED → SGB → ADVANCED → OG → OG INV → SGB INV → CLASSIC → OG RED**.
The first three are the real colorizations; the rest are DMG-shade novelties:
- **OG RED**: the Game Boy Color boot-ROM look for Pokemon Red -- one global
@@ -97,8 +97,9 @@ The first three are the real colorizations; the rest are DMG-shade novelties:
- **SGB** (default): the per-map Super Game Boy region palettes
(`data/sgb/sgb_palettes.asm`). Sprites tint with the region palette, as on
real SGB. (This is the mode formerly mislabeled "GBC".)
- **RED++**: pokered-gbc SuperPalettes -- real per-tile GBC coloring plus
per-species mon colors (`data/palettes_gbc.lua`).
- **ADVANCED**: pokered-gbc SuperPalettes -- real per-tile GBC coloring plus
per-species mon colors (`data/palettes_gbc.lua`). (Formerly labeled
"RED++"; it is the richest colorization rather than anything Red-specific.)
- **OG**: force the four DMG grays (colorization off).
- **OG INV**: inverted DMG grays.
- **SGB INV**: each SGB zone palette with shade order reversed.
@@ -109,7 +110,8 @@ The shade-remap transform is applied centrally in `PaletteFX.sendColors`, so
it covers overworld, menus, battles, and tilt upright billboards. OG RED's
global BG palette is supplied by `OverworldState:overworldBgColors` (per-map
override in the overworld pass). Persisted as `save.options.colors`; the
`gbc` / `gbc_inv` save ids are kept for back-compat under the new labels.
`gbc` / `gbc_inv` / `redpp` save ids are kept for back-compat under the new
labels.
## GBC FX
@@ -127,6 +129,14 @@ It runs as a final present pass after world + UI composite in
([github.com/mattakins/Pixel_Transparency](https://github.com/mattakins/Pixel_Transparency)).
Default OFF; persisted as `save.options.gbcfx`.
Mobile GPUs often compile the pass but present a black frame, so Android and
iOS hide the row entirely, pin the level to OFF, and rewrite a level already
persisted in `options.lua` (issue #136). `POKEPORT_GBCFX` overrides that
decision either way, same tri-state as `POKEPORT_TOUCH`: `=0` refuses the
effect, `=1` forces it available. The Anbernic handheld pack exports `0` from
its launcher because the device reports `"Linux"` while its GPU is in the
phone class (see [Anbernic RG34XXSP](anbernic-rg34xxsp.md)).
## Peer-to-peer link play (lua-enet)
Trades and link battles connect two copies of the game directly over
@@ -141,6 +151,30 @@ tradeoff vs. the relay). Headless tests drive the protocol over an
in-memory loopback (`Net.loopbackPair`); under LÖVE the same test file
also exercises real UDP pairing.
## Fair play in link and online matches
A link session is decided by the battle and nothing else, so for its
duration:
- **Game speed is pinned to normal.** The GAME SPEED option and
`POKEPORT_SPEED` are ignored from the moment LINK PLAY opens until it
closes, and apply again after. Fast-forward otherwise runs one peer's
queue faster than the peer it is locked to and drains a tournament shot
clock faster than the opponent racing it.
- **Online play runs vanilla.** Picking ONLINE MATCH or TOURNAMENT with
mods enabled offers to switch them all off and relaunch (mods merge at
boot, so a restart is the only way). The restart is confirmed, not
silent. They stay listed as disabled, ready to switch back on.
- **Only a meaningful split ends a match.** The per-turn state signature
both peers exchange is split three ways: `actives` and `bench` carry
species, HP, status, stat stages, PP and the rest of the party, and a
divergence there ends the match as a draw. `volatile` carries per-turn
flags both sides recompute anyway - a divergence there is logged and
reported to mods, and play continues.
The relay logs which component diverged on which turn, so a desync report
names something specific.
## Custom boot text
The boot sequence replaces the Nintendo / GAME FREAK identifiers with
@@ -161,8 +195,9 @@ migrated once into `options.lua` on load.
- Music / SFX volume
- Music Filter
- OG GLITCHES on / off (Gen 1 quirks vs. modern-clean battle rules)
- COLORS (OG RED / SGB / RED++ / OG / OG INV / SGB INV / CLASSIC), also
hotkey `2` (OG RED = GBC boot-ROM look; RED++ uses pokered-gbc
- BATTLE LAYOUT (OG / WIDE); see "Widescreen battle layout" below
- COLORS (OG RED / SGB / ADVANCED / OG / OG INV / SGB INV / CLASSIC), also
hotkey `2` (OG RED = GBC boot-ROM look; ADVANCED uses pokered-gbc
SuperPalettes + per-species mon colors)
- TILT (OFF / 15 / 35 / 50), also hotkey `3` while free-roaming
- ZOOM (FIT / OUTn / INn), also hotkey `4` while free-roaming; wheel and
@@ -179,4 +214,127 @@ Into-battle wipes still run the original eight styles inside the classic
blocks cascade outward from that square into the surrounding world so the
void outside the OG wipe fills in lockstep. Once the battle state is up,
letterbox voids around the battle canvas fill **white** instead of black
so the whole window reads as one continuous battle screen.
so the whole window reads as one continuous battle screen.
## Widescreen battle layout
Options **BATTLE LAYOUT** picks the battle screen's composition: **OG**
(the default: the original 160×144 arrangement, unchanged) or **WIDE**,
which gives battles a 304×144 native-pixel surface and a Gen 3-style
arrangement on it:
- the foe's status box upper left, the foe's picture upper right;
- the player's picture lower left, the player's status box lower right,
with a longer HP bar and the numeric HP under it;
- a full-width message window;
- a split "What will X do?" prompt / 2×2 command window;
- a 2×2 move menu, navigated with all four directions, with a PP and type
panel attached to its right.
Only the composition changes. Pictures, palettes, HP-bar colors, font
pages, window borders, sounds, animations, timing and every battle rule
stay the engine's, so a COLORS mode or an asset mod still owns the look.
Each side's picture keeps its original pixels and placement math and is
composited into its own region of the wider battlefield -- nothing is
scaled or squeezed -- and animations, which are authored in the original
160-pixel space, shift as one rigid group onto whichever side they play
on. The whole screen is drawn at the window's integer fit scale for the
wider surface, so a 304-pixel screen is drawn a step smaller than a
160-pixel one in the same window.
The wide surface is live only while the battle itself is the screen on
top: a party menu, the bag or a nickname prompt is a 160×144 screen and
brings the classic surface back with it.
## 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.
## Save editor (bundled, reachable from the launcher)
The save editor ships inside every build instead of being a developer-only
script, and the launcher's SAVE SLOT card grows an **Edit** label next to
Delete on every slot that actually holds a save. Edit suspends the
launcher, opens that slot's file in the editor, and **Close** hands the
process back to the launcher with the slot list re-read (a rename, a badge
or a dex change shows up on the row immediately). Unsaved edits arm a
confirm first, so leaving cannot lose work. `love . --editor` still opens
it standalone, where Close quits instead; `--save <path>` points it at any
file, and a save can be dragged onto the window.
The editor now wears the launcher's visual language - the same navy radial
field, 16px translucent cards, tri-colour version rail and green/yellow/red
semantics - so the two windows read as one app. Six tabs:
- **Party**: the roster with sprites, HP bars and level chips on the left,
and the mon inspector permanently docked on the right instead of floating
over the list. Species, level, DVs and moves all round-trip through the
Gen 1 formulas, so the inspector can never show illegal stats.
- **Boxes**: the 12 PC boxes as a 5x4 grid with a fill meter per box and a
party dock, so deposit and withdraw live in one place. Empty slots are
clickable and create a mon there.
- **Items**: money, a searchable item picker (replacing the arrows that
cycled one id at a time through ~250 items), the 20-slot bag, PC storage
with no slot cap, and the eight badges as toggle chips.
- **Events**: flags, defeated trainers, taken items and per-map object
toggles, with a real filter field and a two-column paged grid.
- **Map**: any map rendered with the game's own renderer, warps followable,
and the player / lastHeal / lastOutdoor spawn points settable by clicking
a cell. Setting lastOutdoor on a map the game would not accept as an
outdoor source is refused with the reason.
- **Dex**: seen / owned completion meters and a four-column grid; owning
implies seen and un-seeing clears owned, exactly as the game requires.
Two rules run through all of it. Every mutation goes through one funnel
that sets the dirty flag and writes the status line together, so nothing
changes silently and no branch can quietly no-op - "Party is full", "Bag is
full", "click a cell first" all say so. And every destructive verb (Remove,
Release, Clear all, Wipe dex) arms on the first click and commits on the
second, relabelling itself to `Confirm?` in between.
A validation pill in the tab rail mirrors what the running game would
quarantine on load; clicking it jumps to the tab holding the first problem.
+1 -1
View File
@@ -65,7 +65,7 @@ Each tagged release `vX.Y.Z` carries the existing per-platform archives
A release missing either asset is treated as "no in-place update available":
`Check` reports `needs_full` and sends the player to `Check.releaseUrl()`
(`https://github.com/bryanthaboi/pokemon-gen1-recomp-project/releases/latest`).
(`https://github.com/bryanthaboi/gen1recomp/releases/latest`).
## Save-directory layout
+93
View File
@@ -0,0 +1,93 @@
#!/usr/bin/env bash
# After first boot of the freshly flashed Stock OS Mod, reinsert the SD card
# and run this to install gen1recomp + Red/Blue ROMs into roms/PORTS.
set -euo pipefail
ROOT="$(cd "$(dirname "$0")" && pwd)"
STAGE="$ROOT/.bazinga/work/rg34xxsp-install"
DECPREP="$(cd "$ROOT/../decprep" && pwd)"
ZIP="$ROOT/dist/rg34xxsp/gen1recomp-rg34xxsp.zip"
say() { printf '\033[1;32m==>\033[0m %s\n' "$*"; }
fail() { printf '\033[1;31merror:\033[0m %s\n' "$*" >&2; exit 1; }
# Find the Anbernic user/ROMs volume (usually "NO NAME" after first-boot expand).
find_roms_root() {
local v candidate
for v in /Volumes/*; do
[ -d "$v" ] || continue
# Prefer a volume that already has Roms/ or PORTS/
if [ -d "$v/Roms" ] || [ -d "$v/roms" ] || [ -d "$v/PORTS" ] || [ -d "$v/ports" ]; then
echo "$v"
return 0
fi
done
# Fallback: large FAT volume named NO NAME on disk8
for v in "/Volumes/NO NAME" /Volumes/NO\ NAME /Volumes/ROMS /Volumes/EASYROMS; do
if [ -d "$v" ]; then
echo "$v"
return 0
fi
done
return 1
}
say "looking for Anbernic ROMs volume"
ROMS_ROOT="$(find_roms_root)" || fail "no ROMs volume mounted. Boot the RG34XXSP once (wait for first-boot setup), power off, reinsert the SD, then rerun."
say "using: $ROMS_ROOT"
# Resolve PORTS dir (stock uses Roms/PORTS)
if [ -d "$ROMS_ROOT/Roms/PORTS" ]; then
PORTS="$ROMS_ROOT/Roms/PORTS"
elif [ -d "$ROMS_ROOT/roms/PORTS" ]; then
PORTS="$ROMS_ROOT/roms/PORTS"
elif [ -d "$ROMS_ROOT/Roms/ports" ]; then
PORTS="$ROMS_ROOT/Roms/ports"
elif [ -d "$ROMS_ROOT/PORTS" ]; then
PORTS="$ROMS_ROOT/PORTS"
else
mkdir -p "$ROMS_ROOT/Roms/PORTS"
PORTS="$ROMS_ROOT/Roms/PORTS"
fi
say "PORTS: $PORTS"
# Refresh staged payload
mkdir -p "$STAGE/PORTS"
if [ -f "$ZIP" ]; then
rm -rf "$STAGE/PORTS/Gen1recomp.sh" "$STAGE/PORTS/gen1recomp" "$STAGE/PORTS/port.json" \
"$STAGE/PORTS/gameinfo.xml" "$STAGE/PORTS/README.md"
unzip -q -o "$ZIP" -d "$STAGE/PORTS"
else
fail "missing $ZIP — run ./build-rg34xxsp.sh first"
fi
# Ensure ROMs are in lovegame (Choose ROM scans this folder on stock OS)
[ -f "$DECPREP/Pokemon - Red Version.gb" ] || fail "missing Red ROM in $DECPREP"
[ -f "$DECPREP/Pokemon - Blue Version.gb" ] || fail "missing Blue ROM in $DECPREP"
cp -f "$DECPREP/Pokemon - Red Version.gb" "$STAGE/PORTS/gen1recomp/lovegame/"
cp -f "$DECPREP/Pokemon - Blue Version.gb" "$STAGE/PORTS/gen1recomp/lovegame/"
say "copying gen1recomp port"
rm -rf "$PORTS/gen1recomp" "$PORTS/Gen1recomp.sh"
cp -R "$STAGE/PORTS/gen1recomp" "$PORTS/"
cp -f "$STAGE/PORTS/Gen1recomp.sh" "$PORTS/"
cp -f "$STAGE/PORTS/port.json" "$PORTS/gen1recomp/" 2>/dev/null || true
cp -f "$STAGE/PORTS/README.md" "$PORTS/gen1recomp/" 2>/dev/null || true
chmod +x "$PORTS/Gen1recomp.sh" "$PORTS/gen1recomp/bin/love.aarch64"
# Also drop carts in the stock GB folder for the emulator library
GB_DIR=""
for candidate in "$ROMS_ROOT/Roms/GB" "$ROMS_ROOT/roms/GB" "$ROMS_ROOT/Roms/gb"; do
if [ -d "$candidate" ]; then GB_DIR="$candidate"; break; fi
done
if [ -n "$GB_DIR" ]; then
say "copying .gb into $GB_DIR"
cp -f "$DECPREP/Pokemon - Red Version.gb" "$GB_DIR/"
cp -f "$DECPREP/Pokemon - Blue Version.gb" "$GB_DIR/"
fi
sync
say "installed:"
ls -lh "$PORTS/Gen1recomp.sh"
ls -lh "$PORTS/gen1recomp/lovegame/"*.gb
say "eject the SD, insert TF1 in the RG34XXSP, open Ports → Gen1recomp, Choose ROM."
+138 -11
View File
@@ -1,8 +1,12 @@
-- Native LÖVE2D port of Pokemon Red. A packaged build creates its private
-- game-data cache from a user-provided ROM on first boot.
--
-- Set POKEPORT_EDITOR=1 or pass `--editor` to `love .` to boot the save
-- editor tool (tools/save-editor/) instead of the game.
-- The save editor (tools/save-editor/) ships inside every build and is
-- reachable two ways:
-- * standalone: POKEPORT_EDITOR=1 or `love . --editor`, its own window
-- * from the launcher: Edit on a save row, which suspends the launcher,
-- opens the editor on that slot's file, and restores the launcher when
-- the editor's Close button is pressed (openEditor / closeEditor below)
local editorMode = os.getenv("POKEPORT_EDITOR") == "1" or POKEPORT_EDITOR_MODE == true
@@ -18,6 +22,11 @@ local driverCo -- optional frame-driver (POKEPORT_DRIVER=file.lua): a
-- bot or screenshot run is not at the mercy of the player's last choice.
local speedOverride = tonumber(os.getenv("POKEPORT_SPEED"))
-- POKEPORT_TOUCH=1 forces the mobile on-screen controls on and lets the
-- mouse stand in for a finger, so the overlay can be exercised on desktop
-- (see src/core/TouchControls.lua).
local mouseTouch = os.getenv("POKEPORT_TOUCH") == "1"
-- How many times to run a scripted act+step loop per rendered frame. Only
-- scripted runs use this; interactive play fast-forwards through
-- Game.speedOverride / the GAME SPEED option instead.
@@ -26,6 +35,105 @@ local function scriptedIterations()
return math.max(1, math.floor(require("src.core.GameSpeed").clamp(speedOverride)))
end
-- ------------------------------------------------------------ save editor
-- The launcher instance parked while the editor is up, plus the version whose
-- cache the editor mounted (so closing can put the read path back).
local editorHost, editorVersion, editorWindow
local closeEditor -- forward declaration: openEditor hands it to the editor
-- The editor's modules use flat names (require("Kit"), require("Party")), so
-- their directories have to be on the require path. It must be
-- love.filesystem's path, not package.path: in a packaged build these files
-- live inside the .love archive, which the stock Lua searcher cannot open.
local function addEditorRequirePath()
local fs = love.filesystem
if not (fs.setRequirePath and fs.getRequirePath) then
-- very old LOVE: a source checkout still resolves through package.path
package.path = fs.getSource() .. "/tools/save-editor/?.lua;"
.. fs.getSource() .. "/tools/save-editor/panels/?.lua;"
.. package.path
return
end
local current = fs.getRequirePath()
if current:find("tools/save%-editor") then return end
fs.setRequirePath("tools/save-editor/?.lua;tools/save-editor/panels/?.lua;"
.. current)
end
-- Desktop only: the launcher window (1024x768) is tighter than the editor's
-- design size, so grow it while editing and put it back on Close. Never
-- shrinks, never touches a fullscreen or mobile window.
local function resizeForEditor()
if not (love.window and love.window.getMode and love.window.setMode) then return end
local osName = love.system.getOS()
if osName ~= "OS X" and osName ~= "Windows" and osName ~= "Linux" then return end
local w, h, flags = love.window.getMode()
if flags.fullscreen then return end
local dw, dh = love.window.getDesktopDimensions()
local wantW = math.max(w, math.min(1360, math.floor((dw or w) * 0.92)))
local wantH = math.max(h, math.min(860, math.floor((dh or h) * 0.88)))
if wantW <= w and wantH <= h then return end
editorWindow = { w = w, h = h }
love.window.setMode(wantW, wantH, flags)
end
local function restoreWindow()
if not editorWindow then return end
local _, _, flags = love.window.getMode()
love.window.setMode(editorWindow.w, editorWindow.h, flags)
editorWindow = nil
end
-- Open the editor on a launcher save row. The version's cache has to be
-- mounted before the editor's Data:load runs, or a Blue save would be edited
-- against Red's species/item tables.
local function openEditor(version, slotId)
local SaveData = require("src.core.SaveData")
local path = SaveData.slotDiskPath(version, slotId)
if not path then
if Importer then
Importer.saveNotice = Importer.saveNotice or {}
Importer.saveNotice[version] =
{ ok = false, text = "Could not resolve that save slot on disk." }
end
return
end
local GameVersion = require("src.core.GameVersion")
GameVersion.set(version)
require("src.import.CacheFs").mountVersion(version)
editorVersion = version
editorHost = Importer
Importer = nil
editorMode = true
resizeForEditor()
addEditorRequirePath()
EditorApp = require("App")
EditorApp.load(path, { version = version, slotId = slotId, embedded = true,
onClose = function() closeEditor() end })
end
-- Back to the launcher. Everything the editor mounted or cached has to come
-- back out: the version overlay (CacheFs) and the generated modules require
-- cached behind it (Data), or pressing Play on the OTHER game would boot it
-- with this one's data.
function closeEditor()
local version = editorVersion
editorMode = false
if EditorApp and EditorApp.unload then EditorApp.unload() end
EditorApp = nil
if version then
require("src.import.CacheFs").unmountVersion(version)
require("src.core.Data"):unloadGenerated()
end
editorVersion = nil
restoreWindow()
Importer = editorHost
editorHost = nil
if Importer and version and Importer.savesChanged then
Importer:savesChanged(version)
end
end
local function bootGame(version)
-- The launcher hands us the chosen game (Red / Blue); scripted and headless
-- runs fall back to POKEPORT_VERSION, then Red. Set the active version and
@@ -76,12 +184,17 @@ function love.load(args)
end
love.graphics.setDefaultFilter("nearest", "nearest")
-- Standalone editor. A bare `--editor` run has no launcher behind it, so
-- Close quits; --save points it at a specific file, otherwise it opens the
-- default save path for POKEPORT_VERSION (Red unless overridden), whose
-- cache has to be mounted before the editor's Data:load.
if editorMode then
package.path = love.filesystem.getSource() .. "/tools/save-editor/?.lua;"
.. love.filesystem.getSource() .. "/tools/save-editor/panels/?.lua;"
.. package.path
local version = os.getenv("POKEPORT_VERSION") or "red"
require("src.core.GameVersion").set(version)
require("src.import.CacheFs").mountVersion(version)
addEditorRequirePath()
EditorApp = require("App")
EditorApp.load(savePath)
EditorApp.load(savePath, { version = version })
return
end
@@ -122,10 +235,11 @@ function love.load(args)
-- column shows Play when that game's ROM is already imported, or Choose ROM
-- / drag-drop when it is not (Yellow is still a placeholder). Any dropped
-- .gb is routed to Red or Blue by its SHA-1; pressing Play boots that game.
-- Edit on a save row opens the bundled editor on that slot (openEditor).
Importer = RomImporter.new(function(version)
Importer = nil
bootGame(version)
end, { launcher = true, forceImport = forceImport })
end, { launcher = true, forceImport = forceImport, onEditSave = openEditor })
end
function love.update(dt)
@@ -201,19 +315,19 @@ end
function love.gamepadpressed(joystick, button)
if editorMode then return end
if Importer then return end
if Importer then return Importer:gamepadpressed(joystick, button) end
Game:gamepadpressed(joystick, button)
end
function love.gamepadreleased(joystick, button)
if editorMode then return end
if Importer then return end
if Importer then return Importer:gamepadreleased(joystick, button) end
Game:gamepadreleased(joystick, button)
end
function love.gamepadaxis(joystick, axis, value)
if editorMode then return end
if Importer then return end
if Importer then return Importer:gamepadaxis(joystick, axis, value) end
Game:gamepadaxis(joystick, axis, value)
end
@@ -274,6 +388,9 @@ function love.mousepressed(x, y, button)
if editorMode and EditorApp.mousepressed then
return EditorApp.mousepressed(x, y, button)
end
if mouseTouch and Game and button == 1 then
Game:touchpressed("mouse", x, y)
end
end
function love.mousereleased(x, y, button)
@@ -281,10 +398,20 @@ function love.mousereleased(x, y, button)
if editorMode and EditorApp.mousereleased then
return EditorApp.mousereleased(x, y, button)
end
if mouseTouch and Game and button == 1 then
Game:touchreleased("mouse", x, y)
end
end
function love.mousemoved(x, y)
if editorMode or Importer then return end
if mouseTouch and Game and love.mouse.isDown(1) then
Game:touchmoved("mouse", x, y)
end
end
function love.textinput(text)
if Importer then return end
if Importer then return Importer:textinput(text) end
if editorMode and EditorApp.textinput then
return EditorApp.textinput(text)
end
+5 -5
View File
@@ -43,11 +43,11 @@ The embedded `game.love` deliberately excludes `data/generated/`,
`assets/generated/`, and any ROM. It contains the first-boot Lua importer and
`tools/rom_manifest.json`.
ROM import on Android uses `love.system.pickFile()`
`GameActivity.showRomFilePicker` (Storage Access Framework), which copies the
chosen file to `picked_rom.gb` under the app save directory. `RomImporter`
imports pending (not-yet-ready) `.gb` files from that folder on Choose /
refocus; see `docs/launcher.md` (Android multi-ROM import). The APK payload
ROM / mod / save import on Android uses `love.system.pickFile([kind])`
`GameActivity.showFilePicker` (Storage Access Framework), which copies the
chosen file under the app save directory as `picked_rom.gb`,
`picked_mod.zip`, or `picked_save.sav`. `RomImporter` imports pending files
from that folder on Choose / refocus; see `docs/launcher.md`. The APK payload
itself remains data-free (no embedded ROM or generated cache).
### SDK / NDK
@@ -2,6 +2,12 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android" >
<uses-permission android:name="android.permission.VIBRATE" />
<uses-permission android:name="android.permission.BLUETOOTH" />
<!-- Link play (src/link/): both the LAN backend (enet UDP) and the relay
backend (luasocket TCP) are ordinary sockets, and Android denies those
to a UID without INTERNET. Bind and connect then come back EPERM, which
the link screen shows as "(Operation not permitted)" (issue #287).
scripts/build_android.sh must not strip this again. -->
<uses-permission android:name="android.permission.INTERNET" />
<!-- OpenGL ES 2.0 -->
<uses-feature android:glEsVersion="0x00020000" />
<!-- Touchscreen support -->
+1 -1
View File
@@ -18,4 +18,4 @@ android.useAndroidX=true
android.defaults.buildfeatures.buildconfig=true
android.nonTransitiveRClass=true
android.nonFinalResIds=true
app.name=Pokemon Red
app.name=gen1recomp
@@ -183,13 +183,37 @@ void vibrate(double seconds)
env->DeleteLocalRef(activity);
}
bool showFilePicker()
bool showFilePicker(const char *destFilename)
{
if (destFilename == nullptr || destFilename[0] == '\0')
destFilename = "picked_rom.gb";
JNIEnv *env = (JNIEnv*) SDL_AndroidGetJNIEnv();
jclass activity = env->FindClass("org/love2d/android/GameActivity");
jmethodID method = env->GetStaticMethodID(activity, "showRomFilePicker", "()Z");
jboolean result = env->CallStaticBooleanMethod(activity, method);
jmethodID method = env->GetStaticMethodID(activity, "showFilePicker",
"(Ljava/lang/String;)Z");
jstring jname = env->NewStringUTF(destFilename);
jboolean result = env->CallStaticBooleanMethod(activity, method, jname);
env->DeleteLocalRef(jname);
env->DeleteLocalRef(activity);
return result;
}
bool showCreateDocument(const char *suggestedName)
{
if (suggestedName == nullptr || suggestedName[0] == '\0')
suggestedName = "export.sav";
JNIEnv *env = (JNIEnv*) SDL_AndroidGetJNIEnv();
jclass activity = env->FindClass("org/love2d/android/GameActivity");
jmethodID method = env->GetStaticMethodID(activity, "showCreateDocument",
"(Ljava/lang/String;)Z");
jstring jname = env->NewStringUTF(suggestedName);
jboolean result = env->CallStaticBooleanMethod(activity, method, jname);
env->DeleteLocalRef(jname);
env->DeleteLocalRef(activity);
return result;
@@ -63,9 +63,16 @@ void vibrate(double seconds);
* Shows the system's "pick a document" UI (Storage Access Framework).
* Returns true if the picker was launched; the picked file (if any) is
* copied asynchronously by GameActivity.onActivityResult into the app's
* external save directory, not returned here -- see src/import/RomImporter.lua.
* external save directory under destFilename (default picked_rom.gb), not
* returned here -- see src/import/RomImporter.lua.
**/
bool showFilePicker();
bool showFilePicker(const char *destFilename = nullptr);
/**
* Shows ACTION_CREATE_DOCUMENT so Lua can export a staged pending_export.sav
* to a user-chosen location. suggestedName is the dialog default filename.
**/
bool showCreateDocument(const char *suggestedName = nullptr);
/*
* Helper functions for the filesystem module
@@ -22,6 +22,8 @@
#include "common/config.h"
#include "System.h"
#include <cstring>
#if defined(LOVE_MACOSX)
#include <CoreServices/CoreServices.h>
#elif defined(LOVE_IOS)
@@ -180,11 +182,32 @@ void System::vibrate(double seconds) const
#endif
}
bool System::pickFile() const
bool System::pickFile(const char *kind) const
{
#ifdef LOVE_ANDROID
return love::android::showFilePicker();
const char *dest = "picked_rom.gb";
if (kind != nullptr)
{
if (strcmp(kind, "mod") == 0)
dest = "picked_mod.zip";
else if (strcmp(kind, "sav") == 0 || strcmp(kind, "save") == 0)
dest = "picked_save.sav";
else if (strcmp(kind, "rom") == 0)
dest = "picked_rom.gb";
}
return love::android::showFilePicker(dest);
#else
LOVE_UNUSED(kind);
return false;
#endif
}
bool System::createFile(const char *suggestedName) const
{
#ifdef LOVE_ANDROID
return love::android::showCreateDocument(suggestedName);
#else
LOVE_UNUSED(suggestedName);
return false;
#endif
}
@@ -111,9 +111,21 @@ public:
* Android only for now; the result (if any) is not returned here -- see
* love::android::showFilePicker and src/import/RomImporter.lua.
*
* @param kind Optional pick kind: nullptr/"rom" -> picked_rom.gb,
* "mod" -> picked_mod.zip, "sav"/"save" -> picked_save.sav.
* @return Whether the picker was shown.
**/
virtual bool pickFile() const;
virtual bool pickFile(const char *kind = nullptr) const;
/**
* Shows the platform's native "create / save a file" UI (Android SAF
* ACTION_CREATE_DOCUMENT). Copies staged pending_export.sav from the app
* save directory to the user-chosen URI. See GameActivity.showCreateDocument.
*
* @param suggestedName Default filename shown in the dialog.
* @return Whether the create dialog was shown.
**/
virtual bool createFile(const char *suggestedName = nullptr) const;
/**
* Gets if the user is playing music on background.
@@ -97,7 +97,15 @@ int w_vibrate(lua_State *L)
int w_pickFile(lua_State *L)
{
luax_pushboolean(L, instance()->pickFile());
const char *kind = luaL_optstring(L, 1, nullptr);
luax_pushboolean(L, instance()->pickFile(kind));
return 1;
}
int w_createFile(lua_State *L)
{
const char *suggested = luaL_optstring(L, 1, nullptr);
luax_pushboolean(L, instance()->createFile(suggested));
return 1;
}
@@ -117,6 +125,7 @@ static const luaL_Reg functions[] =
{ "openURL", w_openURL },
{ "vibrate", w_vibrate },
{ "pickFile", w_pickFile },
{ "createFile", w_createFile },
{ "hasBackgroundMusic", w_hasBackgroundMusic },
{ 0, 0 }
};
@@ -22,12 +22,15 @@ package org.love2d.android;
import org.libsdl.app.SDLActivity;
import java.io.BufferedInputStream;
import java.io.BufferedOutputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
@@ -61,13 +64,24 @@ public class GameActivity extends SDLActivity {
protected final int[] recordAudioRequestDummy = new int[1];
public static final int EXTERNAL_STORAGE_REQUEST_CODE = 2;
public static final int RECORD_AUDIO_REQUEST_CODE = 3;
public static final int ROM_PICKER_REQUEST_CODE = 4;
// Mirrors conf.lua's t.identity ("pokemon-love2d"): where the picked ROM
public static final int FILE_PICKER_REQUEST_CODE = 4;
public static final int FILE_CREATE_REQUEST_CODE = 5;
/** @deprecated Prefer FILE_PICKER_REQUEST_CODE; kept for older call sites. */
public static final int ROM_PICKER_REQUEST_CODE = FILE_PICKER_REQUEST_CODE;
// Mirrors conf.lua's t.identity ("pokemon-love2d"): where the picked file
// is dropped so RomImporter's existing folder scan finds it -- see
// src/import/RomImporter.lua and Filesystem::setIdentity (sets Android's
// save directory to getExternalFilesDir()/save/<identity>).
private static final String ROM_SAVE_IDENTITY = "pokemon-love2d";
private static final String PICKED_ROM_FILENAME = "picked_rom.gb";
private static final String PICKED_MOD_FILENAME = "picked_mod.zip";
private static final String PICKED_SAVE_FILENAME = "picked_save.sav";
private static final String PENDING_EXPORT_FILENAME = "pending_export.sav";
private static final String EXPORT_DONE_FILENAME = "export_done.flag";
// Destination basename for the in-flight SAF pick (set by showFilePicker).
private String pendingPickFilename = PICKED_ROM_FILENAME;
// Suggested download name for the in-flight SAF create (set by showCreateDocument).
private String pendingCreateSuggestedName = "export.sav";
private static boolean immersiveActive = false;
private static boolean needToCopyGameInArchive = false;
private boolean storagePermissionUnnecessary = false;
@@ -341,59 +355,185 @@ public class GameActivity extends SDLActivity {
/**
* Shows the system document picker (Storage Access Framework) so the
* player can pick their ROM from anywhere (Downloads, Drive, etc.)
* without needing to know where the app's external files folder is.
* Requires API 19+ (ACTION_OPEN_DOCUMENT); the picked file (if any)
* player can pick a ROM / mod / save from anywhere (Downloads, Drive,
* etc.) without needing to know where the app's external files folder
* is. Requires API 19+ (ACTION_OPEN_DOCUMENT); the picked file (if any)
* arrives later in onActivityResult, not synchronously here.
*
* @param destFilename basename under the app save identity (e.g.
* picked_rom.gb, picked_mod.zip, picked_save.sav)
*/
@Keep
public static boolean showRomFilePicker() {
public static boolean showFilePicker(String destFilename) {
if (android.os.Build.VERSION.SDK_INT < 19) return false;
GameActivity self = (GameActivity) mSingleton;
if (self == null) return false;
if (destFilename == null || destFilename.length() == 0) {
destFilename = PICKED_ROM_FILENAME;
}
// Reject path separators so a hostile JNI caller cannot escape the
// save identity directory.
if (destFilename.indexOf('/') >= 0 || destFilename.indexOf('\\') >= 0) {
Log.d("GameActivity", "refusing unsafe picker dest: " + destFilename);
return false;
}
self.pendingPickFilename = destFilename;
Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT);
intent.addCategory(Intent.CATEGORY_OPENABLE);
intent.setType("*/*");
try {
self.startActivityForResult(intent, ROM_PICKER_REQUEST_CODE);
self.startActivityForResult(intent, FILE_PICKER_REQUEST_CODE);
return true;
} catch (Exception e) {
Log.d("GameActivity", "could not open ROM file picker: " + e.getMessage());
Log.d("GameActivity", "could not open file picker: " + e.getMessage());
return false;
}
}
/** ROM convenience wrapper; prefer showFilePicker with an explicit name. */
@Keep
public static boolean showRomFilePicker() {
return showFilePicker(PICKED_ROM_FILENAME);
}
/** Mod .zip convenience wrapper used by love.system.pickFile("mod"). */
@Keep
public static boolean showModFilePicker() {
return showFilePicker(PICKED_MOD_FILENAME);
}
/** Battery .sav convenience wrapper used by love.system.pickFile("sav"). */
@Keep
public static boolean showSaveFilePicker() {
return showFilePicker(PICKED_SAVE_FILENAME);
}
/**
* Shows ACTION_CREATE_DOCUMENT so the player can save a staged export
* (pending_export.sav in the app save identity) to Downloads / Drive /
* etc. Suggested name is the dialog's default filename.
*/
@Keep
public static boolean showCreateDocument(String suggestedName) {
if (android.os.Build.VERSION.SDK_INT < 19) return false;
GameActivity self = (GameActivity) mSingleton;
if (self == null) return false;
if (suggestedName == null || suggestedName.length() == 0) {
suggestedName = "export.sav";
}
if (suggestedName.indexOf('/') >= 0 || suggestedName.indexOf('\\') >= 0) {
Log.d("GameActivity", "refusing unsafe create name: " + suggestedName);
return false;
}
File source = new File(
new File(self.getExternalFilesDir(null), "save"),
ROM_SAVE_IDENTITY + "/" + PENDING_EXPORT_FILENAME);
if (!source.isFile()) {
Log.d("GameActivity", "no pending export at " + source);
return false;
}
self.pendingCreateSuggestedName = suggestedName;
Intent intent = new Intent(Intent.ACTION_CREATE_DOCUMENT);
intent.addCategory(Intent.CATEGORY_OPENABLE);
intent.setType("application/octet-stream");
intent.putExtra(Intent.EXTRA_TITLE, suggestedName);
try {
self.startActivityForResult(intent, FILE_CREATE_REQUEST_CODE);
return true;
} catch (Exception e) {
Log.d("GameActivity", "could not open create-document picker: " + e.getMessage());
return false;
}
}
private File saveIdentityDir() {
return new File(new File(getExternalFilesDir(null), "save"), ROM_SAVE_IDENTITY);
}
private boolean copyFileToUri(File source, Uri destUri) {
InputStream in = null;
OutputStream out = null;
try {
in = new BufferedInputStream(new FileInputStream(source));
out = getContentResolver().openOutputStream(destUri);
if (out == null) return false;
byte[] buf = new byte[8192];
int n;
while ((n = in.read(buf)) != -1) {
out.write(buf, 0, n);
}
out.flush();
return true;
} catch (IOException e) {
Log.d("GameActivity", "copy to URI failed: " + e.getMessage());
return false;
} finally {
try { if (in != null) in.close(); } catch (IOException ignored) {}
try { if (out != null) out.close(); } catch (IOException ignored) {}
}
}
@Override
protected void onActivityResult(int requestCode, int resultCode, Intent data) {
super.onActivityResult(requestCode, resultCode, data);
if (requestCode != ROM_PICKER_REQUEST_CODE) return;
if (requestCode == FILE_CREATE_REQUEST_CODE) {
if (resultCode != RESULT_OK || data == null || data.getData() == null) {
Log.d("GameActivity", "create-document cancelled");
return;
}
File source = new File(saveIdentityDir(), PENDING_EXPORT_FILENAME);
if (!source.isFile()) {
Log.d("GameActivity", "pending export missing at result time");
return;
}
Uri uri = data.getData();
if (copyFileToUri(source, uri)) {
// Signal Lua on next focus that the SAF export finished.
File flag = new File(saveIdentityDir(), EXPORT_DONE_FILENAME);
try {
FileOutputStream fos = new FileOutputStream(flag, false);
fos.write("ok".getBytes());
fos.close();
} catch (IOException e) {
Log.d("GameActivity", "could not write export_done flag: " + e.getMessage());
}
// Keep pending_export.sav so a retry still works; Lua may remove it.
} else {
Log.d("GameActivity", "could not write export to " + uri);
}
return;
}
if (requestCode != FILE_PICKER_REQUEST_CODE) return;
if (resultCode != RESULT_OK || data == null || data.getData() == null) {
Log.d("GameActivity", "ROM picker returned no file (cancelled?)");
Log.d("GameActivity", "file picker returned no file (cancelled?)");
return;
}
Uri uri = data.getData();
File destDir = new File(new File(getExternalFilesDir(null), "save"), ROM_SAVE_IDENTITY);
File destDir = saveIdentityDir();
if (!destDir.exists() && !destDir.mkdirs()) {
Log.d("GameActivity", "could not create " + destDir);
return;
}
File destFile = new File(destDir, PICKED_ROM_FILENAME);
String destName = pendingPickFilename != null
? pendingPickFilename : PICKED_ROM_FILENAME;
File destFile = new File(destDir, destName);
InputStream source;
try {
source = getContentResolver().openInputStream(uri);
} catch (FileNotFoundException e) {
Log.d("GameActivity", "could not open picked ROM: " + e.getMessage());
Log.d("GameActivity", "could not open picked file: " + e.getMessage());
return;
}
if (source == null) {
Log.d("GameActivity", "ContentResolver returned no stream for picked ROM");
Log.d("GameActivity", "ContentResolver returned no stream for picked file");
return;
}
if (!copyAssetFile(source, destFile.getPath())) {
Log.d("GameActivity", "could not copy picked ROM to " + destFile);
Log.d("GameActivity", "could not copy picked file to " + destFile);
}
}
+1 -1
View File
@@ -8,7 +8,7 @@
return {
summary = "Oak's Charmander gift becomes a level 20 Mew with inverted sprites.",
author = "Pokemon Gen 1 Recompilation Project",
contact = "https://github.com/bryanthaboi/pokemon-gen1-recomp-project",
contact = "https://github.com/bryanthaboi/gen1recomp",
tags = { "beginner", "cosmetic", "data-only", "legacy" },
differences = {
changed = {
+5 -3
View File
@@ -1,6 +1,6 @@
# Example mod gallery
Seven reference mods, one per modder persona. Each is small enough to read
Eight reference mods, one per modder persona. Each is small enough to read
in one sitting, exercises a different slice of the mod API, and is a real,
runnable, tested mod — not a snippet.
@@ -16,6 +16,7 @@ Copy the one closest to what you want to build.
| 5 | [`example_weather`](example_weather) | Mechanic designer | `MECHANIC` | Rain that scales WATER and FIRE damage, behind a ruleset |
| 6 | [`example_dexnav`](example_dexnav) | Tool builder | `TOOL` | A START-menu dex overlay with an inter-mod API |
| 7 | [`example_mini_conversion`](example_mini_conversion) | TC team | `TOTAL_CONVERSION` | Sable Cove: one town, three species, one badge |
| 8 | [`example_silly_oak`](example_silly_oak) | Intro author | `UI` | Extra Oak questions, sprite swaps, answers in `mod.save` |
## None of these load by default
@@ -46,7 +47,8 @@ and `field`, and exercises:
- both buses — `events:on` / `events:emit` and `hooks:wrap` — across
`music.select`, `battle.damage`, `ui.start_menu.items`, `ui.options.rows`,
`battle.started`, `battle.turn_started`, `battle.ended`, `flag.changed`,
`game.ready` and `assets.transformed`
`game.ready`, `assets.transformed`, `intro.oak_speech.build`,
`intro.oak_speech.answered` and `intro.oak_speech.finished`
- `mod.save`, `mod.options`, `mod.exports`, `mod.commands`, `mod.ui`,
`mod:read`, `mod.log` and `mod.path`
- asset transforms, the `trueColor` opt-out, script labels and `choice`,
@@ -66,7 +68,7 @@ mods/examples/<id>/
.modkitignore keeps the suite out of the distributed package
```
`tests/mod_examples_tests.lua` in the engine's own suite loads all seven
`tests/mod_examples_tests.lua` in the engine's own suite loads all eight
together and asserts the above, so the gallery cannot rot.
These example mods arent perfect and are just examples to show you basics of wahts possible, but way more than just this is possible.
@@ -3,7 +3,7 @@
return {
summary = "Faster final starters, half-price TMs, a re-slotted Route 1.",
author = "Pokemon Gen 1 Recompilation Project",
contact = "https://github.com/bryanthaboi/pokemon-gen1-recomp-project",
contact = "https://github.com/bryanthaboi/gen1recomp",
tags = { "balance", "data-only", "beginner" },
differences = {
changed = {
+1 -1
View File
@@ -3,7 +3,7 @@
return {
summary = "A start-menu dex overlay with seen/owned counts and an inter-mod API.",
author = "Pokemon Gen 1 Recompilation Project",
contact = "https://github.com/bryanthaboi/pokemon-gen1-recomp-project",
contact = "https://github.com/bryanthaboi/gen1recomp",
tags = { "ui", "tool", "quality-of-life" },
differences = {
changed = { "the START menu gains a DEXNAV row above SAVE" },
+1 -1
View File
@@ -3,7 +3,7 @@
return {
summary = "An authored chip song for Pallet Town, a new Mew cry, and a jukebox screen.",
author = "Pokemon Gen 1 Recompilation Project",
contact = "https://github.com/bryanthaboi/pokemon-gen1-recomp-project",
contact = "https://github.com/bryanthaboi/gen1recomp",
tags = { "audio", "chiptune", "ui" },
differences = {
changed = {
+1 -1
View File
@@ -3,7 +3,7 @@
return {
summary = "A courier in Viridian lost a parcel in Pewter. Fetch it for a NUGGET.",
author = "Pokemon Gen 1 Recompilation Project",
contact = "https://github.com/bryanthaboi/pokemon-gen1-recomp-project",
contact = "https://github.com/bryanthaboi/gen1recomp",
tags = { "quest", "story", "scripting" },
differences = {
changed = {
@@ -3,7 +3,7 @@
return {
summary = "Sable Cove: one town, three species, one badge. The smallest whole conversion.",
author = "Pokemon Gen 1 Recompilation Project",
contact = "https://github.com/bryanthaboi/pokemon-gen1-recomp-project",
contact = "https://github.com/bryanthaboi/gen1recomp",
tags = { "total-conversion", "capstone" },
differences = {
changed = {
+1 -1
View File
@@ -3,7 +3,7 @@
return {
summary = "A teal player recolor derived from your own cache, plus two palette records.",
author = "Pokemon Gen 1 Recompilation Project",
contact = "https://github.com/bryanthaboi/pokemon-gen1-recomp-project",
contact = "https://github.com/bryanthaboi/gen1recomp",
tags = { "cosmetic", "graphics", "beginner" },
screenshots = {
{ transform = "shots/pallet_town.lua", caption = "Pallet Town under the recolored palette" },
@@ -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/gen1recomp",
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")
+1 -1
View File
@@ -3,7 +3,7 @@
return {
summary = "Opt-in rain: WATER hits harder, FIRE hits softer, for five turns a battle.",
author = "Pokemon Gen 1 Recompilation Project",
contact = "https://github.com/bryanthaboi/pokemon-gen1-recomp-project",
contact = "https://github.com/bryanthaboi/gen1recomp",
tags = { "battle", "mechanic", "ruleset", "hardcore" },
differences = {
changed = {
+13 -1
View File
@@ -56,16 +56,28 @@ done
mkdir -p "$CACHE" "$WORK" "$DIST/mac" "$DIST/win" "$DIST/linux"
# --------------------------------------------------------------- game.love
# tools/save-editor is part of the shipped app, not a dev-only script: the
# launcher's Edit button on a save row opens it in-process (main.lua), and
# `--editor` / POKEPORT_EDITOR=1 opens it standalone. It is required through
# love.filesystem's require path, so it has to live inside the archive.
say "packing game.love"
LOVE_FILE="$WORK/game.love"
rm -f "$LOVE_FILE"
(cd "$ROOT" && zip -q -9 -r "$LOVE_FILE" \
main.lua conf.lua src data assets tools/rom_manifest.json tools/rom_manifest_blue.json \
main.lua conf.lua src data assets tools/save-editor \
tools/rom_manifest.json tools/rom_manifest_blue.json \
-x '*.DS_Store' 'data/generated/*' 'assets/generated/*')
if unzip -Z1 "$LOVE_FILE" \
| grep -Eq '^(data|assets)/generated/[^/]+|^(data|assets)/generated/.+/'; then
fail "game.love unexpectedly contains generated ROM data"
fi
# The editor is only reachable if its entry point and both module directories
# made it in; a silent miss would ship a launcher whose Edit button crashes.
for required in tools/save-editor/App.lua tools/save-editor/Kit.lua \
tools/save-editor/panels/Party.lua; do
unzip -Z1 "$LOVE_FILE" | grep -qx "$required" \
|| fail "game.love is missing $required (save editor would not load)"
done
say "game.love: $(du -h "$LOVE_FILE" | cut -f1)"
# ------------------------------------------------------- stamp release version
+10 -4
View File
@@ -113,11 +113,12 @@ import pathlib, re, sys
path = pathlib.Path(sys.argv[1])
text = path.read_text()
# Drop network / mic / legacy storage, not needed for offline play.
# Keep VIBRATE (love.system.vibrate) and BLUETOOTH (optional gamepads).
# Drop mic / legacy storage, not needed by this game.
# Keep VIBRATE (love.system.vibrate), BLUETOOTH (optional gamepads) and
# INTERNET: link play is not offline-only any more, and stripping INTERNET
# made every LAN host and every relay connect fail with EPERM (issue #287).
# Orientation / label come from gradle.properties placeholders.
for perm in (
"android.permission.INTERNET",
"android.permission.RECORD_AUDIO",
"android.permission.WRITE_EXTERNAL_STORAGE",
):
@@ -136,14 +137,19 @@ pack_game_love() {
say "packing game.love for love-android embed flavor"
mkdir -p "$EMBED_ASSETS"
rm -f "$LOVE_FILE"
# tools/save-editor ships with the app: the launcher's Edit button on a save
# row opens it in-process, so it must be inside the archive (see build.sh).
(cd "$ROOT" && zip -q -9 -r "$LOVE_FILE" \
main.lua conf.lua src data assets tools/rom_manifest.json tools/rom_manifest_blue.json \
main.lua conf.lua src data assets tools/save-editor \
tools/rom_manifest.json tools/rom_manifest_blue.json \
-x '*.DS_Store' -x '*/.git/*' -x '*/.DS_Store' \
-x 'data/generated/*' -x 'assets/generated/*')
if unzip -Z1 "$LOVE_FILE" \
| grep -Eq '^(data|assets)/generated/[^/]+|^(data|assets)/generated/.+/'; then
fail "game.love unexpectedly contains generated ROM data"
fi
unzip -Z1 "$LOVE_FILE" | grep -qx 'tools/save-editor/App.lua' \
|| fail "game.love is missing the save editor (Edit on a save row would crash)"
say "game.love: $(du -h "$LOVE_FILE" | cut -f1) -> $LOVE_FILE"
# This script packs its own game.love (it does not reuse build.sh's), so it
+6 -2
View File
@@ -164,15 +164,19 @@ pack_game_love() {
say "packing game.love for love-ios resources"
mkdir -p "$RESOURCES_DIR"
rm -f "$LOVE_FILE"
# Same payload as scripts/build.sh / build_android.sh: game sources only.
# Same payload as scripts/build.sh / build_android.sh: game sources plus
# tools/save-editor, which the launcher's Edit button opens in-process.
(cd "$ROOT" && zip -q -9 -r "$LOVE_FILE" \
main.lua conf.lua src data assets tools/rom_manifest.json tools/rom_manifest_blue.json \
main.lua conf.lua src data assets tools/save-editor \
tools/rom_manifest.json tools/rom_manifest_blue.json \
-x '*.DS_Store' -x '*/.git/*' -x '*/.DS_Store' \
-x 'data/generated/*' -x 'assets/generated/*')
if unzip -Z1 "$LOVE_FILE" \
| grep -Eq '^(data|assets)/generated/[^/]+|^(data|assets)/generated/.+/'; then
fail "game.love unexpectedly contains generated ROM data"
fi
unzip -Z1 "$LOVE_FILE" | grep -qx 'tools/save-editor/App.lua' \
|| fail "game.love is missing the save editor (Edit on a save row would crash)"
say "game.love: $(du -h "$LOVE_FILE" | cut -f1) -> $LOVE_FILE"
}
+6
View File
@@ -116,7 +116,13 @@ if [ -f data/generated/maps.lua ]; then
echo "-- T3 content: skipped (--quick)"
else
run_tier "T3 content behavior (Red)" run_content_behavior
# The save editor ships inside every build (the launcher's Edit button on
# a save row opens it), so its panel suites run in CI rather than by hand.
run_tier "T3 save editor" "$LUA" tests/run_save_editor_tests.lua
run_tier "T3 save editor: boxes + items" "$LUA" tests/save_editor_task6_tests.lua
run_tier "T3 save editor: events + dex" "$LUA" tests/save_editor_task7_tests.lua
run_tier "T3 save editor: map browser" "$LUA" tests/save_editor_task8_tests.lua
run_tier "T3 save editor: mod awareness" "$LUA" tests/save_editor_mod_tests.lua
run_tier "T5 link (loopback lockstep)" "$LUA" tests/run_link_tests.lua
fi
else
+783 -222
View File
File diff suppressed because it is too large Load Diff
+13 -12
View File
@@ -8,6 +8,7 @@
local MoveEffects = require("src.battle.MoveEffects")
local Runtime = require("src.mods.Runtime")
local StatusRegistry = require("src.battle.StatusRegistry")
local Strings = require("src.core.Strings")
local EffectRegistry = {}
@@ -91,7 +92,7 @@ function EffectRegistry.runDamaging(battle, ctx, record)
if target.invulnerable and not neverMiss then
-- Explosion/Selfdestruct still animate on a miss (HandleIfPlayerMoveMissed)
if not (record and record.explode) then battle:cancelMoveAnim() end
battle:sayNext(("%s's\nattack missed!"):format(displayName(user)))
battle:sayNext(Strings("%s's\nattack missed!", displayName(user)))
return
end
@@ -113,7 +114,7 @@ function EffectRegistry.runDamaging(battle, ctx, record)
if not battle:accuracyRoll(move, user, target) then
-- Explosion/Selfdestruct still animate on a miss (HandleIfPlayerMoveMissed)
if not (record and record.explode) then battle:cancelMoveAnim() end
battle:sayNext(("%s's\nattack missed!"):format(displayName(user)))
battle:sayNext(Strings("%s's\nattack missed!", displayName(user)))
-- Jump Kick crash, Explode self-destruct
if record and record.onMiss then record.onMiss(ctx, "accuracy") end
user.trappingTurns = nil
@@ -140,7 +141,7 @@ function EffectRegistry.runDamaging(battle, ctx, record)
end
if not counterable or (battle.lastDamage or 0) == 0 then
battle:cancelMoveAnim()
battle:sayNext(("%s's\nattack missed!"):format(displayName(user)))
battle:sayNext(Strings("%s's\nattack missed!", displayName(user)))
return
end
dmg = math.min(65535, battle.lastDamage * 2)
@@ -163,14 +164,14 @@ function EffectRegistry.runDamaging(battle, ctx, record)
if info.typeMult == 0 then
-- type immunity zeros damage and sets wMoveMissed in Gen 1, so no anim
if not (record and record.explode) then battle:cancelMoveAnim() end
battle:sayNext(("It doesn't affect\n%s!"):format(displayName(target)))
battle:sayNext(Strings("It doesn't affect\n%s!", displayName(target)))
if record and record.onMiss then record.onMiss(ctx, "immune") end
return
end
if info.missed then
-- 0.25x floored the damage to zero: the original registers a miss
if not (record and record.explode) then battle:cancelMoveAnim() end
battle:sayNext(("%s's\nattack missed!"):format(displayName(user)))
battle:sayNext(Strings("%s's\nattack missed!", displayName(user)))
if record and record.onMiss then record.onMiss(ctx, "floored") end
return
end
@@ -214,12 +215,12 @@ function EffectRegistry.runDamaging(battle, ctx, record)
-- multi-hit loop (core.asm .moveDidNotMiss before the jump back
-- to GetPlayerAnimationType), so crit/effectiveness reprint on
-- every strike -- damage was only rolled once
if info.crit then battle:sayNext("Critical hit!") end
if info.ohko then battle:sayNext("One-hit KO!") end
if info.crit then battle:sayNext(Strings("Critical hit!")) end
if info.ohko then battle:sayNext(Strings("One-hit KO!")) end
if info.typeMult > 10 then
battle:sayNext("It's super\neffective!")
battle:sayNext(Strings("It's super\neffective!"))
elseif info.typeMult < 10 then
battle:sayNext("It's not very\neffective...")
battle:sayNext(Strings("It's not very\neffective..."))
end
if Runtime.wants("battle.damage_dealt") then
Runtime.emit("battle.damage_dealt", {
@@ -237,9 +238,9 @@ function EffectRegistry.runDamaging(battle, ctx, record)
if hits > 1 then
-- player: _MultiHitText; enemy: _HitXTimesText (always plural)
if user.isPlayer then
battle:sayNext(("Hit the enemy\n%d times!"):format(hits))
battle:sayNext(Strings("Hit the enemy\n%d times!", hits))
else
battle:sayNext(("Hit %d times!"):format(hits))
battle:sayNext(Strings("Hit %d times!", hits))
end
end
@@ -251,7 +252,7 @@ function EffectRegistry.runDamaging(battle, ctx, record)
elseif moveInst.struggle then
-- struggle recoils even when its effect id resolves to no record
local recoil = math.max(1, math.floor(dmg / 2))
battle:sayNext(("%s's\nhit with recoil!"):format(displayName(user)))
battle:sayNext(Strings("%s's\nhit with recoil!", displayName(user)))
battle:applyDamage(user, recoil)
end
+65 -58
View File
@@ -15,6 +15,7 @@ local Logger = require("src.core.Logger")
local StatusRegistry = require("src.battle.StatusRegistry")
local TurnOrder = require("src.battle.TurnOrder")
local TypeChart = require("src.battle.TypeChart")
local Strings = require("src.core.Strings")
local MoveEffects = {}
@@ -36,14 +37,14 @@ local STAT_LABEL = {
local function changeStage(battle, who, stat, delta, fromEnemy)
if fromEnemy and (who.substituteHP or who.mist) then
if who.mist then
return { displayName(who) .. " is\nprotected by MIST!" }
return { Strings("%s is\nprotected by MIST!", displayName(who)) }
end
return { "But, it failed!" }
return { Strings("But, it failed!") }
end
local cur = who.stages[stat] or 0
local new = math.max(-6, math.min(6, cur + delta))
if new == cur then
return { ("Nothing happened!") }
return { Strings("Nothing happened!") }
end
who.stages[stat] = new
-- effects.asm:505-506: after any stat-stage change, modified stats are
@@ -53,13 +54,13 @@ local function changeStage(battle, who, stat, delta, fromEnemy)
-- _MonsStatsRoseText/_MonsStatsFellText: "X's / STAT rose!"; the
-- two-stage variants scroll "greatly" onto a third line
if delta >= 2 then
return { ("%s's\n%s\ngreatly rose!"):format(displayName(who), STAT_LABEL[stat]) }
return { Strings("%s's\n%s\ngreatly rose!", displayName(who), STAT_LABEL[stat]) }
elseif delta == 1 then
return { ("%s's\n%s rose!"):format(displayName(who), STAT_LABEL[stat]) }
return { Strings("%s's\n%s rose!", displayName(who), STAT_LABEL[stat]) }
elseif delta == -1 then
return { ("%s's\n%s fell!"):format(displayName(who), STAT_LABEL[stat]) }
return { Strings("%s's\n%s fell!", displayName(who), STAT_LABEL[stat]) }
end
return { ("%s's\n%s\ngreatly fell!"):format(displayName(who), STAT_LABEL[stat]) }
return { Strings("%s's\n%s\ngreatly fell!", displayName(who), STAT_LABEL[stat]) }
end
MoveEffects.changeStage = changeStage
@@ -88,10 +89,10 @@ end
local function statusMove(status)
return function(battle, user, target, move)
if target.mon.status then
return { "But, it failed!" }
return { Strings("But, it failed!") }
end
if status == "PSN" and target.substituteHP then
return { "But, it failed!" }
return { Strings("But, it failed!") }
end
local msgs = inflictStatus(battle, target, status, {
toxic = move and move.id == "TOXIC",
@@ -99,7 +100,7 @@ local function statusMove(status)
source = move and move.id,
})
if #msgs == 0 then
return { "But, it failed!" }
return { Strings("But, it failed!") }
end
return msgs
end
@@ -111,7 +112,7 @@ local function statusSide(status, chance)
-- target (regardless of the burn roll)
if move and move.type == "FIRE" and target.mon.status == "FRZ" then
target.mon.status = nil
return { ("Fire defrosted\n%s!"):format(displayName(target)) }
return { Strings("Fire defrosted\n%s!", displayName(target)) }
end
if battle.rng(0, 255) >= chance then return {} end
return inflictStatus(battle, target, status, {
@@ -144,10 +145,10 @@ end
local function confuse(battle, target, pierceSub)
if target.confusedTurns or (target.substituteHP and not pierceSub) then
return { "But, it failed!" }
return { Strings("But, it failed!") }
end
target.confusedTurns = battle.rng(2, 5)
return { ("%s\nbecame confused!"):format(displayName(target)) }
return { Strings("%s\nbecame confused!", displayName(target)) }
end
-- ---------------------------------------------------------------------
@@ -181,53 +182,53 @@ MoveEffects.primary = {
LEECH_SEED_EFFECT = function(battle, user, target)
-- leech_seed.asm has no substitute check: seeding lands through one
if target.leechSeeded then
return { "But, it failed!" }
return { Strings("But, it failed!") }
end
for _, t in ipairs(target.curTypes) do
if t == "GRASS" then return { "But, it failed!" } end
if t == "GRASS" then return { Strings("But, it failed!") } end
end
target.leechSeeded = true
return { ("%s\nwas seeded!"):format(displayName(target)) }
return { Strings("%s\nwas seeded!", displayName(target)) }
end,
HEAL_EFFECT = function(battle, user, target, move)
local mon = user.mon
if move.id == "REST" then
if mon.hp == mon.stats.hp then return { "But, it failed!" } end
if mon.hp == mon.stats.hp then return { Strings("But, it failed!") } end
mon.hp = mon.stats.hp
mon.status = "SLP"
user.sleepTurns = 2
user.toxicCounter = nil
return { ("%s\nstarted sleeping!"):format(displayName(user)) }
return { Strings("%s\nstarted sleeping!", displayName(user)) }
end
if mon.hp == mon.stats.hp then return { "But, it failed!" } end
if mon.hp == mon.stats.hp then return { Strings("But, it failed!") } end
mon.hp = math.min(mon.stats.hp, mon.hp + math.floor(mon.stats.hp / 2))
return { ("%s\nregained health!"):format(displayName(user)) }
return { Strings("%s\nregained health!", displayName(user)) }
end,
LIGHT_SCREEN_EFFECT = function(battle, user)
if user.lightScreen then return { "But, it failed!" } end
if user.lightScreen then return { Strings("But, it failed!") } end
user.lightScreen = true
return { ("%s's\nprotected against\nspecial attacks!"):format(displayName(user)) }
return { Strings("%s's\nprotected against\nspecial attacks!", displayName(user)) }
end,
REFLECT_EFFECT = function(battle, user)
if user.reflect then return { "But, it failed!" } end
if user.reflect then return { Strings("But, it failed!") } end
user.reflect = true
return { ("%s\ngained armor!"):format(displayName(user)) }
return { Strings("%s\ngained armor!", displayName(user)) }
end,
MIST_EFFECT = function(battle, user)
if user.mist then return { "But, it failed!" } end
if user.mist then return { Strings("But, it failed!") } end
user.mist = true
-- _ShroudedInMistText (lowercase "mist")
return { ("%s's\nshrouded in mist!"):format(displayName(user)) }
return { Strings("%s's\nshrouded in mist!", displayName(user)) }
end,
FOCUS_ENERGY_EFFECT = function(battle, user)
if user.focusEnergy then return { "But, it failed!" } end
if user.focusEnergy then return { Strings("But, it failed!") } end
user.focusEnergy = true
return { ("%s's\ngetting pumped!"):format(displayName(user)) }
return { Strings("%s's\ngetting pumped!", displayName(user)) }
end,
HAZE_EFFECT = function(battle, user, target)
@@ -254,33 +255,33 @@ MoveEffects.primary = {
target.skipMove = true
end
target.mon.status = nil
return { "All STATUS changes\nare eliminated!" }
return { Strings("All STATUS changes\nare eliminated!") }
end,
SUBSTITUTE_EFFECT = function(battle, user)
if user.substituteHP then return { ("%s\nhas a SUBSTITUTE!"):format(displayName(user)) } end
if user.substituteHP then return { Strings("%s\nhas a SUBSTITUTE!", displayName(user)) } end
local cost = math.floor(user.mon.stats.hp / 4)
-- substitute.asm only fails on subtraction underflow (current HP
-- strictly below maxHP/4); at equality the substitute is built and
-- the user is left standing on exactly 0 HP (it faints only when
-- the engine next checks HP, not here)
if user.mon.hp < cost then
return { "Too weak to make\na SUBSTITUTE!" }
return { Strings("Too weak to make\na SUBSTITUTE!") }
end
user.mon.hp = user.mon.hp - cost
user.substituteHP = cost + 1
-- _SubstituteText
return { "It created a\nSUBSTITUTE!" }
return { Strings("It created a\nSUBSTITUTE!") }
end,
CONVERSION_EFFECT = function(battle, user, target)
-- conversion.asm fails against a mid-Fly/Dig target (INVULNERABLE)
if target.invulnerable then
return { "But, it failed!" }
return { Strings("But, it failed!") }
end
user.curTypes = { target.curTypes[1], target.curTypes[2] }
-- _ConvertedTypeText
return { ("Converted type to\n%s's!"):format(displayName(target)) }
return { Strings("Converted type to\n%s's!", displayName(target)) }
end,
-- MIMIC_EFFECT lives in BattleState:resolveMimic: MimicEffect
@@ -311,27 +312,27 @@ MoveEffects.primary = {
table.insert(user.curMoves, { id = mv.id, pp = 5, mimic = true })
end
-- _TransformedText: the copied name prints bare (wNameBuffer)
return { ("%s\ntransformed into\n%s!"):format(displayName(user), target.name) }
return { Strings("%s\ntransformed into\n%s!", displayName(user), target.name) }
end,
DISABLE_EFFECT = function(battle, user, target)
if target.disabledSlot then return { "But, it failed!" } end
if target.disabledSlot then return { Strings("But, it failed!") } end
local usable = {}
for i, mv in ipairs(target.curMoves) do
if mv.pp > 0 then table.insert(usable, i) end
end
if #usable == 0 then return { "But, it failed!" } end
if #usable == 0 then return { Strings("But, it failed!") } end
local slot = usable[battle.rng(1, #usable)]
target.disabledSlot = slot
target.disabledTurns = battle.rng(1, 8)
local id = target.curMoves[slot].id
-- _MoveWasDisabledText: "X's / MOVE was / disabled!"
return { ("%s's\n%s was\ndisabled!"):format(displayName(target),
return { Strings("%s's\n%s was\ndisabled!", displayName(target),
battle.data.moves[id].name) }
end,
SPLASH_EFFECT = function()
return { "No effect!" }
return { Strings("No effect!") }
end,
}
@@ -428,7 +429,9 @@ local function drainHalf(text)
local mon = ctx.user.mon
mon.hp = math.min(mon.stats.hp, mon.hp + heal)
ctx.drain()
ctx.say(text:format(displayName(ctx.target)))
-- `text` arrives as a source string (Strings.source at the call
-- site keeps it in the catalog); look it up here, at use time
ctx.say(Strings(text, displayName(ctx.target)))
end
end
@@ -436,7 +439,7 @@ end
-- flags the miss before the special-damage override)
local function immuneMsg(ctx)
if TypeChart.effectiveness(ctx.move.type, ctx.target.curTypes) == 0 then
return ("It doesn't affect\n%s!"):format(displayName(ctx.target))
return Strings("It doesn't affect\n%s!", displayName(ctx.target))
end
return nil
end
@@ -498,12 +501,12 @@ MoveEffects.full = {
-- removed): overkill and substitute hits recoil at full strength
local recoil = math.max(1, math.floor(ctx.rawDamage
/ (ctx.moveInst.struggle and 2 or 4)))
ctx.say(("%s's\nhit with recoil!"):format(displayName(ctx.user)))
ctx.say(Strings("%s's\nhit with recoil!", displayName(ctx.user)))
ctx.battle:applyDamage(ctx.user, recoil)
end,
},
DRAIN_HP_EFFECT = {
afterDamage = drainHalf("Sucked health from\n%s!"),
afterDamage = drainHalf(Strings.source("Sucked health from\n%s!")),
},
DREAM_EATER_EFFECT = {
-- only works on sleeping targets (checked before damage)
@@ -511,7 +514,7 @@ MoveEffects.full = {
if ctx.target.mon.status ~= "SLP" then return false, "But, it failed!" end
return true
end,
afterDamage = drainHalf("%s's\ndream was eaten!"),
afterDamage = drainHalf(Strings.source("%s's\ndream was eaten!")),
},
-- charge moves: first turn just charges; Fly AND Dig go
@@ -557,7 +560,7 @@ MoveEffects.full = {
user.thrashTurns, user.thrashMove, user.thrashAnnounced = nil, nil, nil
if not user.confusedTurns then
user.confusedTurns = ctx.rng(2, 5)
ctx.say(("%s\nbecame confused!"):format(displayName(user)))
ctx.say(Strings("%s\nbecame confused!", displayName(user)))
end
end
end
@@ -566,7 +569,7 @@ MoveEffects.full = {
JUMP_KICK_EFFECT = {
onMiss = function(ctx, reason)
if reason ~= "accuracy" then return end
ctx.say(("%s\nkept going and\ncrashed!"):format(displayName(ctx.user)))
ctx.say(Strings("%s\nkept going and\ncrashed!", displayName(ctx.user)))
ctx.damage(ctx.user, 1)
end,
},
@@ -581,8 +584,12 @@ MoveEffects.full = {
},
HYPER_BEAM_EFFECT = {
afterDamage = function(ctx)
-- no recharge when the target faints OR its substitute breaks
if ctx.target.mon.hp > 0 and not ctx.brokeSub then
-- Gen 1: no recharge when the target faints OR its substitute breaks.
-- Ruleset hyperBeamSkipRechargeOnKO=false forces Gen 2+ always-recharge.
local ruleset = ctx.battle and ctx.battle.ruleset
local skipOnKO = not ruleset or ruleset.hyperBeamSkipRechargeOnKO ~= false
local targetDown = ctx.target.mon.hp <= 0 or ctx.brokeSub
if not skipOnKO or not targetDown then
ctx.user.mustRecharge = true
end
end,
@@ -591,7 +598,7 @@ MoveEffects.full = {
afterDamage = function(ctx)
local battle = ctx.battle
battle.payDay = (battle.payDay or 0) + 2 * ctx.user.mon.level
ctx.say("Coins scattered\neverywhere!")
ctx.say(Strings("Coins scattered\neverywhere!"))
end,
},
SWIFT_EFFECT = { neverMiss = true },
@@ -606,7 +613,7 @@ MoveEffects.full = {
local user = ctx.user
user.bideTurns = ctx.rng(2, 3)
user.bideDamage = 0
ctx.say(("%s\nis storing energy!"):format(displayName(user)))
ctx.say(Strings("%s\nis storing energy!", displayName(user)))
end,
},
SWITCH_AND_TELEPORT_EFFECT = {
@@ -627,27 +634,27 @@ MoveEffects.full = {
end
if ok then
if move.id == "ROAR" then
ctx.say(("%s\nran away scared!"):format(displayName(target)))
ctx.say(Strings("%s\nran away scared!", displayName(target)))
elseif move.id == "WHIRLWIND" then
ctx.say(("%s\nwas blown away!"):format(displayName(target)))
ctx.say(Strings("%s\nwas blown away!", displayName(target)))
else
ctx.say(("%s\nran from battle!"):format(displayName(user)))
ctx.say(Strings("%s\nran from battle!", displayName(user)))
end
battle.result = "run"
battle.afterQueue = "finish"
elseif move.id == "TELEPORT" then
battle:cancelMoveAnim()
ctx.say("But, it failed!")
ctx.say(Strings("But, it failed!"))
else
battle:cancelMoveAnim()
ctx.say(("It didn't affect\n%s!"):format(displayName(target)))
ctx.say(Strings("It didn't affect\n%s!", displayName(target)))
end
elseif move.id == "TELEPORT" then
battle:cancelMoveAnim()
ctx.say("But, it failed!")
ctx.say(Strings("But, it failed!"))
else
battle:cancelMoveAnim()
ctx.say(("%s\nis unaffected!"):format(displayName(target)))
ctx.say(Strings("%s\nis unaffected!", displayName(target)))
end
end,
},
@@ -665,7 +672,7 @@ MoveEffects.full = {
callsMove = function(ctx)
local last = ctx.target.lastMove
if not last then
ctx.say("The MIRROR MOVE\nfailed!")
ctx.say(Strings("The MIRROR MOVE\nfailed!"))
return nil
end
return last
+31 -21
View File
@@ -5,6 +5,8 @@
-- and residual sweep. Callers without a battle (pure-module tests) fall
-- back to the vanilla records, which is bit-identical behavior.
local Strings = require("src.core.Strings")
local Status = {}
-- pokered's <USER>/<TARGET> text macros print "Enemy " before the enemy
@@ -28,8 +30,11 @@ local function hasType(battler, wanted)
end
-- shared PSN/BRN residual: 1/16 max HP, multiplied (and advanced) by the
-- Toxic counter (HandlePoisonBurnLeechSeed)
local function damageOverTime(what)
-- Toxic counter (HandlePoisonBurnLeechSeed). The caller passes the whole
-- sentence rather than the noun: "hurt by poison" and "hurt by the burn"
-- decline differently once translated, so a shared fragment cannot be the
-- translatable unit.
local function damageOverTime(template)
return function(battler)
local mon = battler.mon
local base = math.max(1, math.floor(mon.stats.hp / 16))
@@ -39,10 +44,15 @@ local function damageOverTime(what)
battler.toxicCounter = battler.toxicCounter + 1
end
mon.hp = math.max(0, mon.hp - dmg)
return { ("%s's\nhurt by %s!"):format(name(battler), what) }
return { Strings(template, name(battler)) }
end
end
-- Labels stay plain literals on purpose: this table is built at require
-- time, before Strings.load has a catalog, so a Strings() here would
-- freeze the English. They are already translatable through the
-- statuses registry (mod.content.statuses:patch(id, { label = ... })).
--
-- The five persistent conditions as records: the beforeMove gauntlet, the
-- residual sweep, the inflict text/immunities (StatusRegistry.inflict),
-- the catch/wobble bonuses (Catching.attempt), the HUD label, and the
@@ -57,13 +67,13 @@ Status.RECORDS = {
battler.sleepTurns = (battler.sleepTurns or 1) - 1
if battler.sleepTurns <= 0 then
battler.mon.status = nil
return false, { name(battler) .. "\nwoke up!" } -- wakes but loses the turn
return false, { Strings("%s\nwoke up!", name(battler)) } -- wakes, loses the turn
end
return false, { name(battler) .. "\nis fast asleep!" }
return false, { Strings("%s\nis fast asleep!", name(battler)) }
end,
onInflict = function(battle, target, opts, display)
target.sleepTurns = battle.rng(1, 7)
return { ("%s\nfell asleep!"):format(display) }
return { Strings("%s\nfell asleep!", display) }
end,
},
FRZ = {
@@ -71,35 +81,35 @@ Status.RECORDS = {
catchBonus = 25, shakeBonus = 10,
beforeMovePriority = 30,
beforeMove = function(battler)
return false, { name(battler) .. "\nis frozen solid!" }
return false, { Strings("%s\nis frozen solid!", name(battler)) }
end,
canInflict = function(target) return not hasType(target, "ICE") end,
onInflict = function(_, _, _, display)
return { ("%s\nwas frozen solid!"):format(display) }
return { Strings("%s\nwas frozen solid!", display) }
end,
},
PSN = {
id = "PSN", label = "PSN", hudLabel = "PSN",
catchBonus = 12, shakeBonus = 5,
residual = damageOverTime("poison"),
residual = damageOverTime(Strings.source("%s's\nhurt by poison!")),
canInflict = function(target) return not hasType(target, "POISON") end,
onInflict = function(_, target, opts, display)
if opts.toxic then
target.toxicCounter = 1
-- _BadlyPoisonedText
return { ("%s's\nbadly poisoned!"):format(display) }
return { Strings("%s's\nbadly poisoned!", display) }
end
return { ("%s\nwas poisoned!"):format(display) }
return { Strings("%s\nwas poisoned!", display) }
end,
},
BRN = {
id = "BRN", label = "BRN", hudLabel = "BRN",
catchBonus = 12, shakeBonus = 5,
statPenalty = { stat = "attack", div = 2 },
residual = damageOverTime("the burn"),
residual = damageOverTime(Strings.source("%s's\nhurt by the burn!")),
canInflict = function(target) return not hasType(target, "FIRE") end,
onInflict = function(_, _, _, display)
return { ("%s\nwas burned!"):format(display) }
return { Strings("%s\nwas burned!", display) }
end,
},
PAR = {
@@ -110,7 +120,7 @@ Status.RECORDS = {
beforeMove = function(battler, rng)
-- cp 25 percent / jr nc: fully paralyzed on rand < 63 (63/256)
if rng(0, 255) < 63 then
return false, { name(battler) .. "'s\nfully paralyzed!" }
return false, { Strings("%s's\nfully paralyzed!", name(battler)) }
end
return true, {}
end,
@@ -120,7 +130,7 @@ Status.RECORDS = {
end,
onInflict = function(_, _, _, display)
-- _ParalyzedMayNotAttackText (primary and secondary paralysis)
return { ("%s's\nparalyzed! It may\nnot attack!"):format(display) }
return { Strings("%s's\nparalyzed! It may\nnot attack!", display) }
end,
},
}
@@ -156,7 +166,7 @@ function Status.beforeMove(battler, rng, battle)
end
if battler.flinched then
battler.flinched = false
return false, { name(battler) .. "\nflinched!" }
return false, { Strings("%s\nflinched!", name(battler)) }
end
local record = Status.recordFor(battleStatuses(battle), mon.status)
local handler = record and record.beforeMove
@@ -174,23 +184,23 @@ function Status.beforeMove(battler, rng, battle)
end
if battler.boundTurns and battler.boundTurns > 0 then
battler.boundTurns = battler.boundTurns - 1
msgs[#msgs + 1] = name(battler) .. "\ncan't move!"
msgs[#msgs + 1] = Strings("%s\ncan't move!", name(battler))
return false, msgs
end
if battler.disabledTurns then
battler.disabledTurns = battler.disabledTurns - 1
if battler.disabledTurns <= 0 then
battler.disabledTurns, battler.disabledSlot = nil, nil
table.insert(msgs, name(battler) .. "'s\ndisabled no more!")
table.insert(msgs, Strings("%s's\ndisabled no more!", name(battler)))
end
end
if battler.confusedTurns then
battler.confusedTurns = battler.confusedTurns - 1
if battler.confusedTurns <= 0 then
battler.confusedTurns = nil
table.insert(msgs, name(battler) .. "\nsnapped out of\nconfusion!")
table.insert(msgs, Strings("%s\nsnapped out of\nconfusion!", name(battler)))
else
table.insert(msgs, name(battler) .. "\nis confused!")
table.insert(msgs, Strings("%s\nis confused!", name(battler)))
-- cp 50 percent + 1 / jr c: hurt itself on rand >= 128 (128/256)
if rng(0, 255) < 128 then
return false, msgs, true -- hurt itself
@@ -231,7 +241,7 @@ function Status.residual(battler, opponent, battle)
dmg = math.min(dmg, mon.hp)
mon.hp = mon.hp - dmg
opponent.mon.hp = math.min(opponent.mon.stats.hp, opponent.mon.hp + dmg)
table.insert(msgs, ("LEECH SEED saps\n%s!"):format(name(battler)))
table.insert(msgs, Strings("LEECH SEED saps\n%s!", name(battler)))
end
return msgs
end
+2 -1
View File
@@ -5,6 +5,7 @@
local Runtime = require("src.mods.Runtime")
local Status = require("src.battle.Status")
local Strings = require("src.core.Strings")
local StatusRegistry = {}
@@ -45,7 +46,7 @@ function StatusRegistry.inflict(battle, target, status, opts)
if record and record.onInflict then
msgs = record.onInflict(battle, target, opts, display)
else
msgs = { ("%s\nwas afflicted\nby %s!"):format(display,
msgs = { Strings("%s\nwas afflicted\nby %s!", display,
record and record.label or tostring(status)) }
end
Runtime.emit("battle.status_inflicted", {
+5 -4
View File
@@ -16,9 +16,10 @@
-- the MINIMUM-scored move is chosen, ties broken uniformly among the
-- tied minima (core.asm:2971-3002). A non-minimal move is never
-- selectable. Respects Disable (and PP only when the ruleset depletes
-- enemy PP Gen 1 AI never reads wEnemyMonPP).
-- enemy PP -- Gen 1 AI never reads wEnemyMonPP).
local TypeChart = require("src.battle.TypeChart")
local Strings = require("src.core.Strings")
local TrainerAI = {}
@@ -99,7 +100,7 @@ function TrainerAI.useItem(battle, item)
local enemy = battle.enemy
local trainerName = battle.trainer.name
local itemName = battle.data.items[item] and battle.data.items[item].name or item
local msgs = { ("%s\nused %s!"):format(trainerName, itemName) }
local msgs = { Strings("%s\nused %s!", trainerName, itemName) }
if item == "FULL_HEAL" then
enemy.mon.status = nil
enemy.toxicCounter = nil
@@ -112,10 +113,10 @@ function TrainerAI.useItem(battle, item)
elseif X_STAT[item] then
local stat = X_STAT[item]
enemy.stages[stat] = math.min(6, (enemy.stages[stat] or 0) + 1)
table.insert(msgs, ("%s's\n%s rose!"):format(enemy.name, stat:upper()))
table.insert(msgs, Strings("%s's\n%s rose!", enemy.name, stat:upper()))
elseif item == "GUARD_SPEC" then
enemy.mist = true
table.insert(msgs, ("%s's\nprotected against\nstat changes!"):format(enemy.name))
table.insert(msgs, Strings("%s's\nprotected against\nstat changes!", enemy.name))
end
return msgs
end
+379
View File
@@ -0,0 +1,379 @@
-- Widescreen battle layout (OPTION -> BATTLE LAYOUT -> WIDE).
--
-- The battle simulation, timing, animations and rules stay BattleState's;
-- this module only replaces the composition and asks the renderer for a
-- 304x144 native-pixel UI surface while it is up. Pictures, font pages,
-- border glyphs, species palettes and HP tiles all resolve through the
-- engine, so a COLORS mode or an asset mod still owns the look.
--
-- The extra 144 pixels of width buy a Gen 3-style arrangement: foe status
-- upper left with its picture upper right, the player's picture lower left
-- with their status lower right, a full-width message window, a split
-- prompt/command window, and a 2x2 move menu with an attached PP/type panel.
local Font = require("src.render.Font")
local HudTiles = require("src.render.HudTiles")
local PaletteFX = require("src.render.PaletteFX")
local Runtime = require("src.mods.Runtime")
local Strings = require("src.core.Strings")
local TypeChart = require("src.battle.TypeChart")
local WideBattle = {
WIDTH = 304,
HEIGHT = 144,
-- everything above this line is battlefield; the 40 rows below it are
-- the message / command / move windows
FIELD_BOTTOM = 104,
}
-- The forced-mono display modes re-threshold the whole finished frame
-- through the shade shader, and picImage hands them raw DMG grays for that
-- (#207). The wide layout has to know: it exposes a matching whole-surface
-- zone and leaves the HP bar fill gray, exactly like the zone pass does in
-- the classic layout (#229). Keep in sync with picImage / ensureZones.
local function monoMode()
local m = PaletteFX.mode
return m == "og" or m == "og_inv" or m == "classic"
end
local function shownHP(battler)
return math.max(0, math.floor(battler.shownHP or battler.mon.hp or 0))
end
-- a name truncated to `pixels` with a trailing '.', measured through the
-- font's own advances so a variable-width page still fits
local function fitName(text, pixels)
local spans = Font.split(text or "")
local n = Font.spansFitting(spans, pixels)
if n >= #spans then return text or "" end
local out = {}
for i = 1, math.max(0, n - 1) do
out[#out + 1] = (text or ""):sub(spans[i].from, spans[i].to)
end
return table.concat(out) .. "."
end
local function saveScissor()
if not love.graphics.getScissor then return nil end
local x, y, w, h = love.graphics.getScissor()
if x == nil then return false end
return { x, y, w, h }
end
local function restoreScissor(saved)
if not love.graphics.setScissor then return end
if saved and saved ~= false then
love.graphics.setScissor(saved[1], saved[2], saved[3], saved[4])
else
love.graphics.setScissor()
end
end
-- Draw fn's content translated by (dx, dy) and clipped to a surface rect.
-- The scissor is in canvas space, so it bounds the region itself while the
-- translate moves the classic 160x144 coordinates into it.
local function inRegion(x, y, w, h, dx, dy, fn)
local g = love.graphics
local saved = saveScissor()
g.setScissor(x, y, w, h)
g.push()
g.translate(dx, dy)
fn()
g.pop()
restoreScissor(saved)
end
local function levelAt(battle, battler, x, y)
if battler.shownStatus then
Font.draw(battle:statusLabel({ status = battler.shownStatus }), x, y)
else
HudTiles.tile(0x6E, x, y) -- '<LV>'
Font.draw(tostring(battler.mon.level), x + 8, y)
end
end
-- One side's status box: name and level on the first line, a long HP bar
-- under it, and the numeric HP on the player's box only (the foe's exact
-- HP is never shown, like the original).
local function drawStatusPanel(battle, battler, x, y, player)
local tx, ty = math.floor(x / 8), math.floor(y / 8)
local tw, th = player and 15 or 16, player and 5 or 4
Font.drawBox(tx, ty, tw, th)
love.graphics.setColor(0, 0, 0, 1)
local nameWidth = player and 64 or 80
Font.draw(fitName(battler.name, nameWidth), x + 8, y + 8)
levelAt(battle, battler, x + tw * 8 - 40, y + 8)
HudTiles.drawHPBar(battle.data, tx + 1, ty + 2, {
hp = shownHP(battler),
stats = battler.mon.stats,
}, nil, monoMode(), tw - 5)
if player then
Font.draw(("%3d/%3d"):format(shownHP(battler), battler.mon.stats.hp),
x + tw * 8 - 64, y + 24)
end
end
-- the party ball rows DrawAllPokeballs puts up with the intro text, moved
-- out to the wide screen's own corners
local function drawIntroBalls(battle)
if not battle.introBalls then return end
if battle.enemyParty and
(battle.kind == "trainer" or battle.kind == "link") then
battle:drawBallRow(battle.enemyParty, 88, 40, -8)
end
battle:drawBallRow(battle.playerParty or battle.game.save.party, 216, 96, 8)
end
local function drawHUDs(battle, slide)
if battle.enemy and not battle.showEnemyTrainer
and not battle.enemySendingOut and not battle:growInScale(battle.enemy)
and slide == 0 and not battle.introBalls and not battle.enemy.fainted then
drawStatusPanel(battle, battle.enemy, 0, 0, false)
end
if battle.safari then
Font.drawBox(23, 7, 15, 4)
love.graphics.setColor(0, 0, 0, 1)
Font.draw(("BALLx%2d"):format(battle.safari.balls), 200, 72)
elseif battle.player and not battle.demo and not battle.showPlayerBack
and slide == 0 then
drawStatusPanel(battle, battle.player, 184, 56, true)
end
drawIntroBalls(battle)
end
local function drawMessageBox(battle)
Font.drawBox(0, 13, 38, 5)
love.graphics.setColor(0, 0, 0, 1)
if battle.scrollPx and battle.scrollPx > 0 then
battle.scrollPx = battle.scrollPx - 2
if battle.scrollPx <= 0 then battle.scrollPx = nil end
end
local off = battle.scrollPx or 0
local ys = { 112, 128 }
for li, line in ipairs(battle.shown or {}) do
local y = (ys[li] or 128) + off
for i = 1, #line do
Font.drawCode(line[i], 8 + (i - 1) * 8, y)
end
end
if (battle.msgWaiting or battle.msgPrompt) and battle.frame % 60 < 30 then
Font.drawCode(0xEE, 288, 132)
end
end
local function drawCommandMenu(battle)
local col = (battle.menuIndex - 1) % 2
local row = math.floor((battle.menuIndex - 1) / 2)
if battle.safari then
Font.drawBox(0, 13, 38, 5)
love.graphics.setColor(0, 0, 0, 1)
Font.draw(Strings("BALLx"), 16, 112)
Font.draw(Strings("BAIT"), 168, 112)
Font.draw(Strings("THROW ROCK"), 16, 128)
Font.draw(Strings("RUN"), 168, 128)
Font.drawCode(0xED, col == 0 and 8 or 160, 112 + row * 16)
return
end
-- the prompt on the left, the 2x2 commands on the right
Font.drawBox(0, 13, 20, 5)
Font.drawBox(20, 13, 18, 5)
love.graphics.setColor(0, 0, 0, 1)
Font.draw(Strings("What will"), 8, 112)
local who = battle.player and battle.player.name or ""
Font.draw(fitName(who, 112) .. Strings(" do?"), 8, 128)
Font.draw(Strings("FIGHT"), 176, 112)
Font.drawCode(0xE1, 240, 112) -- 'PK'
Font.drawCode(0xE2, 248, 112) -- 'MN'
Font.draw(Strings("ITEM"), 176, 128)
Font.draw(Strings("RUN"), 240, 128)
Font.drawCode(0xED, col == 0 and 168 or 232, 112 + row * 16)
end
local function drawMoveDetails(battle, move)
Font.drawBox(28, 13, 10, 5)
if not move then return end
local def = battle.data.moves[move.id]
if not def then return end
local maxPP = def.pp + (move.ppUps or 0) * math.floor(def.pp / 5)
love.graphics.setColor(0, 0, 0, 1)
Font.draw(("PP %2d/%2d"):format(move.pp or 0, maxPP), 232, 112)
Font.draw(fitName(TypeChart.displayName(def.type), 64), 232, 128)
end
local function drawMoveGrid(battle, moves, selected)
-- The 8px font needs 28 tiles for two complete twelve-character move
-- names plus their cursors; the details panel gets the other ten.
Font.drawBox(0, 13, 28, 5)
love.graphics.setColor(0, 0, 0, 1)
for i, move in ipairs(moves or {}) do
local col = (i - 1) % 2
local row = math.floor((i - 1) / 2)
local x, y = col == 0 and 16 or 120, 112 + row * 16
local def = battle.data.moves[move.id]
Font.draw(fitName(def and def.name or move.id or "", 96), x, y)
end
local col = (selected - 1) % 2
local row = math.floor((selected - 1) / 2)
Font.drawCode(0xED, col == 0 and 8 or 112, 112 + row * 16)
drawMoveDetails(battle, moves and moves[selected])
end
local function drawMoveMenu(battle)
drawMoveGrid(battle, battle.player.curMoves, battle.moveIndex)
if battle.moveSwapIndex then
local col = (battle.moveSwapIndex - 1) % 2
local row = math.floor((battle.moveSwapIndex - 1) / 2)
Font.drawCode(0xEC, col == 0 and 8 or 112, 112 + row * 16)
end
end
local function drawTextArea(battle)
if battle.phase == "messages" and (battle.current or battle.animPlaying) then
drawMessageBox(battle)
elseif battle.phase == "menu" then
drawCommandMenu(battle)
elseif battle.phase == "moveSelect" then
drawMoveMenu(battle)
elseif battle.phase == "mimicSelect" then
drawMoveGrid(battle, battle.mimicMoves, battle.mimicIndex)
else
Font.drawBox(0, 13, 38, 5)
end
end
-- Battle animations are authored in the original 160px coordinate space.
-- Shift each complete OAM frame as one rigid group between the new player
-- and enemy anchors: drawing the whole animation through both side regions
-- would duplicate any tiles overlapping the other side's source range (most
-- visibly the send-out POOF reappearing on the far right).
function WideBattle.animationOffset(sprites)
if not sprites or #sprites == 0 then return 0, 0 end
local minX, maxX = math.huge, -math.huge
for _, sprite in ipairs(sprites) do
minX = math.min(minX, sprite.x - 8)
maxX = math.max(maxX, sprite.x)
end
local center = (minX + maxX) / 2
local t = math.max(0, math.min(1, (center - 40) / 80))
return math.floor(20 + 116 * t + 0.5),
math.floor(8 * (1 - t) + 0.5)
end
local function currentAnimationSprites(battle)
if battle.animPlaying and battle.animPlayer then
local step = battle.animPlayer.steps[battle.animPlayer.stepIndex]
return step and step.sprites
end
if battle.lockedBall and battle.animPlayer then
return battle.lockedBall
end
end
local function drawAnimationLayer(battle)
local sprites = currentAnimationSprites(battle)
if not sprites or #sprites == 0 then return end
local dx, dy = WideBattle.animationOffset(sprites)
inRegion(0, 0, WideBattle.WIDTH, WideBattle.FIELD_BOTTOM, dx, dy,
function() battle:drawAnimLayer(false) end)
end
-- The whole 304x144 composition for one frame.
function WideBattle.draw(battle)
local g = love.graphics
-- The field is the display mode's paper. Under a forced-mono mode the
-- whole surface is remapped downstream (WideBattle.zones), so the field
-- goes down as DMG white and comes out of that pass as the mode's paper;
-- painting the resolved shade there would run it through the remap twice
-- and land a shade off the letterbox the renderer fills around it.
if monoMode() then
g.setColor(1, 1, 1, 1)
else
g.setColor(PaletteFX.paperShade(battle.data))
end
g.rectangle("fill", 0, 0, WideBattle.WIDTH, WideBattle.HEIGHT)
-- AskName clears the field the same way the classic layout does
if battle.blankForAskName then return end
local fx = battle.fx
local sx = (fx and fx.shakeX) or 0
local sy = (fx and fx.shakeY) or 0
if sx == 0 and sy == 0 and fx and fx.shake and fx.shake > 0 then
sx = battle.frame % 4 < 2 and 2 or -2
end
local slide = (battle.introSlide or 0) * 4
-- Each side keeps its original sprite pixels and placement math: the two
-- 160x144 OAM regions are translated apart and clipped into the wider
-- battlefield rather than either monster being scaled. wideRegion tells
-- drawBattlerPic its own side window is already the clip.
battle.wideRegion = true
inRegion(0, 32, 160, WideBattle.FIELD_BOTTOM - 32, 20 + sx, 8 + sy,
function() battle:drawPicsLayer(slide, 0, 0, "player", true) end)
inRegion(160, 0, 144, WideBattle.FIELD_BOTTOM, 136 + sx, sy,
function() battle:drawPicsLayer(slide, 0, 0, "enemy", true) end)
battle.wideRegion = nil
drawHUDs(battle, slide)
drawAnimationLayer(battle)
drawTextArea(battle)
if fx and fx.flash and fx.flash > 0 and battle.frame % 4 < 2 then
g.setColor(1, 1, 1, 0.85)
g.rectangle("fill", 0, 0, WideBattle.WIDTH, WideBattle.HEIGHT)
end
g.setColor(1, 1, 1, 1)
if Runtime.wantsHook("battle.overlay") then
Runtime.call("battle.overlay", function() end, battle)
end
end
-- The palette zones for the wide surface. The composition already resolves
-- species colors, paper shade and HP-bar colors itself, so the colorized
-- modes take the trueColor opt-out (`colors = false`) over the whole
-- surface; the forced-mono modes still want their whole-screen remap, and
-- get one sized to the wide surface instead of the 160x144 rectangle
-- PaletteFX.ensureZones would invent (which would leave 144 columns raw).
function WideBattle.zones()
local w, h = WideBattle.WIDTH, WideBattle.HEIGHT
if monoMode() then
-- sendColors runs the mode's own substitution (CLASSIC's pea greens,
-- the inverted permutation), exactly as it does for ensureZones' zone
return { PaletteFX.zone(PaletteFX.GRAYS, 0, 0, w / 8 - 1, h / 8 - 1) }
end
return { { colors = false, x = 0, y = 0, w = w, h = h } }
end
-- 2x2 move-grid navigation: LEFT/RIGHT cross the row, UP/DOWN the column,
-- and a direction pointing at an empty slot holds the current one.
function WideBattle.moveGridIndex(index, count, direction)
if count < 1 then return nil end
local row = math.floor((index - 1) / 2)
local col = (index - 1) % 2
if direction == "left" or direction == "right" then
local other = row * 2 + (1 - col) + 1
return other <= count and other or index
end
local otherRow = 1 - row
local other = otherRow * 2 + col + 1
return other <= count and other or index
end
local DIRECTIONS = { "left", "right", "up", "down" }
-- the slot a directional press selects, or nil when none was pressed (the
-- caller then runs its normal list navigation / A / B / SELECT handling)
function WideBattle.navigate(index, count, input)
for _, key in ipairs(DIRECTIONS) do
if input:wasPressed(key) then
return WideBattle.moveGridIndex(index, count, key)
end
end
return nil
end
return WideBattle
+3
View File
@@ -16,4 +16,7 @@ return {
-- Wild/trainer enemies never spend PP (DecrementPP only touches the
-- player side in pokered). They therefore never Struggle from empty PP.
enemyUnlimitedPP = true,
-- Gen 1 Hyper Beam: no recharge when the target faints (or its
-- substitute breaks). Set false to always recharge like Gen 2+.
hyperBeamSkipRechargeOnKO = true,
}
+2
View File
@@ -11,4 +11,6 @@ return {
focusEnergyBug = false,
-- Gen 2+ style: AI opponents deplete PP and can Struggle when empty.
enemyUnlimitedPP = false,
-- Gen 2+: Hyper Beam always forces a recharge turn, even on a KO.
hyperBeamSkipRechargeOnKO = false,
}
+61 -4
View File
@@ -102,10 +102,41 @@ function Data:seedDefaults()
-- after-battle rows so Blaine's SetEventRange deactivation and talk
-- after-text work like the other gyms (scripts/CinnabarGym.asm).
self:seedCinnabarGymTrainerHeaders()
-- #197: the Fighting Dojo Karate Master is text_asm, so the extractor
-- writes no header for him -- seed one so he engages on sight and has
-- his defeat / re-talk lines (same idea as the Cinnabar seed above).
self:seedFightingDojoKarateMaster()
-- #189: 1F cabin door order vs rooms map (survey zoom)
require("src.world.SsAnneLayout").apply(self.maps)
end
-- The Karate Master (FightingDojo.asm) is a text_asm object: his object has
-- no def_trainers row (DisplayTextID routes to his ASM script), so the
-- extractor emits headers only for the four blackbelts ([2]..[5]). Seed
-- object index [1] so he behaves like the real leader:
-- * range 4 (matches the strongest blackbelt) -> CheckFightingMapTrainers
-- spots the player in his DOWN line and he challenges on sight (#197),
-- * battle = his pre-battle challenge, won = "Hwa! Arrgh! Beaten!",
-- * after = the "Stay and train at Karate with us!" re-talk line.
-- Deliberately NO `event`: EVENT_BEAT_KARATE_MASTER is owned by
-- victories.lua (OPP_BLACKBELT#1) exactly like the gym leaders, and
-- engageTrainer sets header.event *before* checkVictoryRewards runs -- if
-- this header also set it, the reward's flag guard would early-return and
-- swallow the prize dialogue. trainerDefeated tracks him via
-- defeatedTrainers[npc.id] like the leaders.
function Data:seedFightingDojoKarateMaster()
local headers = self.trainer_headers
if not headers then return end
headers.FightingDojo = headers.FightingDojo or {}
if headers.FightingDojo[1] then return end
headers.FightingDojo[1] = {
range = 4,
battle = "_FightingDojoKarateMasterText",
won = "_FightingDojoKarateMasterDefeatedText",
after = "_FightingDojoKarateMasterStayAndTrainWithUsText",
}
end
function Data:seedCinnabarGymTrainerHeaders()
local headers = self.trainer_headers
if not headers or headers.CinnabarGym then return end
@@ -175,10 +206,15 @@ function Data:load()
(function() local n = 0 for _ in pairs(self.moves) do n = n + 1 end return n end)())
end
-- dev-mode hot reload only (src/dev/HotReload.lua): drop every namespace the
-- mod merge created, then re-require the generated modules so base records
-- return to their on-disk values even where a mod edited them in place
function Data:reloadGenerated()
-- Drop every namespace the mod merge created and evict the generated modules
-- from package.loaded, so the next load() re-reads them off disk instead of
-- handing back the cached tables. Two callers:
-- * reloadGenerated below (dev hot reload)
-- * main.lua, when the launcher closes the save editor -- the editor may
-- have loaded the OTHER game's cache, and require would otherwise serve
-- those modules to a subsequent Play (see CacheFs.unmountVersion, which
-- clears the matching read-path overlay).
function Data:unloadGenerated()
local pristine = self._pristineKeys
if pristine then
for key in pairs(self) do
@@ -191,6 +227,13 @@ function Data:reloadGenerated()
for _, name in ipairs(OPTIONAL) do
package.loaded["data.generated." .. name] = nil
end
end
-- dev-mode hot reload only (src/dev/HotReload.lua): drop every namespace the
-- mod merge created, then re-require the generated modules so base records
-- return to their on-disk values even where a mod edited them in place
function Data:reloadGenerated()
self:unloadGenerated()
self:load()
end
@@ -215,6 +258,20 @@ function Data:resolveText(mapLabel, textConst)
local s = self.text[entry.text]
if s then return s, entry.asm end
end
-- A text_asm entry names its wrapper label but carries no string, because
-- the extractor cannot follow asm. A handful of those wrappers are plain
-- `text_far <label> / text_end` pairs with no logic at all -- BoulderText
-- (home/overworld_text.asm:16), MartSignText, PokeCenterSignText -- and
-- for those the extracted _Label string IS the whole behavior, so the
-- boulders and signs printed nothing at all (#318). Wrappers that really
-- do run logic have no _Label string to find, so they still fall through
-- to their hand-ported script in data/scripts/.
-- needsAsm comes back false here on purpose: showMapText's warning tells
-- the reader to go port a script, and for these there is nothing to port.
if entry.label then
local s = self.text["_" .. entry.label]
if s then return s, false end
end
return nil, entry.asm
end
+8 -6
View File
@@ -9,6 +9,7 @@
local Json = require("src.link.Json")
local Logger = require("src.core.Logger")
local Runtime = require("src.mods.Runtime")
local Strings = require("src.core.Strings")
local DiscordPresence = {
APP_ID = "1529183141267374262",
@@ -436,7 +437,7 @@ end
local function buildActivity()
local details, activityState
if state.activity == "battle" then
details = state.battleLabel or "In battle"
details = state.battleLabel or Strings("In battle")
activityState = state.location and (state.location) or "Kanto"
elseif state.activity == "exploring" and state.location then
details = state.location
@@ -550,20 +551,20 @@ local function subscribe(game)
track("battle.started", function(ev)
local battle = ev and ev.battle
local label = "In battle"
local label = Strings("In battle")
if ev and ev.kind == "wild" then
local name = speciesName(game, ev.species)
if name then
label = "Battling wild " .. name
if ev.level then label = label .. " Lv" .. tostring(ev.level) end
else
label = "Wild battle"
label = Strings("Wild battle")
end
elseif ev and ev.kind == "trainer" then
local tname = battle and battle.trainer and battle.trainer.name
label = tname and ("Battling " .. tname) or "Trainer battle"
label = tname and ("Battling " .. tname) or Strings("Trainer battle")
elseif ev and ev.kind == "link" then
label = "Link battle"
label = Strings("Link battle")
end
local mapId = state.mapId
or (game.save and game.save.player and game.save.player.map)
@@ -594,7 +595,7 @@ local function subscribe(game)
state.mapId = nil
setPresence({
activity = "menu",
location = id == "IntroMovie" and "Watching the intro" or "Title screen",
location = id == "IntroMovie" and "Watching the intro" or Strings("Title screen"),
clearBattle = true,
})
end
@@ -783,5 +784,6 @@ end
-- test / debug helpers
DiscordPresence._state = state
DiscordPresence.locationName = locationName
DiscordPresence.handleJoinRequest = handleJoinRequest
return DiscordPresence
+52 -12
View File
@@ -8,7 +8,7 @@ local Logger = require("src.core.Logger")
local Renderer = require("src.render.Renderer")
local SaveData = require("src.core.SaveData")
local StateStack = require("src.core.StateStack")
local TouchInput = require("src.core.TouchInput")
local TouchControls = require("src.core.TouchControls")
local ModLoader = require("src.mods.Loader")
local ModRuntime = require("src.mods.Runtime")
local Screens = require("src.ui.Screens")
@@ -44,8 +44,8 @@ function Game:load()
self.input = Input
Input:init()
self.touchInput = TouchInput
TouchInput:init()
self.touchControls = TouchControls
TouchControls:init()
self.renderer = Renderer
Renderer:init()
@@ -53,6 +53,9 @@ function Game:load()
require("src.render.Font").load(Data)
-- menu cursor/border/geometry constants; field.theme restyles them
require("src.ui.Theme").load(Data)
-- the engine's own text, after the merge so a translation mod's catalog
-- is already in Data.strings; empty on a mod-free boot and skipped
require("src.core.Strings").load(Data)
self.stack = StateStack
StateStack:init()
@@ -96,6 +99,20 @@ function Game:load()
end
Logger.info("game loaded")
-- Scaling bug reports (#87, #208) are unanswerable without these three
-- numbers: LOVE units, drawable pixels, and the integer physical pixels
-- per GB pixel the renderer settled on. Cheap, once, and it turns "it
-- looks stretched" into something reproducible.
if love.graphics and love.graphics.getDimensions then
local ww, wh = love.graphics.getDimensions()
local pw, ph = ww, wh
if love.graphics.getPixelDimensions then
pw, ph = love.graphics.getPixelDimensions()
end
Logger.info(string.format(
"display: %dx%d units, %dx%d px, fit scale %d px/GB px",
ww, wh, pw, ph, Renderer:fitScale()))
end
end
-- the merged field.boot: spawn, names, money and the naming presets a
@@ -174,15 +191,21 @@ end
-- or screenshot run does not depend on whatever the player last chose.
function Game:logicSpeed()
local GameSpeed = require("src.core.GameSpeed")
-- Link play is always 1X on both machines, and this wins over every other
-- source including POKEPORT_SPEED. Fast-forward multiplies the logic
-- clock, so a peer at 10X burned a tournament shot clock ten times faster
-- than the opponent it is racing, and drove its own animation/message
-- queue at a different rate than the peer it is locked to. Nothing about
-- a match should depend on what either player set this to.
if self.linkSession or (self.linkNet and not self.linkNet.closed) then
return 1
end
if self.speedOverride then return GameSpeed.clamp(self.speedOverride) end
local opts = self.save and self.save.options
return GameSpeed.clamp(opts and opts.speed or GameSpeed.DEFAULT)
end
function Game:update(dt)
-- Touch timers / prior-frame auto-releases before the fixed step so
-- deferred A and edge pulses land in Input's press queue for this step.
TouchInput:update(dt)
-- Fast-forward scales only the logic clock (see src/core/GameSpeed.lua).
-- Give the accumulator room for one full frame at the current speed,
-- or the anti-spiral clamp quietly caps every level above ~15X.
@@ -224,6 +247,16 @@ function Game:draw()
-- white clear
local base = self.stack:visibleBase()
local worldBelow = self.stack.states[base] == self.overworld
-- The UI surface is resolved once, before any state draws: the top state
-- may want more than the Game Boy's 160x144 (the widescreen battle layout
-- asks for 304x144). Anything else keeps the classic surface, so a menu
-- pushed over a wide battle brings the screen straight back to 160x144.
local top = self.stack:top()
if top and top.uiSize then
Renderer:setUISize(top:uiSize())
else
Renderer:setUISize(Renderer.WIDTH, Renderer.HEIGHT)
end
Renderer:beginFrame(worldBelow)
self.stack:draw()
-- SGB colorization: the topmost state that knows its palette owns the
@@ -246,6 +279,8 @@ function Game:draw()
worldZones = self.overworld:sgbWorldZones()
end
Renderer:endFrame(zones, worldZones)
-- on-screen mobile controls: pure screen-space, over the finished frame
TouchControls:draw()
end
-- overworld survey zoom: wheel up / '=' zooms in, wheel down / '-' out
@@ -375,6 +410,9 @@ function Game:keyreleased(key)
end
function Game:gamepadpressed(joystick, button)
-- a controller is being used: the touch overlay steps aside until the
-- next screen touch (mobile only; a no-op elsewhere)
TouchControls:noteGamepad()
-- BindingsMenu's pad capture rides the same top-state routing as keys
local top = self.stack and self.stack:top()
if top and top.onGamepadPressed then
@@ -389,6 +427,8 @@ function Game:gamepadreleased(joystick, button)
end
function Game:gamepadaxis(joystick, axis, value)
-- past-deadzone only, so resting-stick drift can't hide the overlay
if math.abs(value) > 0.5 then TouchControls:noteGamepad() end
Input:gamepadaxis(joystick, axis, value)
end
@@ -398,12 +438,12 @@ end
-- state is worse than asking the player to re-press.
function Game:focus(f)
Input:reset()
TouchInput:reset()
TouchControls:reset()
end
function Game:visible(v)
Input:reset()
TouchInput:reset()
TouchControls:reset()
end
-- A disconnected/dropped controller can't send the button-up for whatever
@@ -411,19 +451,19 @@ end
-- flags it owned.
function Game:joystickremoved(joystick)
Input:reset()
TouchInput:reset()
TouchControls:joystickremoved()
end
function Game:touchpressed(id, x, y)
TouchInput:touchpressed(id, x, y)
TouchControls:touchpressed(id, x, y)
end
function Game:touchmoved(id, x, y)
TouchInput:touchmoved(id, x, y)
TouchControls:touchmoved(id, x, y)
end
function Game:touchreleased(id, x, y)
TouchInput:touchreleased(id, x, y)
TouchControls:touchreleased(id, x, y)
end
-- Point the loader's mod.save backing at this save's modData so per-mod
+12 -1
View File
@@ -144,7 +144,7 @@ function Input:step()
elseif next(sources) ~= nil then
self.state[btn] = true
end
-- sources == {}: real press fully released before this step keep up
-- sources == {}: real press fully released before this step -- keep up
end
for btn, sources in pairs(self.sources) do
if next(sources) == nil then
@@ -154,6 +154,17 @@ function Input:step()
self.pressQueue = {}
end
-- The on-screen touch overlay (src/core/TouchControls.lua) presses GB
-- buttons directly by name -- not through a keyboard alias -- so a player
-- rebind can never detach or shadow the overlay.
function Input:overlayPressed(btn)
press(self, btn, "touch:" .. btn)
end
function Input:overlayReleased(btn)
release(self, btn, "touch:" .. btn)
end
function Input:gamepadpressed(joystick, button)
local btn = self.padBindings[button]
if btn then
+31 -1
View File
@@ -24,7 +24,31 @@ local FILTER_HIGHGAIN = { 0.4, 0.16, 0.064 }
local filterLevel = 0
local function applyVolume(src)
if src then pcall(src.setVolume, src, VOLUME * volumeScale) end
if not src then return end
local vol = VOLUME * volumeScale
if Runtime.wantsHook("music.volume") then
local ctx = {
song = state.current,
mapSong = state.mapSong,
onBike = state.onBike,
surfing = state.surfing,
fading = state.fade ~= nil,
optionScale = volumeScale,
}
local ok, Game = pcall(require, "src.core.Game")
if ok and Game then
local ow = Game.overworld
if ow and ow.player then
ctx.x, ctx.y = ow.player.cellX, ow.player.cellY
ctx.mapId = ow.map and ow.map.id
ctx.tod = ow.tod
end
end
vol = Runtime.call("music.volume", function(v) return v end, vol, ctx)
vol = tonumber(vol) or (VOLUME * volumeScale)
if vol < 0 then vol = 0 end
end
pcall(src.setVolume, src, vol)
end
-- Source:setFilter needs OpenAL EFX; the pcall degrades to unfiltered
@@ -408,6 +432,12 @@ end
-- restores the map theme after a one-shot jingle
function Music.update(data)
if state.chip then require("src.core.ChipAudio").update() end
-- distance / indoor muffling mods re-apply volume every frame while
-- subscribed; otherwise applyVolume only runs on song/option changes
if Runtime.wantsHook("music.volume") and not state.fade then
applyVolume(state.source)
applyVolume(state.loopSource)
end
-- volume ramp (Music.fadeOut): hold the current level for `control`
-- frames, then drop one level (FadeOutAudio decrements both rAUDVOL
-- nibbles when its counter reaches 0); at level 0 the music stops.
+139 -17
View File
@@ -16,6 +16,7 @@ local SaveSerializer = require("src.core.SaveSerializer")
local Runtime = require("src.mods.Runtime")
local Semver = require("src.mods.Semver")
local Boxes = require("src.pokemon.Boxes")
local Stats = require("src.pokemon.Stats")
local Bag = require("src.inventory.Bag")
local Badges = require("src.inventory.Badges")
@@ -132,17 +133,26 @@ local function detectPortable()
-- Recover the folder containing the .app so a packaged app finds its
-- marker. On Windows/Linux the executable is not a bundle, so this is nil
-- and the plain source-base directory (next to the .exe/AppImage) is used.
local function appContainer(path)
local appPath = path and path:match("^(.*%.app)/Contents/")
return appPath and appPath:match("^(.*)/[^/]+$") or nil
local function parentDir(path)
return path and path:match("^(.*)/[^/]+$") or nil
end
-- Order: the .app's containing folder (packaged macOS), then the
-- source-base directory (next to a packaged .exe/AppImage), then the
-- source itself (a `love <gamedir>` run drops portable.txt in the game
-- folder). First one holding the marker wins. Built by appending so a
-- nil (e.g. no .app in the path) never truncates the ipairs scan.
local function appContainer(path)
return parentDir(path and path:match("^(.*%.app)/Contents/"))
end
-- The Linux AppImage build works similarly to macOs, but the runtime mounts
-- the squashfs at a temp path, and that becomes the base directory.
-- The runtime exports the real .AppImage path in $APPIMAGE, so we get the
-- base dir from there.
local function appImageContainer()
return parentDir(os.getenv("APPIMAGE"))
end
-- Order: the packaged-app containing folder (macOS .app / Linux AppImage),
-- then the source-base directory (next to a packaged .exe), then the source
-- itself (a `love <gamedir>` run drops portable.txt in the game folder).
-- First one holding the marker wins. Built by appending so a nil (e.g. no
-- .app in the path) never truncates the ipairs scan.
local candidates = {}
local appDir = appContainer(src) or appContainer(sbd)
local appDir = appContainer(src) or appContainer(sbd) or appImageContainer()
if appDir then candidates[#candidates + 1] = appDir end
if sbd then candidates[#candidates + 1] = sbd end
if src then candidates[#candidates + 1] = src end
@@ -193,6 +203,9 @@ function SaveData.defaultOptions()
textSpeed = 3,
animations = true,
battleStyle = "shift",
-- battle screen composition: og (the 160x144 original) | wide
-- (304x144, src/battle/WideBattle.lua)
battleLayout = "og",
ruleset = "gen1_faithful",
-- 0-7 like the GB's NR50 master volume
musicVol = 7,
@@ -442,6 +455,25 @@ function SaveData.slotSummary(save)
}
end
-- The absolute on-disk path of a slot's save file, for the one caller that
-- cannot go through love.filesystem: the save editor reads and writes with
-- raw io.* so it can also open a file the player dragged in from anywhere.
-- Resolves against the same root persistFs would write to -- the portable
-- game folder when portable mode is on, otherwise LOVE's save directory --
-- so Edit on a launcher save row lands on the file the game actually plays.
-- nil when neither root is available (headless tests with an injected fs).
function SaveData.slotDiskPath(version, slotId)
version = version or GameVersion.get()
if not knownVersion(version) or not slotId then return nil end
local base = SaveData.portableBaseDir()
or (love and love.filesystem and love.filesystem.getSaveDirectory
and love.filesystem.getSaveDirectory())
if not base then return nil end
local sep = package.config:sub(1, 1)
local rel = select(1, slotNames(version, slotId))
return base .. sep .. rel:gsub("/", sep)
end
-- Slots visible to the launcher: every registered slot for a version, each
-- with whether it holds a save and the cheap summary above. A fresh
-- install with nothing registered returns an empty array; a legacy install
@@ -458,11 +490,44 @@ function SaveData.listSlots(version)
for _, id in ipairs(list) do
local save = decodeSlot(fs, version, id)
local name, meta = SaveData.slotSummary(save)
out[#out + 1] = { id = id, exists = save ~= nil, name = name, meta = meta }
out[#out + 1] = { id = id, exists = save ~= nil, name = name, meta = meta,
label = reg.names and reg.names[id] or nil }
end
return out
end
-- Give a registered slot a custom label (#205: "a way to name save slots so
-- you can see that in the launcher"). The label lives in the options
-- registry next to list/active, never in the save file itself, so renaming
-- needs no save rewrite and an empty slot can be labeled too. The label is
-- trimmed; an empty (or whitespace-only) one clears it. Returns true, or
-- false + an error string when the id is not registered.
function SaveData.renameSlot(version, slotId, name)
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)
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 = false
for _, id in ipairs(reg.list) do
if id == slotId then found = true break end
end
if not found then return false, "slot not registered" end
local label = type(name) == "string" and name:match("^%s*(.-)%s*$") or nil
if label == "" then label = nil end
reg.names = reg.names or {}
reg.names[slotId] = label
if next(reg.names) == nil then reg.names = nil end
opts.saveSlots[version] = reg
SaveData.saveOptions(opts, fs)
return true
end
-- Point the active slot at slotId (registering it if new) and persist the
-- choice to options.lua; also update the process-global cache so the very
-- next save/load lands in the chosen slot.
@@ -552,6 +617,45 @@ function SaveData.writeSlot(version, slotId, saveTable)
return true
end
-- Delete a registered slot: remove its main/.bak/.tmp files, drop it from the
-- options registry, and if it was active point active at another remaining
-- slot (or clear active when the list is empty). Returns true, or false +
-- an error string when the id is unknown / not registered.
function SaveData.deleteSlot(version, slotId)
version = version or GameVersion.get()
if not knownVersion(version) then return false, "unknown version" end
if type(slotId) ~= "string" or slotId == "" then
return false, "missing slot id"
end
local fs = persistFs(nil)
ensureVersionSlots(version, fs)
local opts = SaveData.loadOptions(fs)
opts.saveSlots = opts.saveSlots or {}
local reg = opts.saveSlots[version]
if not reg or not reg.list then return false, "slot not registered" end
local found, idx = false, nil
for i, id in ipairs(reg.list) do
if id == slotId then found = true; idx = i; break end
end
if not found then return false, "slot not registered" end
local main, bak, tmp = slotNames(version, slotId)
remove(fs, main)
remove(fs, bak)
remove(fs, tmp)
table.remove(reg.list, idx)
if reg.names then reg.names[slotId] = nil end
if reg.active == slotId then
reg.active = reg.list[1] -- may be nil when the list is now empty
end
opts.saveSlots[version] = reg
SaveData.saveOptions(opts, fs)
slotsChecked[version] = true
activeSlotCache[version] = reg.active or false
return true
end
-- Test seam: drop the process-global slot cache so a suite can exercise
-- migration/resolution against a freshly injected filesystem. Unused by
-- the game, which resolves each version exactly once per boot.
@@ -934,6 +1038,17 @@ local function scrubKnownMon(mon, data)
for stat, v in pairs(mon.statExp) do mon.statExp[stat] = clamp(v, 0, 65535, 0) end
end
mon.level = clamp(mon.level, 1, 100, 1)
-- Box mons imported from a real .sav carry NO stat block: box_struct stops
-- before MON_LEVEL/MON_STATS, so src/save_convert/GenSave.lua decodeMon
-- only fills `stats` for party slots. Every HP-bar draw then nil-indexes
-- mon.stats: the status screen opened in the box (#233) and the party list
-- after withdrawing one (#304). The original derives them on demand
-- (status_screen.asm:66-76, add_mon.asm _MoveMon); deriving once here means
-- every later reader (menus, battle, items, SGB bar zones, the link
-- fingerprint) sees a party-shaped mon. Runs after the level clamp above
-- so the derived stats use a sane level. A save that already has stats is
-- untouched.
Stats.ensure(data.pokemon and data.pokemon[mon.species], mon)
local moves = mon.moves
if type(moves) ~= "table" then return end
local hadMoves = #moves > 0
@@ -1117,9 +1232,16 @@ function SaveData.defaultHeal(boot)
end
-- Post-credits home (issue #103). pokered left the player in HALL_OF_FAME
-- after jp Init; this port places CONTINUE at the NewGameWarp bedroom and
-- retargets LAST_MAP exits (Red's house mats) at the heal-point town so
-- leaving the house does not dump the player back at Indigo Plateau.
-- after jp Init; this port relocates CONTINUE instead, and retargets
-- LAST_MAP exits (Red's house mats) at the heal-point town so leaving the
-- house does not dump the player back at Indigo Plateau.
--
-- The landing spot is the blackout point, not the NewGameWarp bedroom:
-- HallOfFameResetEventsAndSaveScript sets wLastBlackoutMap := PALLET_TOWN,
-- so resuming puts the player outside the front door. Spawning them back
-- in the upstairs bedroom was a port-only detour (#253). Non-vanilla
-- spawns are unaffected -- defaultHeal returns boot's own start map for
-- those, which is what the bedroom branch resolved to anyway.
-- Marks postGameHomeOk so a later intentional HoF save is not relocated.
function SaveData.applyPostGameHome(save, boot)
boot = type(boot) == "table" and boot or {}
@@ -1127,9 +1249,9 @@ function SaveData.applyPostGameHome(save, boot)
save.lastHeal = { map = heal.map, x = heal.x, y = heal.y }
save.lastOutdoor = { id = heal.map, x = heal.x, y = heal.y }
save.player = save.player or {}
save.player.map = boot.startMap or "REDS_HOUSE_2F"
save.player.x = boot.startX or 3
save.player.y = boot.startY or 6
save.player.map = heal.map
save.player.x = heal.x
save.player.y = heal.y
save.player.facing = boot.startFacing or "down"
save.postGameHomeOk = true
return heal
@@ -1171,7 +1293,7 @@ function SaveData.newGame(boot)
inventory = {},
-- Vanilla Gen1 seeds one Potion in the player's item PC
-- (wBoxItems / players_pc.asm); existing saves keep whatever they
-- already have this only applies to New Game.
-- already have -- this only applies to New Game.
pcItems = { POTION = 1 },
party = {},
box = {},
+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
+105
View File
@@ -75,6 +75,32 @@ local function resolveMkdir()
return mkdirFn
end
-- Lazily-resolved windowless rmdir, the mirror of resolveMkdir above:
-- function(absolutePath) or false when FFI is unavailable. Both syscalls
-- refuse a non-empty directory, so a caller has to delete the files first.
local rmdirFn = nil
local function resolveRmdir()
if rmdirFn ~= nil then return rmdirFn end
rmdirFn = false
local ok, ffi = pcall(require, "ffi")
if not ok then return rmdirFn end
if ffi.os == "Windows" then
pcall(ffi.cdef, "int RemoveDirectoryA(const char *lpPathName);")
local resolved = pcall(function() return ffi.C.RemoveDirectoryA end)
if resolved then
rmdirFn = function(path) pcall(ffi.C.RemoveDirectoryA, path) end
end
else
pcall(ffi.cdef, "int rmdir(const char *pathname);")
local resolved = pcall(function() return ffi.C.rmdir end)
if resolved then
rmdirFn = function(path) pcall(ffi.C.rmdir, path) end
end
end
return rmdirFn
end
-- Mount an external directory onto the physfs read path (appended, so the
-- game's own source always wins a name clash). Returns true on success.
--
@@ -122,6 +148,37 @@ local function mountReadable(dir, append)
return fn(dir, append)
end
-- PHYSFS_unmount, resolved the same way PHYSFS_mount is. Only
-- CacheFs.unmountVersion needs it: the launcher can open the save editor on
-- one game's cache and then Play the other, and an overlay left mounted
-- would win the read path for the rest of the process.
local physfsUnmountFn = nil
local function resolveUnmount()
if physfsUnmountFn ~= nil then return physfsUnmountFn end
physfsUnmountFn = false
local ok, ffi = pcall(require, "ffi")
if not ok then return physfsUnmountFn end
pcall(ffi.cdef, "int PHYSFS_unmount(const char *oldDir);")
local libs = {
function() return ffi.C end,
function() return ffi.load("love") end,
}
for _, getlib in ipairs(libs) do
local okl, lib = pcall(getlib)
if okl and lib then
local oks, fn = pcall(function() return lib.PHYSFS_unmount end)
if oks and fn then
physfsUnmountFn = function(d)
local okr, ret = pcall(fn, d)
return okr and ret ~= 0
end
break
end
end
end
return physfsUnmountFn
end
-- The portable game folder when the cache should live there, else nil.
-- Resolved (and, for a fused build, mounted) once and cached. Requires a
-- desktop portable install (SaveData) and a working windowless mkdir.
@@ -229,6 +286,23 @@ function CacheFs.remove(rel)
love.filesystem.remove(rel)
end
-- Remove a single cache-relative directory once its files are gone. Needed
-- because os.remove cannot delete a directory on Windows and
-- love.filesystem.remove never reaches outside the save directory, so the
-- portable game folder gets the same FFI-syscall treatment as its mkdir
-- (issue #74: os.execute would flash a console window per call). Used by the
-- mod installer so an uninstall leaves nothing behind (#330).
function CacheFs.removeDir(rel)
rel = withPrefix(rel)
local root = CacheFs.root()
if root then
local rmdir = resolveRmdir()
if rmdir then rmdir(realPath(root, rel)) end
return
end
love.filesystem.remove(rel)
end
-- Remove the game-folder copy of a cache subtree before a fresh import, so a
-- cache-format bump does not leave orphaned files behind. No-op when the
-- portable cache is inactive (the save-directory copy is cleared by
@@ -280,4 +354,35 @@ function CacheFs.mountVersion(version)
return false
end
-- Undo mountVersion. A process normally mounts exactly one version and then
-- boots it, but the launcher can open the save editor on a Blue save, close
-- it, and press Play on Red: with blue/ still prepended, Red's
-- require("data.generated.*") and its generated art would silently resolve to
-- Blue's files. Callers must also drop the generated modules from
-- package.loaded (src.core.Data:unloadGenerated) -- unmounting alone only
-- fixes the read path, not what require already cached.
--
-- Returns true when nothing was mounted or the unmount took. Red is a no-op
-- because its cache lives at the root and was never overlaid.
function CacheFs.unmountVersion(version)
local prefix = require("src.core.GameVersion").cachePrefix(version)
if prefix == "" then return true end
local sub = prefix:gsub("/+$", "")
local base = CacheFs.root()
if not base and love.filesystem.getSaveDirectory then
base = love.filesystem.getSaveDirectory()
end
local done = false
local fn = resolveUnmount()
if fn and base then
done = fn(base .. SEP .. sub) or done
end
-- also drop the love.filesystem.mount fallback, which registers the folder
-- under its bare name rather than its absolute path
if love.filesystem.unmount then
done = love.filesystem.unmount(sub) or done
end
return done
end
return CacheFs
File diff suppressed because it is too large Load Diff
+13 -5
View File
@@ -47,11 +47,19 @@ local function readSource(source)
return source
end
local f, openErr = io.open(source, "rb")
if not f then return nil, "could not read the save file: " .. tostring(openErr) end
local data = f:read("*a")
f:close()
if type(data) ~= "string" then return nil, "the save file was empty" end
return data
if f then
local data = f:read("*a")
f:close()
if type(data) ~= "string" then return nil, "the save file was empty" end
return data
end
-- Android SAF drops (picked_save.sav) and USB copies land in the LOVE save
-- directory; io.open cannot see them, so fall back to love.filesystem.
if love and love.filesystem and love.filesystem.read then
local data = love.filesystem.read(source)
if type(data) == "string" then return data end
end
return nil, "could not read the save file: " .. tostring(openErr)
end
-- importToSlot(source, version) -> ok, slotIdOrErr
+68 -47
View File
@@ -12,6 +12,7 @@
local Pokemon = require("src.pokemon.Pokemon")
local Flags = require("src.script.Flags")
local Strings = require("src.core.Strings")
local ItemEffects = {}
@@ -46,6 +47,17 @@ ItemEffects.BALLS = BALLS
function ItemEffects.isBall(id) return BALLS[id] or false end
function ItemEffects.isStone(id) return STONES[id] or false end
-- Does using this item take item_effects.asm's .healHP path, the one that
-- plays SFX_HEAL_HP and lengthens the party HP bar with UpdateHPBar2 before
-- the message (item_effects.asm .doneHealing)? The status-only cures branch
-- to .playStatusAilmentCuringSound instead and never touch the bar. BagMenu
-- keeps the party picker open for these so the fill has something to draw
-- on (#252).
function ItemEffects.healsHP(id)
return HEAL_AMOUNT[id] ~= nil or id == "MAX_POTION" or id == "FULL_RESTORE"
or id == "REVIVE" or id == "MAX_REVIVE"
end
-- Does this item need a party-member target?
function ItemEffects.needsTarget(id, itemDef)
return HEAL_AMOUNT[id] or STATUS_HEAL[id] or id == "MAX_POTION"
@@ -119,8 +131,8 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
if battle and (VITAMINS[itemId] or STONES[itemId] or itemId == "PP_UP"
or itemId == "RARE_CANDY" or itemId == "COIN_CASE"
or (itemDef and itemDef.machine)) then
return "failed", { "OAK: " .. save.player.name
.. "!\nThis isn't the\ntime to use that!" }
return "failed", { Strings("OAK: %s!\nThis isn't the\ntime to use that!",
save.player.name) }
end
if BALLS[itemId] then
@@ -138,11 +150,12 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
local mapId, npc = adjacentSleepingSnorlax(save, ow)
if npc then
return "flute_wake", { data.text._PlayedFluteHadEffectText
or "{PLAYER} played the\nPOKé FLUTE." }, { mapId = mapId, npc = npc }
or Strings("{PLAYER} played the\nPOKé FLUTE.") },
{ mapId = mapId, npc = npc }
end
-- otherwise: play the tune, nothing happens (ItemUsePokeFlute's
-- PlayedFluteNoEffectText branch)
return "flute_field", { "Played the POKé\nFLUTE.\fNow, that's a\ncatchy tune!" }
return "flute_field", { Strings("Played the POKé\nFLUTE.\fNow, that's a\ncatchy tune!") }
end
local woke = false
local function wake(mon)
@@ -157,24 +170,24 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
-- WakeUpEntireParty runs on the enemy's bench too
for _, mon in ipairs(battle.enemyParty or {}) do wake(mon) end
if not woke then
return "failed", { "Played the POKé\nFLUTE.\fNow, that's a\ncatchy tune!" }
return "failed", { Strings("Played the POKé\nFLUTE.\fNow, that's a\ncatchy tune!") }
end
return "flute", { ("%s played the\nPOKé FLUTE."):format(save.player.name),
"All sleeping\nPOKéMON woke up!" }
return "flute", { Strings("%s played the\nPOKé FLUTE.", save.player.name),
Strings("All sleeping\nPOKéMON woke up!") }
end
-- battle-only items
if X_ITEMS[itemId] or itemId == "DIRE_HIT" or itemId == "GUARD_SPEC"
or itemId == "POKE_DOLL" then
if not battle then
return "failed", { "OAK: " .. save.player.name .. "!\nThis isn't the\ntime to use that!" }
return "failed", { Strings("OAK: %s!\nThis isn't the\ntime to use that!", save.player.name) }
end
local b = battle.player
if itemId == "X_ACCURACY" then
-- ItemUseXAccuracy sets USING_X_ACCURACY: moves never miss
-- (not an accuracy stage)
b.xAccuracy = true
return "consumed", { ("%s's\nhits will never\nmiss!"):format(b.name) }
return "consumed", { Strings("%s's\nhits will never\nmiss!", b.name) }
end
if X_ITEMS[itemId] then
local stat = X_ITEMS[itemId]
@@ -183,28 +196,28 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
-- effect, so at +6 it is still consumed and StatModifierUpEffect
-- just prints "Nothing happened!"
if cur >= 6 then
return "consumed", { "Nothing happened!" }
return "consumed", { Strings("Nothing happened!") }
end
b.stages[stat] = cur + 1
return "consumed", { ("%s's\n%s rose!"):format(b.name, stat:upper()) }
return "consumed", { Strings("%s's\n%s rose!", b.name, stat:upper()) }
end
-- ItemUseDireHit/ItemUseGuardSpec always set the bit and consume
-- the item, even when it is already active
if itemId == "DIRE_HIT" then
b.focusEnergy = true
return "consumed", { ("%s's\ngetting pumped!"):format(b.name) }
return "consumed", { Strings("%s's\ngetting pumped!", b.name) }
end
if itemId == "GUARD_SPEC" then
b.mist = true
return "consumed", { ("%s's\nprotected against\nstat changes!"):format(b.name) }
return "consumed", { Strings("%s's\nprotected against\nstat changes!", b.name) }
end
if itemId == "POKE_DOLL" then
if battle.kind ~= "wild" then
-- ItemUsePokeDoll jumps to ItemUseNotTime in trainer battles
return "failed", { "OAK: " .. save.player.name
.. "!\nThis isn't the\ntime to use that!" }
return "failed", { Strings(
"OAK: %s!\nThis isn't the\ntime to use that!", save.player.name) }
end
return "consumed_escape", { "The wild POKéMON\nran away!" }
return "consumed_escape", { Strings("The wild POKéMON\nran away!") }
end
end
@@ -213,7 +226,7 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
-- restore every move with no menu.
if itemId == "ETHER" or itemId == "MAX_ETHER"
or itemId == "ELIXER" or itemId == "MAX_ELIXER" then
if not target then return "failed", { "It won't have\nany effect." } end
if not target then return "failed", { Strings("It won't have\nany effect.") } end
local restored = false
local full = itemId == "MAX_ETHER" or itemId == "MAX_ELIXER"
local allMoves = itemId == "ELIXER" or itemId == "MAX_ELIXER"
@@ -235,9 +248,9 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
restored = mv and restore(mv) or false
end
if not restored then
return "failed", { "It won't have\nany effect." }
return "failed", { Strings("It won't have\nany effect.") }
end
return "consumed", { ("%s's PP\nwas restored!"):format(monName(data, target)) }
return "consumed", { Strings("%s's PP\nwas restored!", monName(data, target)) }
end
local heal = HEAL_AMOUNT[itemId]
@@ -250,49 +263,57 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
target.status = nil
cureActiveToxic(battle, target)
require("src.core.Sound").play(data, "Heal_Ailment")
return "consumed", { ("%s's\nstatus returned\nto normal!"):format(monName(data, target)) }
return "consumed", { Strings("%s's\nstatus returned\nto normal!", monName(data, target)) }
end
if not target or target.hp <= 0 or target.hp >= target.stats.hp then
return "failed", { "It won't have\nany effect." }
return "failed", { Strings("It won't have\nany effect.") }
end
-- wHPBarOldHP: the bar animation starts from the HP the mon had BEFORE
-- the item landed (item_effects.asm latches it with the party menu still
-- up), so latch it here and hand it back as extra.healedFrom for the
-- party-menu fill (#252)
local before = target.hp
if itemId == "MAX_POTION" or itemId == "FULL_RESTORE" then
target.hp = target.stats.hp
else
target.hp = math.min(target.stats.hp, target.hp + heal)
end
local msgs = { ("%s's HP\nwas restored!"):format(monName(data, target)) }
local msgs = { Strings("%s's HP\nwas restored!", monName(data, target)) }
if itemId == "FULL_RESTORE" then
target.status = nil
cureActiveToxic(battle, target)
end
require("src.core.Sound").play(data, "Heal_HP")
return "consumed", msgs
return "consumed", msgs, { healedFrom = before }
end
local cures = STATUS_HEAL[itemId]
if cures then
if not target or not target.status or not cures[target.status] then
return "failed", { "It won't have\nany effect." }
return "failed", { Strings("It won't have\nany effect.") }
end
target.status = nil
cureActiveToxic(battle, target)
require("src.core.Sound").play(data, "Heal_Ailment")
return "consumed", { ("%s's\nstatus returned\nto normal!"):format(monName(data, target)) }
return "consumed", { Strings("%s's\nstatus returned\nto normal!", monName(data, target)) }
end
if itemId == "REVIVE" or itemId == "MAX_REVIVE" then
if not target or target.hp > 0 then
return "failed", { "It won't have\nany effect." }
return "failed", { Strings("It won't have\nany effect.") }
end
target.status = nil
target.hp = itemId == "REVIVE" and math.floor(target.stats.hp / 2) or target.stats.hp
require("src.core.Sound").play(data, "Heal_HP")
return "consumed", { ("%s\nis revitalized!"):format(monName(data, target)) }
-- a revive takes the same .healHP -> .doneHealing route, animating up
-- from the fainted mon's 0 HP (#252)
return "consumed", { Strings("%s\nis revitalized!", monName(data, target)) },
{ healedFrom = 0 }
end
if itemId == "RARE_CANDY" then
if not target or target.level >= 100 then
return "failed", { "It won't have\nany effect." }
return "failed", { Strings("It won't have\nany effect.") }
end
local Growth = require("src.pokemon.Growth")
local Stats = require("src.pokemon.Stats")
@@ -302,56 +323,56 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
local old = target.stats
target.stats = Stats.calc(speciesDef, target.level, target.dvs, target.statExp)
target.hp = math.min(target.stats.hp, target.hp + (target.stats.hp - old.hp))
return "consumed", { ("%s grew\nto level %d!"):format(monName(data, target), target.level) },
return "consumed", { Strings("%s grew\nto level %d!", monName(data, target), target.level) },
{ leveledTo = target.level }
end
if STONES[itemId] then
if not target then return "failed", { "It won't have\nany effect." } end
if not target then return "failed", { Strings("It won't have\nany effect.") } end
local speciesDef = data.pokemon[target.species]
for _, evo in ipairs(speciesDef.evolutions) do
if evo.method == "ITEM" and evo.item == itemId then
return "consumed", nil, { evolveTo = evo.species }
end
end
return "failed", { "It won't have\nany effect." }
return "failed", { Strings("It won't have\nany effect.") }
end
-- vitamins: +2560 stat exp, refused at 25600+ (ItemUseVitamin,
-- engine/items/item_effects.asm)
local vitaminStat = VITAMINS[itemId]
if vitaminStat then
if not target then return "failed", { "It won't have\nany effect." } end
if not target then return "failed", { Strings("It won't have\nany effect.") } end
target.statExp = target.statExp or {}
local cur = target.statExp[vitaminStat] or 0
if cur >= 25600 then
return "failed", { "It won't have\nany effect." }
return "failed", { Strings("It won't have\nany effect.") }
end
target.statExp[vitaminStat] = math.min(65535, cur + 2560)
local Stats = require("src.pokemon.Stats")
target.stats = Stats.calc(data.pokemon[target.species], target.level,
target.dvs, target.statExp)
target.hp = math.min(target.hp, target.stats.hp)
return "consumed", { ("%s's %s\nrose!"):format(monName(data, target),
return "consumed", { Strings("%s's %s\nrose!", monName(data, target),
vitaminStat == "hp" and "HP" or vitaminStat:upper()) }
end
-- PP UP boosts the move the player picked (ItemUsePPUp's move menu)
if itemId == "PP_UP" then
if not target then return "failed", { "It won't have\nany effect." } end
if not target then return "failed", { Strings("It won't have\nany effect.") } end
local mv = target.moves[moveIndex or 1]
local mdef = mv and data.moves[mv.id]
if mdef and (mv.ppUps or 0) < 3 then
mv.ppUps = (mv.ppUps or 0) + 1
-- each PP UP adds maxPP/5 uses on top of the base maximum
mv.pp = mv.pp + math.floor(mdef.pp / 5)
return "consumed", { ("%s's PP\nincreased!"):format(mdef.name) }
return "consumed", { Strings("%s's PP\nincreased!", mdef.name) }
end
return "failed", { "It won't have\nany effect." }
return "failed", { Strings("It won't have\nany effect.") }
end
if itemDef and itemDef.machine then
if not target then return "failed", { "It won't have\nany effect." } end
if not target then return "failed", { Strings("It won't have\nany effect.") } end
local speciesDef = data.pokemon[target.species]
local ok = false
for _, m in ipairs(speciesDef.tmhm) do
@@ -362,11 +383,11 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
-- plays SFX_DENIED before MonCannotLearnMachineMoveText (the generic
-- ItemUseNotTime/NoCyclingAllowedHere paths are silent)
require("src.core.Sound").play(data, "Denied")
return "failed", { ("%s can't\nlearn that move!"):format(monName(data, target)) }
return "failed", { Strings("%s can't\nlearn that move!", monName(data, target)) }
end
for _, mv in ipairs(target.moves) do
if mv.id == itemDef.machine.move then
return "failed", { "It knows that\nmove already!" }
return "failed", { Strings("It knows that\nmove already!") }
end
end
-- HMs are never consumed; TMs are single-use
@@ -375,14 +396,14 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
if itemId == "OLD_ROD" or itemId == "GOOD_ROD" or itemId == "SUPER_ROD" then
if battle then
return "failed", { "OAK: " .. save.player.name .. "!\nThis isn't the\ntime to use that!" }
return "failed", { Strings("OAK: %s!\nThis isn't the\ntime to use that!", save.player.name) }
end
return "fish", itemId
end
if itemId == "BICYCLE" then
if battle then
return "failed", { "OAK: " .. save.player.name .. "!\nThis isn't the\ntime to use that!" }
return "failed", { Strings("OAK: %s!\nThis isn't the\ntime to use that!", save.player.name) }
end
return "bicycle"
end
@@ -392,26 +413,26 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
end
if itemId == "TOWN_MAP" then
if battle then
return "failed", { "OAK: " .. save.player.name .. "!\nThis isn't the\ntime to use that!" }
return "failed", { Strings("OAK: %s!\nThis isn't the\ntime to use that!", save.player.name) }
end
return "townmap"
end
if itemId == "ITEMFINDER" then
if battle then
return "failed", { "OAK: " .. save.player.name .. "!\nThis isn't the\ntime to use that!" }
return "failed", { Strings("OAK: %s!\nThis isn't the\ntime to use that!", save.player.name) }
end
return "itemfinder"
end
if itemId == "COIN_CASE" then
return "failed", { ("Coin count:\n%d"):format(save.coins or 0) }
return "failed", { Strings("Coin count:\n%d", save.coins or 0) }
end
if itemId == "REPEL" or itemId == "SUPER_REPEL" or itemId == "MAX_REPEL" then
local steps = itemId == "REPEL" and 100 or itemId == "SUPER_REPEL" and 200 or 250
save.repelSteps = steps
return "consumed", { ("%s used\n%s!"):format(save.player.name, name) }
return "consumed", { Strings("%s used\n%s!", save.player.name, name) }
end
return "failed", { "OAK: " .. save.player.name .. "!\nThis isn't the\ntime to use that!" }
return "failed", { Strings("OAK: %s!\nThis isn't the\ntime to use that!", save.player.name) }
end
return ItemEffects
+88 -28
View File
@@ -21,6 +21,7 @@ local Logger = require("src.core.Logger")
local Protocol = require("src.link.Protocol")
local Runtime = require("src.mods.Runtime")
local TurnOrder = require("src.battle.TurnOrder")
local Strings = require("src.core.Strings")
local LinkBattle = {}
@@ -111,6 +112,19 @@ local function scalar(v)
return tostring(v)
end
-- A volatile slot is "off" three interchangeable ways -- absent, false, or
-- a counter sitting at 0 -- because every reader tests `if b.key then` or
-- `key > 0`. They have to hash the same, or a match ends over a difference
-- that does not exist. They routinely disagree: the menu-phase flinch
-- clear (BattleState:update) and the FIGHT-branch boundTurns mirror
-- (fightLockedAction) are written by whichever machine is sitting at ITS
-- OWN menu, and in a link battle that is a different battler on each side,
-- so one peer holds flinched=false / boundTurns=0 where the other still
-- holds nil with two identical simulations underneath.
local function off(v)
return v == nil or v == false or v == 0
end
local function stageStr(b)
local out = {}
for i, stat in ipairs(STAGES) do
@@ -130,7 +144,7 @@ end
local function volStr(b)
local out = {}
for _, key in ipairs(VOLATILE) do
if b[key] ~= nil then
if not off(b[key]) then
out[#out + 1] = key .. "=" .. scalar(b[key])
end
end
@@ -167,6 +181,17 @@ end
local PARTS = { "actives", "volatile", "bench" }
-- Which components are allowed to end a match. `actives` and `bench` carry
-- what decides one -- species, HP, status, stat stages, PP, the rest of the
-- party -- so a divergence there is a real split between the two
-- simulations and stays a draw. `volatile` is per-turn bookkeeping that
-- both sides recompute from the authoritative state every turn (see `off`
-- above): it can disagree for a turn without either side being wrong, and
-- when it does mean something real it lands in `actives` as damage or
-- status within a turn or two, where it is caught. Ending a match on it
-- alone cost players games they were winning, over nothing.
local FATAL_PART = { actives = true, bench = true }
-- opts: { myParty = packed, theirParty = packed, theirName, role =
-- "host"/"guest", seed, verdict, strict }. Returns nil plus a reason when
-- the handshake says the two link surfaces don't match: a lockstep
@@ -177,18 +202,18 @@ function LinkBattle.new(game, net, opts)
local theirName = opts.theirName or "FOE"
if not Handshake.battleAllowed(opts.verdict) then
return nil, "Link battle needs\nthe same mods on\nboth games."
return nil, Strings("Link battle needs\nthe same mods on\nboth games.")
end
-- both parties pass through the same pack->unpack clamp on both
-- machines, so the copies are identical everywhere
local unpackOpts = { strict = opts.strict or false, forceLevel = opts.forceLevel }
local myParty, myErr = unpackParty(game, opts.myParty, unpackOpts, function(p)
return ("Your %s can't\nbattle on the\nother game."):format(tostring(p.species))
return Strings("Your %s can't\nbattle on the\nother game.", tostring(p.species))
end)
if not myParty then return nil, myErr end
local theirParty, theirErr = unpackParty(game, opts.theirParty, unpackOpts, function(p, why)
return ("Their %s isn't\nin this game.\n(%s)"):format(tostring(p.species), tostring(why))
return Strings("Their %s isn't\nin this game.\n(%s)", tostring(p.species), tostring(why))
end)
if not theirParty then return nil, theirErr end
if #myParty == 0 or #theirParty == 0 then
@@ -232,7 +257,7 @@ function LinkBattle.new(game, net, opts)
self.enemyParty = theirParty
self.playerParty = myParty -- intro ball row uses the clamped copies
self.opponentName = theirName
self.introText = ("%s wants\nto battle!"):format(theirName)
self.introText = Strings("%s wants\nto battle!", theirName)
self.remoteHashes = {}
self.localHashes = {}
self.remoteParts = {}
@@ -292,7 +317,7 @@ function LinkBattle.new(game, net, opts)
battle = s, side = s.sides[2], battler = s.enemy, previous = previous,
})
s.enemySendingOut = true
s:sayNext(("%s sent\nout %s!"):format(theirName, s.enemy.name))
s:sayNext(Strings("%s sent\nout %s!", theirName, s.enemy.name))
s:actNext(function()
s.enemySendingOut = false
s:startGrowIn(s.enemy)
@@ -314,9 +339,21 @@ function LinkBattle.new(game, net, opts)
Logger.warn("link: desync turn %s component=%s (%s vs %s)",
tostring(turn), component, tostring(localH), tostring(remoteH))
Runtime.emit("link.desync", { turn = turn, component = component,
localHash = localH, remoteHash = remoteH })
endAsDraw(s, ("Link desync!\n%s differs.\fAre both games\nrunning the same\nmods?")
:format(component))
localHash = localH, remoteHash = remoteH,
fatal = true })
endAsDraw(s, Strings(
"Link desync!\n%s differs.\fAre both games\nrunning the same\nmods?",
component))
end
-- a non-fatal component split: both sides log it and carry on, so the
-- match is decided by the battle rather than by bookkeeping
local function noteDrift(s, turn, component, localH, remoteH)
Logger.warn("link: %s drift on turn %s (%s vs %s) -- match continues",
component, tostring(turn), tostring(localH), tostring(remoteH))
Runtime.emit("link.desync", { turn = turn, component = component,
localHash = localH, remoteHash = remoteH,
fatal = false })
end
-- a verified turn stays recorded: consuming it here left a finished
@@ -331,8 +368,11 @@ function LinkBattle.new(game, net, opts)
if mine and theirs then
for _, component in ipairs(PARTS) do
if mine[component] ~= theirs[component] then
reportDesync(s, turn, component, mine[component], theirs[component])
return
if FATAL_PART[component] then
reportDesync(s, turn, component, mine[component], theirs[component])
return
end
noteDrift(s, turn, component, mine[component], theirs[component])
end
end
end
@@ -349,7 +389,7 @@ function LinkBattle.new(game, net, opts)
local function resolveLockstep(s, myMsg, theirMsg)
if myMsg.kind == "run" or theirMsg.kind == "run" then
local who = myMsg.kind == "run" and game.save.player.name or theirName
endAsDraw(s, ("%s ran from\nthe battle!"):format(who))
endAsDraw(s, Strings("%s ran from\nthe battle!", who))
return
end
s.phase = "messages"
@@ -479,9 +519,9 @@ function LinkBattle.new(game, net, opts)
party = myParty,
onSwitch = function(mon)
if mon == s.player.mon then
s:say(("%s is\nalready out!"):format(s.player.name))
s:say(Strings("%s is\nalready out!", s.player.name))
elseif mon.hp <= 0 then
s:say("There's no will\nto fight!")
s:say(Strings("There's no will\nto fight!"))
else
s:resolveSwitch(mon)
end
@@ -491,7 +531,7 @@ function LinkBattle.new(game, net, opts)
end
self.openItems = function(s)
s:say("Items can't be\nused in a link\nbattle!")
s:say(Strings("Items can't be\nused in a link\nbattle!"))
s.phase = "messages"
s.afterQueue = "menu"
end
@@ -509,7 +549,7 @@ function LinkBattle.new(game, net, opts)
return
end
end
s:sayNext(("%s is out of\nPOKéMON!\f%s wins!"):format(game.save.player.name,
s:sayNext(Strings("%s is out of\nPOKéMON!\f%s wins!", game.save.player.name,
theirName))
s.result = "lose"
s.afterQueue = "finish"
@@ -522,7 +562,7 @@ function LinkBattle.new(game, net, opts)
return
end
end
s:sayNext(("%s is out of\nPOKéMON!\f%s wins!"):format(theirName,
s:sayNext(Strings("%s is out of\nPOKéMON!\f%s wins!", theirName,
game.save.player.name))
s.result = "win"
s.afterQueue = "finish"
@@ -543,13 +583,13 @@ function LinkBattle.new(game, net, opts)
-- only a draw if our own simulation hasn't already decided
-- (the winner's bye can arrive while we're still animating)
if not s.result then
endAsDraw(s, ("%s left the\nbattle."):format(theirName))
endAsDraw(s, Strings("%s left the\nbattle.", theirName))
end
elseif msg.type == "forfeit" then
-- the peer's own shot clock ran out; unlike a mutual RUN/desync
-- draw, this has a definite winner (us)
if not s.result then
endWithResult(s, "win", ("%s ran out of\ntime!"):format(theirName))
endWithResult(s, "win", Strings("%s ran out of\ntime!", theirName))
end
else
-- a tournament control message (bracket_update, the next
@@ -563,10 +603,19 @@ function LinkBattle.new(game, net, opts)
if net.closed and not s.linkEnded and not s.result then
endAsDraw(s)
end
-- Both early returns below skip baseUpdate, which is where the
-- presentational clock normally advances -- so tick it here, in the same
-- order baseUpdate would (fx, then the queue). Without this an
-- animation caught mid-flight froze for the whole wait: a flash stopped
-- on its inverted BGP step and repainted the UI in inverted shades, and
-- a pic part-way through a slide or grow-in stayed off screen, which is
-- the "the screen went inverted" / "a Pokemon just vanished" pair.
if s.phase == "waitRemote" then
s:tickFx()
return -- the other side is still choosing
end
if s.phase == "messages" and s.afterQueue == "linkNext" then
s:tickFx()
if not s:updateQueue() then
s.afterQueue = "menu"
s.phase = "menu"
@@ -582,7 +631,7 @@ function LinkBattle.new(game, net, opts)
if s.turnClock <= 0 then
s.turnClockActive = false
send({ type = "forfeit" })
endWithResult(s, "lose", "Time's up! You\nforfeit the match.")
endWithResult(s, "lose", Strings("Time's up! You\nforfeit the match."))
end
elseif opts.turnLimit then
s.turnClockActive = false
@@ -643,17 +692,16 @@ function LinkBattle.newSpectator(game, net, opts)
local guestName = opts.guestName or "GUEST"
if not Handshake.battleAllowed(opts.verdict) then
return nil, "Link battle needs\nthe same mods on\nboth games."
return nil, Strings("Link battle needs\nthe same mods on\nboth games.")
end
local unpackOpts = { strict = opts.strict or false, forceLevel = opts.forceLevel }
local hostParty, hostErr = unpackParty(game, opts.hostParty, unpackOpts, function(p)
return ("%s's %s can't\nbattle on this\ngame."):format(hostName, tostring(p.species))
return Strings("%s's %s can't\nbattle on this\ngame.", hostName, tostring(p.species))
end)
if not hostParty then return nil, hostErr end
local guestParty, guestErr = unpackParty(game, opts.guestParty, unpackOpts, function(p, why)
return ("%s's %s can't\nbattle on this\ngame.\n(%s)"):format(
guestName, tostring(p.species), tostring(why))
return Strings("%s's %s can't\nbattle on this\ngame.\n(%s)", guestName, tostring(p.species), tostring(why))
end)
if not guestParty then return nil, guestErr end
if #hostParty == 0 or #guestParty == 0 then
@@ -672,7 +720,7 @@ function LinkBattle.newSpectator(game, net, opts)
self.enemyParty = guestParty
self.playerParty = hostParty
self.opponentName = guestName
self.introText = ("%s vs %s!"):format(hostName, guestName)
self.introText = Strings("%s vs %s!", hostName, guestName)
local function orderMove(action)
if action and action.id then return game.data.moves[action.id] end
@@ -712,7 +760,7 @@ function LinkBattle.newSpectator(game, net, opts)
battle = s, side = s.sides[2], battler = s.enemy, previous = previous,
})
s.enemySendingOut = true
s:sayNext(("%s sent\nout %s!"):format(guestName, s.enemy.name))
s:sayNext(Strings("%s sent\nout %s!", guestName, s.enemy.name))
s:actNext(function()
s.enemySendingOut = false
s:startGrowIn(s.enemy)
@@ -741,6 +789,13 @@ function LinkBattle.newSpectator(game, net, opts)
end
s:act(function()
-- the two real players cleared their flinch flags when their move
-- menu opened; a spectator has no menu, so it does it here instead,
-- at the same point in the turn (see BattleState:clearTurnFlinches).
-- Without this a flinch survived into the next turn and ate a move
-- that landed in the real match, and the replay -- sharing the RNG
-- stream -- was watching a different battle from that point on.
s:clearTurnFlinches()
local hostAction = hostMsg.kind ~= "switch" and hostMsg.kind ~= "run"
and decodeWireAction(s, hostMsg, s.player) or nil
local guestAction = guestMsg.kind ~= "switch" and guestMsg.kind ~= "run"
@@ -789,7 +844,7 @@ function LinkBattle.newSpectator(game, net, opts)
return
end
end
s:sayNext(("%s is out of\nPOKéMON!\f%s wins!"):format(hostName, guestName))
s:sayNext(Strings("%s is out of\nPOKéMON!\f%s wins!", hostName, guestName))
s.result = "guestWin"
s.afterQueue = "finish"
end
@@ -801,7 +856,7 @@ function LinkBattle.newSpectator(game, net, opts)
return
end
end
s:sayNext(("%s is out of\nPOKéMON!\f%s wins!"):format(guestName, hostName))
s:sayNext(Strings("%s is out of\nPOKéMON!\f%s wins!", guestName, hostName))
s.result = "hostWin"
s.afterQueue = "finish"
end
@@ -836,7 +891,11 @@ function LinkBattle.newSpectator(game, net, opts)
if net.closed and not s.linkEnded and not s.result then
endSpectate(s)
end
-- same as the real-participant loop: every path that skips baseUpdate
-- still has to advance the presentational clock, and a spectator sits in
-- waitBoth between every single turn
if s.phase == "messages" and s.afterQueue == "linkNext" then
s:tickFx()
if not s:updateQueue() then
s.afterQueue = "waitBoth"
s.phase = "waitBoth"
@@ -844,6 +903,7 @@ function LinkBattle.newSpectator(game, net, opts)
return
end
if s.phase == "menu" or s.phase == "waitBoth" then
s:tickFx()
return -- frozen between resolved turns; never a real decision here
end
baseUpdate(s, dt)
+112 -47
View File
@@ -11,6 +11,7 @@ local Protocol = require("src.link.Protocol")
local Runtime = require("src.mods.Runtime")
local Screens = require("src.ui.Screens")
local TextBox = require("src.render.TextBox")
local Strings = require("src.core.Strings")
local LinkState = {}
LinkState.__index = LinkState
@@ -31,7 +32,12 @@ local function indexOf(list, value)
end
local function levelForWire(v)
return v == ANY and nil or v
-- ANY ("use each mon's real level") goes on the wire as nil (no forced
-- level). An explicit guard, not `v == ANY and nil or v`: that idiom's
-- true branch is nil, so it falls through to `or v` and returned the
-- literal "ANY" string, which then crashed math.floor in unpackMon (#204).
if v == ANY then return nil end
return v
end
local function forceLevelLabel(v)
@@ -67,6 +73,9 @@ end
function LinkState.new(game)
local self = setmetatable({}, LinkState)
self.game = game
-- a link session runs at 1X on both machines whatever either player set
-- GAME SPEED to (see Game:logicSpeed); cleared in exitWith
game.linkSession = true
self.stage = "menu"
self.index = 1
self.addr = ipDigits(Net.lanIP())
@@ -85,13 +94,14 @@ function LinkState.newJoinOnline(game, code)
self.stage = "onlineJoining"
else
self.stage = "menu" -- exitWith below needs a real stage to unwind from
self:exitWith("Link error:\n" .. (self.net.error or "?"))
self:exitWith(Strings("Link error:\n%s", self.net.error or "?"))
end
return self
end
function LinkState:exitWith(message, reason)
DiscordPresence.setJoinCode(nil)
self.game.linkSession = nil -- back to the player's own GAME SPEED
Runtime.emit("link.ended", { reason = reason or (message and "error" or "bye") })
if self.net then self.net:close() end
self.game.stack:pop()
@@ -100,6 +110,46 @@ function LinkState:exitWith(message, reason)
end
end
-- Online play meets strangers, so it requires vanilla on both ends
-- (Handshake.onlineAllowed). Mods merge into the shared Data registries at
-- boot and there is no unmerge, so switching them off has to go through a
-- relaunch -- but the player should not have to go find the mod manager and
-- work out which mods count. This turns every enabled mod off, records
-- them so the mod manager can put them back, and relaunches. The restart
-- is confirmed rather than silent: it drops unsaved progress.
function LinkState:offerVanillaRestart()
local game = self.game
local loader = game.mods
local mods = Handshake.mods(game)
local names = {}
for i, mod in ipairs(mods) do
if i > 2 then break end
names[#names + 1] = tostring(mod.id):upper():sub(1, 12)
end
local list = table.concat(names, ", ")
if #mods > #names then list = list .. (" +%d"):format(#mods - #names) end
local text = Strings(
"Online play runs\nvanilla for both\nplayers.\fTurn off %s\nand restart?", list)
self.game.linkSession = nil
Runtime.emit("link.ended", { reason = "error" })
if self.net then self.net:close() end
game.stack:pop()
game.stack:push(TextBox.new(game, text, nil, { choice = function(yes)
if not yes then return end
-- setEnabled persists the toggle itself (Loader:_saveState), so the
-- relaunch comes up vanilla and the mod manager lists them as disabled
-- for the player to switch back on afterwards
for _, mod in ipairs(mods) do
if loader and loader.setEnabled then loader:setEnabled(mod.id, false) end
end
if game.restartWithMods then
game:restartWithMods()
elseif love.event and love.event.quit then
love.event.quit("restart")
end
end }))
end
-- -------------------------------------------------------------------
-- handshake v2 (D8): both peers announce engine version, api version and
-- a fingerprint of their link surface, and the verdict comes from the two
@@ -169,7 +219,7 @@ function LinkState:update(dt)
if self.net then
self.net:update()
if self.net.error and not PRE_CONNECT_STAGES[self.stage] then
self:exitWith("Link error:\n" .. self.net.error:sub(1, 60))
self:exitWith(Strings("Link error:\n%s", self.net.error:sub(1, 60)))
return
end
-- the peer vanished without a bye (only once the inbox is drained,
@@ -178,7 +228,7 @@ function LinkState:update(dt)
and not PRE_CONNECT_STAGES[self.stage] and self.stage ~= "addrEntry"
and self.stage ~= "codeEntry" and self.stage ~= "notice"
and self.stage ~= "battleRunning" then
self:exitWith("The link was\nbroken.")
self:exitWith(Strings("The link was\nbroken."))
return
end
end
@@ -196,7 +246,7 @@ function LinkState:update(dt)
self.index = 1
elseif self.index == 2 or self.index == 3 then
if not Handshake.onlineAllowed(self.game) then
self:exitWith("Online play needs\nno mods enabled.\fDisable them in\nSTART > MODS.")
self:offerVanillaRestart()
return
end
if self.index == 2 then
@@ -222,7 +272,7 @@ function LinkState:update(dt)
if self.net:host() then
self.stage = "hosting"
else
self:exitWith("Link error:\n" .. (self.net.error or "?"))
self:exitWith(Strings("Link error:\n%s", self.net.error or "?"))
end
else
self.stage = "addrEntry"
@@ -241,7 +291,7 @@ function LinkState:update(dt)
if self.net:hostOnline() then
self.stage = "onlineHosting"
else
self:exitWith("Link error:\n" .. (self.net.error or "?"))
self:exitWith(Strings("Link error:\n%s", self.net.error or "?"))
end
else
self.stage = "codeEntry"
@@ -279,7 +329,7 @@ function LinkState:update(dt)
if self.net:joinOnline(nil, code) then
self.stage = "onlineJoining"
else
self:exitWith("Link error:\n" .. (self.net.error or "?"))
self:exitWith(Strings("Link error:\n%s", self.net.error or "?"))
end
end
@@ -318,7 +368,7 @@ function LinkState:update(dt)
if self.net:join(table.concat(octets, ".")) then
self.stage = "joining"
else
self:exitWith("Link error:\n" .. (self.net.error or "?"))
self:exitWith(Strings("Link error:\n%s", self.net.error or "?"))
end
end
@@ -428,7 +478,7 @@ function LinkState:update(dt)
end
if not battle then
self.net:send({ type = "bye" })
self:exitWith(why or "Link battle\ncan't start.", "error")
self:exitWith(why or Strings("Link battle\ncan't start."), "error")
return
end
self.game.stack:push(battle)
@@ -486,15 +536,24 @@ function LinkState:updateTrade(input)
local t = self.trade
if t.stage == "cancelled" then
self:exitWith(t.error and ("The trade stopped:\n%s."):format(t.error)
or "The trade was\ncancelled.")
self:exitWith(t.error and Strings("The trade stopped:\n%s.", t.error)
or Strings("The trade was\ncancelled."))
return
end
if t.stage == "done" then
local sent = t.party[t.myPick]
local received, evoTo = t:apply(self.game)
-- Autosave the instant the swap commits into game.save.party, matching the
-- Cable Club: pokered engine/link/cable_club.asm calls SaveSAVtoSRAM
-- (engine/menus/save.asm) right after every trade so the trade is on the
-- cartridge before the animation runs. Without this the received mon
-- lives only in memory until a manual START-menu save, so a force-quit
-- would lose it and a reset would clone the sent mon (#222). Guarded so
-- headless LinkBattle-style fake games with no writeSave are unaffected.
if self.game.writeSave then self.game:writeSave() end
local name = received.nickname or self.game.data.pokemon[received.species].name
Runtime.emit("link.ended", { reason = "done" })
self.game.linkSession = nil -- this path pops without exitWith
self.net:close()
self.game.stack:pop()
local game = self.game
@@ -507,10 +566,16 @@ function LinkState:updateTrade(input)
enemyOtId = received.otId,
onDone = function()
game.stack:push(TextBox.new(game,
("Trade completed!\f%s received\n%s!"):format(game.save.player.name, name),
Strings("Trade completed!\f%s received\n%s!", game.save.player.name, name),
function()
if evoTo then
require("src.pokemon.Evolution").evolve(game, received, evoTo)
-- via="TRADE": a trade evolution cannot be B-cancelled
-- (pokered LINK_STATE_TRADING skips the flash B-poll) (#213).
-- Re-save once the evolution movie finishes so the evolved
-- species (not the pre-evo landed by t:apply) is what persists,
-- keeping disk in step with the autosave above (#222).
require("src.pokemon.Evolution").evolve(game, received, evoTo,
function() if game.writeSave then game:writeSave() end end, "TRADE")
end
end))
end,
@@ -528,7 +593,7 @@ function LinkState:updateTrade(input)
-- the trade) -- B is dead after that, matching the A branch's own
-- self.confirmed == nil guard
self.net:send({ type = "bye" })
self:exitWith("The trade was\ncancelled.")
self:exitWith(Strings("The trade was\ncancelled."))
elseif t.stage == "picking" and input:wasPressed("a") then
if t:canPick(self.index) then
self.net:send(t:pick(self.index))
@@ -555,30 +620,30 @@ end
function LinkState:draw()
if self.stage == "menu" then
drawTitle("BOIS CLUB LIVE")
Font.draw("LINK CABLE (LAN)", 32, 44)
Font.draw("ONLINE MATCH", 32, 60)
Font.draw("TOURNAMENT", 32, 76)
Font.draw(Strings("LINK CABLE (LAN)"), 32, 44)
Font.draw(Strings("ONLINE MATCH"), 32, 60)
Font.draw(Strings("TOURNAMENT"), 32, 76)
Font.drawCode(CURSOR, 24, 44 + (self.index - 1) * 16)
elseif self.stage == "lanMenu" then
drawTitle("LINK CABLE (LAN)")
Font.draw("HOST A GAME", 32, 48)
Font.draw("JOIN A GAME", 32, 68)
Font.draw(Strings("HOST A GAME"), 32, 48)
Font.draw(Strings("JOIN A GAME"), 32, 68)
Font.drawCode(CURSOR, 24, self.index == 1 and 48 or 68)
Font.draw("UDP port " .. Net.defaultPort(), 8, 128)
Font.draw(Strings("UDP port %s", Net.defaultPort()), 8, 128)
elseif self.stage == "onlineMenu" then
drawTitle("ONLINE MATCH")
Font.draw("HOST ONLINE", 32, 48)
Font.draw("JOIN ONLINE", 32, 68)
Font.draw(Strings("HOST ONLINE"), 32, 48)
Font.draw(Strings("JOIN ONLINE"), 32, 68)
Font.drawCode(CURSOR, 24, self.index == 1 and 48 or 68)
elseif self.stage == "onlineHosting" then
drawTitle("HOSTING ONLINE")
Font.draw("Tell your friend", 16, 40)
Font.draw("the code:", 16, 52)
Font.draw(Strings("Tell your friend"), 16, 40)
Font.draw(Strings("the code:"), 16, 52)
Font.draw(self.net.code or "??????", 32, 68)
Font.draw("Waiting for join...", 8, 96)
Font.draw(Strings("Waiting for join..."), 8, 96)
elseif self.stage == "codeEntry" then
drawTitle("ENTER CODE")
@@ -590,18 +655,18 @@ function LinkState:draw()
Font.drawCode(0xEE, x, 76) -- ▼ under the active slot
end
end
Font.draw("A: connect B: back", 8, 128)
Font.draw(Strings("A: connect B: back"), 8, 128)
elseif self.stage == "onlineJoining" then
drawTitle("CONNECTING...")
Font.draw("Calling...", 8, 56)
Font.draw(Strings("Calling..."), 8, 56)
Font.draw(self.net.target or "", 8, 72)
elseif self.stage == "hosting" then
drawTitle("HOSTING")
Font.draw("Friend joins at:", 16, 48)
Font.draw(Strings("Friend joins at:"), 16, 48)
Font.draw(self.net.address or "?", 16, 64)
Font.draw("Waiting for join...", 8, 96)
Font.draw(Strings("Waiting for join..."), 8, 96)
elseif self.stage == "addrEntry" then
drawTitle("ENTER HOST ADDRESS")
@@ -616,34 +681,34 @@ function LinkState:draw()
for octet = 1, 3 do
Font.draw(".", 16 + octet * 32 - 8, 64)
end
Font.draw("Port: " .. Net.defaultPort(), 16, 96)
Font.draw("A: connect B: back", 8, 128)
Font.draw(Strings("Port: %s", Net.defaultPort()), 16, 96)
Font.draw(Strings("A: connect B: back"), 8, 128)
elseif self.stage == "joining" then
drawTitle("JOINING...")
Font.draw("Calling...", 8, 56)
Font.draw(Strings("Calling..."), 8, 56)
Font.draw(self.net.target or "", 8, 72)
elseif self.stage == "modeSelect" then
drawTitle("CONNECTED!")
Font.draw("TRADE", 32, 48)
Font.draw("BATTLE", 32, 68)
Font.draw(Strings("TRADE"), 32, 48)
Font.draw(Strings("BATTLE"), 32, 68)
Font.drawCode(CURSOR, 24, self.index == 1 and 48 or 68)
elseif self.stage == "battleOptions" then
drawTitle("BATTLE OPTIONS")
Font.draw("LEVELS:", 16, 56)
Font.draw(Strings("LEVELS:"), 16, 56)
Font.draw(forceLevelLabel(self.levelChoice or ANY), 88, 56)
Font.draw("A: continue B: back", 8, 128)
Font.draw(Strings("A: continue B: back"), 8, 128)
elseif self.stage == "waitMode" or self.stage == "waitHello" then
drawTitle("CONNECTED!")
if self.stage == "waitHello" then
Font.draw("Checking the", 16, 56)
Font.draw("other game...", 16, 72)
Font.draw(Strings("Checking the"), 16, 56)
Font.draw(Strings("other game..."), 16, 72)
else
Font.draw("Waiting for the", 16, 56)
Font.draw("host to choose...", 16, 72)
Font.draw(Strings("Waiting for the"), 16, 56)
Font.draw(Strings("host to choose..."), 16, 72)
end
elseif self.stage == "notice" then
@@ -652,12 +717,12 @@ function LinkState:draw()
if i > 8 then break end -- what fits above the prompt row
Font.draw(line, 8, 24 + (i - 1) * 12)
end
Font.draw(self.noticeExits and "A: back" or "A: trade anyway", 8, 128)
Font.draw(self.noticeExits and "A: back" or Strings("A: trade anyway"), 8, 128)
elseif self.stage == "trade" then
drawTitle("TRADE")
local t = self.trade
Font.draw("YOURS", 8, 20)
Font.draw(Strings("YOURS"), 8, 20)
for i, mon in ipairs(self.game.save.party) do
local def = self.game.data.pokemon[mon.species]
local label = (mon.nickname or def.name):sub(1, 8)
@@ -665,7 +730,7 @@ function LinkState:draw()
Font.draw(label, 16, 20 + i * 12)
if i == self.index then Font.drawCode(CURSOR, 8, 20 + i * 12) end
end
Font.draw("THEIRS", 84, 20)
Font.draw(Strings("THEIRS"), 84, 20)
for i, mon in ipairs(t.theirParty or {}) do
local def = self.game.data.pokemon[mon.species]
Font.draw((mon.nickname or def.name):sub(1, 8), 92, 20 + i * 12)
@@ -676,16 +741,16 @@ function LinkState:draw()
elseif t.stage == "waitParty" then hint = "Exchanging data..."
elseif t.stage == "picking" then
hint = t:canPick(self.index) and "Pick one to trade"
or "X: not on theirs"
or Strings("X: not on theirs")
elseif t.stage == "waitPick" then hint = "Waiting for them..."
elseif t.stage == "confirming" then
hint = self.confirmed and "Waiting..." or "A: trade B: cancel"
hint = self.confirmed and "Waiting..." or Strings("A: trade B: cancel")
end
Font.draw(hint or "", 8, 132)
elseif self.stage == "battleWait" or self.stage == "battleRunning" then
drawTitle("LINK BATTLE")
Font.draw("Exchanging data...", 16, 64)
Font.draw(Strings("Exchanging data..."), 16, 64)
end
love.graphics.setColor(1, 1, 1, 1)
end
+13 -9
View File
@@ -26,6 +26,7 @@
local Json = require("src.link.Json")
local Logger = require("src.core.Logger")
local Strings = require("src.core.Strings")
local hasEnet, enet = pcall(require, "enet")
if not hasEnet then enet = nil end
@@ -173,7 +174,7 @@ function Net:connectTCP(addr)
tcp:settimeout(5)
local ok, err = tcp:connect(host, port)
if not ok then
self.error = ("can't reach relay %s:%d\n(%s)"):format(host, port, tostring(err))
self.error = Strings("can't reach relay %s:%d\n(%s)", host, port, tostring(err))
return false
end
tcp:settimeout(0)
@@ -236,11 +237,14 @@ local function handleGenericRelayControl(self, msg)
self.paired = true
return true
elseif msg.type == "join_error" then
self.error = ({
not_found = "That code wasn't\nfound.",
full = "That game already\nhas two players.",
expired = "That code has\nexpired.",
})[msg.reason] or ("Couldn't join:\n%s"):format(tostring(msg.reason))
self.error = Strings(({
not_found = Strings.source("That code wasn't\nfound."),
full = Strings.source("That game already\nhas two players."),
expired = Strings.source("That code has\nexpired."),
})[msg.reason] or "")
if self.error == "" then
self.error = Strings("Couldn't join:\n%s", tostring(msg.reason))
end
self.closed = true
return true
elseif msg.type == "peer_gone" then
@@ -328,7 +332,7 @@ function Net:update()
-- an unreachable join target surfaces as a service error
-- (ICMP unreachable on the connected UDP socket)
if self.mode == "joining" and not self.paired then
self.error = ("no answer from\n%s"):format(self.target or "the host")
self.error = Strings("no answer from\n%s", self.target or "the host")
else
self.error = tostring(event)
end
@@ -358,14 +362,14 @@ function Net:update()
self.closed = true
if not self.paired then
self.error = self.error or
("no answer from\n%s"):format(self.target or "the host")
Strings("no answer from\n%s", self.target or "the host")
end
end
end
end
if self.mode == "joining" and not self.paired
and self.joinDeadline and now() > self.joinDeadline then
self.error = ("no answer from\n%s"):format(self.target or "the host")
self.error = Strings("no answer from\n%s", self.target or "the host")
self.closed = true
pcall(function() self.peer:disconnect_now() end)
end
+30 -4
View File
@@ -43,7 +43,12 @@ Protocol.plainCopy = plainCopy
-- serialize a mon instance for the wire (plain data only). ppUps rides
-- along because the real cable transmitted it and its absence silently
-- capped a PP-Upped move at base PP on the receiving side.
-- capped a PP-Upped move at base PP on the receiving side. ot/otId ride
-- along too: pokered's trade sends the whole party block including each
-- mon's OT ID (party_struct MON_OTID) and the OT-names block (wPartyMonOT),
-- and the receiver keeps them verbatim -- a differing OT/ID is what marks a
-- mon as traded (boosted EXP, high-level disobedience). Omitting them made
-- a received mon show the receiver as its OT (#215).
function Protocol.packMon(mon)
local moves = {}
for _, mv in ipairs(mon.moves) do
@@ -59,6 +64,8 @@ function Protocol.packMon(mon)
dvs = mon.dvs,
statExp = mon.statExp,
moves = moves,
ot = mon.ot,
otId = mon.otId,
extra = plainCopy(mon.extra),
}
end
@@ -71,6 +78,14 @@ function Protocol.unpackMon(data, packed, opts)
local Stats = require("src.pokemon.Stats")
local Growth = require("src.pokemon.Growth")
local strict = opts and opts.strict
-- forceLevel comes from an "auto-level" ruling. The picker's ANY choice
-- ("use each mon's real level", Gen1's only mode) is a string sentinel on
-- the LinkState/Tournament side (see levelForWire) that must mean "no
-- forced level" here. Coerce once so a non-numeric level string -- the ANY
-- sentinel, an old peer, or a mod (#204) -- can never reach math.floor
-- below: tonumber("ANY") == nil, i.e. keep the packed real level, while
-- tonumber(50)/tonumber("50") both give 50.
local forceLevel = opts and tonumber(opts.forceLevel) or nil
local def = data.pokemon[packed.species]
if not def then
if strict then return nil, "unknown POKéMON" end
@@ -81,8 +96,8 @@ function Protocol.unpackMon(data, packed, opts)
-- ignored and everyone rebuilds at the same fixed level instead, so a
-- Lv12 and a Lv100 party can battle on equal footing. Both sides pass
-- the identical forceLevel for a given match, so this stays symmetric.
if opts and opts.forceLevel then
level = math.max(2, math.min(100, math.floor(opts.forceLevel)))
if forceLevel then
level = math.max(2, math.min(100, math.floor(forceLevel)))
end
local dvs = {}
for _, k in ipairs({ "hp", "attack", "defense", "speed", "special" }) do
@@ -115,10 +130,19 @@ function Protocol.unpackMon(data, packed, opts)
-- current HP/status (a different level's numbers, possibly mid-fight)
-- isn't meaningful anymore -- auto-level starts everyone full and fresh,
-- same as a standardized tournament format would
local forced = opts and opts.forceLevel
local forced = forceLevel
local hp = forced and stats.hp
or math.max(0, math.min(stats.hp, math.floor(packed.hp or stats.hp)))
local status = forced and nil or packed.status
-- preserve the sender's original-trainer identity (party_struct MON_OTID +
-- wPartyMonOT on a real cable), clamped/typed like every other field so a
-- tampered packet can't inject a bad ID or a huge name. Left nil when the
-- packet omits them (a v1/old peer) -- no worse than before for that legacy
-- path, and once ot is set the load-time stampOT backfill (mon.ot or ...)
-- becomes a no-op so the sender's identity survives save/reload (#215).
local otId = packed.otId
and math.max(0, math.min(65535, math.floor(packed.otId))) or nil
local ot = type(packed.ot) == "string" and packed.ot:sub(1, 10) or nil
return {
species = packed.species,
level = level,
@@ -129,6 +153,8 @@ function Protocol.unpackMon(data, packed, opts)
hp = hp,
status = status,
nickname = packed.nickname,
ot = ot,
otId = otId,
moves = moves,
-- a namespace whose mod this install lacks survives untouched, so the
-- mon keeps it for the trip home
+47 -36
View File
@@ -18,6 +18,7 @@ local Protocol = require("src.link.Protocol")
local Runtime = require("src.mods.Runtime")
local Sound = require("src.core.Sound")
local TextBox = require("src.render.TextBox")
local Strings = require("src.core.Strings")
local Tournament = {}
Tournament.__index = Tournament
@@ -57,7 +58,12 @@ local function levelLabel(v)
end
local function levelForWire(v)
return v == ANY and nil or v
-- ANY ("use each mon's real level") goes on the wire as nil (no forced
-- level). An explicit guard, not `v == ANY and nil or v`: that idiom's
-- true branch is nil, so it falls through to `or v` and returned the
-- literal "ANY" string, which then crashed math.floor in unpackMon (#204).
if v == ANY then return nil end
return v
end
local function forceLevelLabel(v)
@@ -85,6 +91,11 @@ function Tournament.new(game)
self.settingsIndex = 1
self.roster = {}
self.spectatorRoster = {}
-- everything from here to exitWith runs at 1X regardless of the GAME
-- SPEED option (see Game:logicSpeed): a tournament's shot clock counts
-- down on the logic step, so fast-forward would hand one player less
-- real time to choose than the opponent they are racing
game.linkSession = true
Sound.startLoop(game.data, MUSIC)
return self
end
@@ -107,6 +118,7 @@ end
function Tournament:exitWith(message)
DiscordPresence.setJoinCode(nil)
self.game.linkSession = nil -- back to the player's own GAME SPEED
Sound.stopLoop(MUSIC)
Runtime.emit("link.ended", { reason = message and "error" or "bye" })
if self.net then self.net:close() end
@@ -123,7 +135,7 @@ end
function Tournament:startHosting()
self.net = Net.new()
if not self.net:connectTCP(Net.defaultRelayAddress()) then
self:exitWith("Link error:\n" .. (self.net.error or "?"))
self:exitWith(Strings("Link error:\n%s", self.net.error or "?"))
return
end
local size, minL, maxL = partyStats(self.game.save.party)
@@ -146,7 +158,7 @@ end
function Tournament:startJoining(code)
self.net = Net.new()
if not self.net:connectTCP(Net.defaultRelayAddress()) then
self:exitWith("Link error:\n" .. (self.net.error or "?"))
self:exitWith(Strings("Link error:\n%s", self.net.error or "?"))
return
end
local size, minL, maxL = partyStats(self.game.save.party)
@@ -166,9 +178,9 @@ end
-- -------------------------------------------------------------------
local JOIN_ERROR_TEXT = {
not_found = "That code wasn't\nfound.",
already_started = "That tournament\nhas already begun.",
expired = "That code has\nexpired.",
not_found = Strings.source("That code wasn't\nfound."),
already_started = Strings.source("That tournament\nhas already begun."),
expired = Strings.source("That code has\nexpired."),
}
function Tournament:handleMessage(msg)
@@ -193,17 +205,17 @@ function Tournament:handleMessage(msg)
end
elseif msg.type == "tournament_host_error" then
if msg.reason == "party_ineligible" then
self:exitWith(("Can't host:\nneed %d Pokemon\nLv %s-%s."):format(
msg.requiredPartySize, levelLabel(msg.minLevel), levelLabel(msg.maxLevel)))
self:exitWith(Strings("Can't host:\nneed %d Pokemon\nLv %s-%s.", msg.requiredPartySize, levelLabel(msg.minLevel), levelLabel(msg.maxLevel)))
else
self:exitWith("Couldn't host\nthat tournament.")
self:exitWith(Strings("Couldn't host\nthat tournament."))
end
elseif msg.type == "tournament_join_error" then
if msg.reason == "party_ineligible" then
self:exitWith(("Your party needs\n%d Pokemon, Lv\n%s-%s."):format(
msg.requiredPartySize, levelLabel(msg.minLevel), levelLabel(msg.maxLevel)))
self:exitWith(Strings("Your party needs\n%d Pokemon, Lv\n%s-%s.", msg.requiredPartySize, levelLabel(msg.minLevel), levelLabel(msg.maxLevel)))
else
self:exitWith(JOIN_ERROR_TEXT[msg.reason] or "Couldn't join\nthat tournament.")
self:exitWith(JOIN_ERROR_TEXT[msg.reason]
and Strings(JOIN_ERROR_TEXT[msg.reason])
or Strings("Couldn't join\nthat tournament."))
end
elseif msg.type == "tournament_roster" then
self.roster = msg.players
@@ -276,7 +288,7 @@ function Tournament:beginMatchBattle()
-- shouldn't happen (both sides already passed the online-play mods
-- gate), but a mismatched engine/build is still possible -- bail out
-- of just this match rather than crash the tournament
self:exitWith("Link error:\nversion mismatch\nwith opponent.")
self:exitWith(Strings("Link error:\nversion mismatch\nwith opponent."))
return
end
self.linkSeed = self.isHost and love.math.random(1, 2 ^ 30) or nil
@@ -346,11 +358,11 @@ function Tournament:update(dt)
self.net:update()
if self.net.error and self.stage ~= "menu" and self.stage ~= "hostSettings"
and self.stage ~= "codeEntry" then
self:exitWith("Link error:\n" .. self.net.error:sub(1, 60))
self:exitWith(Strings("Link error:\n%s", self.net.error:sub(1, 60)))
return
end
if self.net.closed and self.stage ~= "done" then
self:exitWith("The tournament\nconnection was\nlost.")
self:exitWith(Strings("The tournament\nconnection was\nlost."))
return
end
end
@@ -376,7 +388,7 @@ function Tournament:update(dt)
and LinkBattle.newHost(self.game, self.net, self.pendingBattleOpts)
or LinkBattle.newGuest(self.game, self.net, self.pendingBattleOpts)
if not battle then
self:exitWith(why or "Link battle\ncan't start.")
self:exitWith(why or Strings("Link battle\ncan't start."))
return
end
-- anything after `party` in this same batch belongs to the
@@ -413,7 +425,7 @@ function Tournament:update(dt)
forceLevel = levelForWire(self.settings.forceLevel),
})
if not battle then
self:exitWith(why or "Can't watch this\nmatch.")
self:exitWith(why or Strings("Can't watch this\nmatch."))
return
end
for j = #msgs, i + 1, -1 do
@@ -536,8 +548,8 @@ local SETTINGS_LABELS = { "POKEMON", "MIN LV", "MAX LV", "TIMER", "LEVELS", "PLA
function Tournament:draw()
if self.stage == "menu" then
drawTitle("TOURNAMENT")
Font.draw("HOST", 32, 48)
Font.draw("JOIN", 32, 68)
Font.draw(Strings("HOST"), 32, 48)
Font.draw(Strings("JOIN"), 32, 68)
Font.drawCode(CURSOR, 24, self.index == 1 and 48 or 68)
elseif self.stage == "hostSettings" then
@@ -556,7 +568,7 @@ function Tournament:draw()
Font.draw(values[i], 96, y)
if i == self.settingsIndex then Font.drawCode(CURSOR, 8, y) end
end
Font.draw("START: create", 8, 128)
Font.draw(Strings("START: create"), 8, 128)
elseif self.stage == "codeEntry" then
drawTitle("ENTER CODE")
@@ -568,28 +580,27 @@ function Tournament:draw()
Font.drawCode(0xEE, x, 76)
end
end
Font.draw("A: join B: back", 8, 128)
Font.draw(Strings("A: join B: back"), 8, 128)
elseif self.stage == "registering" then
drawTitle("CONNECTING...")
Font.draw("B: cancel", 8, 128)
Font.draw(Strings("B: cancel"), 8, 128)
elseif self.stage == "bracket" or self.stage == "matchHello"
or self.stage == "matchWaitParty" or self.stage == "spectateWait" then
drawTitle(("TOURNAMENT %s"):format(self.code or "??????"))
drawTitle(Strings("TOURNAMENT %s", self.code or "??????"))
if self.bracket then
local y = 20
for _, round in ipairs(self.bracket.rounds) do
Font.draw(("ROUND %d"):format(round.round), 8, y)
Font.draw(Strings("ROUND %d", round.round), 8, y)
y = y + 10
for _, m in ipairs(round.matches) do
local line
if m.bye then
line = ("%s (bye)"):format(m.a or m.b or "?")
line = Strings("%s (bye)", m.a or m.b or "?")
else
local mark = m.state == "live" and "*" or (m.winner and "" or "")
line = ("%s%s vs %s%s"):format(
m.winner == m.a and ">" or " ", m.a or "?",
line = Strings("%s%s vs %s%s", m.winner == m.a and ">" or " ", m.a or "?",
m.b or "?", m.winner == m.b and "<" or (mark == "*" and " *" or ""))
end
Font.draw(line, 12, y)
@@ -599,11 +610,11 @@ function Tournament:draw()
end
else
if self.participating == false then
Font.draw("(organizing --", 16, 32)
Font.draw("not playing)", 16, 42)
Font.draw(Strings("(organizing --"), 16, 32)
Font.draw(Strings("not playing)"), 16, 42)
end
Font.draw("Waiting for", 16, 48)
Font.draw("players to join:", 16, 60)
Font.draw(Strings("Waiting for"), 16, 48)
Font.draw(Strings("players to join:"), 16, 60)
local y = 60
for i, name in ipairs(self.roster) do
y = 60 + i * 10
@@ -615,18 +626,18 @@ function Tournament:draw()
end
end
if self.isCreator and #self.roster >= 2 and not self.bracket then
Font.draw("A: START B: cancel", 8, 132)
Font.draw(Strings("A: START B: cancel"), 8, 132)
else
Font.draw("B: cancel", 8, 132)
Font.draw(Strings("B: cancel"), 8, 132)
end
elseif self.stage == "done" then
drawTitle("TOURNAMENT OVER")
if self.champion then
Font.draw(("%s is the"):format(self.champion), 16, 56)
Font.draw("champion!", 16, 68)
Font.draw(Strings("%s is the", self.champion), 16, 56)
Font.draw(Strings("champion!"), 16, 68)
end
Font.draw("A: continue", 8, 128)
Font.draw(Strings("A: continue"), 8, 128)
end
love.graphics.setColor(1, 1, 1, 1)
end
+88 -9
View File
@@ -3,17 +3,29 @@
-- manifests only. The full loader (src/mods/Loader.lua) still owns the real
-- load at boot; this reads the same options.mods enable-state the loader
-- writes, derives per-mod status with the pure ManagerState.resolveToggle,
-- and installs a dropped/chosen .zip into the save-dir "mods/<id>/" tree.
-- installs a dropped/chosen .zip into a "mods/<id>/" tree, and uninstalls a
-- mod by removing that tree + clearing options.mods[id].
--
-- Where that tree lives is CacheFs's call, not love.filesystem's: a portable
-- install (portable.txt beside the executable) keeps its mods in the game
-- folder like everything else it owns, and only the OS save directory
-- otherwise (#330 -- love.filesystem.write always resolves to the save dir,
-- so the installer used to strand every mod in appdata). Reads stay on
-- love.filesystem: the portable folder is on the physfs read path either way
-- (it IS the source for a `love <gamedir>` run, and CacheFs mounts it for a
-- fused build), which is why those mods still loaded while landing in the
-- wrong place.
--
-- Split in two: the pure derivation (deriveList, locateRoot) has no love and
-- no filesystem, so the engine tier can table-drive it; the discovery and
-- install paths reach for love.filesystem and SaveData.
-- no filesystem, so the engine tier can table-drive it; the discovery,
-- install, and uninstall paths reach for love.filesystem and SaveData.
local Manifest = require("src.mods.Manifest")
local ManagerState = require("src.mods.ManagerState")
local Semver = require("src.mods.Semver")
local Version = require("src.core.Version")
local SaveData = require("src.core.SaveData")
local CacheFs = require("src.import.CacheFs")
local LauncherMods = {}
@@ -142,6 +154,13 @@ local function discover()
local fs = love and love.filesystem
local out = {}
if not (fs and fs.getInfo and fs.getDirectoryItems) then return out end
-- A fused portable build keeps its mods in the game folder next to the
-- executable; resolving the cache root is what mounts that folder onto the
-- physfs read path, so this is what makes those mods enumerable at all
-- (#330). A source run needs nothing (the game folder IS the source), and
-- the launcher's readiness check has usually resolved it already; the call
-- is cached and idempotent.
CacheFs.root()
if not fs.getInfo("mods") then return out end
local seen = {}
for _, name in ipairs(fs.getDirectoryItems("mods")) do
@@ -232,11 +251,14 @@ local function topLevelPaths(mount)
return paths
end
-- Copy the mounted archive subtree at `src` to the install path `dst`. Reads
-- come from love.filesystem (the .zip is mounted there); every write goes
-- through CacheFs so it lands in the portable game folder when portable.txt is
-- in play and in the OS save directory otherwise (#330). No explicit mkdir:
-- CacheFs.write creates the parent chain on both paths, which also means an
-- empty folder inside the .zip is simply not carried over (it holds nothing).
local function copyTree(src, dst)
local fs = love.filesystem
if not fs.createDirectory(dst) then
return nil, "could not create " .. dst
end
for _, name in ipairs(fs.getDirectoryItems(src)) do
local s = src .. "/" .. name
local d = dst .. "/" .. name
@@ -247,13 +269,18 @@ local function copyTree(src, dst)
else
local data = fs.read(s)
if data == nil then return nil, "could not read " .. name end
local ok, err = fs.write(d, data)
local ok, err = CacheFs.write(d, data)
if not ok then return nil, "could not write " .. name .. ": " .. tostring(err) end
end
end
return true
end
-- Delete an installed mod subtree. Enumeration stays on love.filesystem (the
-- portable game folder is on its read path), but the deletes go through
-- CacheFs so a portable install's real files actually go away instead of
-- love.filesystem no-opping outside the save directory (#330). Directories
-- are removed after their children, since rmdir refuses a non-empty one.
local function removeTree(path)
local fs = love.filesystem
local info = fs.getInfo(path)
@@ -262,7 +289,15 @@ local function removeTree(path)
for _, child in ipairs(fs.getDirectoryItems(path)) do
removeTree(path .. "/" .. child)
end
CacheFs.removeDir(path)
else
CacheFs.remove(path)
end
-- A portable install can still be carrying a pre-#330 copy in the OS save
-- directory, which is where every install used to land and which physfs
-- searches first. CacheFs only touched the game folder, so clear the
-- save-directory twin too or that copy would keep the mod alive; outside
-- portable mode this repeats the delete CacheFs just did and no-ops.
fs.remove(path)
end
@@ -321,10 +356,19 @@ function LauncherMods.installZip(source)
return nil, "a mod named '" .. manifest.id .. "' is already installed"
end
fs.createDirectory("mods")
-- CacheFs.prefix steers ROM-cache writes into a version subtree (blue/...);
-- the mods tree is shared by Red and Blue, so pin the prefix to the root for
-- the copy and the rollback, then hand back whatever the launcher had set
-- (an import coroutine leaves it pointed at that version -- RomImporter.lua).
-- No fs.createDirectory("mods") here any more: CacheFs.write creates the
-- parent chain in both homes, and doing it through love.filesystem would
-- only ever make the directory in the save dir (#330).
local savedPrefix = CacheFs.prefix
CacheFs.prefix = ""
local copied, copyErr = copyTree(root, dest)
if not copied then removeTree(dest) end
CacheFs.prefix = savedPrefix
if not copied then
removeTree(dest)
cleanup()
return nil, copyErr or "could not copy the mod files"
end
@@ -332,4 +376,39 @@ function LauncherMods.installZip(source)
return true, manifest.id
end
-- uninstall(id) -> true | nil, errString
-- Removes mods/<id>/ from wherever it was installed (the portable game folder
-- or the save directory, CacheFs decides -- #330) and clears options.mods[id]
-- so the loader and in-game manager no longer see it. Rejects 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
-- same root pin as installZip: the mods tree is not version-prefixed (#330)
local savedPrefix = CacheFs.prefix
CacheFs.prefix = ""
removeTree(dest)
CacheFs.prefix = savedPrefix
-- 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
+6 -1
View File
@@ -68,6 +68,7 @@ local devShim = { installed = false, permissions = {}, warned = {}, depth = 0 }
local SUPPORTED_REQUIRES = {
["src.mods.Semver"] = true,
["src.audio.ChipAsm"] = true,
["src.pokemon.Stats"] = true, -- Stats.isShiny / calc for indicator mods
}
local function scanRequire(name)
@@ -161,7 +162,11 @@ function Loader:_loadState()
if next(options.mods or {}) == nil and self.fs.getInfo
and self.fs.getInfo(MOD_STATE_FILE) then
local chunk = self.fs.load(MOD_STATE_FILE)
local ok, state = chunk and pcall(chunk)
-- `chunk and pcall(chunk)` truncates to one value, so state came back nil
-- however well the chunk ran and the migration below never fired once:
-- the guard has to be a statement for pcall's second return to survive.
local ok, state = false, nil
if chunk then ok, state = pcall(chunk) end
if ok and type(state) == "table" then
for id, disabled in pairs(state) do
if disabled then
+21 -20
View File
@@ -10,6 +10,7 @@ local Semver = require("src.mods.Semver")
local Version = require("src.core.Version")
local Theme = require("src.ui.Theme")
local OptionRows = require("src.ui.OptionRows")
local Strings = require("src.core.Strings")
local ManagerState = {}
ManagerState.__index = ManagerState
@@ -269,7 +270,7 @@ function ManagerState:modRows()
end
end
if #rows == 0 then
rows[1] = { header = true, label = "NO MODS INSTALLED" }
rows[1] = { header = true, label = Strings("NO MODS INSTALLED") }
end
return rows
end
@@ -281,7 +282,7 @@ function ManagerState:profileRows()
rows[#rows + 1] = { profile = p, label = p.name,
glyph = opts.activeProfile == p.name and GLYPH.errored or " " }
end
rows[#rows + 1] = { saveAs = true, label = "SAVE CURRENT AS.." }
rows[#rows + 1] = { saveAs = true, label = Strings("SAVE CURRENT AS..") }
rows[#rows + 1] = { adhoc = true,
label = opts.activeProfile and "[AD-HOC]" or "[AD-HOC] (LIVE)" }
return rows
@@ -316,30 +317,30 @@ function ManagerState:detailRows(m)
rows[#rows + 1] = { label = m.enabled and "DISABLE" or "ENABLE",
action = function() self:beginToggle(m) end }
if self:schemaFor(m) then
rows[#rows + 1] = { label = "OPTIONS..",
rows[#rows + 1] = { label = Strings("OPTIONS.."),
action = function() self:openOptions(m) end }
end
if m.permissions and #m.permissions > 0 then
rows[#rows + 1] = { label = "PERMISSIONS..",
rows[#rows + 1] = { label = Strings("PERMISSIONS.."),
action = function() self:goTo("permissions") end }
end
if m.error then
rows[#rows + 1] = { label = "VIEW ERROR..",
rows[#rows + 1] = { label = Strings("VIEW ERROR.."),
action = function() self:goTo("errors") end }
end
rows[#rows + 1] = { label = "BACK", action = function() self:goBack() end }
rows[#rows + 1] = { label = Strings("BACK"), action = function() self:goBack() end }
return rows
end
function ManagerState:applyRows()
local rows = {}
rows[#rows + 1] = { label = "APPLY & RESTART", action = function()
rows[#rows + 1] = { label = Strings("APPLY & RESTART"), action = function()
self:openConfirm({ "RESTART NOW?" }, function() self:restartGame() end)
end }
rows[#rows + 1] = { label = "DISCARD CHANGES", action = function()
rows[#rows + 1] = { label = Strings("DISCARD CHANGES"), action = function()
self:discardChanges()
end }
rows[#rows + 1] = { label = "BACK", action = function() self:goBack() end }
rows[#rows + 1] = { label = Strings("BACK"), action = function() self:goBack() end }
return rows
end
@@ -352,7 +353,7 @@ function ManagerState:permissionRows(m)
label = info and info.text or name }
end
if #rows == 0 then
rows[1] = { inert = true, label = "DATA & API ONLY" }
rows[1] = { inert = true, label = Strings("DATA & API ONLY") }
end
return rows
end
@@ -564,7 +565,7 @@ function ManagerState:openCascade(r, m, want)
else
local dep = self.byId[r.alsoDisable[1]]
lines[#lines + 1] = (dep and dep.name or r.alsoDisable[1]) .. " NEEDS THIS."
lines[#lines + 1] = #r.alsoDisable > 1 and "DISABLE ALL?" or "DISABLE BOTH?"
lines[#lines + 1] = #r.alsoDisable > 1 and "DISABLE ALL?" or Strings("DISABLE BOTH?")
end
self.overlay = { kind = "confirm", lines = lines, index = 1,
onYes = function() self:commitToggle(r.apply) end }
@@ -700,7 +701,7 @@ end
function ManagerState:saveCurrentAs()
local NamingScreen = require("src.ui.NamingScreen")
self.game.stack:push(NamingScreen.new(self.game, {
title = "PROFILE NAME?",
title = Strings("PROFILE NAME?"),
maxLen = 10,
onDone = function(name)
local opts = self:optionsTable()
@@ -726,7 +727,7 @@ end
function ManagerState:renameProfile(p)
local NamingScreen = require("src.ui.NamingScreen")
self.game.stack:push(NamingScreen.new(self.game, {
title = "RENAME?",
title = Strings("RENAME?"),
maxLen = 10,
default = p.name,
onDone = function(name)
@@ -886,7 +887,7 @@ function ManagerState:buildOptionRows(m, schema)
end }
end
end
rows[#rows + 1] = { id = "__reset", label = "RESET DEFAULTS",
rows[#rows + 1] = { id = "__reset", label = Strings("RESET DEFAULTS"),
value = function() return "" end,
activate = function()
for _, row in ipairs(schema) do
@@ -1047,7 +1048,7 @@ function ManagerState:drawApply()
y = y + 1
end
if #staged == 0 then
Font.draw(Runtime.safeMode and "SAFE MODE" or "NO CHANGES", 16, y * 8)
Font.draw(Runtime.safeMode and "SAFE MODE" or Strings("NO CHANGES"), 16, y * 8)
y = y + 1
end
local rows = self:rowsForScreen()
@@ -1076,12 +1077,12 @@ function ManagerState:drawOverlay()
end
if overlay.kind == "confirm" then
local yesY = ty + #lines + 1
Font.draw("YES", 5 * 8, yesY * 8)
Font.draw("NO", 5 * 8, (yesY + 1) * 8)
Font.draw(Strings("YES"), 5 * 8, yesY * 8)
Font.draw(Strings("NO"), 5 * 8, (yesY + 1) * 8)
Font.drawCode(Theme.cursor, 4 * 8,
(overlay.index == 1 and yesY or yesY + 1) * 8)
else
Font.draw("A:OK", 5 * 8, (ty + #lines + 1) * 8)
Font.draw(Strings("A:OK"), 5 * 8, (ty + #lines + 1) * 8)
end
end
@@ -1090,7 +1091,7 @@ function ManagerState:draw()
OptionRows.draw(self.game, self.optionRows or {}, self.cursor,
self.scroll or 0)
love.graphics.setColor(0, 0, 0, 1)
Font.draw(self.notice or "B:DONE (NO RESTART)", 8, 136)
Font.draw(self.notice or Strings("B:DONE (NO RESTART)"), 8, 136)
love.graphics.setColor(1, 1, 1, 1)
if self.overlay then self:drawOverlay() end
return
@@ -1099,7 +1100,7 @@ function ManagerState:draw()
love.graphics.rectangle("fill", 0, 0, 160, 144)
love.graphics.setColor(1, 1, 1, 1)
Font.drawBox(0, 0, 20, 18)
Font.draw(self.banner or "MOD MANAGER", 16, 8)
Font.draw(self.banner or Strings("MOD MANAGER"), 16, 8)
if self.screen == "list" then
self:drawList()
elseif self.screen == "detail" then
+31 -2
View File
@@ -584,6 +584,17 @@ R.text = {
example = 'mod.content.text:override("_PalletTownText1", "HELLO!")',
}
-- The engine's own authored text, the half of the game `text` does not
-- cover (src/core/Strings.lua). The id is the English source string, so a
-- translation supplies one entry per literal the engine draws and anything
-- it has not reached yet keeps rendering in English. Ids that collide in
-- English but not elsewhere carry a "context|source" prefix.
R.strings = {
semantics = "record", target = "strings",
value = f.str,
example = 'mod.content.strings:override("But, it failed!", "Echec !")',
}
-- the self-contained bytecode blob ChipAsm.song/sfx emit (13.1 shape 2);
-- shared by every namespace that plays a chip program
local chipProgram = f.rec{
@@ -918,8 +929,20 @@ R.battle_sprite_scales = {
-- 1 = native pixels; the drawn size relative to the pic's own pixels
scale = f.numRange(0.25, 4.0),
},
example = 'mod.content.battle_sprite_scales:register("abra_back", ' ..
'{ path = "assets/generated/battle/back/abrab.png", scale = 1.5 })',
example = [[
mod.content.battle_sprite_scales:register("abra_back", {
path = "assets/generated/battle/back/abrab.png",
scale = 1.5,
})]],
notes = [[
Scales one battle pic by its asset path, overriding the species-level
`battleScaleFront`/`battleScaleBack` (see [pokemon](#pokemon)) for that
image. The only way to scale a pic that is not species-keyed, like the
player's trainer back sprite. Resolution order at draw time:
image-level, then species-level, then the defaults (1x front, 2x back).
The pic stays grounded at any scale: player feet stay flush on the
text-box top, the enemy pic stays bottom-pinned in its slot, and the
scale composes with the send-out grow animation.]],
}
-- ------- progression
@@ -1146,6 +1169,12 @@ R.field = {
name = f.opt(f.str) })),
nest = f.opt(f.any) },
flyOrder = f.list(f.str),
-- the player's own trainer art (FieldDefaults.PLAYER_PICS): the battle
-- back pic, the catch tutorial's old man, and the front pic the intro,
-- trainer card and Hall of Fame share. Every key is optional so a
-- conversion can replace one pic and inherit the rest.
playerPics = f.rec{
back = f.opt(f.str), demoBack = f.opt(f.str), front = f.opt(f.str) },
-- the new-game and boot config a total conversion replaces
boot = f.rec{
startMap = f.opt(f.str), startX = f.opt(f.int(0)), startY = f.opt(f.int(0)),
+67 -11
View File
@@ -14,6 +14,7 @@ local Runtime = require("src.mods.Runtime")
local Screens = require("src.ui.Screens")
local Stats = require("src.pokemon.Stats")
local TextBox = require("src.render.TextBox")
local Strings = require("src.core.Strings")
local Evolution = {}
@@ -23,7 +24,7 @@ Evolution.METHODS = {
return trigger.kind == "levelup" and mon.level >= (evo.level or 0)
end,
describe = function(evo)
return ("Level %d"):format(evo.level or 0)
return Strings("Level %d", evo.level or 0)
end,
},
ITEM = {
@@ -106,21 +107,67 @@ function Evolution.apply(game, mon, newSpecies, via)
})
end
-- After the "evolved into" text, Gen1 re-runs the level-up learn check on
-- the EVOLVED species (engine/pokemon/evos_moves.asm EvolveMon calls the
-- LearnMoveFromLevelUp predef, engine/pokemon/learn_move.asm) -- a mon
-- evolving at exactly a learnset level gains that move (GYARADOS learns
-- BITE at 20, so MAGIKARP->GYARADOS @20 learns BITE, @21 does not) (#12).
-- Mirrors the rare-candy learn loop in src/ui/BagMenu.lua so a full move
-- list opens the forget prompt. mon.species is already the new species
-- (Evolution.apply ran before the congrats text). onDone runs once the
-- learn list is exhausted, replacing the caller's direct onDone.
function Evolution.learnEvolutionMoves(game, mon, onDone)
local Experience = require("src.battle.Experience")
local def = game.data.pokemon[mon.species]
-- movesLearnedAt uses entry.level == level (exact Gen1 rule); do NOT use
-- Pokemon.movesAtLevel (<= level), which would over-grant older moves.
local moves = Experience.movesLearnedAt(def, mon.level)
local i = 0
local function nextStep()
i = i + 1
local moveId = moves[i]
if not moveId then
if onDone then onDone() end
return
end
for _, mv in ipairs(mon.moves) do
if mv.id == moveId then return nextStep() end
end
local mdef = game.data.moves[moveId]
if not mdef then return nextStep() end
local name = mon.nickname or def.name
if #mon.moves < 4 then
table.insert(mon.moves, { id = moveId, pp = mdef.pp })
Runtime.emit("pokemon.move_learned", { mon = mon, moveId = moveId })
game.stack:push(TextBox.new(game,
Strings("%s learned\n%s!", name, mdef.name), nextStep))
else
-- LearnMoveFromLevelUp with a full moveset: the forget UI
Screens.push(game, "MoveLearnMenu", mon, moveId, nextStep)
end
end
nextStep()
end
-- Play the evolution movie (flashing forms), then apply + text.
-- Headless (no real graphics) falls back to the plain text flow.
function Evolution.evolve(game, mon, newSpecies, onDone, via)
if love.image and love.image.newImageData then
Screens.push(game, "EvolutionState", mon, newSpecies, onDone)
-- forward `via` so EvolutionState can keep trade evolutions
-- non-cancelable (LINK_STATE_TRADING) while others accept B (#213)
Screens.push(game, "EvolutionState", mon, newSpecies, onDone, via)
return
end
Music.play(game.data, Music.special(game.data, "evolution"))
local oldName = mon.nickname or game.data.pokemon[mon.species].name
Evolution.apply(game, mon, newSpecies, via)
local msg = ("What?\n%s is\nevolving!\fCongratulations!\nYour %s\nevolved into\n%s!")
:format(oldName, oldName, game.data.pokemon[newSpecies].name)
local msg = Strings("What?\n%s is\nevolving!\fCongratulations!\nYour %s\nevolved into\n%s!",
oldName, oldName, game.data.pokemon[newSpecies].name)
game.stack:push(TextBox.new(game, msg, function()
Music.restoreMap(game.data)
if onDone then onDone() end
-- re-run the evolved species' level-up learn check before onDone
-- (evos_moves.asm EvolveMon -> learn_move.asm LearnMoveFromLevelUp, #12)
Evolution.learnEvolutionMoves(game, mon, onDone)
end))
end
@@ -138,13 +185,22 @@ function Evolution.request(game, mon, trigger, onDone)
return species
end
-- After-battle hook: evolve everyone who qualifies (queued one at a time).
function Evolution.checkParty(game, onDone)
-- After-battle hook: evolve mons that leveled this battle and still
-- qualify (queued one at a time, party order). Gen1 EvolveAfterBattle
-- only considers mons that gained a level during the fight -- a B-cancel
-- means "not this time", and the next offer waits for the next level-up
-- (or Rare Candy / stone, which call Evolution.evolve directly).
-- leveledUp is a set of party mon tables; nil/empty yields no evolutions.
function Evolution.checkParty(game, onDone, leveledUp)
local pending = {}
for _, mon in ipairs(game.save.party) do
local target, evo = Evolution.pendingFor(game, mon, { kind = "levelup" })
if target then
table.insert(pending, { mon = mon, to = target, via = evo and evo.method })
if leveledUp then
for _, mon in ipairs(game.save.party) do
if leveledUp[mon] then
local target, evo = Evolution.pendingFor(game, mon, { kind = "levelup" })
if target then
table.insert(pending, { mon = mon, to = target, via = evo and evo.method })
end
end
end
end
local i = 0
+5 -1
View File
@@ -31,7 +31,7 @@ end
-- Day Care retrieve (pokered WriteMonMoves + wLearningMovesFromDayCare):
-- grant learnset moves with startLevel < moveLevel <= newLevel, shifting
-- the oldest slot out when full. Silent no LearnMove prompts.
-- the oldest slot out when full. Silent -- no LearnMove prompts.
function Pokemon.learnMovesFromDayCare(data, mon, speciesDef, startLevel, newLevel)
if not (speciesDef and speciesDef.learnset and mon) then return end
mon.moves = mon.moves or {}
@@ -75,6 +75,10 @@ function Pokemon.new(data, species, level, rng)
statExp = { hp = 0, attack = 0, defense = 0, speed = 0, special = 0 },
stats = stats,
hp = stats.hp,
-- the Gen1 catch-rate byte freezes at catch time: evolution does NOT
-- update it, so PKHeX expects a preevolution's rate on an evolved mon
-- (#206). Evolution.apply mutates in place and never touches this.
catchRate = def.catchRate,
status = nil, -- "SLP"|"PSN"|"BRN"|"FRZ"|"PAR"
moves = moves,
}
+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

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