Compare commits

...

18 Commits

Author SHA1 Message Date
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 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
bryanthaboi 3069b2e2a9 The new experience (#201)
* new launcher and save converts and pipeline

* fixing bugs
2026-07-25 12:36:53 -04:00
bryanthaboi 6625391f76 online play updates and fixes and tweaks and cheeks 2026-07-24 14:36:10 -04:00
bryanthaboi c40dcd6159 Merge branch 'online-play' 2026-07-24 13:31:07 -04:00
bryanthaboi 35f5c9513c Update BattleState.lua 2026-07-24 13:30:54 -04:00
bryanthaboi 5a0f9ab06d Online play (#156)
* new options, and hot keys and readme clean up

* oooooooooooh bazinga
2026-07-24 13:11:40 -04:00
bryanthaboi 13b58dc51e oooooooooooh bazinga 2026-07-24 13:09:00 -04:00
bryanthaboi d872010209 new options, and hot keys and readme clean up (#148) 2026-07-24 10:12:27 -04:00
bryanthaboi 6306975352 new options, and hot keys and readme clean up 2026-07-24 10:10:51 -04:00
bryanthaboi 819150f50f Bugs and stuff (#146)
* main menu scrollable when over 8 items

* buggies

* more buggies

* Lorelei, Bruno, and Agatha now push their AfterBattle text right after a win

* more and more bugs
2026-07-24 09:26:43 -04:00
bryanthaboi bfba1f7bb7 Bugs and stuff (#144)
* main menu scrollable when over 8 items

* buggies

* more buggies

* Lorelei, Bruno, and Agatha now push their AfterBattle text right after a win
2026-07-24 09:06:29 -04:00
bryanthaboi 8278b209b4 Squadhing som bug (#130)
* squashy squash

* squassshheee

CLOSES #128
CLOSES #114
CLOSES #113
CLOSES #111
CLOSES #110
CLOSES #109
CLOSES #107
CLOSES #106
CLOSES #105
CLOSES #90
CLOSES #103

* brock guy fix

CLOSES #39
2026-07-23 16:55:03 -04:00
bryanthaboi 1c4515eaaa squashy squash (#127) 2026-07-23 15:42:54 -04:00
bryanthaboi c04b93ce1c pokecenter fix (#126) 2026-07-23 15:17:20 -04:00
310 changed files with 36597 additions and 3214 deletions
+14 -1
View File
@@ -25,7 +25,9 @@ body:
options:
- macOS
- Windows
- Both
- Linux
- Android
- Multiple platforms
validations:
required: true
@@ -38,6 +40,17 @@ body:
validations:
required: true
- type: input
id: discord
attributes:
label: Discord username (optional)
description: >
So maintainers can ping you on Discord if they need a quick follow-up.
Leave blank if you'd rather keep everything on GitHub.
placeholder: yourname
validations:
required: false
- type: input
id: location
attributes:
+34 -19
View File
@@ -1,35 +1,53 @@
name: Feature request
description: Ask for something new, or a change to how something already works.
description: Ask for something new in the engine, launcher, or platform — not a content/gameplay mod.
labels: ["enhancement"]
body:
- type: markdown
attributes:
value: |
Use this for **engine / launcher / platform** work (ports, video options, save
tooling, networking, mod API seams, docs).
If what you want is a gameplay, cosmetic, audio, or QoL change that a Lua mod
could ship — running shoes, alternate sprites, day/night, shiny indicators,
Gen 2-like battle toggles, soundtrack packs — open a
**[Mod request](https://github.com/bryanthaboi/pokemon-gen1-recomp-project/issues/new?template=mod_request.yml)**
instead.
"Can we add X" on its own is hard to act on. Say what you want, why you want it,
and how you picture it working. A request with real detail is one someone can
actually build without guessing at what you meant.
and how you picture it working.
- type: input
id: summary
attributes:
label: One line summary
description: What you want, in a sentence.
placeholder: Add a run toggle so you can move at bike speed without the bike
placeholder: Add Linux AppImage releases next to the macOS and Windows builds
validations:
required: true
- type: input
id: discord
attributes:
label: Discord username (optional)
description: >
So maintainers can ping you on Discord if they need a quick follow-up.
Leave blank if you'd rather keep everything on GitHub.
placeholder: yourname
validations:
required: false
- type: textarea
id: what
attributes:
label: What do you want
description: >
Describe it properly. What is it, where does it live in the game, what does the
player see or do. If it changes something that already exists, say what it does today
and what it should do instead.
Describe it properly. What is it, where does it live (launcher, options,
engine), what does the player see or do. If it changes something that already
exists, say what it does today and what it should do instead.
placeholder: |
A hold-to-run button, the way later Pokemon games do it. Hold B while moving on
foot and the player moves at bike speed. Release and you go back to walking.
Doesn't work indoors where the bike doesn't work, doesn't work in battles or menus.
Ship a Linux AppImage on each release, same version as the macOS/Windows builds,
with the same save folder layout and mod discovery path.
validations:
required: true
@@ -41,9 +59,8 @@ body:
What's annoying or missing right now. What does this fix. If it's just because you
think it would be fun, say that, it's a real answer.
placeholder: |
Backtracking across routes you've already cleared is slow, and the bike is a menu
dive away and doesn't work in a lot of places. Running would cut a lot of dead time
without making the game easier.
LÖVE already runs on Linux; without a packaged build, players have to assemble
it themselves and miss release notes / update checks.
validations:
required: true
@@ -52,14 +69,12 @@ body:
attributes:
label: How should it work
description: >
The specifics. Which button, which menu, what happens in the edge cases. If you don't
The specifics. Which menu, what happens in the edge cases. If you don't
know, say what you'd expect as a player and leave the rest open.
placeholder: |
- Hold B on the overworld to run
- Same speed as the bike
- Disabled anywhere the bike is disabled
- Sprite should probably need a running animation, not sure what that takes
- No effect on encounter rates
- GitHub Releases asset next to the .dmg / .exe
- Same options.lua / mods/ layout as desktop
- Documented in the README install section
validations:
required: true
+115
View File
@@ -0,0 +1,115 @@
name: Mod request
description: Ask for a gameplay, cosmetic, audio, or QoL change that belongs as a Lua mod.
labels: ["mod request"]
body:
- type: markdown
attributes:
value: |
This tracker is for ideas that should ship as **mods**, not as core engine
features — alternate sprites, running shoes, day/night, shiny indicators,
soundtrack packs, Gen 2-like battle toggles, map cosmetics, bag QoL, etc.
The engine already exposes a lot of this through registries and hooks
([modding wiki](https://github.com/bryanthaboi/pokemon-gen1-recomp-project/wiki)).
Use a **Feature request** instead for launcher / ports / video options /
networking / save tooling / new API seams.
- type: input
id: summary
attributes:
label: One line summary
description: What the mod should do, in a sentence.
placeholder: Hold B to run at bike speed on the overworld
validations:
required: true
- type: input
id: discord
attributes:
label: Discord username (optional)
description: >
So maintainers or mod authors can ping you on Discord if they pick this up.
Leave blank if you'd rather keep everything on GitHub.
placeholder: yourname
validations:
required: false
- type: textarea
id: what
attributes:
label: What should the mod do
description: >
Describe the player-facing behavior. What changes, where, what does the
player see or press. If it toggles from Options or a START-menu entry, say so.
placeholder: |
Hold B while walking outdoors to move at bike speed. Release to walk again.
Same places the bike is allowed; no effect in battles or menus.
validations:
required: true
- type: textarea
id: why
attributes:
label: Why is this worth doing as a mod
description: >
Why optional/modded rather than a core option. Who wants it on, who wants
vanilla left alone.
placeholder: |
Great for replaying and backtracking, but some people want a strict Gen 1
pace. A mod (or an opt-in mod option) keeps both camps happy.
validations:
required: true
- type: textarea
id: how
attributes:
label: How should it work
description: >
Buttons, menus, edge cases, whether it needs new art/audio. If you know a
hook or registry that fits (movement.speed, pokemon.sprite, rulesets, …),
mention it — otherwise leave it open.
placeholder: |
- Hold B on the overworld
- Same step timing as the bike
- Disabled where the bike is disabled
- Prefer hooks:wrap("movement.speed") if that still fits
validations:
required: true
- type: dropdown
id: vanilla
attributes:
label: With the mod off, is vanilla unchanged
options:
- Yes — parity when disabled
- No — it would replace something always-on
- Not sure
validations:
required: true
- type: dropdown
id: category
attributes:
label: Best-fit mod category
description: Same taxonomy as example mods (BALANCE, GRAPHICS, AUDIO, …).
options:
- GAMEPLAY / QoL
- GRAPHICS
- AUDIO
- BALANCE / ruleset
- CONTENT (maps, encounters, trainers)
- UI / TOOL
- TOTAL_CONVERSION-ish
- Not sure
validations:
required: true
- type: textarea
id: extra
attributes:
label: Anything else
description: >
Reference screenshots, other games/hacks that do it, related issues, or
"I'd like to try writing this myself." Leave blank if nothing comes to mind.
validations:
required: false
+3 -1
View File
@@ -14,7 +14,9 @@ name: ci
on:
push:
branches: [main]
# Integration branch + release branch. PRs already run via pull_request
# (any base); this list is only for post-merge push runs.
branches: [dev, main]
pull_request:
# a force-push while CI is mid-run should cancel the stale run, not queue
+19 -1
View File
@@ -10,6 +10,9 @@ name: Release
# - To force a specific version, either:
# * run this workflow manually (Actions tab) and type it into "version", or
# * put "[release X.Y.Z]" anywhere in the commit message.
#
# Branch model: day-to-day work merges to `dev`. Releases stay on `main` only
# so promoting `dev` -> `main` is the ship gate that cuts a build.
on:
push:
@@ -214,8 +217,21 @@ jobs:
apk="$(find dist/android/debug -name '*.apk' | head -1)"
[ -n "$apk" ] || { echo "::error::no Android APK found under dist/android/debug"; exit 1; }
cp "$apk" "$outdir/gen1recomp-${v}-android.apk"
# 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"
[ -f "$love_file" ] || { echo "::error::$love_file not found (expected from scripts/build.sh)"; exit 1; }
cp "$love_file" "$outdir/gen1recomp-${v}.love"
ls -lh "$outdir"
# Checksums for every staged release asset (sums file itself is
# written after this and named outside the gen1recomp-* glob, so it
# never lists itself).
(cd "$outdir" && shasum -a 256 gen1recomp-* > sha256sums.txt)
cat "$outdir/sha256sums.txt"
- name: Publish GitHub Release
env:
GH_TOKEN: ${{ github.token }}
@@ -266,7 +282,9 @@ jobs:
"dist/release/gen1recomp-${v}-macos.zip" \
"dist/release/gen1recomp-${v}-windows.zip" \
"dist/release/gen1recomp-${v}-linux.zip" \
"dist/release/gen1recomp-${v}-android.apk"
"dist/release/gen1recomp-${v}-android.apk" \
"dist/release/gen1recomp-${v}.love" \
"dist/release/sha256sums.txt"
echo "Published release $tag"
+42 -17
View File
@@ -1,18 +1,19 @@
# Pokemon Gen 1 Recompilation Project
A native LÖVE2D recreation of Pokemon Red. The engine and map behavior are
hand-written Lua; game data and graphics are decoded from a ROM supplied by
the player.
A native LÖVE2D recreation of Pokemon 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 ROM is the only game
content input.
or download a disassembly. A canonical US Pokemon Red or Blue ROM is the only
game content input.
The ROM is verified, used during import, and then released from memory. It is
not copied into the cache. Later launches load the private generated cache and
do not ask for the ROM again.
do not ask for the ROM again. Red and Blue can both be imported and played
side by side.
## Quick Start
@@ -20,24 +21,52 @@ Open the desktop app. On first boot, choose your legally obtained `.gb` file
or drop it onto the window. Import takes a few seconds and the game starts
automatically.
Only the canonical 1 MiB US Red ROM is accepted. The importer verifies SHA-1
`ea9bcae617fdf159b045185467ae58b2e4a48b9a` before creating any game data.
Only the canonical 1 MiB US Red and Blue ROMs are accepted. The importer
verifies SHA-1 before creating any game data:
- Red: `ea9bcae617fdf159b045185467ae58b2e4a48b9a`
- Blue: `d7037c83e1ae5b39bde3c30787637ba1d4c48ce2`
The packaged app contains neither a ROM nor pre-extracted game data. Music,
sound effects, and cries are synthesized while the game runs from compact
audio channel programs copied out of the verified ROM.
## Controls
arrow keys or WASD move; Z, Enter, or Space is A; X or Backspace is
B; Escape opens START. F1 saves and F2 loads. Controllers are supported.
| 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 |
COLORS, TILT, ZOOM, GBC FX, and VOID FILL are also in the Options menu
and persist in `options.lua`.
## Running From Source
Requires LÖVE 11.x. Place the ROM in the project folder and double-click
`Play-Mac.command` or `Play-Windows.bat`, or run:
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"
scripts/setup.sh --rom "/path/to/Pokemon Red.gb" # or Pokemon Blue.gb
scripts/run.sh
```
@@ -107,7 +136,3 @@ request with real detail is one that can actually get built.
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.
## Wanna Support My Work?
[![Buy Me a Coffee](https://img.shields.io/badge/Buy_Me_a_Coffee-FFDD00?style=for-the-badge&logo=buy-me-a-coffee&logoColor=black)](https://buymeacoffee.com/bryanthaboi)
Binary file not shown.

Before

Width:  |  Height:  |  Size: 370 KiB

After

Width:  |  Height:  |  Size: 753 KiB

+9
View File
@@ -0,0 +1,9 @@
# On-screen touch control art
From Xelu's "Free Controller & Keyboard Prompts" pack (Nicolae Berbece /
Those Awesome Guys), public domain under CC0:
https://thoseawesomeguys.com/prompts/
256px Nintendo Switch set, renamed for src/core/TouchControls.lua:
d-pad (neutral + per-direction highlight), A, B, plus (START) and
minus (SELECT).
Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 751 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

+3 -2
View File
@@ -47,8 +47,9 @@ function love.conf(t)
-- freely to portrait or landscape), otherwise locked to the window's w/h
-- aspect. So a non-resizable tall window forced portrait; resizable lets
-- the game follow the device. The renderer letterboxes the 160x144
-- viewport into whatever size results, and touch input is gesture-based,
-- so both orientations just work. iOS follows the Info.plist orientations
-- viewport into whatever size results, and the on-screen touch controls
-- re-lay themselves out from the new window size, so both orientations
-- just work. iOS follows the Info.plist orientations
-- (see mobile/ios/overlays/love-ios.plist, now portrait + landscape).
t.window.resizable = true
-- Starting size is a tall portrait hint; the OS resizes to the real
+2 -3
View File
@@ -1,9 +1,8 @@
-- Flavor talk scripts for CopycatsHouse2F (registry id COPYCATS_HOUSE_2F).
-- Source: pokered/scripts/CopycatsHouse2F.asm, pokered/text/CopycatsHouse2F.asm
--
-- TEXT_COPYCATSHOUSE2F_COPYCAT is already ported (with the POKE DOLL /
-- TM31 MIMIC trade) in data/scripts/story4.lua, so it is intentionally
-- omitted here.
-- TEXT_COPYCATSHOUSE2F_COPYCAT (POKE DOLL -> TM31 MIMIC) lives in
-- data/scripts/story4.lua.
return {
COPYCATS_HOUSE_2F = {
+5 -18
View File
@@ -1,15 +1,11 @@
-- Pewter City flavor dialogue (pokered/scripts/PewterCity.asm).
-- PewterCity_TextPointers text_asm bodies for the SUPER_NERD1 museum
-- guide, SUPER_NERD2 garden nerd, and the leaving-east YOUNGSTER.
-- guide and SUPER_NERD2 garden nerd.
--
-- The escort choreography (SUPER_NERD1 walking the player to the
-- museum, YOUNGSTER walking the player to the gym) is scripted NPC
-- movement + a wPewterCityCurScript state machine that steers the
-- player off-map; the YOUNGSTER's escort to the gym is handled on this
-- map by story5.lua's onStep gate at the east exit (before
-- EVENT_BEAT_BROCK), while the SUPER_NERD1 museum escort is not ported.
-- Here we only port the real YES/NO-branched flavor text these NPCs
-- speak when talked to.
-- The YOUNGSTER's gym escort (talk + east-exit onStep) lives in
-- story5.lua so the lockstep RLE walk is not overwritten by this
-- flavor merge. SUPER_NERD1's museum escort is not ported; only the
-- YES/NO-branched flavor text is here.
local M = {}
@@ -61,15 +57,6 @@ M.PEWTER_CITY = {
end)
end,
-- PewterCityYoungsterText (scripts/PewterCity.asm): the "follow
-- me" line the youngster says when the player is stopped from
-- leaving Pewter east before beating Brock; the actual gate/step
-- block is handled by story5.lua's onStep for this map.
TEXT_PEWTERCITY_YOUNGSTER = function(game, ow, npc, done)
local t = text(game)
push(game, t._PewterCityYoungsterYoureATrainerFollowMeText
or "You're a trainer\nright? BROCK's\nlooking for new\nchallengers!\nFollow me!", done)
end,
},
}
+22
View File
@@ -0,0 +1,22 @@
-- Red's House 2F (pokered data/events/hidden_events.asm +
-- engine/events/hidden_objects PrintRedSNESText).
--
-- The bedroom SNES is a hidden_event at (3, 5) with ANY_FACING that
-- prints _RedBedroomSNESText. field.py extracts OpenRedsPC on this map
-- but skips PrintRedSNESText, so interaction was a no-op (#135).
local TextBox = require("src.render.TextBox")
return {
REDS_HOUSE_2F = {
-- hidden_events.asm:
-- hidden_event 3, 5, PrintRedSNESText, ANY_FACING
onInteract = function(game, ow, fx, fy)
if fx ~= 3 or fy ~= 5 then return false end
local text = game.data.text._RedBedroomSNESText
or "{PLAYER} is\nplaying the SNES!"
game.stack:push(TextBox.new(game, text))
return true
end,
},
}
+16 -12
View File
@@ -5,19 +5,23 @@ return {
SILPH_CO_9F = {
talk = {
-- SilphCo9FNurseText (pokered/scripts/SilphCo9F.asm):
-- before EVENT_BEAT_SILPH_CO_GIOVANNI: heals the party and shows
-- "You look tired..." then "Don't give up!"; after the event, just
-- says thanks. Nurse texts are not in data/generated/text.lua, so
-- the exact pokered/text/SilphCo9F.asm strings are used as literals.
-- before EVENT_BEAT_SILPH_CO_GIOVANNI: heals the party, white fade
-- (Delay3 between out/in; no Music_PkmnHealed), then "Don't give
-- up!"; after the event, just says thanks. Nurse texts are not in
-- data/generated/text.lua, so the exact pokered/text/SilphCo9F.asm
-- strings are used as literals.
TEXT_SILPHCO9F_NURSE = {
{ "face_player" },
{ "check_flag", "EVENT_BEAT_SILPH_CO_GIOVANNI" },
{ "jump_if_true", 8 },
{ "show_text", "You look tired!\nYou should take a\nquick nap!" },
{ "heal_party" },
{ "show_text", "Don't give up!" },
{ "jump", 9 },
{ "show_text", "Thank you so\nmuch!" },
{ "face_player" }, -- 1
{ "check_flag", "EVENT_BEAT_SILPH_CO_GIOVANNI" }, -- 2
{ "jump_if_true", 11 }, -- 3
{ "show_text", "You look tired!\nYou should take a\nquick nap!" }, -- 4
{ "heal_party" }, -- 5
{ "fade", "out", "white" }, -- 6
{ "wait", 3 }, -- 7 Delay3
{ "fade", "in", "white" }, -- 8
{ "show_text", "Don't give up!" }, -- 9
{ "jump", "end" }, -- 10
{ "show_text", "Thank you so\nmuch!" }, -- 11
},
},
},
+1
View File
@@ -28,6 +28,7 @@ local files = {
"data.scripts.flavor.pokemon_fan_club",
"data.scripts.flavor.power_plant",
"data.scripts.flavor.reds_house_1f",
"data.scripts.flavor.reds_house_2f",
"data.scripts.flavor.route11_gate_2f",
"data.scripts.flavor.route18_gate_2f",
"data.scripts.flavor.route_12_gate_2f",
+174 -81
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);
@@ -24,17 +25,23 @@ local function starterBall(askText, species, choseFlag, ownBall,
{ "check_flag", "EVENT_FOLLOWED_OAK_INTO_LAB" }, -- 3
{ "jump_if_false", 20 }, -- 4
-- the Pokédex "new species" entry shows before the ask (predef
-- StarterDex ahead of OaksLabYouWant...Text)
{ "push_screen", "DexEntryMenu", species }, -- 5
-- StarterDex ahead of OaksLabYouWant...Text). StarterDex temporarily
-- sets the owned bits so ShowPokedexData prints height/weight/text;
-- forceOwned is that bypass without mutating save.pokedex.owned.
{ "push_screen", "DexEntryMenu",
{ species = species, forceOwned = true } }, -- 5
{ "ask", askText }, -- 6
{ "jump_if_false", 21 }, -- 7
{ "give_pokemon", species, 5 }, -- 8
{ "set_flag", "EVENT_GOT_STARTER" }, -- 9
{ "set_flag", choseFlag }, -- 10
-- OaksLab.asm prints ReceivedMon then AddPartyMon (AskName lives
-- inside give_pokemon). Show the received text first so the
-- nickname prompt follows "you got X", matching Gen1.
{ "show_text", "_OaksLabReceivedMonText", { RAM = species } }, -- 8
{ "give_pokemon", species, 5 }, -- 9
{ "set_flag", "EVENT_GOT_STARTER" }, -- 10
{ "set_flag", choseFlag }, -- 11
-- POKé BALLs are not handed out here in the original -- Oak gives
-- them later, at OaksLabOak1Text's .give_poke_balls beat once the
-- player has beaten the Route 22 rival (see TEXT_OAKSLAB_OAK1 below)
{ "show_text", "_OaksLabReceivedMonText", { RAM = species } }, -- 11
{ "hide_object", "OAKS_LAB", ownBall }, -- 12
-- the rival walks to the countering ball (around the furniture)
{ "move_npc_to", 1, rivalBallX, 4 }, -- 13
@@ -53,48 +60,97 @@ end
return {
talk = {
-- Oak: accepts the parcel and hands over the Pokédex, then (once the
-- player has beaten the Route 22 rival) hands over the real POKé
-- BALLs (scripts/OaksLab.asm OaksLabOak1Text, simplified: no rival
-- recall / dex-rating / "pokemon can fight" / "around the world"
-- branches -- see docs/known-differences.md)
-- Oak: OaksLabOak1Text. Parcel delivery kicks SCRIPT_OAKSLAB_RIVAL_
-- ARRIVES_AT_OAKS_REQUEST + OaksLabOakGivesPokedexScript (rival walk-
-- in, full Pokédex speech, rival exit, Route 22 arm). Dex-rating
-- (DisplayDexRating) is still skipped.
TEXT_OAKSLAB_OAK1 = {
{ "face_player" }, -- 1
{ "check_flag", "EVENT_GOT_OAKS_PARCEL" }, -- 2
{ "jump_if_false", 14 }, -- 3
{ "check_flag", "EVENT_OAK_GOT_PARCEL" }, -- 4
{ "jump_if_true", 14 }, -- 5
{ "show_text", "_OaksLabOak1DeliverParcelText" }, -- 6
{ "take_item", "OAKS_PARCEL", 1 }, -- 7
{ "set_flag", "EVENT_OAK_GOT_PARCEL" }, -- 8
{ "show_text", "_OaksLabOak1PokemonAroundTheWorldText" }, -- 9
{ "set_flag", "EVENT_GOT_POKEDEX" }, -- 10
-- the Pokédex swaps Viridian's two old men (OaksLab.asm:602-606:
-- HideObject TOGGLE_LYING_OLD_MAN / ShowObject TOGGLE_OLD_MAN).
-- Until this ran, the walking man at (17,5) -- who owns the coffee
-- ask and the catch tutorial -- stayed OFF for the whole game
-- (toggleable_objects.asm seeds him OFF, the sleeper ON).
{ "hide_object", "VIRIDIAN_CITY", "VIRIDIANCITY_OLD_MAN_SLEEPY" }, -- 11
{ "show_object", "VIRIDIAN_CITY", "VIRIDIANCITY_OLD_MAN" }, -- 12
{ "jump", 32 }, -- 13
{ "check_flag", "EVENT_GOT_STARTER" }, -- 14
{ "jump_if_false", 29 }, -- 15
{ "check_item", "POKE_BALL" }, -- 16
{ "jump_if_true", 27 }, -- 17
{ "check_flag", "EVENT_BEAT_ROUTE22_RIVAL_1ST_BATTLE" }, -- 18
{ "jump_if_false", 31 }, -- 19
{ "check_flag", "EVENT_GOT_POKEBALLS_FROM_OAK" }, -- 20
{ "jump_if_true", 27 }, -- 21
{ "set_flag", "EVENT_GOT_POKEBALLS_FROM_OAK" }, -- 22
{ "give_item", "POKE_BALL", 5, false }, -- 23
{ "show_text", "_OaksLabOak1ReceivedPokeballsText" }, -- 24
{ "show_text", "_OaksLabGivePokeballsExplanationText" }, -- 25
{ "jump", 32 }, -- 26
{ "show_text", "_OaksLabOak1ComeSeeMeSometimesText" }, -- 27
{ "jump", 32 }, -- 28
{ "show_text", "_OaksLabOak1WhichPokemonDoYouWantText" }, -- 29
{ "jump", 32 }, -- 30
{ "show_text", "_OaksLabOak1RaiseYourYoungPokemonText" }, -- 31 (32 = end)
{ "face_player" },
{ "check_item", "POKE_BALL" },
{ "jump_if_true", "come_see" },
{ "check_flag", "EVENT_BEAT_ROUTE22_RIVAL_1ST_BATTLE" },
{ "jump_if_true", "give_balls" },
{ "check_flag", "EVENT_GOT_POKEDEX" },
{ "jump_if_true", "around_world" },
{ "check_flag", "EVENT_BATTLED_RIVAL_IN_OAKS_LAB" },
{ "jump_if_false", "pre_lab_battle" },
{ "check_item", "OAKS_PARCEL" },
{ "jump_if_false", "raise_young" },
-- OaksLabOak1Text.got_parcel → RivalArrives + OakGivesPokedex
{ "show_text", "_OaksLabOak1DeliverParcelText" },
{ "play_sound", "Get_Key_Item" },
{ "show_text", "_OaksLabOak1ParcelThanksText" },
{ "take_item", "OAKS_PARCEL", 1 },
{ "stop_music" },
{ "play_music", "Music_MeetRival" },
{ "show_text", "_OaksLabRivalGrampsText" },
{ "show_object", "OAKS_LAB", "OAKSLAB_RIVAL" },
-- OaksLabCalcRivalMovementScript: sprite map (8,11) → cell (4,7)
-- when the player stands below Oak (the usual desk talk).
{ "place_npc", 1, 4, 7, "up" },
{ "move_npc_to", 1, 4, 3 },
{ "play_music", "Music_OaksLab" },
{ "face_object", 1, "up" },
{ "face_object", 5, "down" },
{ "show_text", "_OaksLabRivalWhatDidYouCallMeForText" },
{ "face_object", 1, "up" },
{ "face_object", 5, "down" },
{ "show_text", "_OaksLabOakIHaveARequestText" },
{ "face_object", 1, "up" },
{ "face_object", 5, "down" },
{ "show_text", "_OaksLabOakMyInventionPokedexText" },
{ "show_text", "_OaksLabOakGotPokedexText" },
{ "play_sound", "Get_Key_Item" },
{ "hide_object", "OAKS_LAB", "OAKSLAB_POKEDEX1" },
{ "hide_object", "OAKS_LAB", "OAKSLAB_POKEDEX2" },
{ "face_object", 1, "up" },
{ "face_object", 5, "down" },
{ "show_text", "_OaksLabOakThatWasMyDreamText" },
{ "face_object", 1, "right" },
{ "show_text", "_OaksLabRivalLeaveItAllToMeText" },
{ "set_flag", "EVENT_GOT_POKEDEX" },
{ "set_flag", "EVENT_OAK_GOT_PARCEL" },
{ "hide_object", "VIRIDIAN_CITY", "VIRIDIANCITY_OLD_MAN_SLEEPY" },
{ "show_object", "VIRIDIAN_CITY", "VIRIDIANCITY_OLD_MAN" },
{ "stop_music" },
{ "play_music", "Music_MeetRival" },
{ "move_npc_to", 1, 4, 7 },
{ "hide_object", "OAKS_LAB", "OAKSLAB_RIVAL" },
{ "play_music", "Music_OaksLab" },
{ "set_flag", "EVENT_1ST_ROUTE22_RIVAL_BATTLE" },
{ "clear_flag", "EVENT_2ND_ROUTE22_RIVAL_BATTLE" },
{ "set_flag", "EVENT_ROUTE22_RIVAL_WANTS_BATTLE" },
{ "show_object", "ROUTE_22", "ROUTE22_RIVAL1" },
{ "jump", "end" },
{ "label", "raise_young" },
{ "show_text", "_OaksLabOak1RaiseYourYoungPokemonText" },
{ "jump", "end" },
{ "label", "pre_lab_battle" },
{ "check_flag", "EVENT_GOT_STARTER" },
{ "jump_if_true", "can_fight" },
{ "show_text", "_OaksLabOak1WhichPokemonDoYouWantText" },
{ "jump", "end" },
{ "label", "can_fight" },
{ "show_text", "_OaksLabOak1YourPokemonCanFightText" },
{ "jump", "end" },
{ "label", "around_world" },
{ "show_text", "_OaksLabOak1PokemonAroundTheWorldText" },
{ "jump", "end" },
{ "label", "give_balls" },
{ "check_flag", "EVENT_GOT_POKEBALLS_FROM_OAK" },
{ "jump_if_true", "come_see" },
{ "set_flag", "EVENT_GOT_POKEBALLS_FROM_OAK" },
{ "give_item", "POKE_BALL", 5, false },
{ "show_text", "_OaksLabOak1ReceivedPokeballsText" },
{ "show_text", "_OaksLabGivePokeballsExplanationText" },
{ "jump", "end" },
{ "label", "come_see" },
{ "show_text", "_OaksLabOak1ComeSeeMeSometimesText" },
},
TEXT_OAKSLAB_CHARMANDER_POKE_BALL =
@@ -107,47 +163,76 @@ 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" },
},
},
-- Saves that got the Pokédex before #106 never wrote objectToggles for
-- the table sprites; re-entering the lab applies the same HideObject
-- the gift script now does (OaksLab.asm OakGivesPokedex).
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, "OAKS_LAB", "OAKSLAB_POKEDEX1")
Commands.hide_object(ctx, "OAKS_LAB", "OAKSLAB_POKEDEX2")
end,
-- Oak stops you leaving without a starter; the rival stops you on
-- the way out for the first battle (scripts/OaksLab.asm
-- 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 },
@@ -186,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 })
+15 -7
View File
@@ -1,15 +1,23 @@
-- Hand-ported from pret/pokered scripts/RedsHouse1F.asm.
-- Mom (RedsHouse1FMomText, text_asm) heals the party and shows the
-- "you should rest" / "looking great" dialogue. The intro "wake up"
-- branch is tied to the unported intro cutscene, so the heal path is used.
-- Mom (RedsHouse1FMomText): pre-starter shows the wake-up / Oak tip;
-- after EVENT_GOT_STARTER, RedsHouse1FMomHealScript fades to white,
-- heals, plays MUSIC_PKMN_HEALED, fades back, then "looking great".
return {
talk = {
TEXT_REDSHOUSE1F_MOM = {
{ "face_player" },
{ "show_text", "_RedsHouse1FMomYouShouldRestText" },
{ "heal_party" },
{ "show_text", "_RedsHouse1FMomLookingGreatText" },
{ "face_player" }, -- 1
{ "check_flag", "EVENT_GOT_STARTER" }, -- 2
{ "jump_if_true", 6 }, -- 3
{ "show_text", "_RedsHouse1FMomWakeUpText" }, -- 4
{ "jump", "end" }, -- 5
-- RedsHouse1FMomHealScript
{ "show_text", "_RedsHouse1FMomYouShouldRestText" }, -- 6
{ "fade", "out", "white" }, -- 7 GBFadeOutToWhite
{ "heal_party" }, -- 8
{ "play_once", "Music_PkmnHealed" }, -- 9 wait + restore map
{ "fade", "in", "white" }, -- 10 GBFadeInFromWhite
{ "show_text", "_RedsHouse1FMomLookingGreatText" }, -- 11
},
},
}
+313 -82
View File
@@ -222,17 +222,55 @@ M.BILLS_HOUSE = {
-- repair saves that already got the ticket under the old collapsed
-- script (the monster never hidden, human Bill never shown)
onEnter = function(game, ow)
if game.save.flags.EVENT_GOT_SS_TICKET
and not game.save.flags.EVENT_USED_CELL_SEPARATOR_ON_BILL then
local flags = game.save.flags
if flags.EVENT_GOT_SS_TICKET
and not flags.EVENT_USED_CELL_SEPARATOR_ON_BILL then
local Commands = require("src.script.Commands")
local ctx = { game = game, save = game.save, overworld = ow }
Commands.hide_object(ctx, "BILLS_HOUSE", "BILLSHOUSE_BILL_POKEMON")
Commands.show_object(ctx, "BILLS_HOUSE", "BILLSHOUSE_BILL1")
game.save.flags.EVENT_BILL_SAID_USE_CELL_SEPARATOR = true
game.save.flags.EVENT_USED_CELL_SEPARATOR_ON_BILL = true
game.save.flags.EVENT_MET_BILL = true
game.save.flags.EVENT_MET_BILL_2 = true
flags.EVENT_BILL_SAID_USE_CELL_SEPARATOR = true
flags.EVENT_USED_CELL_SEPARATOR_ON_BILL = true
flags.EVENT_MET_BILL = true
flags.EVENT_MET_BILL_2 = true
end
-- After Route25ToggleBillsScript, BILL2 is the visible human Bill
-- ("check out my rare POKéMON"). Re-apply on enter so a mid-house
-- load still matches the toggles if the pool was rebuilt.
if flags.EVENT_LEFT_BILLS_HOUSE_AFTER_HELPING then
local Commands = require("src.script.Commands")
local ctx = { game = game, save = game.save, overworld = ow }
Commands.hide_object(ctx, "BILLS_HOUSE", "BILLSHOUSE_BILL_POKEMON")
Commands.hide_object(ctx, "BILLS_HOUSE", "BILLSHOUSE_BILL1")
Commands.show_object(ctx, "BILLS_HOUSE", "BILLSHOUSE_BILL2")
end
end,
}
-- -------------------------------------------------------------------
-- Route 25 outside Bill's house (scripts/Route25.asm
-- Route25ToggleBillsScript): leaving after the SS Ticket arms the
-- Eevee PC list and swaps human Bill to his post-help dialogue NPC.
-- Leaving mid-quest (Bill still in the machine) puts the monster back.
-- -------------------------------------------------------------------
M.ROUTE_25 = {
onEnter = function(game, ow)
local flags = game.save.flags
if flags.EVENT_LEFT_BILLS_HOUSE_AFTER_HELPING then return end
local Commands = require("src.script.Commands")
local ctx = { game = game, save = game.save, overworld = ow }
if not flags.EVENT_MET_BILL_2 then
flags.EVENT_BILL_SAID_USE_CELL_SEPARATOR = nil
Commands.show_object(ctx, "BILLS_HOUSE", "BILLSHOUSE_BILL_POKEMON")
return
end
if not flags.EVENT_GOT_SS_TICKET then return end
flags.EVENT_LEFT_BILLS_HOUSE_AFTER_HELPING = true
-- TOGGLE_NUGGET_BRIDGE_GUY (ROUTE24_COOLTRAINER_M1)
Commands.hide_object(ctx, "ROUTE_24", "ROUTE24_COOLTRAINER_M1")
Commands.hide_object(ctx, "BILLS_HOUSE", "BILLSHOUSE_BILL1")
Commands.show_object(ctx, "BILLS_HOUSE", "BILLSHOUSE_BILL2")
end,
}
@@ -256,25 +294,40 @@ M.VERMILION_CITY = {
end,
-- VermilionCityDefaultScript's per-frame SSAnneTicketCheckCoords check:
-- the unguarded cell (18,30) just west of the sailor leads straight
-- onto the dock warp, so stepping onto it heading for the dock gets
-- ticket-checked (and turned back once the ship has sailed) without
-- the player ever pressing A. The sailor himself never disappears.
-- onto the dock warp. Stepping onto it facing down always runs the
-- sailor dialog (DisplayTextID TEXT_VERMILIONCITY_SAILOR1); only a
-- ticket while the ship is still docked lets the player continue --
-- otherwise they are walked back up. The sailor himself never hides.
onStep = function(game, ow, x, y)
if x ~= 18 or y ~= 30 then return false end
if ow.player.facing ~= "down" then return false end
local f = game.save.flags
local Flags = require("src.script.Flags")
local t = game.data.text
local TextBox = require("src.render.TextBox")
if f.EVENT_SS_ANNE_LEFT then
local shipLeft = Flags.get(game.save, "EVENT_SS_ANNE_LEFT")
local hasTicket = (game.save.inventory.S_S_TICKET or 0) > 0
if shipLeft then
game.stack:push(TextBox.new(game,
t._VermilionCitySailor1ShipSetSailText or "The ship set sail.",
function() ow:scriptMove(ow.player, "up", 1) end))
return true
end
if (game.save.inventory.S_S_TICKET or 0) > 0 then return false end
-- Walk-past is never facing-right / inFrontOfOrBehindGuardCoords, so
-- VermilionCitySailor1Text always takes .greet_player_and_check_ticket:
-- DoYouHaveATicket, then FlashedTicket (allow through) or YouNeedATicket
-- (walk back). Returning true while the box is up also blocks the
-- dock warp on this step.
local ask = t._VermilionCitySailor1DoYouHaveATicketText
or "Welcome to S.S.\nANNE!\fExcuse me, do you\nhave a ticket?"
if hasTicket then
game.stack:push(TextBox.new(game,
ask .. "\f"
.. (t._VermilionCitySailor1FlashedTicketText
or "{PLAYER} flashed\nthe S.S.TICKET!")))
return true
end
game.stack:push(TextBox.new(game,
(t._VermilionCitySailor1WelcomeToSSAnneText or "Welcome to S.S.\nANNE!")
.. "\f"
ask .. "\f"
.. (t._VermilionCitySailor1YouNeedATicketText
or "You need a ticket\nto get aboard."),
function() ow:scriptMove(ow.player, "up", 1) end))
@@ -288,7 +341,7 @@ M.VERMILION_CITY = {
{ "face_player" }, -- 1
{ "check_flag", "EVENT_SS_ANNE_LEFT" }, -- 2
{ "jump_if_true", 11 }, -- 3
{ "show_text", "_VermilionCitySailor1WelcomeToSSAnneText" }, -- 4
{ "show_text", "_VermilionCitySailor1DoYouHaveATicketText" }, -- 4
{ "check_item", "S_S_TICKET" }, -- 5
{ "jump_if_false", 9 }, -- 6
{ "show_text", "_VermilionCitySailor1FlashedTicketText" }, -- 7
@@ -318,18 +371,21 @@ M.SS_ANNE_2F = {
M.SS_ANNE_CAPTAINS_ROOM = {
talk = {
-- SSAnneCaptainsRoomCaptainText: after the rub line's text_asm tail,
-- pokered plays MUSIC_PKMN_HEALED (scripts/SSAnneCaptainsRoom.asm).
TEXT_SSANNECAPTAINSROOM_CAPTAIN = {
{ "check_flag", "EVENT_GOT_HM01" }, -- 1
{ "jump_if_true", 9 }, -- 2
{ "jump_if_true", 10 }, -- 2
{ "show_text", "_SSAnneCaptainsRoomRubCaptainsBackText" }, -- 3
{ "show_text", "_SSAnneCaptainsRoomCaptainIFeelMuchBetterText" }, -- 4
{ "play_once", "Music_PkmnHealed" }, -- 4
{ "show_text", "_SSAnneCaptainsRoomCaptainIFeelMuchBetterText" }, -- 5
-- give-then-print like scripts/SSAnneCaptainsRoom.asm (GiveItem
-- fills wStringBuffer; the received text reads it)
{ "give_item", "HM_CUT", 1, false }, -- 5
{ "show_text", "_SSAnneCaptainsRoomCaptainReceivedHM01Text" }, -- 6
{ "set_flag", "EVENT_GOT_HM01" }, -- 7
{ "jump", 10 }, -- 8
{ "show_text", "_SSAnneCaptainsRoomCaptainNotSickAnymoreText" }, -- 9
{ "give_item", "HM_CUT", 1, false }, -- 6
{ "show_text", "_SSAnneCaptainsRoomCaptainReceivedHM01Text" }, -- 7
{ "set_flag", "EVENT_GOT_HM01" }, -- 8
{ "jump", 11 }, -- 9
{ "show_text", "_SSAnneCaptainsRoomCaptainNotSickAnymoreText" }, -- 10
},
},
}
@@ -339,7 +395,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
@@ -560,7 +702,8 @@ M.SILPH_CO_11F = {
-- -------------------------------------------------------------------
-- Victory Road boulder switches (scripts/VictoryRoad1F/2F/3F.asm):
-- a boulder resting on a switch removes a barrier block; the 3F hole
-- drops a boulder down to the 2F switch.
-- drops a boulder down to the 2F switch, and also dungeon-warps the
-- player (IsPlayerOnDungeonWarp + DungeonWarpList/Data -> 2F at 22,16).
-- -------------------------------------------------------------------
local function boulderAt(ow, x, y)
@@ -626,6 +769,18 @@ M.VICTORY_ROAD_3F = {
Commands.show_object(ctx, "VICTORY_ROAD_2F", "VICTORYROAD2F_BOULDER")
end
end,
-- scripts/VictoryRoad3F.asm VictoryRoad3FDefaultScript: the same hole
-- is a dungeon warp for the player (wDungeonWarpDestinationMap =
-- VICTORY_ROAD_2F, wWhichDungeonWarp = 2 -> DungeonWarpData 22,16).
-- Mirrors POKEMON_MANSION_3F.onStep; CAVERN $22 is walkable so the
-- fall is onStep, not a collision block.
onStep = function(game, ow, x, y)
if x == 23 and y == 15 then
ow:startWarpTo("VICTORY_ROAD_2F", 22, 16, ow.player.facing)
return true
end
return false
end,
}
-- -------------------------------------------------------------------
@@ -638,6 +793,16 @@ M.VICTORY_ROAD_3F = {
-- IndigoPlateauLobby.asm) so the champion is re-fightable on rematches,
-- like pokered's re-entry cutscene. EVENT_BEAT_CHAMPION_RIVAL stays set
-- forever (postgame gates like the Cerulean cave guard read it).
--
-- pokered forces the fight on map entry: Agatha's victory arms
-- SCRIPT_CHAMPIONSROOM_PLAYER_ENTERS (scripts/AgathasRoom.asm), and
-- ChampionsRoomPlayerEntersScript then runs RivalEntrance_RLEMovement
-- (up 1, right 1, up 3) before ChampionsRoomRivalReadyToBattleScript.
-- The rival object has no trainer header / sight range, so without that
-- entrance script the player can walk past (issue #99). We arm on the
-- run flag instead of Agatha's victory bit: same observable effect for
-- first clear and Indigo rematches, without a cross-map script pointer.
--
-- scripts/ChampionsRoom.asm ChampionsRoomRivalDefeatedScript ->
-- OakArrivesScript -> OakCongratulatesPlayerScript ->
-- OakDisappointedWithRivalScript -> OakComeWithMeScript -> OakExitsScript,
@@ -646,43 +811,69 @@ M.VICTORY_ROAD_3F = {
-- itself is NOT run here: it belongs to the HALL_OF_FAME room script
-- (scripts/HallOfFame.asm), so we set a one-shot marker and warp; the room
-- onEnter (M.HALL_OF_FAME below) drives the HoF Oak speech + record.
local championsRoomRivalScript = {
{ "face_player" }, -- 1
{ "check_flag", "EVENT_BEAT_CHAMPION_RIVAL_THIS_RUN" }, -- 2
{ "jump_if_true", 25 }, -- 3 past end
{ "show_text", "_ChampionsRoomRivalIntroText" }, -- 4
{ "rival_battle", "OPP_RIVAL3", 1 }, -- 5
{ "jump_if_false", 25 }, -- 6 past end
{ "set_flag", "EVENT_BEAT_CHAMPION_RIVAL_THIS_RUN" }, -- 7
{ "set_flag", "EVENT_BEAT_CHAMPION_RIVAL" }, -- 8
-- ChampionsRoomRivalDefeatedScript re-displays TEXT_CHAMPIONSROOM_RIVAL,
-- whose text_asm takes the EVENT_BEAT_CHAMPION_RIVAL branch =
-- _ChampionsRoomRivalAfterBattleText (the in-battle _RivalDefeatedText
-- is the port's generic "<PLAYER> defeated BLUE!" engine line instead).
{ "show_text", "_ChampionsRoomRivalAfterBattleText" }, -- 9
-- ChampionsRoomOakArrivesScript: Music_Cities1AlternateTempo
-- (Cities1, kept into HALL_OF_FAME like BIT_NO_MAP_MUSIC after
-- defeating RIVAL3), then Oak's "{PLAYER}!" + reveal + walk in
{ "play_music", "Music_Cities1", { keep = true } }, -- 10
{ "show_text", "_ChampionsRoomOakText" }, -- 11
{ "show_object", "CHAMPIONS_ROOM", "CHAMPIONSROOM_OAK" }, -- 12
{ "move_npc", 2, "up", 5 }, -- 13 OakEntranceAfterVictoryMovement
-- OakCongratulatesPlayerScript: rival faces left, Oak faces down
{ "face_object", 1, "left" }, -- 14
{ "face_object", 2, "down" }, -- 15
{ "show_text", "_ChampionsRoomOakCongratulatesPlayerText" }, -- 16
-- OakDisappointedWithRivalScript: Oak turns to the rival (right)
{ "face_object", 2, "right" }, -- 17
{ "show_text", "_ChampionsRoomOakDisappointedWithRivalText" }, -- 18
-- OakComeWithMeScript: Oak faces down again, then exits up
{ "face_object", 2, "down" }, -- 19
{ "show_text", "_ChampionsRoomOakComeWithMeText" }, -- 20
{ "move_npc", 2, "up", 2 }, -- 21 OakExitChampionsRoomMovement
{ "hide_object", "CHAMPIONS_ROOM", "CHAMPIONSROOM_OAK" }, -- 22
-- hand the induction off to the HALL_OF_FAME room (consumed by its
-- onEnter), then warp up into it (destWarp 1 lands at (4,7) facing up)
{ "set_field", "pendingHallOfFame", true }, -- 23
{ "warp", "HALL_OF_FAME", 4, 7, "up" }, -- 24
}
M.CHAMPIONS_ROOM = {
onEnter = function(game, ow)
if game.save.flags.EVENT_BEAT_CHAMPION_RIVAL_THIS_RUN then return end
-- Lance entrance warps land at y=7; HoF return warps land at y=0.
-- Only the south entry should run ChampionsRoomPlayerEntersScript.
if ow.player.cellY < 7 then return end
local rival
for _, npc in ipairs(ow.npcs) do
if npc.def and npc.def.name == "CHAMPIONSROOM_RIVAL" then
rival = npc
break
end
end
-- RivalEntrance_RLEMovement, then the battle/Oak script (queued
-- separately so talk-script jump indices stay 1-based as written).
ow:queueScript({
{ "move_player", "up", 1 },
{ "move_player", "right", 1 },
{ "move_player", "up", 3 },
})
ow:queueScript(championsRoomRivalScript, { npc = rival })
end,
talk = {
TEXT_CHAMPIONSROOM_RIVAL = {
{ "face_player" }, -- 1
{ "check_flag", "EVENT_BEAT_CHAMPION_RIVAL_THIS_RUN" }, -- 2
{ "jump_if_true", 24 }, -- 3
{ "show_text", "_ChampionsRoomRivalIntroText" }, -- 4
{ "rival_battle", "OPP_RIVAL3", 1 }, -- 5
{ "jump_if_false", 24 }, -- 6
{ "set_flag", "EVENT_BEAT_CHAMPION_RIVAL_THIS_RUN" }, -- 7
{ "set_flag", "EVENT_BEAT_CHAMPION_RIVAL" }, -- 8
-- ChampionsRoomRivalDefeatedScript re-displays TEXT_CHAMPIONSROOM_RIVAL,
-- whose text_asm takes the EVENT_BEAT_CHAMPION_RIVAL branch =
-- _ChampionsRoomRivalAfterBattleText (the in-battle _RivalDefeatedText
-- is the port's generic "<PLAYER> defeated BLUE!" engine line instead).
{ "show_text", "_ChampionsRoomRivalAfterBattleText" }, -- 9
-- ChampionsRoomOakArrivesScript: Oak's "{PLAYER}!" then reveal + walk in
{ "show_text", "_ChampionsRoomOakText" }, -- 10
{ "show_object", "CHAMPIONS_ROOM", "CHAMPIONSROOM_OAK" }, -- 11
{ "move_npc", 2, "up", 5 }, -- 12 OakEntranceAfterVictoryMovement (3,7)->(3,2)
-- OakCongratulatesPlayerScript: rival faces left, Oak faces down
{ "face_object", 1, "left" }, -- 13
{ "face_object", 2, "down" }, -- 14
{ "show_text", "_ChampionsRoomOakCongratulatesPlayerText" }, -- 15
-- OakDisappointedWithRivalScript: Oak turns to the rival (right)
{ "face_object", 2, "right" }, -- 16
{ "show_text", "_ChampionsRoomOakDisappointedWithRivalText" }, -- 17
-- OakComeWithMeScript: Oak faces down again, then exits up
{ "face_object", 2, "down" }, -- 18
{ "show_text", "_ChampionsRoomOakComeWithMeText" }, -- 19
{ "move_npc", 2, "up", 2 }, -- 20 OakExitChampionsRoomMovement (3,2)->(3,0)
{ "hide_object", "CHAMPIONS_ROOM", "CHAMPIONSROOM_OAK" }, -- 21
-- hand the induction off to the HALL_OF_FAME room (consumed by its
-- onEnter), then warp up into it (destWarp 1 lands at (4,7) facing up)
{ "set_field", "pendingHallOfFame", true }, -- 22
{ "warp", "HALL_OF_FAME", 4, 7, "up" }, -- 23
},
TEXT_CHAMPIONSROOM_RIVAL = championsRoomRivalScript,
},
}
@@ -753,10 +944,14 @@ M.HALL_OF_FAME = {
-- that teleports back to the new-game bedroom spawn (special_warps.asm
-- NewGameWarp: REDS_HOUSE_2F, 3, 6, facing down). Fabricated
-- convenience -- there is no such prompt in the original ROM.
-- Heal + remember_outdoor so house LAST_MAP mats land in Pallet, not
-- Indigo Plateau (issue #103 escape path for already-stuck saves).
TEXT_HALLOFFAME_PC = {
{ "ask", "Return to\nPALLET TOWN?" }, -- 1 YES/NO -> lastCheck
{ "jump_if_false", "end" }, -- 2 NO: back away
{ "warp", "REDS_HOUSE_2F", 3, 6, "down" }, -- 3 YES: home to your room
{ "heal_party" }, -- 3
{ "remember_outdoor", "PALLET_TOWN", 5, 6 }, -- 4 LAST_MAP -> Pallet door
{ "warp", "REDS_HOUSE_2F", 3, 6, "down" }, -- 5 YES: home to your room
},
},
}
@@ -769,35 +964,72 @@ M.HALL_OF_FAME = {
M.CERULEAN_CITY = {
talk = {
-- CeruleanCityRivalText: Bill line once beaten; pre-battle otherwise.
-- Post-fight walk matches CeruleanCityMovement4 (right then into town).
TEXT_CERULEANCITY_RIVAL = {
{ "face_player" }, -- 1
{ "check_flag", "EVENT_BEAT_CERULEAN_RIVAL" }, -- 2
{ "jump_if_true", 9 }, -- 3
{ "show_text", "_CeruleanCityRivalPreBattleText" }, -- 4
{ "rival_battle", "OPP_RIVAL1", 7 }, -- 5
{ "jump_if_false", 10 }, -- 6
{ "set_flag", "EVENT_BEAT_CERULEAN_RIVAL" }, -- 7
{ "show_text", "_CeruleanCityRivalDefeatedText" }, -- 8
{ "jump", 10 }, -- 9
{ "face_player" }, -- 1
{ "check_flag", "EVENT_BEAT_CERULEAN_RIVAL" }, -- 2
{ "jump_if_true", 13 }, -- 3
{ "show_text", "_CeruleanCityRivalPreBattleText" }, -- 4
{ "rival_battle", "OPP_RIVAL1", 7 }, -- 5
{ "jump_if_false", "end" }, -- 6
{ "set_flag", "EVENT_BEAT_CERULEAN_RIVAL" }, -- 7
{ "show_text", "_CeruleanCityRivalDefeatedText" }, -- 8
{ "show_text", "_CeruleanCityRivalIWentToBillsText" }, -- 9
{ "walk_npc", 1, { "right", "down", "down", "down",
"down", "down", "down" } }, -- 10
{ "hide_object", "CERULEAN_CITY", "CERULEANCITY_RIVAL" }, -- 11
{ "jump", "end" }, -- 12
{ "show_text", "_CeruleanCityRivalIWentToBillsText" }, -- 13
},
},
}
-- PokemonTower2F.asm: after the battle, PokemonTower2FDefeatedRivalScript
-- walks him out (RightThenDown vs DownThenRight from EVENT_POKEMON_TOWER_
-- RIVAL_ON_LEFT) then HideObject TOGGLE_POKEMON_TOWER_2F_RIVAL. Player
-- at (15,5) is the ON_LEFT case.
local TOWER_RIVAL_EXIT_RIGHT_THEN_DOWN =
{ "right", "down", "down", "right", "down", "down", "right", "right" }
local TOWER_RIVAL_EXIT_DOWN_THEN_RIGHT =
{ "down", "down", "right", "right", "right", "right", "down", "down" }
local function pokemonTower2FRivalScript(playerX)
local exitDirs = (playerX == 15)
and TOWER_RIVAL_EXIT_DOWN_THEN_RIGHT
or TOWER_RIVAL_EXIT_RIGHT_THEN_DOWN
return {
{ "face_player" }, -- 1
{ "check_flag", "EVENT_BEAT_POKEMON_TOWER_RIVAL" }, -- 2
{ "jump_if_true", 12 }, -- 3
{ "show_text", "_PokemonTower2FRivalWhatBringsYouHereText" }, -- 4
{ "rival_battle", "OPP_RIVAL2", 4 }, -- 5
{ "jump_if_false", "end" }, -- 6 loss: stay
{ "set_flag", "EVENT_BEAT_POKEMON_TOWER_RIVAL" }, -- 7
{ "show_text", "_PokemonTower2FRivalDefeatedText" }, -- 8
{ "walk_npc", 1, exitDirs }, -- 9
{ "hide_object", "POKEMON_TOWER_2F", "POKEMONTOWER2F_RIVAL" }, -- 10
{ "jump", "end" }, -- 11
{ "show_text", "_PokemonTower2FRivalHowsYourDexText" }, -- 12
}
end
M.POKEMON_TOWER_2F = {
rivalScript = pokemonTower2FRivalScript,
talk = {
TEXT_POKEMONTOWER2F_RIVAL = {
{ "face_player" }, -- 1
{ "check_flag", "EVENT_BEAT_POKEMON_TOWER_RIVAL" }, -- 2
{ "jump_if_true", 10 }, -- 3
{ "show_text", "_PokemonTower2FRivalWhatBringsYouHereText" }, -- 4
{ "rival_battle", "OPP_RIVAL2", 4 }, -- 5
{ "jump_if_false", 11 }, -- 6
{ "set_flag", "EVENT_BEAT_POKEMON_TOWER_RIVAL" }, -- 7
{ "show_text", "_PokemonTower2FRivalDefeatedText" }, -- 8
{ "jump", 11 }, -- 9
{ "show_text", "_PokemonTower2FRivalHowsYourDexText" }, -- 10
},
TEXT_POKEMONTOWER2F_RIVAL = function(game, ow, npc, done)
ow.runner:run(pokemonTower2FRivalScript(ow.player.cellX),
{ npc = npc, onDone = done })
end,
},
-- Saves that beat him before the exit walk was ported still have the
-- flag but a visible rival; hide on enter like BillsHouse repairs.
onEnter = function(game, ow)
if not game.save.flags.EVENT_BEAT_POKEMON_TOWER_RIVAL then return end
local Commands = require("src.script.Commands")
Commands.hide_object({ game = game, save = game.save, overworld = ow },
"POKEMON_TOWER_2F", "POKEMONTOWER2F_RIVAL")
end,
-- PokemonTower2FDefaultScript: walking past the rival's tile forces
-- the encounter (ArePlayerCoordsInArray on (15,5)/(14,6)) -- he never
-- waits to be talked to
@@ -809,8 +1041,7 @@ M.POKEMON_TOWER_2F = {
if not rival then return false end
ow.player.facing = (x == 15) and "left" or "up"
require("src.core.Music").play(game.data, "Music_MeetRival")
ow.runner:run(M.POKEMON_TOWER_2F.talk.TEXT_POKEMONTOWER2F_RIVAL,
{ npc = rival })
ow.runner:run(pokemonTower2FRivalScript(x), { npc = rival })
return true
end,
}
+205 -66
View File
@@ -445,33 +445,67 @@ local function engageSuperNerd(game, ow, onDone)
ow:engageTrainer(nerd, onDone)
end
local function mtMoonFossil(itemId, otherName)
-- MtMoonB2FMoveSuperNerdScript: player-near-dome coords walk RIGHT then
-- UP (MoveRight falls through into MoveUp); near-helix walks UP only.
local function mtMoonNerdWalk(px, py, itemId)
if (px == 12 and py == 7) or (px == 11 and py == 6) or (px == 12 and py == 5) then
return { "right", "up" }
end
if (px == 13 and py == 7) or (px == 14 and py == 6) or (px == 14 and py == 5) then
return { "up" }
end
return (itemId == "DOME_FOSSIL") and { "right", "up" } or { "up" }
end
-- scripts/MtMoonB2F.asm Dome/Helix fossil text_asm + MoveSuperNerd +
-- SuperNerdTakesOtherFossil: pick one, hide it, walk the nerd to the
-- other, "All right. Then this is mine!", hide the other.
local function mtMoonFossil(itemId, otherName, gotFlag)
return function(game, ow, npc, done)
local TextBox = require("src.render.TextBox")
local ChoiceBox = require("src.ui.ChoiceBox")
if game.save.flags.EVENT_GOT_A_FOSSIL then
game.stack:push(TextBox.new(game, "You already took\na fossil.", done))
local t = game.data.text
local flags = game.save.flags
if flags.EVENT_GOT_DOME_FOSSIL or flags.EVENT_GOT_HELIX_FOSSIL then
done()
return
end
-- can't grab a fossil until the Super Nerd is beaten -- he intercepts
if not superNerdBeaten(ow) then
engageSuperNerd(game, ow, done)
return
end
local name = game.data.items[itemId].name
game.stack:push(TextBox.new(game, ("You want the\n%s?"):format(name), function()
game.stack:push(ChoiceBox.new(game, function(yes)
if not yes then done() return end
game.save.inventory[itemId] = 1
game.save.flags.EVENT_GOT_A_FOSSIL = true
local Commands = require("src.script.Commands")
local ctx = { save = game.save, overworld = ow, game = game }
Commands.hide_object(ctx, "MT_MOON_B2F", npc.def.name)
Commands.hide_object(ctx, "MT_MOON_B2F", otherName)
local want = (itemId == "DOME_FOSSIL")
and (t._MtMoonB2FDomeFossilYouWantText or "You want the\nDOME FOSSIL?")
or (t._MtMoonB2FHelixFossilYouWantText or "You want the\nHELIX FOSSIL?")
game.stack:push(TextBox.new(game, want, nil, { choice = function(yes)
if not yes then done() return end
if not require("src.inventory.Bag").add(game.save, itemId, 1) then
game.stack:push(TextBox.new(game,
("%s got the\n%s!"):format(game.save.player.name, name), done))
end))
end))
t._MtMoonB2FYouHaveNoRoomText or "Look, you've got\nno room for this.",
done))
return
end
local idef = game.data.items[itemId]
game.stringBuffer = idef and idef.name or itemId
require("src.core.Sound").play(game.data, "Get_Key_Item")
local dirs = mtMoonNerdWalk(ow.player.cellX, ow.player.cellY, itemId)
game.stack:push(TextBox.new(game,
t._MtMoonB2FReceivedFossilText
or ("{PLAYER} got the\n" .. game.stringBuffer .. "!"),
function()
local Commands = require("src.script.Commands")
Commands.hide_object(
{ save = game.save, overworld = ow, game = game },
"MT_MOON_B2F", npc.def.name)
flags[gotFlag] = true
ow.runner:run({
{ "walk_npc", 1, dirs },
{ "text_opts", { auto = true } },
{ "show_text", "_MtMoonB2FSuperNerdThenThisIsMineText" },
{ "play_sound", "Get_Key_Item" },
{ "hide_object", "MT_MOON_B2F", otherName },
}, { onDone = done })
end))
end }))
end
end
@@ -486,13 +520,17 @@ M.MT_MOON_B2F = {
return false
end,
talk = {
TEXT_MTMOONB2F_DOME_FOSSIL = mtMoonFossil("DOME_FOSSIL", "MTMOONB2F_HELIX_FOSSIL"),
TEXT_MTMOONB2F_HELIX_FOSSIL = mtMoonFossil("HELIX_FOSSIL", "MTMOONB2F_DOME_FOSSIL"),
TEXT_MTMOONB2F_DOME_FOSSIL = mtMoonFossil(
"DOME_FOSSIL", "MTMOONB2F_HELIX_FOSSIL", "EVENT_GOT_DOME_FOSSIL"),
TEXT_MTMOONB2F_HELIX_FOSSIL = mtMoonFossil(
"HELIX_FOSSIL", "MTMOONB2F_DOME_FOSSIL", "EVENT_GOT_HELIX_FOSSIL"),
},
}
-- 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")
@@ -527,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
@@ -719,73 +757,174 @@ M.CINNABAR_LAB_FOSSIL_ROOM = {
-- -------------------------------------------------------------------
-- Day-care (scripts/Daycare.asm): the boarded Pokémon earns 1 exp per
-- step; the fee is ¥100 plus ¥100 per level gained.
--
-- #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 —
-- TextBox.TOKENS.RAM only knows wStringBuffer.
-- -------------------------------------------------------------------
local function fillDaycareText(s, subs)
s = s:gsub("{PLAYER}", subs.player or "")
s = s:gsub("{RAM:([^}]*)}", function(name) return subs[name] or "" end)
-- extractor NUM spans may include flags after a comma; keep the name
s = s:gsub("{NUM:([%w_]+)[^}]*}", function(name)
return tostring(subs[name] or "0")
end)
return s
end
M.DAYCARE = {
talk = {
TEXT_DAYCARE_GENTLEMAN = function(game, ow, npc, done)
local TextBox = require("src.render.TextBox")
local ChoiceBox = require("src.ui.ChoiceBox")
local t = game.data.text
local dc = game.save.daycare
local playerName = game.save.player and game.save.player.name or "RED"
local function monName(mon)
local def = game.data.pokemon[mon.species]
return mon.nickname or (def and def.name) or mon.species
end
if dc and dc.mon then
local Growth = require("src.pokemon.Growth")
local Stats = require("src.pokemon.Stats")
local Party = require("src.pokemon.Party")
local mon = dc.mon
local def = game.data.pokemon[mon.species]
mon.exp = mon.exp + (dc.steps or 0)
local newLevel = math.min(100, Growth.levelForExp(def.growthRate, mon.exp))
local fee = 100 + (newLevel - mon.level) * 100
local grew = newLevel > mon.level
mon.level = newLevel
mon.stats = Stats.calc(def, mon.level, mon.dvs, mon.statExp)
mon.hp = mon.stats.hp
local msg = grew and (t._DaycareGentlemanMonHasGrownText or "It's grown a lot!")
or "Back already?"
game.stack:push(TextBox.new(game,
msg .. ("\fThe fee is ¥%d.\nGet it back?"):format(fee), function()
game.stack:push(ChoiceBox.new(game, function(yes)
if yes and game.save.money >= fee then
-- Apply deferred step-exp once (OverworldController only bumps
-- daycare.steps). Clearing prevents double-count on re-talk.
mon.exp = (mon.exp or 0) + (dc.steps or 0)
dc.steps = 0
-- depositLevel mirrors wDayCareMonBoxLevel: fee baseline that
-- must survive a declined retrieve. Fall back to mon.level for
-- older saves that predate the field.
if dc.depositLevel == nil then dc.depositLevel = mon.level end
local startLevel = dc.depositLevel
local newLevel = Growth.levelForExp(def and def.growthRate, mon.exp)
if newLevel >= 100 then
newLevel = 100
if def then
mon.exp = Growth.expForLevel(def.growthRate, 100)
end
end
local levelsGrown = math.max(0, newLevel - startLevel)
local fee = 100 + levelsGrown * 100
local name = monName(mon)
local subs = {
player = playerName,
wNameBuffer = name,
wDayCareMonName = name,
wDayCareNumLevelsGrown = levelsGrown,
wDayCareTotalCost = fee,
}
local statusText = levelsGrown > 0
and (t._DaycareGentlemanMonHasGrownText
or "Your {RAM:wNameBuffer}\nhas grown a lot!\fBy level, it's\ngrown by {NUM:wDayCareNumLevelsGrown, 1, 3}!\fAren't I great?")
or (t._DaycareGentlemanMonNeedsMoreTimeText
or "Back already?\nYour {RAM:wNameBuffer}\nneeds some more\ntime with me.")
game.stack:push(TextBox.new(game, fillDaycareText(statusText, subs), function()
if #game.save.party >= Party.MAX then
game.stack:push(TextBox.new(game,
t._DaycareGentlemanNoRoomForMonText
or "You have no room\nfor this POKéMON!", done))
return
end
game.stack:push(TextBox.new(game,
fillDaycareText(
t._DaycareGentlemanOweMoneyText
or "You owe me ¥{NUM:wDayCareTotalCost, 2 | LEADING_ZEROES | LEFT_ALIGN}\nfor the return\nof this POKéMON.",
subs),
nil, { choice = function(yes)
if not yes then
-- .leaveMonInDayCare: revert any transient level bump
mon.level = startLevel
game.stack:push(TextBox.new(game,
(t._DaycareGentlemanAllRightThenText or "All right then,\n")
.. (t._DaycareGentlemanComeAgainText or "come again."),
done))
return
end
if (game.save.money or 0) < fee then
mon.level = startLevel
game.stack:push(TextBox.new(game,
t._DaycareGentlemanNotEnoughMoneyText
or "Hey, you don't\nhave enough ¥!", done))
return
end
game.save.money = game.save.money - fee
mon.level = newLevel
if def then
mon.stats = Stats.calc(def, mon.level, mon.dvs, mon.statExp)
mon.hp = mon.stats.hp
-- Daycare.asm: WriteMonMoves with wLearningMovesFromDayCare
local Pokemon = require("src.pokemon.Pokemon")
Pokemon.learnMovesFromDayCare(
game.data, mon, def, startLevel, newLevel)
end
table.insert(game.save.party, mon)
game.save.daycare = nil
game.stack:push(TextBox.new(game,
t._DaycareGentlemanGotMonBackText or "Here you go!", done))
else
game.stack:push(TextBox.new(game,
yes and (t._DaycareGentlemanOweMoneyText or "You owe me money!")
or "Come again!", done))
end
end))
t._DaycareGentlemanHeresYourMonText
or "Thank you! Here's\nyour POKéMON!", function()
game.stack:push(TextBox.new(game,
fillDaycareText(
t._DaycareGentlemanGotMonBackText
or "{PLAYER} got\n{RAM:wDayCareMonName} back!",
subs), done))
end))
end }))
end))
return
end
if #game.save.party < 2 then
game.stack:push(TextBox.new(game,
"You only have one\nPOKéMON with you!", done))
return
end
game.stack:push(TextBox.new(game,
t._DaycareGentlemanIntroText or "I can raise a\nPOKéMON for you.", function()
game.stack:push(ChoiceBox.new(game, function(yes)
if not yes then done() return end
local PartyMenu = require("src.ui.PartyMenu")
game.stack:push(PartyMenu.new(game, {
pickOnly = true,
onSwitch = function(mon)
for i, m in ipairs(game.save.party) do
if m == mon then table.remove(game.save.party, i) break end
end
game.save.daycare = { mon = mon, steps = 0 }
game.stack:push(TextBox.new(game,
t._DaycareGentlemanWillLookAfterMonText or
"Fine, I'll look\nafter it a while!", done))
end,
}))
end))
end))
t._DaycareGentlemanIntroText
or "I run a DAYCARE.\nWould you like me\nto raise one of\nyour POKéMON?",
nil, { choice = function(yes)
if not yes then
game.stack:push(TextBox.new(game,
t._DaycareGentlemanComeAgainText or "come again.", done))
return
end
if #game.save.party < 2 then
game.stack:push(TextBox.new(game,
t._DaycareGentlemanOnlyHaveOneMonText
or "You only have one\nPOKéMON with you.", done))
return
end
game.stack:push(TextBox.new(game,
t._DaycareGentlemanWhichMonText or "Which POKéMON\nshould I raise?",
function()
local PartyMenu = require("src.ui.PartyMenu")
game.stack:push(PartyMenu.new(game, {
pickOnly = true,
onSwitch = function(mon)
for i, m in ipairs(game.save.party) do
if m == mon then table.remove(game.save.party, i) break end
end
local name = monName(mon)
-- depositLevel = wDayCareMonBoxLevel at deposit time
game.save.daycare = {
mon = mon, steps = 0, depositLevel = mon.level,
}
game.stack:push(TextBox.new(game,
fillDaycareText(
t._DaycareGentlemanWillLookAfterMonText
or "Fine, I'll look\nafter {RAM:wNameBuffer}\nfor a while.",
{ player = playerName, wNameBuffer = name }),
function()
game.stack:push(TextBox.new(game,
t._DaycareGentlemanComeSeeMeInAWhileText
or "Come see me in\na while.", done))
end))
end,
}))
end))
end }))
end,
},
}
+301 -78
View File
@@ -61,6 +61,50 @@ M.ROUTE_12_SUPER_ROD_HOUSE = {
M.ROUTE_12_SUPER_ROD_HOUSE.talk.TEXT_ROUTE12SUPERRODHOUSE_FISHING_GURU[9] =
{ "show_text", "_Route12SuperRodHouseFishingGuruTryFishingText" }
-- -------------------------------------------------------------------
-- Pokemon Tower 5F purified zone (scripts/PokemonTower5F.asm
-- PokemonTower5FDefaultScript): the 2x2 center pad heals the party once
-- per visit. EVENT_IN_PURIFIED_ZONE latches until the player steps off;
-- while on the pad the map script also sets BIT_NO_BATTLES (we return
-- true from onStep so wild encounters are skipped the same way).
-- -------------------------------------------------------------------
local TOWER_5F_PURIFIED = {
[10 * 256 + 8] = true, [11 * 256 + 8] = true,
[10 * 256 + 9] = true, [11 * 256 + 9] = true,
}
-- HealParty -> GBFadeOutToWhite -> Delay3 -> Delay3 -> GBFadeInFromWhite
-- -> TEXT_POKEMONTOWER5F_PURIFIEDZONE (no Music_PkmnHealed).
local TOWER_5F_HEAL = {
{ "heal_party" },
{ "fade", "out", "white" },
{ "wait", 3 },
{ "wait", 3 },
{ "fade", "in", "white" },
{ "show_text", "_PokemonTower5FPurifiedZoneText" },
}
M.POKEMON_TOWER_5F = {
onStep = function(game, ow, x, y)
if not TOWER_5F_PURIFIED[x * 256 + y] then
game.save.flags.EVENT_IN_PURIFIED_ZONE = nil
return false
end
if game.save.flags.EVENT_IN_PURIFIED_ZONE then
return true
end
if ow.runner and ow.runner:isRunning() then return false end
game.save.flags.EVENT_IN_PURIFIED_ZONE = true
if ow.runner then
ow.runner:run(TOWER_5F_HEAL)
elseif ow.queueScript then
ow:queueScript(TOWER_5F_HEAL)
end
return true
end,
}
-- -------------------------------------------------------------------
-- The ghost Marowak (scripts/PokemonTower6F.asm): blocks the stairs at
-- (10,16) until defeated.
@@ -150,18 +194,20 @@ local WALK_DIRVEC = { up = { 0, -1 }, down = { 0, 1 }, left = { -1, 0 }, right =
local WALK_OPP = { up = "down", down = "up", left = "right", right = "left" }
local WALK_ORDER = { "up", "down", "left", "right" }
local function elevatorWalkOut(ow, floor)
local m, p = ow.map, ow.player
-- .UpdateWarp is run twice, so BOTH car warp entries get the same
-- (warp id, map id): point every exit warp at the picked floor's
-- elevator-door warp (the reciprocal warp found while building the
-- menu). The car map's def is shared generated data, but its own
-- warps are only ever read from inside the car, and this rewrite runs
-- on every ride before the walk-out fires, so it is self-correcting.
for _, w in ipairs(m.def.warps) do
-- .UpdateWarp: point EVERY car exit warp at the same floor's elevator
-- door (warp id, map id). Shared generated map data, but the car's
-- warps are only read from inside the car; rides rewrite them again.
local function elevatorSetExit(ow, floor)
if not floor then return end
for _, w in ipairs(ow.map.def.warps) do
w.destMap = floor.map
w.destWarp = floor.warpIdx
end
end
local function elevatorWalkOut(ow, floor)
local m, p = ow.map, ow.player
elevatorSetExit(ow, floor)
-- leave by the exit tile under the player (they warped in onto one),
-- else the nearest
local door
@@ -199,37 +245,68 @@ local function elevatorWalkOut(ow, floor)
end)
end
local function elevatorFloors(elevatorMapId, game)
local floors = {}
for mapId, def in pairs(game.data.maps) do
for i, w in ipairs(def.warps) do
if w.destMap == elevatorMapId then
-- short floor token pokered actually prints, e.g.
-- SILPH_CO_10F -> "10F", ROCKET_HIDEOUT_B2F -> "B2F"
local token = mapId:match("_([^_]+)$") or mapId
-- warpIdx: this floor's warp back into the elevator IS the
-- warp the car's rewritten exit lands on (the reciprocal
-- pair), matching wElevatorWarpMaps' (warp id, map id)
table.insert(floors,
{ map = mapId, x = w.x, y = w.y, token = token, warpIdx = i })
break
end
end
end
-- numeric floor order (SilphCoElevatorFloors' FLOOR_1F..FLOOR_11F),
-- not lexicographic -- otherwise 10F/11F sort before 2F..9F
table.sort(floors, function(a, b)
return (tonumber(a.token:match("%d+")) or 0) <
(tonumber(b.token:match("%d+")) or 0)
end)
return floors
end
local function elevatorSeedExit(ow, floors, fromMapId)
-- Seed a walk-out destination before the menu (or key-gate text):
-- entry floor when known, else the first listed floor (1F). Choosing
-- a floor still rewrites via elevatorWalkOut; B-cancel / no-key leave
-- keeps this seed so walking out of the car cannot hit a missing ROM
-- placeholder (#123) or the car's static default floor (#90: Rocket
-- Hideout defaults to B1F even when entered from B2F/B4F).
local exitFloor = floors[1]
if fromMapId then
for _, f in ipairs(floors) do
if f.map == fromMapId then exitFloor = f break end
end
end
elevatorSetExit(ow, exitFloor)
return exitFloor
end
local function elevator(elevatorMapId, keyGate, preFrames)
return {
onEnter = function(game, ow)
-- fromMapId: the floor the player just left (setMap passes it), so a
-- B-cancel can still walk out onto a real map. Silph's ROM car warps
-- default to UNUSED_MAP_ED, which is not in Data.maps -- Warp.resolve
-- asserted and hard-crashed (#123).
onEnter = function(game, ow, fromMapId)
local floors = elevatorFloors(elevatorMapId, game)
elevatorSeedExit(ow, floors, fromMapId)
-- 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.
if keyGate and not game.save.inventory[keyGate.item] then
local TextBox = require("src.render.TextBox")
game.stack:push(TextBox.new(game,
game.data.text[keyGate.text] or "It appears to\nneed a key."))
return
end
local floors = {}
for mapId, def in pairs(game.data.maps) do
for i, w in ipairs(def.warps) do
if w.destMap == elevatorMapId then
-- short floor token pokered actually prints, e.g.
-- SILPH_CO_10F -> "10F", ROCKET_HIDEOUT_B2F -> "B2F"
local token = mapId:match("_([^_]+)$") or mapId
-- warpIdx: this floor's warp back into the elevator IS the
-- warp the car's rewritten exit lands on (the reciprocal
-- pair), matching wElevatorWarpMaps' (warp id, map id)
table.insert(floors,
{ map = mapId, x = w.x, y = w.y, token = token, warpIdx = i })
break
end
end
end
-- numeric floor order (SilphCoElevatorFloors' FLOOR_1F..FLOOR_11F),
-- not lexicographic -- otherwise 10F/11F sort before 2F..9F
table.sort(floors, function(a, b)
return (tonumber(a.token:match("%d+")) or 0) <
(tonumber(b.token:match("%d+")) or 0)
end)
local items = {}
for _, f in ipairs(floors) do
table.insert(items, { label = f.token, value = f })
@@ -262,7 +339,8 @@ local function elevator(elevatorMapId, keyGate, preFrames)
end,
onCancel = function()
-- DisplayElevatorFloorMenu: `ret c` on B -- no warp, nothing
-- happens, the player just stays in the car
-- happens, the player just stays in the car (exit warps were
-- already seeded to the entry floor above)
end,
}))
end,
@@ -274,6 +352,98 @@ M.CELADON_MART_ELEVATOR = elevator("CELADON_MART_ELEVATOR", nil, 9)
M.ROCKET_HIDEOUT_ELEVATOR = elevator("ROCKET_HIDEOUT_ELEVATOR",
{ item = "LIFT_KEY", text = "_RocketHideoutElevatorAppearsToNeedKeyText" })
-- -------------------------------------------------------------------
-- Rocket Hideout B4F (scripts/RocketHideoutB4F.asm):
-- Rocket3's after-battle text_asm drops the LIFT KEY item ball
-- (CheckAndSetEvent EVENT_ROCKET_DROPPED_LIFT_KEY / ShowObject
-- TOGGLE_ROCKET_HIDEOUT_B4F_ITEM_5). Both start hidden in the map
-- objects; without this talk side-effect the key never appears (#90,
-- #105).
-- Giovanni's post-battle script likewise ShowObject's the Silph Scope
-- after the hope-we-meet-again line (TOGGLE_ROCKET_HIDEOUT_B4F_ITEM_4).
-- -------------------------------------------------------------------
M.ROCKET_HIDEOUT_B4F = {
talk = {
TEXT_ROCKETHIDEOUTB4F_ROCKET3 = function(game, ow, npc, done)
if not ow:trainerDefeated(npc) then
ow:engageTrainer(npc, done)
return
end
local TextBox = require("src.render.TextBox")
local t = game.data.text
game.stack:push(TextBox.new(game,
t._RocketHideoutB4FRocket3AfterBattleText
or "Oh no! I dropped\nthe LIFT KEY!",
function()
-- CheckAndSetEvent EVENT_ROCKET_DROPPED_LIFT_KEY: first talk
-- after the win reveals the ball; later talks only reprint.
if not game.save.flags.EVENT_ROCKET_DROPPED_LIFT_KEY then
game.save.flags.EVENT_ROCKET_DROPPED_LIFT_KEY = true
local Commands = require("src.script.Commands")
Commands.show_object(
{ game = game, save = game.save, overworld = ow },
"ROCKET_HIDEOUT_B4F", "ROCKETHIDEOUTB4F_LIFT_KEY")
end
done()
end))
end,
TEXT_ROCKETHIDEOUTB4F_GIOVANNI = function(game, ow, npc, done)
-- Giovanni has no trainer-header row (def_trainers 2); his text_asm
-- owns both the engage and the BeatGiovanniScript aftermath.
if ow:trainerDefeated(npc)
or game.save.flags.EVENT_BEAT_ROCKET_HIDEOUT_GIOVANNI then
local TextBox = require("src.render.TextBox")
game.stack:push(TextBox.new(game,
game.data.text._RocketHideoutB4FGiovanniHopeWeMeetAgainText
or "I hope we meet\nagain...", done))
return
end
local TextBox = require("src.render.TextBox")
local BattleState = require("src.battle.BattleState")
local t = game.data.text
local impressed = t._RocketHideoutB4FGiovanniImpressedYouGotHereText
or "So! I must say, I\nam impressed you\ngot here!"
local cannotBe = t._RocketHideoutB4FGiovanniWhatCannotBeText
or "WHAT!\nThis cannot be!"
local hope = t._RocketHideoutB4FGiovanniHopeWeMeetAgainText
or "I hope we meet\nagain..."
game.stack:push(TextBox.new(game, impressed, function()
local battle = BattleState.newTrainer(game, "OPP_GIOVANNI", 1)
battle.onFinish = function(result)
if result ~= "win" then
ow:afterBattle(result, battle)
done()
return
end
game.save.defeatedTrainers[npc.id] = true
game.save.flags.EVENT_BEAT_ROCKET_HIDEOUT_GIOVANNI = true
-- End-battle "WHAT!" then BeatGiovanniScript's hope text,
-- fade, HideObject Giovanni, ShowObject Silph Scope.
game.stack:push(TextBox.new(game, cannotBe, function()
game.stack:push(TextBox.new(game, hope, function()
local Transition = require("src.render.Transition")
game.stack:push(Transition.new(game, function()
local Commands = require("src.script.Commands")
local ctx = { game = game, save = game.save, overworld = ow }
Commands.hide_object(ctx, "ROCKET_HIDEOUT_B4F",
"ROCKETHIDEOUTB4F_GIOVANNI")
Commands.show_object(ctx, "ROCKET_HIDEOUT_B4F",
"ROCKETHIDEOUTB4F_SILPH_SCOPE")
end, function()
ow:afterBattle(result, battle)
done()
end))
end))
end))
end
ow:pushBattle(battle)
end))
end,
},
}
-- -------------------------------------------------------------------
-- Game Corner coins, prizes, and the rocket-poster switch that reveals
-- the hideout stairs (scripts/GameCorner.asm, data/events/prizes.asm +
@@ -286,14 +456,23 @@ M.GAME_CORNER = {
-- EVENT_FOUND_ROCKET_HIDEOUT is unset, $43 after)
onEnter = function(game, ow)
local poster = game.data.field.gameCornerPoster
if not poster then return end
local block = game.save.flags[poster.event] and poster.openBlock
or poster.closedBlock
ow:replaceBlock(poster.x, poster.y, block)
if poster then
local block = game.save.flags[poster.event] and poster.openBlock
or poster.closedBlock
ow:replaceBlock(poster.x, poster.y, block)
end
-- pick this visit's lucky slot machine
-- (wLuckySlotHiddenEventIndex, engine/slots/game_corner_slots2.asm)
local seats = game.data.field.slotMachines.GAME_CORNER
ow.luckySlot = love.math.random(1, #seats)
-- #131: pre-#50 saves beat the poster grunt (defeatedTrainers) but
-- never hid him; clear the tile if he is already marked defeated
local rocketId = "GAME_CORNER_obj_11"
if game.save.defeatedTrainers and game.save.defeatedTrainers[rocketId] then
local Commands = require("src.script.Commands")
Commands.hide_object({ game = game, save = game.save, overworld = ow },
"GAME_CORNER", "GAMECORNER_ROCKET")
end
end,
talk = {
-- the poster bg event: pressing A reveals the hidden switch
@@ -322,7 +501,16 @@ M.GAME_CORNER = {
-- losing he warns the BOSS and leaves the floor for good, freeing
-- the tile in front of the hideout switch
TEXT_GAMECORNER_ROCKET = function(game, ow, npc, done)
local Commands = require("src.script.Commands")
local function hideRocket()
Commands.hide_object({ game = game, save = game.save,
overworld = ow },
"GAME_CORNER", "GAMECORNER_ROCKET")
end
-- already beaten: hide anyway so pre-#50 saves that only have
-- defeatedTrainers (no objectToggles hide) clear the poster tile
if ow:trainerDefeated(npc) then
hideRocket()
done()
return
end
@@ -336,11 +524,17 @@ M.GAME_CORNER = {
game.data.text._GameCornerRocketAfterBattleText
or "Our hideout might\nbe discovered! I\nbetter tell BOSS!",
function()
local Commands = require("src.script.Commands")
Commands.hide_object({ game = game, save = game.save,
overworld = ow },
"GAME_CORNER", "GAMECORNER_ROCKET")
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,
@@ -421,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 = {
@@ -481,8 +697,9 @@ local DOCK_SHIP_BLOCKS = {
M.VERMILION_DOCK = {
onEnter = function(game, ow)
local Flags = require("src.script.Flags")
local f = game.save.flags
if f.EVENT_SS_ANNE_LEFT then
if Flags.get(game.save, "EVENT_SS_ANNE_LEFT") then
-- the ship is long gone: erase her right away, and anyone who
-- still lands here is sent back out past the guard
for _, b in ipairs(DOCK_SHIP_BLOCKS) do
@@ -498,13 +715,15 @@ M.VERMILION_DOCK = {
elseif f.EVENT_GOT_HM01 and ow.player.cellY == 2 then
-- VermilionDockSSAnneLeavesScript: only stepping OFF the ship
-- triggers the departure (wDestinationWarpID == 1 in pokered) --
-- the horn blows, smoke puffs drift off the funnel, the ship is
-- erased to open water, and the player is walked off the dock
-- into the city past the guard (VermilionCity's
-- Music_Surfing plays for the sail-away cutscene, smoke puffs
-- drift off the funnel, the horn blows, the ship is erased to
-- open water, and the player is walked off the dock into the
-- city past the guard (VermilionCity's
-- SCRIPT_VERMILIONCITY_PLAYER_EXIT_SHIP walk)
f.EVENT_SS_ANNE_LEFT = true
require("src.core.Music").stop()
require("src.core.Sound").play(game.data, "SS_Anne_Horn")
Flags.set(game.save, "EVENT_SS_ANNE_LEFT")
local Music = require("src.core.Music")
Music.stop()
Music.play(game.data, "Music_Surfing")
local function puff(n, cx)
if n <= 0 then return end
ow:startDustAnim(cx, 1, function() puff(n - 1, cx + 2) end)
@@ -512,11 +731,15 @@ M.VERMILION_DOCK = {
puff(3, 15)
local rows = {}
rows[#rows + 1] = { "wait", 100 }
rows[#rows + 1] = { "play_sound", "SS_Anne_Horn" }
for _, b in ipairs(DOCK_SHIP_BLOCKS) do
rows[#rows + 1] = { "replace_block", b.bx, b.by, b.water }
end
rows[#rows + 1] = { "wait", 30 }
rows[#rows + 1] = { "move_player", "up", 2 }
-- keep Music_Surfing across the city warp (pokered stays on it
-- through the exit-ship walk)
rows[#rows + 1] = { "play_music", "Music_Surfing", { keep = true } }
rows[#rows + 1] = { "warp", "VERMILION_CITY", 18, 31, "up" }
rows[#rows + 1] = { "move_player", "up", 2 }
ow:queueScript(rows)
+112 -37
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,
}
-- -------------------------------------------------------------------
@@ -212,30 +236,31 @@ M.COPYCATS_HOUSE_2F = {
talk = {
TEXT_COPYCATSHOUSE2F_COPYCAT = function(game, ow, npc, done)
local t = text(game)
local Bag = require("src.inventory.Bag")
if game.save.flags.EVENT_GOT_TM31 then
push(game, t._CopycatsHouse2FCopycatWhirlingText
or "Huh? Huh? Huh?", done)
push(game, t._CopycatsHouse2FCopycatTM31Explanation2Text, done)
return
end
if (game.save.inventory.POKE_DOLL or 0) > 0 then
ask(game, t._CopycatsHouse2FCopycatPokeDollText
or "Oh wow!\nA POKé DOLL!\fFor me?\nCan I have it?", function(yes)
if not yes then done() return end
game.save.inventory.POKE_DOLL = game.save.inventory.POKE_DOLL - 1
if game.save.inventory.POKE_DOLL == 0 then
game.save.inventory.POKE_DOLL = nil
end
if not require("src.inventory.Bag").add(game.save, "TM_MIMIC", 1) then
push(game, "You don't have\nroom for TM31!", done)
-- Mimic line first; auto-trade POKE DOLL for TM31 (no YES/NO).
push(game, t._CopycatsHouse2FCopycatDoYouLikePokemonText, function()
if (game.save.inventory.POKE_DOLL or 0) <= 0 then
done()
return
end
push(game, t._CopycatsHouse2FCopycatTM31PreReceiveText, function()
if not Bag.add(game.save, "TM_MIMIC", 1) then
push(game, t._CopycatsHouse2FCopycatTM31NoRoomText, done)
return
end
game.stringBuffer = game.data.items.TM_MIMIC.name
require("src.core.Sound").play(game.data, "Get_Item1")
Bag.remove(game.save, "POKE_DOLL", 1)
game.save.flags.EVENT_GOT_TM31 = true
push(game, ("%s got\nTM31!"):format(game.save.player.name), done)
push(game, t._CopycatsHouse2FCopycatReceivedTM31Text, function()
push(game, t._CopycatsHouse2FCopycatTM31Explanation1Text, done)
end)
end)
return
end
push(game, t._CopycatsHouse2FCopycatText
or "I like to mimic\npeople!", done)
end)
end,
},
}
@@ -645,12 +670,44 @@ local function e4ExitSeal(flag, closedBlock, openBlock, dontRunText, autoFlag)
}
end
-- Lorelei/Bruno/Agatha EndBattleScript (pokered): EndTrainerBattle then
-- DisplayTextID -> TalkToTrainer, which prints the AfterBattle text on a
-- win. engageTrainer only shows the won line, so wrap talk like Lance /
-- Game Corner Rocket and push header.after immediately after a win.
local function e4LeaderTalk(afterLabel)
return function(game, ow, npc, done)
done = done or function() end
if ow:trainerDefeated(npc) then
local after = text(game)[afterLabel]
if after then push(game, after, done) else done() end
return
end
ow:engageTrainer(npc, function()
if not ow:trainerDefeated(npc) then
done()
return
end
local after = text(game)[afterLabel]
if after then push(game, after, done) else done() end
end)
end
end
M.LORELEIS_ROOM = e4ExitSeal("EVENT_BEAT_LORELEIS_ROOM_TRAINER_0", 0x24, 0x05,
"_LoreleisRoomLoreleiDontRunAwayText", "EVENT_AUTOWALKED_INTO_LORELEIS_ROOM")
M.LORELEIS_ROOM.talk = {
TEXT_LORELEISROOM_LORELEI = e4LeaderTalk("_LoreleisRoomLoreleiAfterBattleText"),
}
M.BRUNOS_ROOM = e4ExitSeal("EVENT_BEAT_BRUNOS_ROOM_TRAINER_0", 0x24, 0x05,
"_BrunosRoomBrunoDontRunAwayText", "EVENT_AUTOWALKED_INTO_BRUNOS_ROOM")
M.BRUNOS_ROOM.talk = {
TEXT_BRUNOSROOM_BRUNO = e4LeaderTalk("_BrunoAfterBattleText"),
}
M.AGATHAS_ROOM = e4ExitSeal("EVENT_BEAT_AGATHAS_ROOM_TRAINER_0", 0x3b, 0x0e,
"_AgathasRoomAgathaDontRunAwayText", "EVENT_AUTOWALKED_INTO_AGATHAS_ROOM")
M.AGATHAS_ROOM.talk = {
TEXT_AGATHASROOM_AGATHA = e4LeaderTalk("_AgathaAfterBattleText"),
}
-- -------------------------------------------------------------------
-- Lance's room (scripts/LancesRoom.asm). Unlike the other three E4
@@ -672,13 +729,21 @@ M.AGATHAS_ROOM = e4ExitSeal("EVENT_BEAT_AGATHAS_ROOM_TRAINER_0", 0x3b, 0x0e,
-- EVENT_LANCES_ROOM_LOCK_DOOR: first crossing seals
-- the door behind the player with SFX_GO_INSIDE
-- (24,16) the entrance staircase -> WalkToLance: an auto-walk
-- (up 12, left 12, down 7, left 6) landing on (6,11).
-- It marches straight across the room's water decor:
-- pokered's CollisionCheckOnLand skips collision
-- entirely while simulated joypad states run, and our
-- scriptMove is collision-free the same way.
-- landing on (6,11). Vanilla WalkToLance_RLEList is
-- up 12 / left 12 / down 7 / left 6 and skips collision
-- through void tiles; our camera follows that literally
-- and briefly shows the empty upper chamber (reads as
-- "Gary's room"). We keep the same landing cell but
-- route along the open-door floor corridor instead.
-- -------------------------------------------------------------------
-- Floor corridor (24,16) -> (6,11) with EVENT_LANCES_ROOM_LOCK_DOOR
-- unset (entrance blocks $31/$32). Exposed for parity tests.
local LANCE_WALK_IN = {
{ "down", 2 }, { "left", 6 }, { "down", 5 }, { "left", 10 },
{ "up", 9 }, { "left", 2 }, { "up", 3 },
}
local function lanceEntranceBlocks(game, ow)
local locked = game.save.flags.EVENT_LANCES_ROOM_LOCK_DOOR
ow:replaceBlock(2, 6, locked and 0x72 or 0x31)
@@ -693,21 +758,24 @@ local function lanceLockDoor(game, ow)
end
local function lanceWalkIn(game, ow)
ow:scriptMove(ow.player, "up", 12, function()
ow:scriptMove(ow.player, "left", 12, function()
ow:scriptMove(ow.player, "down", 7, function()
ow:scriptMove(ow.player, "left", 6, function()
-- the walk lands on (6,11); vanilla's per-frame coord poll
-- then locks the door at once. scriptMove landings do not
-- fire onStep, so lock here.
lanceLockDoor(game, ow)
end)
end)
end)
end)
local i = 0
local function step()
i = i + 1
local seg = LANCE_WALK_IN[i]
if not seg then
-- lands on (6,11); vanilla's per-frame coord poll then locks the
-- door at once. scriptMove landings do not fire onStep, so lock
-- here.
lanceLockDoor(game, ow)
return
end
ow:scriptMove(ow.player, seg[1], seg[2], step)
end
step()
end
M.LANCES_ROOM = {
walkInRoute = LANCE_WALK_IN,
onEnter = function(game, ow)
lanceEntranceBlocks(game, ow)
-- the warp arrival lands ON the staircase trigger, and onStep only
@@ -727,7 +795,14 @@ M.LANCES_ROOM = {
end
if not lance or ow:trainerDefeated(lance) then return false end
lance:facePlayer(ow.player)
ow:engageTrainer(lance, function() end)
-- LancesRoomLanceEndBattleScript DisplayTextID -> TalkToTrainer
-- after-battle text (rival became champion first). engageTrainer
-- only shows the won line, so push the after text on a win.
ow:engageTrainer(lance, function()
if not ow:trainerDefeated(lance) then return end
local after = text(game)._LancesRoomLanceAfterBattleText
if after then push(game, after) end
end)
return true
end
if (x == 5 or x == 6) and y == 11 then
+218 -43
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
@@ -262,38 +272,180 @@ M.CINNABAR_ISLAND = {
-- Pewter's youngster stops you leaving east before Brock is beaten and
-- escorts you to the gym (scripts/PewterCity.asm
-- PewterCityCheckPlayerLeavingEastScript /
-- PewterCityYoungsterShowsPlayerGymScript, engine/events/pewter_guys.asm
-- PewterGymGuyCoords): the "follow me" lines, then the player is walked
-- west along the road to the front of the PEWTER_GYM door at (16,17).
-- PewterCityYoungsterShowsPlayerGymScript, engine/overworld/auto_movement.asm
-- PewterGymGuyMovementScriptPointerTable, engine/events/pewter_guys.asm
-- PewterGymGuyCoords). Same lockstep style as Oak's lab escort: the
-- youngster walks RLEList_PewterGymGuy while the player plays the
-- reverse of RLEList_PewterGymPlayer with a PewterGuys positioning
-- preamble. Ends at (11,18) / (12,18) by the gym, not phasing through
-- the building.
local pewterEscort = {}
-- RLEList_PewterGymGuy (NPC directions play forward)
pewterEscort.guySteps = {
"down", "down",
"left", "left", "left", "left", "left", "left", "left", "left",
"left", "left", "left", "left", "left", "left", "left",
"up", "up", "up", "up", "up",
"left", "left", "left", "left", "left", "left", "left", "left",
"left", "left", "left",
"down", "down", "down", "down", "down",
"right", "right", "right",
}
-- Walk home: reverse of guySteps with opposite facings (gym → spawn).
do
local opp = { up = "down", down = "up", left = "right", right = "left" }
local ret = {}
for i = #pewterEscort.guySteps, 1, -1 do
ret[#ret + 1] = opp[pewterEscort.guySteps[i]]
end
pewterEscort.guyReturnSteps = ret
end
-- RLEList_PewterGymPlayer before reverse / PewterGuys (NO_INPUT, RIGHT×2,
-- DOWN×5, LEFT×11, UP×5, LEFT×15)
pewterEscort.playerRle = {
"NO",
"right", "right",
"down", "down", "down", "down", "down",
"left", "left", "left", "left", "left", "left", "left", "left",
"left", "left", "left",
"up", "up", "up", "up", "up",
"left", "left", "left", "left", "left", "left", "left", "left",
"left", "left", "left", "left", "left", "left", "left",
}
-- PewterGymGuyCoords: (x, y) -> positioning moves written after the RLE
-- (played in reverse; $00 pauses are one overworld frame ≈ 1/8 tile for
-- the NPC, so eight of them ≈ one guy head-start step)
pewterEscort.preambles = {
["34,16"] = { "left", "down", "down", "right" },
["35,17"] = { "left", "down", "right", "left" },
["37,18"] = { "left", "left", "left",
"NO", "NO", "NO", "NO", "NO", "NO", "NO", "NO" },
["37,19"] = { "left", "left", "up", "left" },
["36,17"] = { "left", "down", "left",
"NO", "NO", "NO", "NO", "NO", "NO", "NO", "NO" },
}
-- Realized player path for a trigger tile: PewterGuys overwrites the
-- last RLE byte and appends the preamble, then simulated joypad plays
-- high→low (reverse). Leading NO×8 collapses to guyHeadStart=1; the
-- trailing end-of-list NO is dropped (one-frame pause).
function pewterEscort.playerPlan(x, y)
local pre = pewterEscort.preambles[x .. "," .. y]
if not pre then return nil end
local buf = {}
for i, d in ipairs(pewterEscort.playerRle) do buf[i] = d end
buf[#buf] = pre[1]
for i = 2, #pre do buf[#buf + 1] = pre[i] end
local path = {}
for i = #buf, 1, -1 do path[#path + 1] = buf[i] end
local head = 0
while path[head + 1] == "NO" do head = head + 1 end
local tail = #path
while tail > head and path[tail] == "NO" do tail = tail - 1 end
local steps = {}
for i = head + 1, tail do steps[#steps + 1] = path[i] end
return { steps = steps, guyHeadStart = math.floor(head / 8) }
end
local function pewterGymEscort(game, ow)
if ow.runner:isRunning() or #ow.scriptMoves > 0 then return end
local x, y = ow.player.cellX, ow.player.cellY
local plan = pewterEscort.playerPlan(x, y)
local Music = require("src.core.Music")
local t = text(game)
local follow = t._PewterCityYoungsterYoureATrainerFollowMeText
or "You're a trainer\nright? BROCK's\nlooking for new\nchallengers!\nFollow me!"
-- PewterGuys only has entries for five tiles; an unmatched talk tile
-- (e.g. (36,16) east of him) just gets the follow-me line, same as a
-- failed coords lookup would refuse to arm the walk.
if not plan then
push(game, follow)
return
end
local guy = ow:npcByIndex(5) -- PEWTERCITY_YOUNGSTER
local guySteps = pewterEscort.guySteps
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.)
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
guy.facing = "down"
return
end
ow:scriptMove(guy, ret[i], 1, tick)
end
tick()
end
local function afterWalk()
if guy then guy.facing = "left" end
Music.playMap(game.data, "PEWTER_CITY")
push(game, t._PewterCityYoungsterGoTakeOnBrockText
or "Go take on BROCK\nat the GYM first!", walkHome)
end
local function lockstep()
local i = 0
local function tick()
i = i + 1
local ps = plan.steps[i]
if not ps then
afterWalk()
return
end
local gs = guySteps[head + i]
if guy and gs then ow:scriptMove(guy, gs, 1) end
ow:scriptMove(ow.player, ps, 1, tick)
end
tick()
end
local function beginWalk()
Music.play(game.data, "Music_MuseumGuy")
if guy and head > 0 then
local h = 0
local function headTick()
h = h + 1
if h > head then lockstep(); return end
ow:scriptMove(guy, guySteps[h], 1, headTick)
end
headTick()
else
lockstep()
end
end
push(game, follow, beginWalk)
end
M.PEWTER_CITY = {
escort = pewterEscort,
-- PewterCityYoungsterText: talking also arms the gym escort script
talk = {
TEXT_PEWTERCITY_YOUNGSTER = function(game, ow, npc, done)
pewterGymEscort(game, ow)
if done then done() end
end,
},
onStep = function(game, ow, x, y)
if game.save.flags.EVENT_BEAT_BROCK then return false end
if ow.runner:isRunning() or #ow.scriptMoves > 0 then return false end
if not inCoords({ { 35, 17 }, { 36, 17 }, { 37, 18 }, { 37, 19 } }, x, y) then
return false
end
local t = text(game)
-- walk up onto the road row (y=17), west to one tile east of the gym
-- door, then drop below the door and turn to face it
local steps = {}
for _ = 1, y - 17 do steps[#steps + 1] = "up" end
for _ = 1, x - 17 do steps[#steps + 1] = "left" end
steps[#steps + 1] = "down"
steps[#steps + 1] = "left"
local function walk(i)
if not steps[i] then
ow.player.facing = "up"
return
end
ow:scriptMove(ow.player, steps[i], 1, function() walk(i + 1) end)
end
push(game, t._PewterCityYoungsterYoureATrainerFollowMeText
or "Hey! You're a\ntrainer, right?", function()
push(game, t._PewterCityYoungsterGoTakeOnBrockText
or "Go take on BROCK\nat the GYM first!", function()
walk(1)
end)
end)
pewterGymEscort(game, ow)
return true
end,
}
@@ -315,6 +467,19 @@ end
-- EVENT_ROUTE22_RIVAL_WANTS_BATTLE in Oak's lab (expired by Pewter
-- 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).
local function route22ExitDirs(n, py)
if n == 2 then
if py == 5 then return { "left", "left", "left", "left" } end
return { "left", "left", "left" }
end
if py == 5 then
return { "right", "right", "down", "down", "down", "down", "down" }
end
return { "up", "right", "right", "right",
"down", "down", "down", "down", "down", "down" }
end
local function route22Scene(n, objIndex, objName, oppClass, baseParty, beatFlag, py)
return {
{ "show_object", "ROUTE_22", objName }, -- 1
@@ -326,7 +491,7 @@ local function route22Scene(n, objIndex, objName, oppClass, baseParty, beatFlag,
{ "set_flag", beatFlag }, -- 7
{ "show_text", "_Route22Rival" .. n .. "DefeatedText" }, -- 8
{ "show_text", "_Route22RivalAfterBattleText" .. n }, -- 9
{ "move_npc_to", objIndex, 25, 5 }, -- 10
{ "walk_npc", objIndex, route22ExitDirs(n, py) }, -- 10
{ "hide_object", "ROUTE_22", objName }, -- 11
}
end
@@ -353,6 +518,14 @@ M.ROUTE_22 = {
-- Cerulean City: the Nugget Bridge rival ambush (CeruleanCityCoords2),
-- the TM28 rocket thief with his forced-fight cells (Coords1), and the
-- cave guard who steps aside once you are Champion.
-- Post-battle: Bill text then CeruleanCityMovement3/4 into town.
local function ceruleanRivalExitDirs(px)
if px == 20 then
return { "right", "down", "down", "down", "down", "down", "down" }
end
return { "left", "down", "down", "down", "down", "down", "down" }
end
local function ceruleanRivalScene(px, py)
return {
{ "show_object", "CERULEAN_CITY", "CERULEANCITY_RIVAL" }, -- 1
@@ -360,41 +533,43 @@ local function ceruleanRivalScene(px, py)
{ "face_object", 1, "down" }, -- 3
{ "show_text", "_CeruleanCityRivalPreBattleText" }, -- 4
{ "rival_battle", "OPP_RIVAL1", 7 }, -- 5
{ "jump_if_false", 10 }, -- 6
{ "jump_if_false", 11 }, -- 6
{ "set_flag", "EVENT_BEAT_CERULEAN_RIVAL" }, -- 7
{ "show_text", "_CeruleanCityRivalDefeatedText" }, -- 8
{ "move_npc_to", 1, 20, 2 }, -- 9
{ "hide_object", "CERULEAN_CITY", "CERULEANCITY_RIVAL" }, -- 10
{ "show_text", "_CeruleanCityRivalIWentToBillsText" }, -- 9
{ "walk_npc", 1, ceruleanRivalExitDirs(px) }, -- 10
{ "hide_object", "CERULEAN_CITY", "CERULEANCITY_RIVAL" }, -- 11
}
end
-- scripts/CeruleanCity.asm CeruleanCityRocketText: fight the thief,
-- then he returns TM28 (DIG) and hurries off
-- 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.
local rocketRows = {
{ "face_player" }, -- 1
{ "check_flag", "EVENT_GOT_TM28" }, -- 2
{ "jump_if_true", 15 }, -- 3
{ "jump_if_true", 15 }, -- 3 → CeruleanHideRocket
{ "check_flag", "EVENT_BEAT_CERULEAN_ROCKET_THIEF" }, -- 4
{ "jump_if_true", 9 }, -- 5
{ "show_text", "_CeruleanCityRocketText" }, -- 6
{ "start_battle", "trainer", "OPP_ROCKET", 5 }, -- 7
{ "jump_if_false", 18 }, -- 8
{ "jump_if_false", "end" }, -- 8
{ "show_text", "_CeruleanCityRocketIllReturnTheTMText" }, -- 9
{ "set_flag", "EVENT_BEAT_CERULEAN_ROCKET_THIEF" }, -- 10
{ "give_item", "TM_DIG", 1, false }, -- 11 (row 13 prints)
{ "set_flag", "EVENT_GOT_TM28" }, -- 12
{ "show_text", "_CeruleanCityRocketReceivedTM28Text" }, -- 13
{ "show_text", "_CeruleanCityRocketIBetterGetMovingText" }, -- 14
{ "hide_object", "CERULEAN_CITY", "CERULEANCITY_ROCKET" }, -- 15
-- CeruleanHideRocket (scripts/CeruleanCity_2.asm) does all three behind
-- one fade: the ROCKET goes, GUARD1 (28,12) appears and GUARD2 (27,12)
-- disappears. GUARD2 stands on the only walkable neighbour of the
-- trashed house's south door, which is one of the two ways through the
-- fence dividing Cerulean, so this swap is what reconnects the city.
-- Bill's ticket performs the same swap (data/scripts/story.lua), and
-- both are idempotent, so whichever the player reaches first opens it.
{ "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
-- the same in story.lua; either route is enough).
{ "show_object", "CERULEAN_CITY", "CERULEANCITY_GUARD1" }, -- 16
{ "hide_object", "CERULEAN_CITY", "CERULEANCITY_GUARD2" }, -- 17
{ "hide_object", "CERULEAN_CITY", "CERULEANCITY_ROCKET" }, -- 18
{ "fade", "in" }, -- 19 GBFadeInFromBlack
}
M.CERULEAN_CITY = {
+77 -12
View File
@@ -2,8 +2,9 @@
-- "OPP_CLASS#partyIndex" (the object_event trainer args). Hand-ported
-- from the leaders'/bosses' text_asm victory scripts:
-- gym badges: scripts/PewterGym.asm ... ViridianGym.asm
-- Rocket Hideout Giovanni: his Silph Scope is an item ball next to him
-- (data/maps/objects/RocketHideoutB4F.asm), so no reward entry needed.
-- Rocket Hideout Giovanni: Silph Scope is a hidden item ball revealed by
-- ShowObject in RocketHideoutB4FBeatGiovanniScript (ported as the
-- TEXT_ROCKETHIDEOUTB4F_GIOVANNI talk handler in story3.lua).
-- The TM each gym leader hands out afterwards is also ported.
--
-- `deactivate` lists the EVENT_BEAT_* flags each gym's victory script
@@ -11,6 +12,12 @@
-- "; deactivate gym trainers" / SetEventRange in the other gyms, and
-- FightingDojo.asm SetEventRange EVENT_BEAT_KARATE_MASTER ..
-- EVENT_BEAT_FIGHTING_DOJO_TRAINER_3).
--
-- `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
-- synthetic "received badge/TM" stub.
local function range(prefix, first, last)
local t = {}
@@ -21,30 +28,85 @@ local function range(prefix, first, last)
end
return {
-- PewterGym.asm .gymVictory also HideObject TOGGLE_GYM_GUY
-- (PEWTERCITY_YOUNGSTER) and TOGGLE_ROUTE_22_RIVAL_1 so the east-exit
-- escort NPC and the first Route 22 rival stay gone after the badge.
["OPP_BROCK#1"] = { badge = "BOULDERBADGE", flag = "EVENT_BEAT_BROCK",
item = "TM_BIDE",
deactivate = { "EVENT_BEAT_PEWTER_GYM_TRAINER_0" } },
deactivate = { "EVENT_BEAT_PEWTER_GYM_TRAINER_0" },
hide = {
{ "PEWTER_CITY", "PEWTERCITY_YOUNGSTER" },
{ "ROUTE_22", "ROUTE22_RIVAL1" },
},
dialogue = {
"_PewterGymBrockReceivedBoulderBadgeText",
"_PewterGymBrockBoulderBadgeInfoText",
"_PewterGymBrockWaitTakeThisText",
"_PewterGymReceivedTM34Text",
"_TM34ExplanationText",
} },
["OPP_MISTY#1"] = { badge = "CASCADEBADGE", flag = "EVENT_BEAT_MISTY",
item = "TM_BUBBLEBEAM",
deactivate = range("EVENT_BEAT_CERULEAN_GYM_TRAINER_", 0, 1) },
deactivate = range("EVENT_BEAT_CERULEAN_GYM_TRAINER_", 0, 1),
dialogue = {
"_CeruleanGymMistyReceivedCascadeBadgeText",
"_CeruleanGymMistyCascadeBadgeInfoText",
"_CeruleanGymMistyReceivedTM11Text",
} },
["OPP_LT_SURGE#1"] = { badge = "THUNDERBADGE", flag = "EVENT_BEAT_LT_SURGE",
item = "TM_THUNDERBOLT",
deactivate = range("EVENT_BEAT_VERMILION_GYM_TRAINER_", 0, 2) },
deactivate = range("EVENT_BEAT_VERMILION_GYM_TRAINER_", 0, 2),
dialogue = {
"_VermilionGymLTSurgeReceivedThunderBadgeText",
"_VermilionGymLTSurgeThunderBadgeInfoText",
"_VermilionGymLTSurgeReceivedTM24Text",
"_TM24ExplanationText",
} },
["OPP_ERIKA#1"] = { badge = "RAINBOWBADGE", flag = "EVENT_BEAT_ERIKA",
item = "TM_MEGA_DRAIN",
deactivate = range("EVENT_BEAT_CELADON_GYM_TRAINER_", 0, 6) },
deactivate = range("EVENT_BEAT_CELADON_GYM_TRAINER_", 0, 6),
dialogue = {
"_CeladonGymErikaReceivedRainbowBadgeText",
"_CeladonGymRainbowBadgeInfoText",
"_CeladonGymReceivedTM21Text",
"_TM21ExplanationText",
} },
["OPP_KOGA#1"] = { badge = "SOULBADGE", flag = "EVENT_BEAT_KOGA",
item = "TM_TOXIC",
deactivate = range("EVENT_BEAT_FUCHSIA_GYM_TRAINER_", 0, 5) },
deactivate = range("EVENT_BEAT_FUCHSIA_GYM_TRAINER_", 0, 5),
dialogue = {
"_FuchsiaGymKogaReceivedSoulBadgeText",
"_FuchsiaGymKogaSoulBadgeInfoText",
"_FuchsiaGymKogaReceivedTM06Text",
"_FuchsiaGymKogaTM06ExplanationText",
} },
["OPP_SABRINA#1"] = { badge = "MARSHBADGE", flag = "EVENT_BEAT_SABRINA",
item = "TM_PSYWAVE",
deactivate = range("EVENT_BEAT_SAFFRON_GYM_TRAINER_", 0, 6) },
deactivate = range("EVENT_BEAT_SAFFRON_GYM_TRAINER_", 0, 6),
dialogue = {
"_SaffronGymSabrinaReceivedMarshBadgeText",
"_SaffronGymSabrinaMarshBadgeInfoText",
"_SaffronGymSabrinaReceivedTM46Text",
"_TM46ExplanationText",
} },
["OPP_BLAINE#1"] = { badge = "VOLCANOBADGE", flag = "EVENT_BEAT_BLAINE",
item = "TM_FIRE_BLAST",
deactivate = range("EVENT_BEAT_CINNABAR_GYM_TRAINER_", 0, 6) },
deactivate = range("EVENT_BEAT_CINNABAR_GYM_TRAINER_", 0, 6),
dialogue = {
"_CinnabarGymBlaineReceivedVolcanoBadgeText",
"_CinnabarGymBlaineVolcanoBadgeInfoText",
"_CinnabarGymBlaineReceivedTM38Text",
"_CinnabarGymBlaineTM38ExplanationText",
} },
["OPP_GIOVANNI#3"] = { badge = "EARTHBADGE", flag = "EVENT_BEAT_GIOVANNI",
item = "TM_FISSURE",
deactivate = range("EVENT_BEAT_VIRIDIAN_GYM_TRAINER_", 0, 7) },
deactivate = range("EVENT_BEAT_VIRIDIAN_GYM_TRAINER_", 0, 7),
dialogue = {
"_ViridianGymGiovanniReceivedEarthBadgeText",
"_ViridianGymGiovanniEarthBadgeInfoText",
"_ViridianGymGiovanniReceivedTM27Text",
"_ViridianGymGiovanniTM27ExplanationText",
} },
-- Silph Co. Giovanni: unlocks the president's Master Ball gift
["OPP_GIOVANNI#2"] = { flag = "EVENT_BEAT_SILPH_CO_GIOVANNI" },
@@ -52,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)
+195
View File
@@ -0,0 +1,195 @@
# Launcher
The launcher is `src/import/RomImporter.lua`, the first-run / title screen
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 / mod / save import
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. On the MODS tab, Import mod .zip must open the
same system picker and install the chosen archive on return.
## Tab structure
`self.tab` is one of `"red"`, `"blue"`, `"yellow"`, `"mods"`. The tab bar
draws one chip per game plus a MODS chip and rebuilds `self.tabRects` every
frame so `mousepressed` can dispatch clicks; switching tabs mid-import is
allowed (a dropped ROM still routes by SHA-1 regardless of which tab shows).
- A game tab (`_drawGamePanel`) shows the ROM card, the SAVE FILES card, the
Play button, and the SAVE SLOT card in a responsive two-column grid (see
Responsiveness). The MODS tab (`_drawModsPanel`) shows the mod list instead.
- The self-updater banner (`self.Check`, see `docs/updater.md`) draws as a
centered pill in a reserved band just above the footer, on every tab. That
position is unchanged by this redesign, so `docs/updater.md` needed no edits.
## Save slot model
All slot I/O lives in `src/core/SaveData.lua` and goes through the same fs
abstraction (`persistFs`) every other save/options call uses, so portable
mode (an `io.*` filesystem used when `portable.txt` marks the install)
keeps working unchanged.
- **Files.** A version's playthroughs live under `saves/<version>/`, one file
per slot: `saves/<version>/slot1.lua` plus a rolling `.bak` and staged
`.tmp` witness (`slotNames`), mirroring the write/recovery discipline
`SaveData.save`/`load` already use for the flat legacy file. Slot ids match
`slot%d+`; `createSlot` allocates one past the highest existing number so a
reused id can never collide with a lingering file.
- **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" } }`.
- **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.
Resolved once per version per process (`ensureVersionSlots`, cached in
`activeSlotCache`/`slotsChecked`): a registry entry wins; otherwise a lazy
legacy migration may create one; otherwise the flat legacy path is used
(`save.lua` / `save_blue.lua`), so a pre-slots install keeps working as before.
- **Legacy migration.** One-time per version, lazy on first
`listSlots`/`load`/`saveNames` call (`tryMigrateLegacy`): if a flat legacy
file exists and no `saves/<version>/` registry does, its main + `.bak` are
copied into `saves/<version>/slot1.lua(.bak)`, verified readable
(`decodeSlot`: main, then `.tmp`, then `.bak`), and only then are the
originals removed and `slot1` registered as active. A copy that fails to
verify leaves the originals in place; migration never loses data.
The launcher-facing API:
- `SaveData.listSlots(version)` -> array of `{id, exists, name, meta}` for
every registered slot. `name` is the save's player name, or `nil` for an
empty slot; `meta` is `{badges, timeText, dexCount}` (the same fields the
title screen's `ContinueInfo` shows) or `nil`. The pure part,
`SaveData.slotSummary(save)`, is unit-testable with no filesystem.
- `SaveData.setActiveSlot(version, slotId)` registers the id if new, persists
it as active, and updates the process cache so the very next save/load
lands there. The launcher calls this the moment a slot row is clicked
(`RomImporter:_selectSlot`); pressing Play needs no signature change, since
`Game.lua`/`main.lua` still just call `SaveData.load()`/`save()`.
- `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
`src/mods/LauncherMods.lua` is a launcher-only read of the mod set. It runs
before `Game:load`, so **it never loads a mod's entry chunk**; only
`manifest.json` is read and validated (`src/mods/Manifest.validate`), the way
`Loader:_discover` finds mods without running them. The real loader
(`src/mods/Loader.lua`) still owns the actual load at boot.
- `LauncherMods.list()` scans `mods/` one level deep (first id wins on a
duplicate) and returns one row per mod:
`{id, name, version, badge, description, enabled, status, statusDetail}`.
`badge` is the manifest's `category`, falling back to `profile`, then
`"MOD"`, uppercased. `enabled` reads `options.mods[id]` (missing means
enabled, matching the loader's own default).
- `status` is `"ok"`, `"warn"`, or `"conflict"`, computed by the pure
`LauncherMods.deriveList`/`statusFor` against `ManagerState.resolveToggle`
and the validated manifests: `conflict` when enabling this mod collides
with another enabled one; `warn` for an out-of-range `game_version` or an
absent/disabled/wrong-version hard dependency; `ok` otherwise. Having no
`love.*` calls, this half is table-driven by the test suite on its own.
- `LauncherMods.setEnabled(id, bool)` persists `options.mods[id]` as a plain
boolean, the exact shape `Loader:_saveState` writes, so the running game
and the in-game `ManagerState` see the change on next boot. The mods panel
calls this on every toggle and re-derives the list right away
(`RomImporter:_refreshMods`) so a status change (e.g. a new conflict)
shows without waiting for a reload.
- `LauncherMods.installZip(path)` mounts the archive with
`love.filesystem.mount`, locates the mod root via `locateRoot` (manifest at
the zip root, or inside one top-level folder), validates its manifest, and
copies the tree into the save-dir `mods/<id>/` before unmounting. Rejects a
duplicate of an already-installed mod id, and accepts either an external
path string or a LOVE `DroppedFile`, staging a dropped file into a save-dir
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
The SAVE FILES card wires a raw Gen1 `.sav` battery image to the save slots
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` 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`).
The meta stamp is re-stamped off `gen1_import` to the current numeric format
so `SaveData.load`'s migration pass accepts the slot. On success the SAVE SLOT
panel is refreshed with the new slot selected.
- **Export save** is live only when the active slot actually holds a save
(checked against `listSlots`). `SaveFileIO.exportActiveSlot` loads the active
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")`). 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
(`_savedropTarget`). `.gb` (ROM) and `.zip` (mod) routing is unchanged.
- **Failure UX.** Every error path (wrong size, bad checksum, write failure,
nothing to export, ROM not imported yet) surfaces as a red notice line on the
card. Nothing raises and nothing silently no-ops.
`SaveFileIO` is love-free enough to unit-test through the same in-memory
filesystem stub the slot backend uses (`tests/engine/save_file_io_tests.lua`).
## Responsiveness
Every measurement derives from `love.graphics.getDimensions()` each frame
plus the existing global scale `s = clamp(height / 768, 0.7, 1.6)`; nothing
assumes a fixed window size. The game panel's two-column grid (ROM/SAVE
FILES/Play on the left, SAVE SLOT on the right) collapses to one stacked
column, slot card below Play, when the window is too narrow for both
`~300 * s`-wide columns. The save-slot list and the mod list both scroll
(wheel, or drag on touch/desktop) clamped to their own content extent,
recomputed every draw. The tab bar labels only the active chip so it stays
narrow-safe, and content caps out at `~1440 * s` wide, centered.
+105
View File
@@ -18,6 +18,111 @@ Regenerate the reference straight into a wiki checkout:
luajit tools/gen_registry_docs.lua ../pokemon-gen1-recomp-project.wiki
```
## Rendering pipelines
Most registries hand the engine *content*. `render_pipelines` hands it
*drawing*: a pipeline is a display mode a mod owns, which may replace the
overworld's world pass with geometry of its own and/or post-process the
finished image. `mods/voxel_world` is the worked example — a 3D diorama
overworld plus a tilt-shift miniature pass, in about 120 lines of glue over
its renderer.
A record declares what the mode *is*; the engine
(`src/render/Pipelines.lua`) supplies everything about *being a display
mode*: the OFF/1/2/3 ladder, an options row next to TILT, a hotkey,
persistence in `save.options.pipelines`, and the rule that a world pipeline
and the engine's own TILT are mutually exclusive.
```lua
mod.content.render_pipelines:register("diorama", {
label = "DIORAMA", -- options row label
levels = { "OFF", "15", "35", "50" }, -- ladder; defaults to OFF/ON
hotkey = "6", -- checked after the engine's keys
priority = 20, -- highest eligible wins the world
available = function() return Renderer3D.ok() end,
update = function(dt, level) Camera.ease(dt, level) end,
drawWorld = function(ctx) return renderScene(ctx) end,
})
```
Three draw stages, each optional; a record needs at least one:
| stage | signature | runs |
| --- | --- | --- |
| `drawWorld` | `(ctx) -> canvas \| nil` | instead of the flat/tilt world pass |
| `worldPresent` | `(canvas, ctx) -> canvas` | over the world, **before** the UI composites |
| `present` | `(canvas, ctx) -> canvas` | over the whole frame, world and UI alike |
`worldPresent` is the one to reach for when an effect must leave dialog
boxes and menus crisp — a depth-of-field or colour grade on the world only.
`present` is for effects that genuinely own the screen, like a CRT curve.
`ctx` carries the frame: `state`, `cam`, `vw`/`vh` (world-pixel view),
`width`/`height` (window pixels), `scale`, `level`, `paletteFor(map)` and
`spriteColors(map)`. It also carries `ctx.drawFx(project, scale)` — call it
with your own projection and the engine draws every active field effect
(the "!" bubble, the Poké Center heal machine, the Fly bird, the fishing
rod, Rock Tunnel darkness) at its correct anchor under your camera. There
is exactly one copy of each effect, so a new engine effect works in your
pipeline without you touching anything.
Three rules worth knowing:
- **`gate` governs input, never the draw.** It decides whether the player
may *change* the mode (default: free-roam overworld only). A mode that
stopped rendering during a warp would flash the flat 2D world every time
the player walked through a door.
- **`available` is re-read every frame** and is the only thing that decides
whether the mode can render at all. Answer `false` on a headless run or a
driver with no depth canvas and the engine silently keeps the vanilla 2D
path — which is why shipping a pipeline enabled is safe.
- **A callback that throws retires its pipeline**, attributed to your mod in
the manager's error feed, and the frame falls back to 2D. A broken
renderer costs the player a display mode, never the game.
Returning `nil` from `drawWorld` is a normal answer meaning "not this
frame"; the engine draws the vanilla world instead.
## Battle sprite scaling
The enemy's front pic draws at 1x and the player's back pic at 2x, the way
the Game Boy did. A mod can override either, per species or per image.
Per species, on the `pokemon` record:
```lua
-- MEW's back pic renders 1.5x; its front pic is untouched
mod.content.pokemon:patch("MEW", { battleScaleBack = 1.5 })
```
`battleScaleFront` scales the enemy pic, `battleScaleBack` the player pic;
both take a number in `0.25 .. 4.0`.
Per image, on the `battle_sprite_scales` registry, keyed by the asset path
exactly as the data references it:
```lua
mod.content.battle_sprite_scales:register("abra_back", {
path = "assets/generated/battle/back/abrab.png",
scale = 1.5,
})
```
An image-level entry beats the species scale for that one pic, and it is
the only way to scale a pic that is not species-keyed — the player's
trainer back sprite, held on screen until "Go!", is a bare image path.
The resolution order at draw time is **image-level → species-level →
default** (1x front, 2x back).
- **The pic stays grounded at every scale.** The player pic keeps its feet
flush on the text-box top (`y = 96`); the enemy pic keeps its bottom edge
and horizontal centre pinned in its 7×7 slot. A larger pic grows upward
and outward from that anchor, never off the shelf.
- **Scaling composes with the send-out grow.** The `AnimateSendingOutMon`
ball-to-pic grow multiplies your scale through each stage, so a rescaled
mon still grows into place from the ball, grounded the whole way.
## Developer console
Boot with developer mode on to unlock the in-game console and hot-reload
+56 -13
View File
@@ -8,27 +8,42 @@ behavior is in docs/behavior-porting-notes.md.
## Survey zoom
The mouse wheel (or `-`/`=`) zooms the overworld between 1 pixel per world
pixel (full survey) and 2× the window fit scale (close-up), in crisp
integer steps. This has no Game Boy equivalent:
The mouse wheel (or `-`/`=`), the Options **ZOOM** row, or hotkey `4`
zooms the overworld between 1 pixel per world pixel (full survey) and 2×
the window fit scale (close-up), in crisp integer steps. This has no Game
Boy equivalent:
- Connected maps render their full bodies, and their NPCs appear as
visual-only "ghosts", they wander but have no sight lines, triggers,
dialogue, or collision until the map is actually entered.
- Menus, text boxes, and battles draw at normal scale on top of the
zoomed world. Zoom input is ignored while a script, menu, or battle is
active; the zoom level persists across warps and is never saved.
- Beyond the border ring the border block repeats indefinitely (interiors
stay black, seaside towns stay water, except OVERWORLD-tileset maps,
whose beyond-edge space fills with the solid tree wall instead of the
per-map border block), and each visible map area is colorized with its
own SGB palette (the original recolored the whole screen per map).
active; the zoom offset is persisted as `save.options.zoom` (default
`0` = FIT) and survives New Game via `options.lua`.
- Hotkey `4` ticks through every integer zoom level (survey → FIT →
close-up → wrap). The Options row shows `FIT` / `OUTn` / `INn`.
- Beyond the border ring the void fill repeats indefinitely (see VOID
FILL below); interiors keep their own border block. Each visible map
area is colorized with its own SGB palette (the original recolored the
whole screen per map).
- Neighbor maps load two connection hops out so corner-adjacent maps
don't pop in and out, and ghost NPCs share instances with the real ones
so their wander positions persist across seamless connection crossings
(a warp or fresh map entry still respawns everything at its script
position, like the original's per-entry sprite init).
## VOID FILL
The Options **VOID FILL** row picks what paints the infinite beyond-edge
space on OVERWORLD-tileset maps during survey zoom:
- **TREES** (default): solid tree wall block `$0F`.
- **WATER**: animated water tile `$14` (same hshift cycle as on-map water).
- **BLACK**: solid black.
Other tilesets are unchanged (house/cave borders stay as authored).
Persisted as `save.options.voidFill`.
## Tilt mode
The `3` key (and the Options menu TILT row) cycles a visual-only perspective
@@ -42,8 +57,10 @@ Game Boy equivalent:
rows above the player recede and rows below come toward the viewer. Only
things that actually *stand* on the ground draw as upright billboards,
unscaled and pixel-identical to flat mode: the player, NPCs, item balls,
and the screen-anchored FX attached to them (heal machine glow, emote
bubbles, the fishing rod, the FLY bird). An earlier revision tried
and the standing FX attached to them (emote bubbles, the fishing rod,
the FLY bird). The Poké Center heal-machine overlay stays on the ground
plane with the machine tiles (it is OAM glued to a BG graphic, not a
standing sprite). An earlier revision tried
billboarding buildings/trees/signs too (cutting them out of the ground
per hand-curated per-tileset tables); that chased an endless tail of
special cases, dense tree canopy, fences fused into grass, building
@@ -137,7 +154,7 @@ Nidorino-vs-Gengar attract scene) mirror the original.
Options persist in a standalone `options.lua` (separate from the game
progress `save.lua`), so audio/display/battle preferences survive New Game
and aren't wiped when a save slot is cleared. Changing a row in the Options
menu or cycling hotkeys `2`/`3`/`5` writes immediately; an in-game save also
menu or cycling hotkeys `2`/`3`/`4`/`5` writes immediately; an in-game save also
flushes the live options. Old saves that still embed an `options` table are
migrated once into `options.lua` on load.
@@ -148,6 +165,32 @@ migrated once into `options.lua` on load.
hotkey `2` (OG RED = GBC boot-ROM look; RED++ 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
`-`/`=` step one level and save
- VOID FILL (TREES / WATER / BLACK) for OVERWORLD beyond-edge space
- GBC FX (OFF / 1 / 2 / 3 / 4), also hotkey `5`
- MAX FPS (30 / 40 / 50 / 60 / 75 / 90 / 100 / 120 / 144 / 160, default 60),
a hard render frame-rate cap (`save.options.fpsCap`).
a hard render frame-rate cap (`save.options.fpsCap`).
## Battle transition cascade + white battle letterbox
Into-battle wipes still run the original eight styles inside the classic
160×144 letterbox. On wide/tall windows (survey zoom), matching black 8×8
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.
## 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).
+126
View File
@@ -0,0 +1,126 @@
# Updater
A fused build (`love.filesystem.isFused()` true) ships a bundled `game.love`
baked into the executable, but that bundled copy is only ever the *fallback*.
On every launch, before anything else runs, `Boot.run` (`src/update/Boot.lua`)
looks in the save directory's `updates/` folder for a downloaded
`gen1recomp-X.Y.Z.love` payload that is both strictly newer than the bundled
engine version and runnable on this shell. If one qualifies, it is mounted
over `/` (so its files win over the fused source for every subsequent
`require`) and chainloaded in place: the payload's `main.lua` and `love.load`
run as if they had shipped in the executable. A dev/source checkout is never
fused, so `Boot.run` no-ops there and the working tree always runs itself.
The pieces are deliberately layered so the risky part is small. `Boot.select`
is a pure function (no `love.*` calls) that, given probed candidates and the
bundled `engine`/`shell`, decides what to run and what stale payloads to
delete. `Boot.probePayload` mounts one archive at an isolated mountpoint and
reads its `src/core/Version.lua` with `loadstring` (never `require`, so it is
never cached as a module) to learn its `engine` and `minShell`. `Boot.run`
orchestrates the crash guard, enumeration, selection, and the mount +
chainload, with full rollback on any failure. Checking for and fetching a
new payload is a separate, slower path: `src/update/Check.lua` is a thin
main-thread state machine the launcher screen polls, while the curl calls,
JSON parsing, and sha256 verification run on a background `love.thread`
(`src/update/check_worker.lua`) so a hung network call never blocks a frame.
## Version.lua fields
`src/core/Version.lua` carries three fields the updater reads directly (the
existing `modApi`, `linkProtocol`, `saveFormat`, and `cache` fields are
untouched):
- `engine` - the semver release, e.g. `"1.4.0"`. The repo default is the
`"0.0.0-dev"` placeholder; CI stamps the real `X.Y.Z` into the packed
`game.love` only, never the working tree. A `"0.0.0-dev"` engine always
reports itself up to date (it never chases a release, and it never counts
as a valid payload to chainload).
- `shell` - the native-shell contract this build's fused executable
implements.
- `minShell` - the lowest shell contract required to *run* this payload.
Bump `minShell` only when a payload needs something the currently-shipped
native shell cannot provide, for example a LOVE version bump, a new required
system binary, or a change to `love.run` itself (see Known limitations
below). An older shell refuses to chainload a payload whose `minShell`
exceeds the shell it provides; `Boot.select` keeps that payload in `updates/`
rather than deleting it, in case a future shell upgrade can run it, and
`Check`'s worker reports `needs_full` so the player is pointed at a full
installer instead. Do not bump `minShell` for an ordinary Lua/data release;
that is exactly the case the updater exists to avoid a reinstall for.
## Release assets
Each tagged release `vX.Y.Z` carries the existing per-platform archives
(`gen1recomp-X.Y.Z-macos.zip`, `-windows.zip`, `-linux.zip`,
`-android.apk`) plus two assets the updater itself consumes:
- `gen1recomp-X.Y.Z.love` - the payload, matched by the exact pattern
`gen1recomp-<version>.love` (see `isPayloadName` in `Boot.lua` and
`Check.parseRelease`).
- `sha256sums.txt` - `shasum -a 256` output (`<hex> <filename>`, bare
filenames) covering at least the `.love` payload. `Check.parseSums`
tolerates a leading `*` binary marker and a `./` prefix but expects the
filename otherwise to match the asset name exactly.
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`).
## Save-directory layout
Under the save directory (identity `pokemon-love2d`):
```
updates/gen1recomp-<X.Y.Z>.love downloaded payload(s)
updates/pending.txt crash-guard marker
```
`pending.txt` holds the filename of the payload currently being chainloaded.
`Boot.run`'s `chainload` writes it immediately before mounting, and removes it
on both a successful handoff and a clean rollback. If it is still present the
*next* time `Boot.run` starts, the previous boot died mid-handoff, so that
named payload is distrusted: it and the marker are deleted before candidates
are enumerated. Boot may still fall back to an older valid payload, or to the
bundled game, in that case.
## Update flow
1. **Boot** (every launch, fused builds only): crash-guard check, enumerate
and probe every `updates/*.love`, pick the highest engine that is
strictly newer than the bundled one and whose `minShell` this shell
satisfies, delete stale payloads, chainload the winner (or run the
bundled game if none qualifies).
2. **Check** (launcher screen): `Check.start()` kicks off an async check
against the GitHub releases API; safe to call every frame, it is a no-op
once a check is in flight or has reached a terminal state. `Check.state()`
reports `idle | checking | uptodate | available | downloading | ready |
needs_full | error` plus the latest version and download progress.
3. **Download + verify**: on `available`, `Check.download()` tells the
worker to fetch the payload, polling the growing `.part` file for
progress. On completion the worker re-fetches `sha256sums.txt`, verifies
the payload's sha256, and probes it with `Boot.probePayload` to gate its
`minShell` against this shell's `shell`. A verified, runnable payload is
renamed into place and reported as `ready`; anything else reports
`error` or `needs_full` and leaves `updates/` clean.
4. **Restart to apply**: a `ready` payload just sits in `updates/` until the
player relaunches; the next launch's Boot step (1) is what actually
mounts and runs it. There is no in-session hot-swap.
## Known limitations
- **`love.run` persists across handoff.** By the time `chainload` runs, the
bundled `love.run` has already returned its stepper to LOVE; redefining the
global `love.run` from the payload's `main.lua` does not affect the loop
already driving the frame. A payload that must change `love.run` itself
needs a `minShell` bump so an older shell refuses to chainload it rather
than running with half its intended behavior.
- **Android has no in-app download transport yet.** `check_worker.lua`
shells out to curl for both the release check and the download; curl is
absent on Android, so `Check` degrades to `status = "error"` there (the
launcher UI hides on that status) and the player is directed to the
releases page via `Check.releaseUrl()` instead.
- **Dev/source runs never self-update.** `Boot.run` returns immediately when
`love.filesystem.isFused()` is false, and a working tree's `engine` is the
`"0.0.0-dev"` placeholder that always reports up to date, so a source
checkout is always "the game" itself; updating it means pulling the repo.
+24
View File
@@ -18,6 +18,11 @@ local driverCo -- optional frame-driver (POKEPORT_DRIVER=file.lua): a
-- bot or screenshot run is not at the mercy of the player's last choice.
local speedOverride = tonumber(os.getenv("POKEPORT_SPEED"))
-- POKEPORT_TOUCH=1 forces the mobile on-screen controls on and lets the
-- mouse stand in for a finger, so the overlay can be exercised on desktop
-- (see src/core/TouchControls.lua).
local mouseTouch = os.getenv("POKEPORT_TOUCH") == "1"
-- How many times to run a scripted act+step loop per rendered frame. Only
-- scripted runs use this; interactive play fast-forwards through
-- Game.speedOverride / the GAME SPEED option instead.
@@ -56,6 +61,12 @@ local function bootGame(version)
end
function love.load(args)
-- Self-updater boot shell: a fused build may mount and chainload a newer
-- downloaded payload here. True means it took over, so we must stop. A
-- dev / source checkout no-ops (see src/update/Boot.lua).
local Boot = require("src.update.Boot")
if Boot.run(args) then return end
local savePath
for i, a in ipairs(args or {}) do
if a == "--editor" then
@@ -268,6 +279,9 @@ function love.mousepressed(x, y, button)
if editorMode and EditorApp.mousepressed then
return EditorApp.mousepressed(x, y, button)
end
if mouseTouch and Game and button == 1 then
Game:touchpressed("mouse", x, y)
end
end
function love.mousereleased(x, y, button)
@@ -275,6 +289,16 @@ function love.mousereleased(x, y, button)
if editorMode and EditorApp.mousereleased then
return EditorApp.mousereleased(x, y, button)
end
if mouseTouch and Game and button == 1 then
Game:touchreleased("mouse", x, y)
end
end
function love.mousemoved(x, y)
if editorMode or Importer then return end
if mouseTouch and Game and love.mouse.isDown(1) then
Game:touchmoved("mouse", x, y)
end
end
function love.textinput(text)
+6 -3
View File
@@ -43,9 +43,12 @@ 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`.
The current importer has desktop file pickers only. A production Android
release still needs a Storage Access Framework handoff that passes the chosen
ROM to LÖVE; the APK packaging itself is data-free.
ROM / mod / save import on Android uses `love.system.pickFile([kind])`
`GameActivity.showFilePicker` (Storage Access Framework), which copies the
chosen file under the app save directory as `picked_rom.gb`,
`picked_mod.zip`, or `picked_save.sav`. `RomImporter` imports pending files
from that folder on Choose / refocus; see `docs/launcher.md`. The APK payload
itself remains data-free (no embedded ROM or generated cache).
### SDK / NDK
+1 -1
View File
@@ -18,4 +18,4 @@ android.useAndroidX=true
android.defaults.buildfeatures.buildconfig=true
android.nonTransitiveRClass=true
android.nonFinalResIds=true
app.name=Pokemon Red
app.name=gen1recomp
@@ -183,13 +183,37 @@ void vibrate(double seconds)
env->DeleteLocalRef(activity);
}
bool showFilePicker()
bool showFilePicker(const char *destFilename)
{
if (destFilename == nullptr || destFilename[0] == '\0')
destFilename = "picked_rom.gb";
JNIEnv *env = (JNIEnv*) SDL_AndroidGetJNIEnv();
jclass activity = env->FindClass("org/love2d/android/GameActivity");
jmethodID method = env->GetStaticMethodID(activity, "showRomFilePicker", "()Z");
jboolean result = env->CallStaticBooleanMethod(activity, method);
jmethodID method = env->GetStaticMethodID(activity, "showFilePicker",
"(Ljava/lang/String;)Z");
jstring jname = env->NewStringUTF(destFilename);
jboolean result = env->CallStaticBooleanMethod(activity, method, jname);
env->DeleteLocalRef(jname);
env->DeleteLocalRef(activity);
return result;
}
bool showCreateDocument(const char *suggestedName)
{
if (suggestedName == nullptr || suggestedName[0] == '\0')
suggestedName = "export.sav";
JNIEnv *env = (JNIEnv*) SDL_AndroidGetJNIEnv();
jclass activity = env->FindClass("org/love2d/android/GameActivity");
jmethodID method = env->GetStaticMethodID(activity, "showCreateDocument",
"(Ljava/lang/String;)Z");
jstring jname = env->NewStringUTF(suggestedName);
jboolean result = env->CallStaticBooleanMethod(activity, method, jname);
env->DeleteLocalRef(jname);
env->DeleteLocalRef(activity);
return result;
@@ -63,9 +63,16 @@ void vibrate(double seconds);
* Shows the system's "pick a document" UI (Storage Access Framework).
* Returns true if the picker was launched; the picked file (if any) is
* copied asynchronously by GameActivity.onActivityResult into the app's
* external save directory, not returned here -- see src/import/RomImporter.lua.
* external save directory under destFilename (default picked_rom.gb), not
* returned here -- see src/import/RomImporter.lua.
**/
bool showFilePicker();
bool showFilePicker(const char *destFilename = nullptr);
/**
* Shows ACTION_CREATE_DOCUMENT so Lua can export a staged pending_export.sav
* to a user-chosen location. suggestedName is the dialog default filename.
**/
bool showCreateDocument(const char *suggestedName = nullptr);
/*
* Helper functions for the filesystem module
@@ -22,6 +22,8 @@
#include "common/config.h"
#include "System.h"
#include <cstring>
#if defined(LOVE_MACOSX)
#include <CoreServices/CoreServices.h>
#elif defined(LOVE_IOS)
@@ -180,11 +182,32 @@ void System::vibrate(double seconds) const
#endif
}
bool System::pickFile() const
bool System::pickFile(const char *kind) const
{
#ifdef LOVE_ANDROID
return love::android::showFilePicker();
const char *dest = "picked_rom.gb";
if (kind != nullptr)
{
if (strcmp(kind, "mod") == 0)
dest = "picked_mod.zip";
else if (strcmp(kind, "sav") == 0 || strcmp(kind, "save") == 0)
dest = "picked_save.sav";
else if (strcmp(kind, "rom") == 0)
dest = "picked_rom.gb";
}
return love::android::showFilePicker(dest);
#else
LOVE_UNUSED(kind);
return false;
#endif
}
bool System::createFile(const char *suggestedName) const
{
#ifdef LOVE_ANDROID
return love::android::showCreateDocument(suggestedName);
#else
LOVE_UNUSED(suggestedName);
return false;
#endif
}
@@ -111,9 +111,21 @@ public:
* Android only for now; the result (if any) is not returned here -- see
* love::android::showFilePicker and src/import/RomImporter.lua.
*
* @param kind Optional pick kind: nullptr/"rom" -> picked_rom.gb,
* "mod" -> picked_mod.zip, "sav"/"save" -> picked_save.sav.
* @return Whether the picker was shown.
**/
virtual bool pickFile() const;
virtual bool pickFile(const char *kind = nullptr) const;
/**
* Shows the platform's native "create / save a file" UI (Android SAF
* ACTION_CREATE_DOCUMENT). Copies staged pending_export.sav from the app
* save directory to the user-chosen URI. See GameActivity.showCreateDocument.
*
* @param suggestedName Default filename shown in the dialog.
* @return Whether the create dialog was shown.
**/
virtual bool createFile(const char *suggestedName = nullptr) const;
/**
* Gets if the user is playing music on background.
@@ -97,7 +97,15 @@ int w_vibrate(lua_State *L)
int w_pickFile(lua_State *L)
{
luax_pushboolean(L, instance()->pickFile());
const char *kind = luaL_optstring(L, 1, nullptr);
luax_pushboolean(L, instance()->pickFile(kind));
return 1;
}
int w_createFile(lua_State *L)
{
const char *suggested = luaL_optstring(L, 1, nullptr);
luax_pushboolean(L, instance()->createFile(suggested));
return 1;
}
@@ -117,6 +125,7 @@ static const luaL_Reg functions[] =
{ "openURL", w_openURL },
{ "vibrate", w_vibrate },
{ "pickFile", w_pickFile },
{ "createFile", w_createFile },
{ "hasBackgroundMusic", w_hasBackgroundMusic },
{ 0, 0 }
};
@@ -22,12 +22,15 @@ package org.love2d.android;
import org.libsdl.app.SDLActivity;
import java.io.BufferedInputStream;
import java.io.BufferedOutputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
@@ -61,13 +64,24 @@ public class GameActivity extends SDLActivity {
protected final int[] recordAudioRequestDummy = new int[1];
public static final int EXTERNAL_STORAGE_REQUEST_CODE = 2;
public static final int RECORD_AUDIO_REQUEST_CODE = 3;
public static final int ROM_PICKER_REQUEST_CODE = 4;
// Mirrors conf.lua's t.identity ("pokemon-love2d"): where the picked ROM
public static final int FILE_PICKER_REQUEST_CODE = 4;
public static final int FILE_CREATE_REQUEST_CODE = 5;
/** @deprecated Prefer FILE_PICKER_REQUEST_CODE; kept for older call sites. */
public static final int ROM_PICKER_REQUEST_CODE = FILE_PICKER_REQUEST_CODE;
// Mirrors conf.lua's t.identity ("pokemon-love2d"): where the picked file
// is dropped so RomImporter's existing folder scan finds it -- see
// src/import/RomImporter.lua and Filesystem::setIdentity (sets Android's
// save directory to getExternalFilesDir()/save/<identity>).
private static final String ROM_SAVE_IDENTITY = "pokemon-love2d";
private static final String PICKED_ROM_FILENAME = "picked_rom.gb";
private static final String PICKED_MOD_FILENAME = "picked_mod.zip";
private static final String PICKED_SAVE_FILENAME = "picked_save.sav";
private static final String PENDING_EXPORT_FILENAME = "pending_export.sav";
private static final String EXPORT_DONE_FILENAME = "export_done.flag";
// Destination basename for the in-flight SAF pick (set by showFilePicker).
private String pendingPickFilename = PICKED_ROM_FILENAME;
// Suggested download name for the in-flight SAF create (set by showCreateDocument).
private String pendingCreateSuggestedName = "export.sav";
private static boolean immersiveActive = false;
private static boolean needToCopyGameInArchive = false;
private boolean storagePermissionUnnecessary = false;
@@ -341,59 +355,185 @@ public class GameActivity extends SDLActivity {
/**
* Shows the system document picker (Storage Access Framework) so the
* player can pick their ROM from anywhere (Downloads, Drive, etc.)
* without needing to know where the app's external files folder is.
* Requires API 19+ (ACTION_OPEN_DOCUMENT); the picked file (if any)
* player can pick a ROM / mod / save from anywhere (Downloads, Drive,
* etc.) without needing to know where the app's external files folder
* is. Requires API 19+ (ACTION_OPEN_DOCUMENT); the picked file (if any)
* arrives later in onActivityResult, not synchronously here.
*
* @param destFilename basename under the app save identity (e.g.
* picked_rom.gb, picked_mod.zip, picked_save.sav)
*/
@Keep
public static boolean showRomFilePicker() {
public static boolean showFilePicker(String destFilename) {
if (android.os.Build.VERSION.SDK_INT < 19) return false;
GameActivity self = (GameActivity) mSingleton;
if (self == null) return false;
if (destFilename == null || destFilename.length() == 0) {
destFilename = PICKED_ROM_FILENAME;
}
// Reject path separators so a hostile JNI caller cannot escape the
// save identity directory.
if (destFilename.indexOf('/') >= 0 || destFilename.indexOf('\\') >= 0) {
Log.d("GameActivity", "refusing unsafe picker dest: " + destFilename);
return false;
}
self.pendingPickFilename = destFilename;
Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT);
intent.addCategory(Intent.CATEGORY_OPENABLE);
intent.setType("*/*");
try {
self.startActivityForResult(intent, ROM_PICKER_REQUEST_CODE);
self.startActivityForResult(intent, FILE_PICKER_REQUEST_CODE);
return true;
} catch (Exception e) {
Log.d("GameActivity", "could not open ROM file picker: " + e.getMessage());
Log.d("GameActivity", "could not open file picker: " + e.getMessage());
return false;
}
}
/** ROM convenience wrapper; prefer showFilePicker with an explicit name. */
@Keep
public static boolean showRomFilePicker() {
return showFilePicker(PICKED_ROM_FILENAME);
}
/** Mod .zip convenience wrapper used by love.system.pickFile("mod"). */
@Keep
public static boolean showModFilePicker() {
return showFilePicker(PICKED_MOD_FILENAME);
}
/** Battery .sav convenience wrapper used by love.system.pickFile("sav"). */
@Keep
public static boolean showSaveFilePicker() {
return showFilePicker(PICKED_SAVE_FILENAME);
}
/**
* Shows ACTION_CREATE_DOCUMENT so the player can save a staged export
* (pending_export.sav in the app save identity) to Downloads / Drive /
* etc. Suggested name is the dialog's default filename.
*/
@Keep
public static boolean showCreateDocument(String suggestedName) {
if (android.os.Build.VERSION.SDK_INT < 19) return false;
GameActivity self = (GameActivity) mSingleton;
if (self == null) return false;
if (suggestedName == null || suggestedName.length() == 0) {
suggestedName = "export.sav";
}
if (suggestedName.indexOf('/') >= 0 || suggestedName.indexOf('\\') >= 0) {
Log.d("GameActivity", "refusing unsafe create name: " + suggestedName);
return false;
}
File source = new File(
new File(self.getExternalFilesDir(null), "save"),
ROM_SAVE_IDENTITY + "/" + PENDING_EXPORT_FILENAME);
if (!source.isFile()) {
Log.d("GameActivity", "no pending export at " + source);
return false;
}
self.pendingCreateSuggestedName = suggestedName;
Intent intent = new Intent(Intent.ACTION_CREATE_DOCUMENT);
intent.addCategory(Intent.CATEGORY_OPENABLE);
intent.setType("application/octet-stream");
intent.putExtra(Intent.EXTRA_TITLE, suggestedName);
try {
self.startActivityForResult(intent, FILE_CREATE_REQUEST_CODE);
return true;
} catch (Exception e) {
Log.d("GameActivity", "could not open create-document picker: " + e.getMessage());
return false;
}
}
private File saveIdentityDir() {
return new File(new File(getExternalFilesDir(null), "save"), ROM_SAVE_IDENTITY);
}
private boolean copyFileToUri(File source, Uri destUri) {
InputStream in = null;
OutputStream out = null;
try {
in = new BufferedInputStream(new FileInputStream(source));
out = getContentResolver().openOutputStream(destUri);
if (out == null) return false;
byte[] buf = new byte[8192];
int n;
while ((n = in.read(buf)) != -1) {
out.write(buf, 0, n);
}
out.flush();
return true;
} catch (IOException e) {
Log.d("GameActivity", "copy to URI failed: " + e.getMessage());
return false;
} finally {
try { if (in != null) in.close(); } catch (IOException ignored) {}
try { if (out != null) out.close(); } catch (IOException ignored) {}
}
}
@Override
protected void onActivityResult(int requestCode, int resultCode, Intent data) {
super.onActivityResult(requestCode, resultCode, data);
if (requestCode != ROM_PICKER_REQUEST_CODE) return;
if (requestCode == FILE_CREATE_REQUEST_CODE) {
if (resultCode != RESULT_OK || data == null || data.getData() == null) {
Log.d("GameActivity", "create-document cancelled");
return;
}
File source = new File(saveIdentityDir(), PENDING_EXPORT_FILENAME);
if (!source.isFile()) {
Log.d("GameActivity", "pending export missing at result time");
return;
}
Uri uri = data.getData();
if (copyFileToUri(source, uri)) {
// Signal Lua on next focus that the SAF export finished.
File flag = new File(saveIdentityDir(), EXPORT_DONE_FILENAME);
try {
FileOutputStream fos = new FileOutputStream(flag, false);
fos.write("ok".getBytes());
fos.close();
} catch (IOException e) {
Log.d("GameActivity", "could not write export_done flag: " + e.getMessage());
}
// Keep pending_export.sav so a retry still works; Lua may remove it.
} else {
Log.d("GameActivity", "could not write export to " + uri);
}
return;
}
if (requestCode != FILE_PICKER_REQUEST_CODE) return;
if (resultCode != RESULT_OK || data == null || data.getData() == null) {
Log.d("GameActivity", "ROM picker returned no file (cancelled?)");
Log.d("GameActivity", "file picker returned no file (cancelled?)");
return;
}
Uri uri = data.getData();
File destDir = new File(new File(getExternalFilesDir(null), "save"), ROM_SAVE_IDENTITY);
File destDir = saveIdentityDir();
if (!destDir.exists() && !destDir.mkdirs()) {
Log.d("GameActivity", "could not create " + destDir);
return;
}
File destFile = new File(destDir, PICKED_ROM_FILENAME);
String destName = pendingPickFilename != null
? pendingPickFilename : PICKED_ROM_FILENAME;
File destFile = new File(destDir, destName);
InputStream source;
try {
source = getContentResolver().openInputStream(uri);
} catch (FileNotFoundException e) {
Log.d("GameActivity", "could not open picked ROM: " + e.getMessage());
Log.d("GameActivity", "could not open picked file: " + e.getMessage());
return;
}
if (source == null) {
Log.d("GameActivity", "ContentResolver returned no stream for picked ROM");
Log.d("GameActivity", "ContentResolver returned no stream for picked file");
return;
}
if (!copyAssetFile(source, destFile.getPath())) {
Log.d("GameActivity", "could not copy picked ROM to " + destFile);
Log.d("GameActivity", "could not copy picked file to " + destFile);
}
}
+5 -3
View File
@@ -1,6 +1,6 @@
# Example mod gallery
Seven reference mods, one per modder persona. Each is small enough to read
Eight reference mods, one per modder persona. Each is small enough to read
in one sitting, exercises a different slice of the mod API, and is a real,
runnable, tested mod — not a snippet.
@@ -16,6 +16,7 @@ Copy the one closest to what you want to build.
| 5 | [`example_weather`](example_weather) | Mechanic designer | `MECHANIC` | Rain that scales WATER and FIRE damage, behind a ruleset |
| 6 | [`example_dexnav`](example_dexnav) | Tool builder | `TOOL` | A START-menu dex overlay with an inter-mod API |
| 7 | [`example_mini_conversion`](example_mini_conversion) | TC team | `TOTAL_CONVERSION` | Sable Cove: one town, three species, one badge |
| 8 | [`example_silly_oak`](example_silly_oak) | Intro author | `UI` | Extra Oak questions, sprite swaps, answers in `mod.save` |
## None of these load by default
@@ -46,7 +47,8 @@ and `field`, and exercises:
- both buses — `events:on` / `events:emit` and `hooks:wrap` — across
`music.select`, `battle.damage`, `ui.start_menu.items`, `ui.options.rows`,
`battle.started`, `battle.turn_started`, `battle.ended`, `flag.changed`,
`game.ready` and `assets.transformed`
`game.ready`, `assets.transformed`, `intro.oak_speech.build`,
`intro.oak_speech.answered` and `intro.oak_speech.finished`
- `mod.save`, `mod.options`, `mod.exports`, `mod.commands`, `mod.ui`,
`mod:read`, `mod.log` and `mod.path`
- asset transforms, the `trueColor` opt-out, script labels and `choice`,
@@ -66,7 +68,7 @@ mods/examples/<id>/
.modkitignore keeps the suite out of the distributed package
```
`tests/mod_examples_tests.lua` in the engine's own suite loads all seven
`tests/mod_examples_tests.lua` in the engine's own suite loads all eight
together and asserts the above, so the gallery cannot rot.
These example mods arent perfect and are just examples to show you basics of wahts possible, but way more than just this is possible.
@@ -6,7 +6,7 @@
"entry": "main.lua",
"profile": "content",
"category": "BALANCE",
"game_version": ">=1.0.0 <2.0.0",
"game_version": ">=0.0.0-0 <2.0.0",
"priority": 100,
"dependencies": [],
"optional_dependencies": [],
+1 -1
View File
@@ -6,7 +6,7 @@
"entry": "main.lua",
"profile": "content",
"category": "TOOL",
"game_version": ">=1.0.0 <2.0.0",
"game_version": ">=0.0.0-0 <2.0.0",
"priority": 100,
"dependencies": [],
"optional_dependencies": [],
+1 -1
View File
@@ -6,7 +6,7 @@
"entry": "main.lua",
"profile": "content",
"category": "AUDIO",
"game_version": ">=1.0.0 <2.0.0",
"game_version": ">=0.0.0-0 <2.0.0",
"priority": 100,
"permissions": ["engine_internals"],
"dependencies": [],
@@ -6,7 +6,7 @@
"entry": "main.lua",
"profile": "content",
"category": "QUEST",
"game_version": ">=1.0.0 <2.0.0",
"game_version": ">=0.0.0-0 <2.0.0",
"priority": 100,
"permissions": ["engine_internals"],
"dependencies": [],
@@ -6,7 +6,7 @@
"entry": "main.lua",
"profile": "total_conversion",
"category": "TOTAL_CONVERSION",
"game_version": ">=1.0.0 <2.0.0",
"game_version": ">=0.0.0-0 <2.0.0",
"priority": 900,
"dependencies": [],
"optional_dependencies": [],
@@ -6,7 +6,7 @@
"entry": "main.lua",
"profile": "content",
"category": "GRAPHICS",
"game_version": ">=1.0.0 <2.0.0",
"game_version": ">=0.0.0-0 <2.0.0",
"priority": 100,
"assets_transforms": "transforms.lua",
"dependencies": [],
@@ -0,0 +1,12 @@
# Changelog
Format: [keep a changelog](https://keepachangelog.com/en/1.1.0/).
Version headings match `manifest.json`'s `version`.
## 1.0.0
### Added
- `intro.oak_speech.build` wrap that injects toast / MEW / snack / rival-trust / pineapple beats.
- Answers written to `mod.save` via `intro.oak_speech.answered`.
- Custom `toast_kid.png` sprite shown mid-speech.
+43
View File
@@ -0,0 +1,43 @@
# Silly Oak Intro Example
Hooks Oak's NEW GAME speech: extra questions, sprite swaps (Oak, rival,
player, MEW, and a custom Toast Kid pic), and answers stored in `mod.save`.
## Try it (play through yourself)
```sh
rm -rf mods/example_silly_oak
cp -r mods/examples/example_silly_oak mods/
love .
```
Then **NEW GAME** and mash A / pick the menus. Disable or delete
`mods/example_silly_oak` when you're done so vanilla boots clean.
## Headless check
```sh
luajit mods/examples/example_silly_oak/tests/example_silly_oak_test.lua
```
## Auto driver (screenshots + save asserts)
```sh
rm -rf mods/example_silly_oak
cp -r mods/examples/example_silly_oak mods/
SHOT_DIR=/tmp/silly_oak POKEPORT_IDENTITY=silly_oak_driver_test \
POKEPORT_DRIVER=tests/drivers/silly_oak_intro_test.lua POKEPORT_SPEED=8 love .
```
`POKEPORT_IDENTITY` keeps this run's save out of your normal slot.
## What it demonstrates
| Seam | Where |
|---|---|
| `hooks:wrap("intro.oak_speech.build")` | `main.lua` — reshape the step list |
| `mod.ui.insertStepAfter` / `insertStepBefore` | `main.lua` — anchored on vanilla step ids |
| step kinds `say` / `yesno` / `choice` | `main.lua` |
| pics: `"oak"`, `"rival"`, `"player"`, pokemon, custom image | `main.lua` |
| `events:on("intro.oak_speech.answered")` | `main.lua``mod.save` |
| `events:on("intro.oak_speech.finished")` | `main.lua` |
Binary file not shown.

After

Width:  |  Height:  |  Size: 245 B

+107
View File
@@ -0,0 +1,107 @@
-- Gallery entry: reshape Oak's intro speech with extra questions, sprite
-- swaps (oak / rival / player / pokemon / a custom image), and answers
-- that land in mod.save.
--
-- Uses hooks:wrap("intro.oak_speech.build") plus intro.oak_speech.answered.
return function(mod)
local toastPic = mod.path .. "/assets/toast_kid.png"
mod.hooks:wrap("intro.oak_speech.build", function(next, steps, speech)
steps = next(steps, speech)
-- after oak says hello, immediately derail
mod.ui.insertStepAfter(steps, "oak_welcome", {
id = "silly_quiz_intro",
kind = "say",
pic = "oak",
text = "Before we start,\nI have a few\vquestions.\fImportant ones.\nScientific ones.",
})
mod.ui.insertStepAfter(steps, "silly_quiz_intro", {
id = "silly_toast",
kind = "yesno",
pic = "oak",
saveKey = "likes_toast",
text = "Do you like\ntoast?",
})
-- brand new sprite mid-speech
mod.ui.insertStepAfter(steps, "silly_toast", {
id = "silly_toast_kid",
kind = "say",
pic = { type = "image", path = toastPic },
reveal = "fade",
saveKey = nil,
text = "This is Toast Kid.\nHe is not a\vPOKéMON.\fHe just showed up\none day.\fAnyway.",
})
-- existing mon with a wipe + cry, parked after the real demo mon
mod.ui.insertStepAfter(steps, "demo_mon", {
id = "silly_mew",
kind = "say",
pic = { type = "pokemon", id = "MEW" },
reveal = "wipe",
cry = "MEW",
text = "This is MEW.\nPlease do not\vtell anyone\vI showed you.",
})
mod.ui.insertStepAfter(steps, "silly_mew", {
id = "silly_snack",
kind = "choice",
pic = "oak",
saveKey = "snack",
text = "Pick a snack.\nThis goes on\vyour permanent\vrecord.",
choices = { "BERRIES", "LEFTOVERS", "OLD ROD" },
})
-- swap to rival pic for a loaded question before naming him
mod.ui.insertStepBefore(steps, "ask_rival_name", {
id = "silly_trust",
kind = "choice",
pic = "rival",
reveal = "fade",
saveKey = "trusts_rival",
text = "Look at this kid.\nTrustworthy?",
choices = { "SURE", "NO" },
values = { true, false },
})
-- player pic for one last bit after both names are set
mod.ui.insertStepAfter(steps, "name_rival", {
id = "silly_pineapple",
kind = "yesno",
pic = "player",
saveKey = "pineapple_on_pizza",
text = "{PLAYER}. Be honest.\nPineapple on\vpizza?",
})
mod.ui.insertStepAfter(steps, "silly_pineapple", {
id = "silly_closing",
kind = "say",
pic = "oak",
text = "Great. Terrible.\nI have notes.\fLet's pretend this\nwas normal.",
})
return steps
end)
-- every answered step with a saveKey lands in mod.save (and therefore
-- save.modData[mod.id] once the slot is written)
mod.events:on("intro.oak_speech.answered", function(ev)
if not ev.saveKey then return end
mod.save:set(ev.saveKey, ev.value)
mod.log:info("intro answer %s = %s", tostring(ev.saveKey), tostring(ev.value))
end)
mod.events:on("intro.oak_speech.finished", function(ev)
local answers = ev.answers or {}
for key, value in pairs(answers) do
if mod.save:get(key) == nil then
mod.save:set(key, value)
end
end
mod.save:set("quiz_done", true)
mod.log:info("silly oak quiz done")
end)
end
@@ -0,0 +1,15 @@
{
"id": "example_silly_oak",
"name": "Silly Oak Intro Example",
"version": "1.0.0",
"api": 2,
"entry": "main.lua",
"profile": "content",
"category": "UI",
"game_version": ">=0.0.0-0 <2.0.0",
"priority": 100,
"dependencies": [],
"optional_dependencies": [],
"conflicts": [],
"description": "Reshapes Oak's intro speech with extra questions, sprite swaps, and answers saved to mod.save."
}
+21
View File
@@ -0,0 +1,21 @@
-- Sharing metadata for the manager detail pane.
return {
summary = "Oak asks dumb questions during the intro and remembers your answers.",
author = "Pokemon Gen 1 Recompilation Project",
contact = "https://github.com/bryanthaboi/pokemon-gen1-recomp-project",
tags = { "intro", "ui", "oak", "hooks" },
differences = {
changed = {
"Oak's NEW GAME speech gains extra questions and sprite beats",
},
added = {
"mod.save keys: likes_toast, snack, trusts_rival, pineapple_on_pizza, quiz_done",
"Custom Toast Kid pic mid-intro",
},
known = { "vanilla naming and the shrink-away still run" },
},
credits = {
{ who = "Pokemon Gen 1 Recompilation Project", for_ = "the intro.oak_speech hooks" },
},
compat = { engine = ">=0.0.0 <2.0.0", modApi = 2 },
}
@@ -0,0 +1,159 @@
-- Standalone: luajit mods/examples/example_silly_oak/tests/example_silly_oak_test.lua
-- Covers the intro.oak_speech build hook, step helpers, sprite descriptors,
-- and answers landing in mod.save.
--
-- Needs an imported ROM dataset (data/generated/). Headless CI and a
-- fresh checkout without a ROM skip cleanly -- the gallery is also
-- covered by tests/mod_examples_tests.lua when generated data is present.
package.path = "./?.lua;./?/init.lua;" .. package.path
local function hasGenerated()
local handle = io.open("data/generated/constants.lua", "r")
if handle then handle:close() return true end
return false
end
if not hasGenerated() then
print("example_silly_oak_test skipped (needs data/generated/)")
os.exit(0)
end
local T = require("tests.modkit")
local Runtime = require("src.mods.Runtime")
local OakSpeech = require("src.ui.OakSpeech")
local Data = require("src.core.Data")
Data:load()
local run = T.sdk.loadMod("mods/examples/example_silly_oak", { data = Data })
T.eq(#run.errors, 0, "loads clean (" .. tostring(run.errors[1]) .. ")")
local mod = run.mod
T.check(mod ~= nil and mod.state == "loaded", "mod reached the loaded state")
local ModUI = require("src.ui.ModUI")
local bucket = function()
return run.loader.modSave.example_silly_oak or {}
end
local toastPath = (mod.path or "mods/examples/example_silly_oak")
.. "/assets/toast_kid.png"
-- ------- build hook injects every silly beat around vanilla anchors
local speech = OakSpeech.new({
data = Data,
save = { player = { name = "RED", rival = "BLUE" } },
stack = { push = function() end, pop = function() end },
}, nil)
local steps = speech:buildSteps()
local ids = {}
for _, step in ipairs(steps) do ids[#ids + 1] = step.id end
local function has(id)
for _, x in ipairs(ids) do if x == id then return true end end
return false
end
T.check(has("oak_welcome") and has("name_player") and has("shrink"),
"vanilla anchors still present")
T.check(has("silly_quiz_intro") and has("silly_toast") and has("silly_toast_kid"),
"toast quiz beats injected")
T.check(has("silly_mew") and has("silly_snack"),
"MEW reveal and snack choice injected")
T.check(has("silly_trust") and has("silly_pineapple") and has("silly_closing"),
"rival trust + pineapple beats injected")
-- order: toast kid before demo_mon, mew after demo_mon, trust before rival ask
local function indexOf(id)
for i, x in ipairs(ids) do if x == id then return i end end
return 0
end
T.check(indexOf("silly_toast_kid") < indexOf("demo_mon"),
"Toast Kid shows before the demo mon")
T.check(indexOf("demo_mon") < indexOf("silly_mew"),
"MEW shows after the demo mon")
T.check(indexOf("silly_trust") < indexOf("ask_rival_name"),
"trust question is before rival naming")
T.check(indexOf("name_rival") < indexOf("silly_pineapple")
and indexOf("silly_pineapple") < indexOf("legend"),
"pineapple lands between rival name and the legend beat")
-- ------- step shapes cover choice / yesno / custom image / pokemon
local byId = {}
for _, step in ipairs(steps) do byId[step.id] = step end
T.eq(byId.silly_toast.kind, "yesno", "toast is a yes/no")
T.eq(byId.silly_toast.saveKey, "likes_toast", "toast writes likes_toast")
T.eq(byId.silly_snack.kind, "choice", "snack is a multi choice")
T.eq(#byId.silly_snack.choices, 3, "snack has three options")
T.check(byId.silly_toast_kid.pic and byId.silly_toast_kid.pic.type == "image",
"Toast Kid uses a custom image pic")
T.check(byId.silly_mew.pic and byId.silly_mew.pic.type == "pokemon"
and byId.silly_mew.pic.id == "MEW" and byId.silly_mew.cry == "MEW",
"MEW beat uses pokemon pic + cry")
T.eq(byId.silly_trust.pic, "rival", "trust question shows the rival pic")
-- ------- resolvePic covers trainer / pokemon / player / image shorthand
local oakImg = OakSpeech.resolvePic({ data = Data }, "oak", speech)
local rivalImg = OakSpeech.resolvePic({ data = Data }, "rival", speech)
local playerImg = OakSpeech.resolvePic({ data = Data }, "player", speech)
local mewImg, mewFlip = OakSpeech.resolvePic({ data = Data },
{ type = "pokemon", id = "MEW", flip = true }, speech)
local customImg = OakSpeech.resolvePic({ data = Data },
{ type = "image", path = toastPath }, speech)
-- headless love stub may return nil images; the call itself must not throw
T.check(oakImg == speech.oakPic or oakImg == nil or type(oakImg) == "userdata"
or type(oakImg) == "table",
"oak shorthand resolves without error")
T.check(rivalImg == speech.rivalPic or rivalImg == nil or type(rivalImg) == "userdata"
or type(rivalImg) == "table",
"rival shorthand resolves without error")
T.check(playerImg == speech.playerPic or playerImg == nil
or type(playerImg) == "userdata" or type(playerImg) == "table",
"player shorthand resolves without error")
T.check(mewFlip == true, "pokemon flip flag is honored")
T.check(customImg ~= nil or true, "custom image path is accepted")
-- ------- answered event writes mod.save (loader.modSave bucket)
Runtime.emit("intro.oak_speech.answered", {
saveKey = "likes_toast", value = true, label = "YES", index = 1,
step = byId.silly_toast, speech = speech,
})
Runtime.emit("intro.oak_speech.answered", {
saveKey = "snack", value = "OLD ROD", label = "OLD ROD", index = 3,
step = byId.silly_snack, speech = speech,
})
Runtime.emit("intro.oak_speech.answered", {
saveKey = "trusts_rival", value = false, label = "NO", index = 2,
step = byId.silly_trust, speech = speech,
})
Runtime.emit("intro.oak_speech.answered", {
saveKey = "pineapple_on_pizza", value = true, label = "YES", index = 1,
step = byId.silly_pineapple, speech = speech,
})
Runtime.emit("intro.oak_speech.finished", {
speech = speech, answers = speech.answers,
})
local saved = bucket()
T.eq(saved.likes_toast, true, "likes_toast saved")
T.eq(saved.snack, "OLD ROD", "snack saved")
T.eq(saved.trusts_rival, false, "trusts_rival saved")
T.eq(saved.pineapple_on_pizza, true, "pineapple_on_pizza saved")
T.eq(saved.quiz_done, true, "quiz_done stamped on finish")
-- ------- ModUI step helpers (public surface)
local tiny = {
{ id = "a", kind = "say" },
{ id = "b", kind = "say" },
}
ModUI.insertStepAfter(tiny, "a", { id = "mid", kind = "choice" })
T.eq(tiny[2].id, "mid", "insertStepAfter lands behind the anchor")
ModUI.insertStepBefore(tiny, "b", { id = "pre_b", kind = "yesno" })
T.eq(tiny[3].id, "pre_b", "insertStepBefore lands ahead of the anchor")
ModUI.removeStep(tiny, "mid")
T.check(tiny[2].id ~= "mid", "removeStep drops by id")
run.release()
T.finish("example_silly_oak")
+1 -1
View File
@@ -6,7 +6,7 @@
"entry": "main.lua",
"profile": "overhaul",
"category": "MECHANIC",
"game_version": ">=1.0.0 <2.0.0",
"game_version": ">=0.0.0-0 <2.0.0",
"priority": 100,
"dependencies": [],
"optional_dependencies": [],
+25
View File
@@ -68,6 +68,31 @@ if unzip -Z1 "$LOVE_FILE" \
fi
say "game.love: $(du -h "$LOVE_FILE" | cut -f1)"
# ------------------------------------------------------- stamp release version
# The working tree ships Version.lua with engine "0.0.0-dev"; the real release
# number only ever lives inside the packed archive. When --version is a strict
# X.Y.Z, patch a copy of Version.lua (engine set to that number) under a staging
# dir and replace the entry inside game.love in place -- never the source tree.
# Short-hash / "dev" builds are left with the "-dev" default so they cannot be
# mistaken for a release. The stamp is then read back out of the archive and the
# build fails if it did not take.
if printf '%s' "$VERSION" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$'; then
say "stamping engine version $VERSION into game.love"
stamp_dir="$WORK/stamp"
rm -rf "$stamp_dir"
mkdir -p "$stamp_dir/src/core"
sed -E "s/(engine[[:space:]]*=[[:space:]]*\")[^\"]*(\")/\1$VERSION\2/" \
"$ROOT/src/core/Version.lua" > "$stamp_dir/src/core/Version.lua"
(cd "$stamp_dir" && zip -q "$LOVE_FILE" src/core/Version.lua)
version_re="$(printf '%s' "$VERSION" | sed 's/\./\\./g')"
unzip -p "$LOVE_FILE" src/core/Version.lua \
| grep -Eq "engine[[:space:]]*=[[:space:]]*\"$version_re\"" \
|| fail "version stamp failed: game.love does not report engine $VERSION"
say "stamped engine version: $VERSION"
else
say "version '$VERSION' is not X.Y.Z, shipping default engine (no stamp)"
fi
# --------------------------------------------------------------- macOS
build_mac() {
say "building macOS app"
+26
View File
@@ -145,6 +145,32 @@ pack_game_love() {
fail "game.love unexpectedly contains generated ROM data"
fi
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
# stamps the release version the same way: patch a copy of Version.lua
# (engine set to $VERSION) under a throwaway staging dir and replace the
# entry inside the archive in place -- never the source tree. VERSION is
# already validated as X.Y.Z above; when it is empty the packaged game keeps
# the "0.0.0-dev" default. The stamp is read back out and the build fails if
# it did not take.
if printf '%s' "$VERSION" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$'; then
say "stamping engine version $VERSION into game.love"
local stamp_dir
stamp_dir="$(mktemp -d)"
mkdir -p "$stamp_dir/src/core"
sed -E "s/(engine[[:space:]]*=[[:space:]]*\")[^\"]*(\")/\1$VERSION\2/" \
"$ROOT/src/core/Version.lua" > "$stamp_dir/src/core/Version.lua"
(cd "$stamp_dir" && zip -q "$LOVE_FILE" src/core/Version.lua)
local version_re
version_re="$(printf '%s' "$VERSION" | sed 's/\./\\./g')"
unzip -p "$LOVE_FILE" src/core/Version.lua \
| grep -Eq "engine[[:space:]]*=[[:space:]]*\"$version_re\"" \
|| fail "version stamp failed: game.love does not report engine $VERSION"
rm -rf "$stamp_dir"
say "stamped engine version: $VERSION"
else
say "no X.Y.Z --version, shipping default engine (no stamp)"
fi
}
# --------------------------------------------------------------- SDK check
+700 -224
View File
File diff suppressed because it is too large Load Diff
+36 -27
View File
@@ -176,25 +176,51 @@ function EffectRegistry.runDamaging(battle, ctx, record)
end
battle.lastDamage = dmg -- wDamage (shared by both sides, read by Counter)
-- the hit blink + damage sound ride the queue behind the animation:
-- on the move's anim row when one was announced, else on a bare hit
-- row (thrash/rage continuations), placed BEFORE the drain rows the
-- hits loop inserts so the blink precedes the bar drain
local hitRow = battle.moveAnimRow
if not hitRow then
battle.nextInsert = (battle.nextInsert or 0) + 1
hitRow = { hitRow = true }
table.insert(battle.queue, battle.nextInsert, hitRow)
end
-- the hit blink + damage sound ride each animation row, placed BEFORE
-- that hit's drain so the blink precedes the bar. Multi-hit moves
-- replay PlayMoveAnimation per strike (pokered: GetPlayerAnimationType
-- / GetEnemyAnimationType loop on wNumAttacksLeft); hit 1 reuses the
-- announcement-time moveAnimRow, later hits queue fresh anim rows.
-- Thrash/rage continuations have no announcement anim -- a bare
-- hitRow carries the blink instead.
local hitSfx = info.typeMult > 10 and "Super_Effective"
or info.typeMult < 10 and "Not_Very_Effective" or "Damage"
local hitFx = { sfx = hitSfx,
blink = battle:animationsOn() and target or nil }
local totalDealt = 0
local landed, brokeSub = 0, false
for h = 1, hits do
if target.mon.hp <= 0 then break end
local hitRow
if h == 1 then
hitRow = battle.moveAnimRow
if not hitRow then
battle.nextInsert = (battle.nextInsert or 0) + 1
hitRow = { hitRow = true }
table.insert(battle.queue, battle.nextInsert, hitRow)
end
else
battle.nextInsert = (battle.nextInsert or 0) + 1
hitRow = { anim = move.id, attackerIsPlayer = user.isPlayer }
table.insert(battle.queue, battle.nextInsert, hitRow)
end
local hadSub = target.substituteHP ~= nil
local dealt = battle:applyDamage(target, dmg)
totalDealt = totalDealt + dealt
landed = h
if dealt > 0 then hitRow.hit = hitFx end
-- PrintCriticalOHKOText + DisplayEffectiveness run inside the
-- 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.typeMult > 10 then
battle:sayNext("It's super\neffective!")
elseif info.typeMult < 10 then
battle:sayNext("It's not very\neffective...")
end
if Runtime.wants("battle.damage_dealt") then
Runtime.emit("battle.damage_dealt", {
battle = battle, user = user, target = target, move = move,
@@ -208,23 +234,6 @@ function EffectRegistry.runDamaging(battle, ctx, record)
end
end
hits = landed > 0 and landed or hits
if totalDealt > 0 then
-- the original's per-hit sound: normal / super / not-very-effective
local hitSfx = info.typeMult > 10 and "Super_Effective"
or info.typeMult < 10 and "Not_Very_Effective" or "Damage"
hitRow.hit = { sfx = hitSfx,
blink = battle:animationsOn() and target or nil }
end
-- PrintCriticalOHKOText prints "Critical hit!"/"One-hit KO!" right
-- after the damage lands, BEFORE DisplayEffectiveness (core.asm
-- .moveDidNotMiss); the multi-hit count follows the last hit
if info.crit then battle:sayNext("Critical hit!") end
if info.ohko then battle:sayNext("One-hit KO!") end
if info.typeMult > 10 then
battle:sayNext("It's super\neffective!")
elseif info.typeMult < 10 then
battle:sayNext("It's not very\neffective...")
end
if hits > 1 then
-- player: _MultiHitText; enemy: _HitXTimesText (always plural)
if user.isPlayer then
+6 -2
View File
@@ -581,8 +581,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,
+7 -2
View File
@@ -15,7 +15,8 @@
-- (super-effective) or adds 1 (not-effective when a better move exists);
-- 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 PP, Disable and Transform/Mimic move overrides.
-- selectable. Respects Disable (and PP only when the ruleset depletes
-- enemy PP — Gen 1 AI never reads wEnemyMonPP).
local TypeChart = require("src.battle.TypeChart")
@@ -212,9 +213,13 @@ end
function TrainerAI.chooseMove(battler, rng, battle)
rng = rng or love.math.random
-- Gen 1: SelectEnemyMove never consults wEnemyMonPP; Struggle only when
-- every move slot is missing/disabled (core.asm:2957-2999). modern_clean
-- depletes enemy PP and falls back to Struggle when none remain.
local unlimited = battle and battle.ruleset and battle.ruleset.enemyUnlimitedPP
local usable = {}
for i, mv in ipairs(battler.curMoves) do
if mv.pp > 0 and battler.disabledSlot ~= i then
if battler.disabledSlot ~= i and (unlimited or mv.pp > 0) then
table.insert(usable, mv)
end
end
+6
View File
@@ -13,4 +13,10 @@ return {
randMax = 255,
-- Focus Energy famously QUARTERS the crit rate instead of x4
focusEnergyBug = true,
-- 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,
}
+4
View File
@@ -9,4 +9,8 @@ return {
randMin = 217,
randMax = 255,
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,
}
+34
View File
@@ -230,6 +230,25 @@ function ChipAudio.ensureMusicPlaying()
end
end
-- Threaded playMusic returns an empty QueueableSource and only calls
-- Source:play once the first worker buffer lands (~1 frame later). Until
-- then Source:isPlaying is false -- callers that treat that as "song over"
-- (Music.oneShotPlaying / pendingRestore) must wait here instead, or a
-- playOnce jingle like Music_PkmnHealed is cut off before it starts.
local forceAwaitingFirstBuffer -- test-only override (see _simulate*)
function ChipAudio.awaitingFirstBuffer()
if forceAwaitingFirstBuffer then return true end
local m = currentMusic
if not (m and m.threaded and not m.started and not m.finished) then
return false
end
-- a dead worker will never deliver the first buffer
if workerReady == false then return false end
if worker and worker.getError and worker:getError() then return false end
return true
end
function ChipAudio.stopMusic()
if currentMusic and currentMusic.source then
pcall(currentMusic.source.stop, currentMusic.source)
@@ -240,6 +259,7 @@ function ChipAudio.stopMusic()
end
pendingBuf = nil
currentMusic = nil
forceAwaitingFirstBuffer = nil
end
-- hot reload: the next play re-reads programs.bin (a mod may have swapped the
@@ -302,6 +322,20 @@ end
-- test hooks (headless): synchronous synthesis straight through ChipSynth
-- ---------------------------------------------------------------------------
-- Force the "threaded, first buffer not yet queued" window so Music's
-- playOnce / pendingRestore race can be asserted without love.thread.
-- Returns a clear() that drops the override (call after the assertion).
function ChipAudio._simulateAwaitingFirstBufferForTest()
local m = currentMusic
if not m or not m.source then return nil end
m.threaded = true
m.started = false
m.finished = false
pcall(function() m.source.playing = false end)
forceAwaitingFirstBuffer = true
return function() forceAwaitingFirstBuffer = nil end
end
function ChipAudio._renderMusicForTest(data, header, seconds)
local engine = ChipSynth.newEngine(data, header, { allowLoops = true })
return ChipSynth.soundData(engine, math.floor(seconds * SAMPLE_RATE), 2)
+33
View File
@@ -102,6 +102,39 @@ 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()
+132 -2
View File
@@ -38,8 +38,18 @@ local state = {
unsubs = {},
pid = nil,
loggedAbsent = false, -- one quiet note when Discord isn't running
game = nil, -- kept for the "friend clicked join" -> push a screen path
joinCode = nil, -- the online-match/tournament code to advertise as a join secret, if any
joinKind = "match", -- "match" (LinkState online) or "tournament"
partySize = 1, -- current headcount for the party.size Discord shows
partyMax = 2,
subscribedJoin = false, -- SUBSCRIBE ACTIVITY_JOIN sent on this connection?
}
-- MSG_DONTWAIT differs by OS (Darwin 0x80, Linux 0x40); only used for the
-- ongoing per-frame inbound poll, never for the one-time connect/handshake
local MSG_DONTWAIT_BY_OS = { ["OS X"] = 0x80, Linux = 0x40 }
local function disabledByEnv()
return os.getenv("POKEPORT_NO_DISCORD") == "1"
or os.getenv("POKEPORT_AUTOPILOT") ~= nil
@@ -378,6 +388,15 @@ local function connect()
state.dirty = true
state.lastSentKey = nil
state.loggedAbsent = false
state.subscribedJoin = false
-- ask for "friend clicked join" dispatches; a plain outbound write, so
-- it's harmless to send even on Windows, where the inbound poll below
-- isn't implemented yet (see pollJoinRequest)
if sendFrame(OP_FRAME, Json.encode({
cmd = "SUBSCRIBE", evt = "ACTIVITY_JOIN", nonce = nonce(),
})) then
state.subscribedJoin = true
end
Logger.info("discord: rich presence connected")
return true
end)
@@ -427,7 +446,7 @@ local function buildActivity()
activityState = "At the title screen"
end
return {
local activity = {
details = details,
state = activityState,
timestamps = { start = state.startedAt },
@@ -436,10 +455,25 @@ local function buildActivity()
large_text = "Pokemon Gen1Recomp",
},
}
-- a party/join-secret pair is what makes Discord show "Ask to Join" on
-- the profile card; the secret carries our own room/tournament code plus
-- a one-letter kind tag ("m"/"t") so the click handler on the other end
-- knows which screen to jump into -- Discord only echoes the string
-- back, so the kind has to ride inside it
if state.joinCode then
local kindTag = state.joinKind == "tournament" and "t" or "m"
activity.party = { id = "pokeport-" .. state.joinCode,
size = { state.partySize, state.partyMax } }
activity.secrets = { join = "join:" .. kindTag .. ":" .. state.joinCode }
end
return activity
end
local function activityKey(activity)
return (activity.details or "") .. "|" .. (activity.state or "")
local partyKey = activity.party
and (activity.party.size[1] .. "/" .. activity.party.size[2]) or ""
return (activity.details or "") .. "|" .. (activity.state or "") .. "|"
.. (activity.secrets and activity.secrets.join or "") .. "|" .. partyKey
end
local function flush(force)
@@ -567,6 +601,96 @@ local function subscribe(game)
end)
end
-- Advertise (or clear, with code=nil) an online-match/tournament code as a
-- Discord join secret. LinkState/Tournament call this once they have a
-- real code from the relay, and clear it again once paired/started or the
-- hosting screen exits (an invite that's already full or gone is worse
-- than no invite). kind is "match" (default) or "tournament"; size/max are
-- the party.size Discord shows (default 1/2, a plain 1v1 room) -- a
-- tournament passes its live roster count and a generous cap instead, and
-- should call this again whenever the roster changes, not just once.
function DiscordPresence.setJoinCode(code, kind, size, max)
pcall(function()
state.joinCode = code
state.joinKind = kind or "match"
state.partySize = size or 1
state.partyMax = max or 2
state.dirty = true
flush(false)
end)
end
-- A friend clicked "Ask to Join" on our profile and Discord delivered the
-- secret back over this same connection. Only act from a safe spot (never
-- yank the player out of a battle or an already-running link session) --
-- "exploring"/"menu" are the coarse buckets subscribe() above already
-- tracks; anything more specific (a shop, a dialogue box) is a rarer miss
-- worth accepting rather than adding a second, finer-grained state tracker
-- just for this.
local function handleJoinRequest(secret)
if not secret or secret == "" then return end
if state.activity == "battle" then return end
local game = state.game
if not game or not game.stack then return end
local top = game.stack:top()
if top and top.stage and top.net then return end -- already in a link session
local kindTag, code = secret:match("^(%a):(.+)$")
if not kindTag then kindTag, code = "m", secret end -- older/plain secret: assume match
Runtime.emit("discord.join_requested", { code = code, kind = kindTag })
if kindTag == "t" then
local ok, Tournament = pcall(require, "src.link.Tournament")
if ok and Tournament.newJoinOnline then
game.stack:push(Tournament.newJoinOnline(game, code))
end
else
local ok, LinkState = pcall(require, "src.link.LinkState")
if ok and LinkState.newJoinOnline then
game.stack:push(LinkState.newJoinOnline(game, code))
end
end
end
-- non-blocking peek for an incoming ACTIVITY_JOIN dispatch. Unix (FFI)
-- only for now: reading a Windows named pipe without risking a stall needs
-- a HANDLE-level PeekNamedPipe that the plain io.open() connection below
-- doesn't give us, so a Windows player's presence/party/join-secret still
-- all work (they can still be seen and clicked), the click just isn't
-- delivered back to their own game session yet.
local function pollJoinRequest()
if state.isWindows or not state.ffi or not state.socket or not state.subscribedJoin then
return
end
pcall(function()
local ffi = state.ffi
local dontwait = MSG_DONTWAIT_BY_OS[love.system.getOS()] or 0
local hbuf = ffi.new("char[8]")
local n = ffi.C.recv(state.socket, hbuf, 8, dontwait)
if not n or tonumber(n) <= 0 then return end -- nothing waiting right now
local header = ffi.string(hbuf, 8)
local opcode = unpackU32(header:sub(1, 4))
local length = unpackU32(header:sub(5, 8))
if not opcode or not length or length < 0 or length > 65536 then return end
-- the body may lag the header by a packet; the connection's normal
-- (short-timeout) blocking read is fine here since it has already
-- announced its exact length
local body = length > 0 and readExact(length) or ""
if body == nil then return end
if opcode == 3 then -- PING -> PONG, keeps Discord from closing on us
sendFrame(4, body)
return
end
if opcode ~= 1 then return end
local decoded = Json.decode(body)
if decoded and decoded.evt == "ACTIVITY_JOIN" then
local secret = decoded.data and decoded.data.secret
if type(secret) == "string" and secret ~= "" then
if secret:sub(1, 5) == "join:" then secret = secret:sub(6) end
handleJoinRequest(secret)
end
end
end)
end
function DiscordPresence.init(game)
local ok, err = pcall(function()
DiscordPresence.shutdown()
@@ -579,6 +703,8 @@ function DiscordPresence.init(game)
state.location = "Title screen"
state.mapId = nil
state.battleLabel = nil
state.joinCode = nil
state.game = game
state.dirty = true
state.nextReconnectAt = 0
state.loggedAbsent = false
@@ -621,6 +747,7 @@ function DiscordPresence.update(_dt)
end
return
end
pollJoinRequest()
if state.dirty then flush(false) end
end)
end
@@ -649,10 +776,13 @@ function DiscordPresence.shutdown()
state.lastSentKey = nil
state.connected = false
state.socket = nil
state.subscribedJoin = false
state.joinCode = nil
end
-- test / debug helpers
DiscordPresence._state = state
DiscordPresence.locationName = locationName
DiscordPresence.handleJoinRequest = handleJoinRequest
return DiscordPresence
+8
View File
@@ -27,4 +27,12 @@ function FixedStep:update(dt)
end
end
-- Drop any pending catch-up steps. A hitch inside one logic step (map
-- seam setMap / song start) makes the next real-time dt huge; without this
-- the while-loop above would advance many walk frames before the next
-- draw, which looks like a slide with no leg animation (issue #93).
function FixedStep:discardCatchup()
self.accum = 0
end
return FixedStep
+78 -14
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")
@@ -37,12 +37,15 @@ function Game:load()
self.mods = ModLoader.new()
self.mods:load(Data)
self.modStatus = self.mods:status()
-- render pipelines dispatch off the merged dataset; point them at the
-- one the mods just merged into before anything can draw a frame
require("src.render.Pipelines").install(Data)
self.input = Input
Input:init()
self.touchInput = TouchInput
TouchInput:init()
self.touchControls = TouchControls
TouchControls:init()
self.renderer = Renderer
Renderer:init()
@@ -93,6 +96,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
@@ -177,9 +194,6 @@ function Game:logicSpeed()
end
function Game:update(dt)
-- Touch timers / prior-frame auto-releases before the fixed step so
-- deferred A and edge pulses land in Input's press queue for this step.
TouchInput:update(dt)
-- Fast-forward scales only the logic clock (see src/core/GameSpeed.lua).
-- Give the accumulator room for one full frame at the current speed,
-- or the anti-spiral clamp quietly caps every level above ~15X.
@@ -198,6 +212,9 @@ function Game:update(dt)
-- Overworld tilt toggle tween: presentational, so it runs on the real
-- frame dt (not the fixed logic step) for a smooth ~0.25s glide.
require("src.render.Tilt").update(dt)
-- mod render pipelines tween on the same real-frame clock, for the same
-- reason: they are presentational, so fast-forward must not speed them up
require("src.render.Pipelines").update(dt)
pcall(function() require("src.core.DiscordPresence").update(dt) end)
-- Steady-state memory backstop: advance the incremental collector one
-- small step every rendered frame. The heavy GPU objects are now freed
@@ -240,13 +257,19 @@ 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
function Game:zoomStep(delta)
local Zoom = require("src.render.Zoom")
if not Zoom.gateOK(self.stack:top(), self.overworld) then return end
Zoom.step(delta, Renderer:fitScale())
local offset = Zoom.step(delta, Renderer:fitScale())
if self.save and self.save.options then
self.save.options.zoom = offset
self:writeOptions()
end
end
function Game:wheelmoved(_, dy)
@@ -320,13 +343,34 @@ function Game:keypressed(key)
self:writeOptions()
end
return
elseif key == "4" then
-- cycle ZOOM through every integer level (survey → FIT → close-up → wrap)
local Zoom = require("src.render.Zoom")
if Zoom.gateOK(self.stack:top(), self.overworld) then
self.save.options.zoom = Zoom.cycle(Renderer:fitScale())
self:writeOptions()
end
return
elseif key == "5" then
-- cycle GBC FX OFF → 1 → 2 → 3 → 4 (unlit-GBC ladder); always on
-- desktop. Mobile refuses the present shader (issue #136).
local GBCFX = require("src.render.GBCFX")
if not GBCFX.isSupported() then return end
self.save.options.gbcfx = GBCFX.cycle()
self:writeOptions()
return
end
-- Mod render pipelines claim their hotkeys last, so one can never shadow
-- an engine display key however a mod declares it (12 §rendering
-- pipelines). syncOptions writes the whole ladder back, including the
-- tilt exclusion a world pipeline forces.
local Pipelines = require("src.render.Pipelines")
if Pipelines.hotkey(key, self.stack:top(), self.overworld) then
Pipelines.syncOptions(self.save.options)
require("src.render.Tilt").setLevel(self.save.options.tilt or 0)
self:writeOptions()
return
end
Input:keypressed(key)
end
@@ -344,6 +388,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
@@ -358,6 +405,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
@@ -367,12 +416,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
@@ -380,19 +429,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
@@ -443,12 +492,20 @@ function Game:applyOptions(opts)
if Sound.applyOptions then Sound.applyOptions(opts) end
require("src.render.PaletteFX").applyOptions(opts)
require("src.render.Tilt").applyOptions(opts)
require("src.render.GBCFX").applyOptions(opts)
-- after Tilt, so a persisted world pipeline can switch the tilt level it
-- just restored back off (the two are mutually exclusive)
require("src.render.Pipelines").applyOptions(opts)
require("src.render.Zoom").applyOptions(opts)
require("src.render.TileRenderer").applyOptions(opts)
-- returns true when a persisted GBC FX level was cleared on mobile
local gbcCleared = require("src.render.GBCFX").applyOptions(opts)
require("src.core.VideoMode").applyOptions(opts)
-- normalizes a nil/garbage cap to the 60 default, so old saves with no
-- fpsCap key pace at the standard rate (issue #88)
require("src.core.FrameCap").applyOptions(opts)
Input:applyBindings(opts.bindings)
-- heal soft-bricked APK installs that already saved gbcfx > 0 (#136)
if gbcCleared then self:writeOptions() end
end
function Game:restoreSave(loaded, recovered)
@@ -460,6 +517,13 @@ function Game:restoreSave(loaded, recovered)
-- SaveData.load and skip on the format guard
local activeMods = self.modStatus and self.modStatus.loaded
SaveData.runMigrations(loaded, self.mods and self.mods.migrations, activeMods)
-- Issue #103: 0.1.11 softlocks left CONTINUE in HALL_OF_FAME with
-- lastOutdoor on Indigo. One-shot relocate + heal before validate.
if SaveData.needsPostGameRescue(loaded) then
SaveData.applyPostGameHome(loaded, self:bootConfig())
local Pokemon = require("src.pokemon.Pokemon")
for _, mon in ipairs(loaded.party or {}) do Pokemon.heal(mon) end
end
local modsDiff = SaveData.modsDiff(loaded, activeMods)
local report = SaveData.validate(loaded, self.data)
report.recovered = recovered
+16
View File
@@ -11,7 +11,12 @@ local DEFAULT_BINDINGS = {
z = "a", ["return"] = "a", space = "a",
x = "b", backspace = "b",
["kpenter"] = "start", escape = "start",
-- Select: fight-menu move reorder + bag item reorder. Tab is the
-- discoverable default (shown in CONTROLS); both shifts stay as
-- aliases so Right-Shift muscle memory from older builds still works.
tab = "select",
rshift = "select",
lshift = "select",
}
-- keys that map to "start" but also to "a" would conflict; keep Enter = a,
@@ -149,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
+49 -9
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
@@ -254,6 +278,7 @@ function Music.stop()
require("src.core.ChipAudio").stopMusic()
state.current, state.source, state.loopSource, state.fade = nil, nil, nil, nil
state.chip = false
state.pendingRestore = nil
if previous and Runtime.wants("music.stopped") then
Runtime.emit("music.stopped", { song = previous })
end
@@ -347,18 +372,25 @@ end
function Music.playOnce(data, song)
if not songDef(data, song) then return false end
Music.play(data, song, false, { reason = "once" })
-- play() can no-op (hook silence, failed def); only arm restore when
-- the jingle actually became current
if state.current ~= song then return false end
state.pendingRestore = true
return true
end
-- is a playOnce jingle still sounding? (AnimateHealingMachine's
-- .waitLoop2 holds the healing machine until MUSIC_PKMN_HEALED ends)
local function chipAwaitingFirstBuffer()
return state.chip
and require("src.core.ChipAudio").awaitingFirstBuffer()
end
-- is a playOnce jingle still in flight? (AnimateHealingMachine's
-- .waitLoop2 / Mom heal / captain rub hold until MUSIC_PKMN_HEALED ends.)
-- pendingRestore stays set from playOnce until Music.update restores the
-- map theme, covering the threaded chip "empty QueueableSource" window
-- where Source:isPlaying is briefly false before the first buffer lands.
function Music.oneShotPlaying()
if not state.pendingRestore then return false end
local src = state.source
if not src then return false end
local ok, playing = pcall(src.isPlaying, src)
return ok and playing or false
return state.pendingRestore == true
end
function Music.restoreMap(data)
@@ -400,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.
@@ -441,8 +479,10 @@ function Music.update(data)
state.source = loopSrc
pcall(loopSrc.play, loopSrc)
end
-- do not treat "threaded source still waiting on its first buffer" as
-- ended, or playOnce jingles get restored over before they can sound
if state.pendingRestore and sourceStopped(state.source)
and not state.loopSource then
and not state.loopSource and not chipAwaitingFirstBuffer() then
Music.restoreMap(data)
end
end
+380 -5
View File
@@ -17,6 +17,7 @@ local Runtime = require("src.mods.Runtime")
local Semver = require("src.mods.Semver")
local Boxes = require("src.pokemon.Boxes")
local Bag = require("src.inventory.Bag")
local Badges = require("src.inventory.Badges")
local GameVersion = require("src.core.GameVersion")
@@ -31,10 +32,11 @@ local OPTIONS_FILENAME = "options.lua"
-- Main / backup / staged-witness names for a version (defaults to the active
-- one). The backup is a rolling copy and .tmp is the staged-write witness;
-- load promotes either when the main file is missing or fails to parse.
local function saveNames(version)
local main = "save" .. GameVersion.saveSuffix(version) .. ".lua"
return main, main .. ".bak", main .. ".tmp"
end
-- Forward-declared here so saveFilename (just below) and every save/load
-- caller share the one upvalue; the body is filled in under "save slots"
-- once the options IO it depends on exists, because it now resolves the
-- ACTIVE slot for a version rather than the fixed flat name.
local saveNames
-- The main save filename for a version -- used by the title screen's
-- CONTINUE gate so it looks for the right game's save.
@@ -91,6 +93,18 @@ local function makePortableFs(dir)
os.remove(full(name))
return true
end,
createDirectory = function(name)
-- portable mode writes real files through io.*, which will not
-- create missing parent directories; mkdir the tree so a slot path
-- like "saves/red" exists before a write lands inside it
local osPath = full(name):gsub("/", SEP)
if SEP == "\\" then
os.execute('mkdir "' .. osPath .. '" 2>nul')
else
os.execute('mkdir -p "' .. osPath .. '" 2>/dev/null')
end
return true
end,
}
end
@@ -186,14 +200,23 @@ function SaveData.defaultOptions()
musicFilter = 0,
-- logic fast-forward multiplier; audio is unaffected (GameSpeed.lua)
speed = 1,
-- port display options (OptionsMenu / hotkeys 2/3/5)
-- port display options (OptionsMenu / hotkeys 2/3/4/5)
colors = "gbc",
tilt = 0,
gbcfx = 0,
-- survey zoom offset from window fit scale (0 = FIT); see Zoom.lua
zoom = 0,
-- OVERWORLD beyond-edge fill: trees | water | black
voidFill = "trees",
-- windowed | borderless (desktop fullscreen); ignored on mobile
videoMode = "windowed",
-- hard render frame-rate cap; render-only pacing (issue #88, FrameCap.lua)
fpsCap = 60,
-- Per-pipeline display levels, keyed by render_pipelines id (see
-- src/render/Pipelines.lua). A level for a mod that is not installed
-- is kept rather than pruned, so re-enabling the mod restores the mode
-- the player left it in.
pipelines = {},
-- Native mod enablement is an installation option, not save-slot data.
-- Missing entries mean enabled so newly installed mods work by default.
mods = {},
@@ -279,6 +302,302 @@ function SaveData.loadOptions(fs)
return SaveData.mergeOptions(data)
end
-- ------- save slots
-- A version's playthroughs live in numbered slots under saves/<version>/;
-- the active slot is where in-game SAVE and CONTINUE land. The registry
-- (the ordered slot list plus which one is active) persists in options.lua
-- under options.saveSlots[version]; the active slot is also cached
-- process-wide (like GameVersion.current) so the hot saveNames path does
-- not re-read options every call. A false cache entry means "no slot in
-- use" and the flat legacy path (save.lua / save_blue.lua) is used, which
-- keeps a brand-new install and every pre-slots caller working unchanged.
local activeSlotCache = {} -- version -> slotId in use, or false when none
local slotsChecked = {} -- version -> true once resolved this process
local function slotDir(version) return "saves/" .. version end
local function slotNames(version, id)
local main = slotDir(version) .. "/" .. id .. ".lua"
return main, main .. ".bak", main .. ".tmp"
end
-- the pre-slots flat names a version always used (save.lua for Red,
-- save_blue.lua for Blue); still the destination before any slot exists
local function legacyNames(version)
local main = "save" .. GameVersion.saveSuffix(version) .. ".lua"
return main, main .. ".bak", main .. ".tmp"
end
-- Slot resolution is only meaningful for versions GameVersion actually knows
-- (red/blue). The launcher also renders a locked placeholder tab ("yellow")
-- that has no info entry and therefore no saveSuffix; resolving its legacy
-- names would index a nil info table and crash. Treat any unknown version as
-- having no slots so the slot APIs degrade to empty/no-op instead.
local function knownVersion(version)
return GameVersion.info(version) ~= nil
end
-- Create the parent directory of a slot path when the fs supports it.
-- love.filesystem.createDirectory makes the whole tree; the injected memfs
-- stub keys files by full path and exposes no such method, so this is a
-- no-op there.
local function ensureParentDir(fs, name)
local dir = name:match("^(.*)/[^/]+$")
if dir and fs.createDirectory then fs.createDirectory(dir) end
end
-- Decode a slot's save using the same recovery order load() uses -- main,
-- then the .tmp write-witness, then the .bak -- so a slot mid-crash still
-- summarizes. nil when nothing readable is present.
local function decodeSlot(fs, version, id)
local main, bak, tmp = slotNames(version, id)
local data = fs.getInfo(main) and SaveSerializer.decode(fs.read(main) or "")
if data then return data end
data = fs.getInfo(tmp) and SaveSerializer.decode(fs.read(tmp) or "")
if data then return data end
data = fs.getInfo(bak) and SaveSerializer.decode(fs.read(bak) or "")
return data or nil
end
-- One-time legacy consolidation: a pre-slots install has a flat save file
-- (+ .bak) and no saves/<version>/ registry. Copy both into slot1, verify
-- the copy reads back, then remove the originals and register slot1 as the
-- active slot. Returns the new slot id, or nil when there is nothing to
-- migrate or the copy could not be verified (originals left in place so no
-- data is ever lost to a failed move).
local function tryMigrateLegacy(version, fs)
local lmain, lbak, ltmp = legacyNames(version)
local mainBody = fs.getInfo(lmain) and fs.read(lmain)
local bakBody = fs.getInfo(lbak) and fs.read(lbak)
if not (mainBody or bakBody) then return nil end
local id = "slot1"
local dmain, dbak = slotNames(version, id)
ensureParentDir(fs, dmain)
if mainBody then fs.write(dmain, mainBody) end
if bakBody then fs.write(dbak, bakBody) end
-- refuse to delete the originals unless the new slot is loadable (from
-- the main copy or, failing that, the backup)
if not decodeSlot(fs, version, id) then return nil end
remove(fs, lmain)
remove(fs, lbak)
remove(fs, ltmp)
local opts = SaveData.loadOptions(fs)
opts.saveSlots = opts.saveSlots or {}
opts.saveSlots[version] = { list = { id }, active = id }
SaveData.saveOptions(opts, fs)
return id
end
-- Resolve (once per version per process) which slot in-game saves use: an
-- existing registry wins; otherwise a lazy legacy migration may create
-- slot1; otherwise false, meaning the flat legacy path.
local function ensureVersionSlots(version, fs)
if slotsChecked[version] then return end
slotsChecked[version] = true
if not knownVersion(version) then
activeSlotCache[version] = false
return
end
local opts = SaveData.loadOptions(fs)
local reg = opts.saveSlots and opts.saveSlots[version]
if reg and type(reg.list) == "table" and #reg.list > 0 then
activeSlotCache[version] = reg.active or reg.list[1]
return
end
activeSlotCache[version] = tryMigrateLegacy(version, fs) or false
end
-- (body for the forward-declared saveNames.) Resolves the ACTIVE slot for
-- the version, falling back to the flat legacy names when no slot is in use.
function saveNames(version)
version = version or GameVersion.get()
local fs = persistFs(nil)
ensureVersionSlots(version, fs)
local slot = activeSlotCache[version]
if slot then return slotNames(version, slot) end
return legacyNames(version)
end
-- Pure extraction of the launcher's per-slot summary from a decoded save,
-- factored out so it is unit-testable with no filesystem: the player name
-- (nil for an empty slot) and { badges, timeText, dexCount } -- the same
-- fields the title screen's ContinueInfo derives. Badges resolve against
-- the vanilla gym list (launcher has no loaded Data), which is what the
-- flat launcher meta line needs.
function SaveData.slotSummary(save)
if type(save) ~= "table" then return nil, nil end
local name = save.player and save.player.name or nil
local dexCount = 0
for _ in pairs((save.pokedex and save.pokedex.owned) or {}) do
dexCount = dexCount + 1
end
local t = math.floor(save.playTime or 0)
local timeText = ("%d:%02d"):format(math.floor(t / 3600),
math.floor(t / 60) % 60)
return name, {
badges = Badges.count(nil, save),
timeText = timeText,
dexCount = dexCount,
}
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
-- is migrated to slot1 first.
function SaveData.listSlots(version)
version = version or GameVersion.get()
if not knownVersion(version) then return {} end
local fs = persistFs(nil)
ensureVersionSlots(version, fs)
local opts = SaveData.loadOptions(fs)
local reg = opts.saveSlots and opts.saveSlots[version]
local list = (reg and reg.list) or {}
local out = {}
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 }
end
return out
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.
function SaveData.setActiveSlot(version, slotId)
version = version or GameVersion.get()
if not knownVersion(version) then return nil end
local fs = persistFs(nil)
local opts = SaveData.loadOptions(fs)
opts.saveSlots = opts.saveSlots or {}
local reg = opts.saveSlots[version] or { list = {}, active = nil }
local found = false
for _, id in ipairs(reg.list) do
if id == slotId then found = true break end
end
if not found then reg.list[#reg.list + 1] = slotId end
reg.active = slotId
opts.saveSlots[version] = reg
SaveData.saveOptions(opts, fs)
slotsChecked[version] = true
activeSlotCache[version] = slotId
return slotId
end
-- Register a new empty slot for the version and return its id. Does NOT
-- write a save file and does NOT change the active slot: an empty slot
-- means the title screen offers NEW GAME only. Ids are "slot%d+",
-- allocated one past the highest existing number so a reused id can never
-- collide with a lingering file.
function SaveData.createSlot(version)
version = version or GameVersion.get()
if not knownVersion(version) then return nil end
local fs = persistFs(nil)
ensureVersionSlots(version, fs)
local opts = SaveData.loadOptions(fs)
opts.saveSlots = opts.saveSlots or {}
local reg = opts.saveSlots[version] or { list = {}, active = nil }
local maxN = 0
for _, id in ipairs(reg.list) do
local n = tonumber(tostring(id):match("^slot(%d+)$"))
if n and n > maxN then maxN = n end
end
local id = "slot" .. (maxN + 1)
reg.list[#reg.list + 1] = id
opts.saveSlots[version] = reg
SaveData.saveOptions(opts, fs)
return id
end
-- The active slot id in use for a version (resolved once per process like
-- saveNames does), or nil when none is registered and the flat legacy path is
-- in use. Public so the launcher's save Import/Export glue can name an export
-- after the slot it came from without reaching into the private cache.
function SaveData.activeSlot(version)
version = version or GameVersion.get()
if not knownVersion(version) then return nil end
local fs = persistFs(nil)
ensureVersionSlots(version, fs)
return activeSlotCache[version] or nil
end
-- Write saveTable into an existing slot's file (SaveSerializer.encode), through
-- the same fs seam every other save/load call uses (so portable mode keeps
-- working) and the same .tmp-witness / .bak recovery discipline SaveData.save
-- uses for the flat path. Used by the launcher's save-import glue, which has
-- already registered the slot via createSlot but written no bytes yet; unlike
-- SaveData.save this targets a specific slot and never rebuilds meta or touches
-- options. Returns true, or false + an error string on a failed write.
function SaveData.writeSlot(version, slotId, saveTable)
version = version or GameVersion.get()
if not knownVersion(version) then return false, "unknown version" end
if type(slotId) ~= "string" then return false, "missing slot id" end
if type(saveTable) ~= "table" then return false, "missing save table" end
local main, bak, tmp = slotNames(version, slotId)
local encoded = SaveSerializer.encode(saveTable)
local fs = persistFs(nil)
ensureParentDir(fs, main)
if fs.getInfo(main) then
local prev = fs.read(main)
if prev then fs.write(bak, prev) end
end
local ok, err = fs.write(tmp, encoded)
if not ok then return false, err end
remove(fs, main)
ok, err = fs.write(main, encoded)
if not ok then return false, err end
remove(fs, tmp)
return true
end
-- Delete a registered slot: remove its main/.bak/.tmp files, drop it from the
-- options registry, and if it was active point active at another remaining
-- slot (or clear active when the list is empty). Returns true, or false +
-- an error string when the id is unknown / not registered.
function SaveData.deleteSlot(version, slotId)
version = version or GameVersion.get()
if not knownVersion(version) then return false, "unknown version" end
if type(slotId) ~= "string" or slotId == "" then
return false, "missing slot id"
end
local fs = persistFs(nil)
ensureVersionSlots(version, fs)
local opts = SaveData.loadOptions(fs)
opts.saveSlots = opts.saveSlots or {}
local reg = opts.saveSlots[version]
if not reg or not reg.list then return false, "slot not registered" end
local found, idx = false, nil
for i, id in ipairs(reg.list) do
if id == slotId then found = true; idx = i; break end
end
if not found then return false, "slot not registered" end
local main, bak, tmp = slotNames(version, slotId)
remove(fs, main)
remove(fs, bak)
remove(fs, tmp)
table.remove(reg.list, idx)
if reg.active == slotId then
reg.active = reg.list[1] -- may be nil when the list is now empty
end
opts.saveSlots[version] = reg
SaveData.saveOptions(opts, fs)
slotsChecked[version] = true
activeSlotCache[version] = reg.active or false
return true
end
-- Test seam: drop the process-global slot cache so a suite can exercise
-- migration/resolution against a freshly injected filesystem. Unused by
-- the game, which resolves each version exactly once per boot.
function SaveData.resetSlotState()
for k in pairs(activeSlotCache) do activeSlotCache[k] = nil end
for k in pairs(slotsChecked) do slotsChecked[k] = nil end
end
-- ------- meta
-- the version/engine/mod-set stamp every v2 save carries; mods is the
@@ -478,6 +797,25 @@ SaveData.addCoreMigration(2, function(save)
end
end)
-- #131 / follow-up to #50: Game Corner poster grunt used to stay on the
-- floor after defeat (defeatedTrainers only). The #50 script now hides
-- him via objectToggles, but saves that already beat him never got the
-- toggle -- he still blocks the hideout switch. from=3 so every pre-4
-- save reconciles once, then is skipped after re-stamp.
SaveData.addCoreMigration(3, function(save)
local defeated = save.defeatedTrainers
if not defeated or not defeated["GAME_CORNER_obj_11"] then return end
save.objectToggles = save.objectToggles or {}
local mapToggles = save.objectToggles.GAME_CORNER
if not mapToggles then
mapToggles = {}
save.objectToggles.GAME_CORNER = mapToggles
end
if mapToggles.GAMECORNER_ROCKET ~= false then
mapToggles.GAMECORNER_ROCKET = false
end
end)
-- ------- write
-- Game progress only; options are written separately via saveOptions.
@@ -502,6 +840,9 @@ function SaveData.save(data, mods)
end
local encoded = SaveSerializer.encode(gameOnly)
local fs = persistFs(nil)
-- the active slot may live in saves/<version>/, which must exist before
-- the .tmp/.bak/main writes land (a no-op for the flat legacy path)
ensureParentDir(fs, FILENAME)
if fs.getInfo(FILENAME) then
local prev = fs.read(FILENAME)
if prev then fs.write(BACKUP_FILENAME, prev) end
@@ -813,6 +1154,36 @@ function SaveData.defaultHeal(boot)
return { map = map, x = boot.startX or 3, y = boot.startY or 6 }
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.
-- Marks postGameHomeOk so a later intentional HoF save is not relocated.
function SaveData.applyPostGameHome(save, boot)
boot = type(boot) == "table" and boot or {}
local heal = SaveData.defaultHeal(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.facing = boot.startFacing or "down"
save.postGameHomeOk = true
return heal
end
-- Softlocked 0.1.11 saves: still standing in HALL_OF_FAME after credits,
-- with lastOutdoor on Indigo. One-shot rescue on CONTINUE.
function SaveData.needsPostGameRescue(save)
if not (save and save.player and save.player.map == "HALL_OF_FAME") then
return false
end
if save.postGameHomeOk then return false end
local hof = save.hallOfFame
return type(hof) == "table" and #hof > 0
end
function SaveData.newGame(boot)
boot = type(boot) == "table" and boot or {}
local map = boot.startMap or "REDS_HOUSE_2F"
@@ -836,6 +1207,10 @@ function SaveData.newGame(boot)
},
flags = {},
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.
pcItems = { POTION = 1 },
party = {},
box = {},
money = boot.startMoney or 3000,
+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 = "rshift",
}
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
+11 -3
View File
@@ -4,14 +4,22 @@
-- tests.
local Version = {
engine = "1.0.0", -- game/engine release (semver triple)
engine = "0.0.0-dev", -- game/engine release (semver). Repo default is the
-- "-dev" placeholder; CI stamps the real X.Y.Z into
-- the packed game.love only, never the working tree.
shell = 1, -- native-shell contract this build implements
minShell = 1, -- lowest shell contract that can RUN this payload.
-- Bump only when a payload needs a newer native
-- binary (e.g. a LOVE version bump); an older shell
-- refuses to chainload a payload whose minShell
-- exceeds the shell it provides.
modApi = 2, -- mod API major (manifest `api`)
linkProtocol = 2, -- link handshake wire version (Handshake.PROTOCOL)
saveFormat = 3, -- save.meta.format
saveFormat = 4, -- save.meta.format
cache = "rom-cache-v5", -- ROM import cache generation (RomImporter marker)
}
-- "gen1recomp v1.0.0"
-- "gen1recomp v0.0.0-dev" (or the stamped release version in shipped builds)
function Version.title(base)
return (base or "gen1recomp")
.. " v" .. Version.engine
+2
View File
@@ -375,6 +375,8 @@ function RomExtractor:extractMaps()
}
self:tick("Maps", mapIndex, #keys)
end
-- keep the extract ROM-faithful; Data:seedDefaults applies the #189
-- S.S. Anne 1F cabin reorder at load time
self:write("maps", out)
return out
end
+1739 -291
View File
File diff suppressed because it is too large Load Diff
+120
View File
@@ -0,0 +1,120 @@
-- SaveFileIO -- the launcher's glue between a raw Gen1 .sav battery image and
-- this project's save slots. Keeps RomImporter lean: the SAVE FILES card just
-- calls importToSlot / exportActiveSlot and renders the {ok, result} outcome.
--
-- Import reads bytes (an absolute picker path, a dropped LOVE file, or raw
-- bytes), runs them through SaveConvert.importSav (32768-byte + checksum
-- validated), then registers a fresh slot, writes it, and makes it active.
-- Export loads the active slot, encodes it back to a 32768-byte SRAM image, and
-- drops it in the save directory's exports/ folder, returning the absolute path
-- so the launcher can offer an "open folder" affordance.
--
-- Every failure returns false + a friendly one-line message (never raises), so
-- the card can surface it as a red notice line rather than crashing.
local SaveConvert = require("src.save_convert.SaveConvert")
local SaveData = require("src.core.SaveData")
local GameVersion = require("src.core.GameVersion")
local SaveFileIO = {}
local SAVE_SIZE = SaveConvert.SAVE_SIZE
-- Resolve raw save bytes from whatever the launcher hands us:
-- * a LOVE DroppedFile (a table/userdata with :open/:read/:getSize), read the
-- way RomImporter reads a dropped ROM;
-- * a raw 32768-byte string (the tests and the in-memory path) used as-is;
-- * any other string treated as an absolute picker path opened with io.open.
-- A picker path is never 32768 bytes long, so the length test disambiguates it
-- from a raw image cleanly. Returns bytes, or nil + an error string.
local function readSource(source)
local t = type(source)
if t == "table" or t == "userdata" then
if type(source.read) ~= "function" then
return nil, "that file could not be read"
end
local ok, openErr = source:open("r")
if not ok then return nil, "could not open the dropped file: " .. tostring(openErr) end
local data, readErr = source:read(source:getSize())
source:close()
if not data then return nil, "could not read the dropped file: " .. tostring(readErr) end
return data
end
if t ~= "string" then
return nil, "no save file was provided"
end
if #source == SAVE_SIZE then
return source
end
local f, openErr = io.open(source, "rb")
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
-- source: an absolute path, a LOVE DroppedFile, or raw 32768 bytes. On success
-- registers a new slot for the version, writes the imported save into it, makes
-- it the active slot, and returns true + the new slot id. On any failure
-- returns false + a friendly message.
function SaveFileIO.importToSlot(source, version)
version = version or GameVersion.get()
local bytes, readErr = readSource(source)
if not bytes then return false, readErr end
if #bytes ~= SAVE_SIZE then
return false, ("A save file must be %d bytes (32 KB); this one is %d.")
:format(SAVE_SIZE, #bytes)
end
local save, convertErr = SaveConvert.importSav(bytes, version)
if not save then return false, convertErr end
-- Tag the game version and normalize the meta stamp: SaveConvert leaves
-- meta.format = "gen1_import", but SaveData.load's migration pass compares
-- the format numerically, so re-stamp it to the current format (the imported
-- table is already current-shaped, so no migration is skipped by doing so).
save.version = version
save.meta = SaveData.buildMeta(nil, save.meta)
local slotId = SaveData.createSlot(version)
if not slotId then return false, "this game has no save slots to import into" end
local ok, writeErr = SaveData.writeSlot(version, slotId, save)
if not ok then
return false, "could not write the imported save: " .. tostring(writeErr)
end
SaveData.setActiveSlot(version, slotId)
return true, slotId
end
-- exportActiveSlot(version) -> ok, pathOrErr
-- Loads the version's active slot save (SaveData.load semantics), encodes it
-- back to a 32768-byte SRAM image, and writes it to
-- exports/gen1recomp-<version>-<slotId>.sav in the save directory (created if
-- absent). Returns true + the absolute path on success, false + a friendly
-- message otherwise.
function SaveFileIO.exportActiveSlot(version)
version = version or GameVersion.get()
local save = SaveData.load(version)
if not save then return false, "this game has no save to export yet" end
local bytes, exportErr = SaveConvert.exportSav(save)
if not bytes then return false, exportErr end
local slotId = SaveData.activeSlot(version) or "save"
local fs = love and love.filesystem
if not (fs and fs.write) then return false, "no filesystem available to export to" end
if fs.createDirectory then fs.createDirectory("exports") end
local rel = ("exports/gen1recomp-%s-%s.sav"):format(version, slotId)
local ok, writeErr = fs.write(rel, bytes)
if not ok then return false, "could not write the export: " .. tostring(writeErr) end
local base = fs.getSaveDirectory and fs.getSaveDirectory() or ""
if base ~= "" then return true, base .. "/" .. rel end
return true, rel
end
return SaveFileIO
+4 -3
View File
@@ -114,10 +114,11 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
local itemDef = data.items[itemId]
local name = itemDef and itemDef.name or itemId
-- ItemUseVitamin / ItemUsePPUp / ItemUseEvoStone / ItemUseCoinCase
-- all refuse mid-battle (jp nz, ItemUseNotTime)
-- ItemUseVitamin / ItemUsePPUp / ItemUseEvoStone / ItemUseCoinCase /
-- ItemUseTMHM all refuse mid-battle (jp nz, ItemUseNotTime)
if battle and (VITAMINS[itemId] or STONES[itemId] or itemId == "PP_UP"
or itemId == "RARE_CANDY" or itemId == "COIN_CASE") then
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!" }
end
+44
View File
@@ -0,0 +1,44 @@
-- Shared 6-slot room-code entry widget: the digit-scrub interaction
-- LinkState's own `ipDigits`/`addrPos` already uses for IP entry, over the
-- Crockford-32-style alphabet pokeserver room/tournament codes are drawn
-- from (23456789ABCDEFGHJKMNPQRSTUVWXYZ -- no 0/O/1/I/L, so a code read
-- aloud or handwritten never has to be checked twice).
local CodeEntry = {}
CodeEntry.CHARSET = "23456789ABCDEFGHJKMNPQRSTUVWXYZ"
CodeEntry.LENGTH = 6
function CodeEntry.new()
local chars = {}
for i = 1, CodeEntry.LENGTH do chars[i] = 1 end -- index into CHARSET, 1-based
return { chars = chars, pos = 1 }
end
local N = #CodeEntry.CHARSET
function CodeEntry.up(state)
state.chars[state.pos] = state.chars[state.pos] % N + 1
end
function CodeEntry.down(state)
state.chars[state.pos] = (state.chars[state.pos] - 2) % N + 1
end
function CodeEntry.left(state)
state.pos = math.max(1, state.pos - 1)
end
function CodeEntry.right(state)
state.pos = math.min(CodeEntry.LENGTH, state.pos + 1)
end
function CodeEntry.text(state)
local out = {}
for i = 1, CodeEntry.LENGTH do
out[i] = CodeEntry.CHARSET:sub(state.chars[i], state.chars[i])
end
return table.concat(out)
end
return CodeEntry
+11
View File
@@ -67,6 +67,17 @@ function Handshake.linkModified(game)
return false
end
-- online play (the relay-based online match / tournament flows in
-- LinkState/Tournament) meets strangers, not a coordinating friend, so it
-- skips the LAN path's per-peer compatibility negotiation entirely and
-- just requires vanilla on both ends: no mod-added Pokemon, no surprises.
-- Mods only ever get baked in at boot (Loader:load), so this is a gate on
-- attempting to go online, not a live mod toggle -- the player disables
-- mods via the mod manager and relaunches.
function Handshake.onlineAllowed(game)
return #Handshake.mods(game) == 0
end
-- mode is nil on the guest: it pairs and announces itself before the host
-- has picked, and compatibility is decided from the two hellos, not the mode
function Handshake.hello(game, mode)
+387 -57
View File
@@ -15,6 +15,7 @@
-- them in link battles).
local Fingerprint = require("src.link.Fingerprint")
local Font = require("src.render.Font")
local Handshake = require("src.link.Handshake")
local Logger = require("src.core.Logger")
local Protocol = require("src.link.Protocol")
@@ -36,18 +37,45 @@ local function makeRng(seed)
end
end
-- battler builder shared by both sides -- NO badge boosts, so both
-- machines compute identical stats
-- Battlers go through BattleState.makeBattler so pics get the same
-- Assets.resolve + SGB/GBC palette + padBottom path as wild/trainer
-- battles. save=nil skips badge boosts so both machines keep identical
-- stats (Gen 1 cable battles famously kept badges; we still diverge).
local function mkBattler(data, mon, isPlayer)
local def = data.pokemon[mon.species]
local ok, img = pcall(love.graphics.newImage,
isPlayer and def.spriteBack or def.spriteFront)
return {
mon = mon, def = def, isPlayer = isPlayer, stages = {},
name = mon.nickname or def.name,
curStats = mon.stats, curTypes = def.types, curMoves = mon.moves,
sprite = ok and img or nil,
}
local BattleState = require("src.battle.BattleState")
return BattleState.makeBattler(data, mon, isPlayer, nil)
end
-- shared by LinkBattle.new (myParty/theirParty) and LinkBattle.newSpectator
-- (hostParty/guestParty): pack->unpack clamp so every perspective watching
-- a given match holds identical mon copies. errFmt(packedMon, why) builds
-- the message shown when strict mode refuses an unrebuildable mon.
local function unpackParty(game, packed, unpackOpts, errFmt)
local out = {}
for _, p in ipairs(packed or {}) do
local mon, why = Protocol.unpackMon(game.data, p, unpackOpts)
if mon then
table.insert(out, mon)
elseif unpackOpts.strict then
return nil, errFmt(p, why)
end
end
return out
end
-- decode a wire action message against whichever battler it belongs to
-- (the opponent's, from a real participant's perspective; either side's,
-- from a spectator's)
local function decodeWireAction(s, msg, battler)
if msg.kind == "move" then
local slot = math.max(1, math.min(#battler.curMoves, math.floor(msg.slot or 1)))
return battler.curMoves[slot]
elseif msg.kind == "struggle" then
return { id = "STRUGGLE", pp = 1, struggle = true }
elseif msg.kind == "locked" then
return s:lockedAction(battler)
end
return nil
end
-- canonical (host-side-first) state hash, unchanged since v1: it stays on
@@ -154,26 +182,15 @@ function LinkBattle.new(game, net, opts)
-- 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 }
local myParty, theirParty = {}, {}
for _, p in ipairs(opts.myParty or {}) do
local mon = Protocol.unpackMon(game.data, p, unpackOpts)
if mon then
table.insert(myParty, mon)
elseif unpackOpts.strict then
return nil, ("Your %s can't\nbattle on the\nother game."):format(
tostring(p.species))
end
end
for _, p in ipairs(opts.theirParty or {}) do
local mon, why = Protocol.unpackMon(game.data, p, unpackOpts)
if mon then
table.insert(theirParty, mon)
elseif unpackOpts.strict then
return nil, ("Their %s isn't\nin this game.\n(%s)"):format(
tostring(p.species), tostring(why))
end
end
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))
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))
end)
if not theirParty then return nil, theirErr end
if #myParty == 0 or #theirParty == 0 then
Logger.warn("link: empty party on one side")
end
@@ -213,6 +230,8 @@ function LinkBattle.new(game, net, opts)
self.player = mkBattler(game.data, myParty[1], true)
self.enemy = mkBattler(game.data, theirParty[1], false)
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.remoteHashes = {}
self.localHashes = {}
@@ -230,22 +249,62 @@ function LinkBattle.new(game, net, opts)
if text then s:say(text) end
end
-- a tournament shot clock (opts.turnLimit) costs the slow player
-- specifically, unlike RUN or a desync -- both of which stay a draw --
-- so it needs its own result rather than reusing endAsDraw
local function endWithResult(s, result, text)
if s.linkEnded then return end
s.result = result
s.afterQueue = "finish"
s.phase = "messages"
if text then s:say(text) end
end
local function orderMove(action)
if action and action.id then return game.data.moves[action.id] end
return nil
end
-- player-side SendOutMon (poof + grow-in + cry); mirrors BattleState
local function sendOutPlayer(s, mon)
local previous = s.player
s.player = mkBattler(game.data, mon, true)
s:syncSides()
Runtime.emit("battle.battler_switched", {
battle = s, side = s.sides[1], battler = s.player, previous = previous,
})
s.sendingOut = true
s:sayNext(s:sendOutText(s.player.name))
s:animNext("POOF_ANIM", false)
s:actNext(function()
s.sendingOut = false
s:startGrowIn(s.player)
require("src.core.Sound").playCry(s.data, s.player.mon.species)
end)
end
-- enemy-side EnemySendOut (grow-in + cry; no poof)
local function sendOutEnemy(s, mon)
local previous = s.enemy
s.enemy = mkBattler(game.data, mon, false)
s:syncSides()
Runtime.emit("battle.battler_switched", {
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:actNext(function()
s.enemySendingOut = false
s:startGrowIn(s.enemy)
s:actNext(function()
require("src.core.Sound").playCry(s.data, s.enemy.mon.species)
end)
end)
end
-- decode a remote action message against the enemy battler
local function decodeTheirAction(s, msg)
if msg.kind == "move" then
local slot = math.max(1, math.min(#s.enemy.curMoves, math.floor(msg.slot or 1)))
return s.enemy.curMoves[slot]
elseif msg.kind == "struggle" then
return { id = "STRUGGLE", pp = 1, struggle = true }
elseif msg.kind == "locked" then
return s:lockedAction(s.enemy)
end
return nil
return decodeWireAction(s, msg, s.enemy)
end
-- with the handshake guaranteeing both games share a link surface, a
@@ -306,17 +365,11 @@ function LinkBattle.new(game, net, opts)
-- switches happen before attacks (both may switch)
if myMsg.kind == "switch" then
local idx = myMsg.index
s:act(function()
s.player = mkBattler(game.data, myParty[idx], true)
s:sayNext(("Go! %s!"):format(s.player.name))
end)
s:act(function() sendOutPlayer(s, myParty[idx]) end)
myAction = nil
end
if theirSwitch then
s:act(function()
s.enemy = mkBattler(game.data, theirParty[theirSwitch], false)
s:sayNext(("%s sent\nout %s!"):format(theirName, s.enemy.name))
end)
s:act(function() sendOutEnemy(s, theirParty[theirSwitch]) end)
end
s:act(function()
@@ -452,10 +505,7 @@ function LinkBattle.new(game, net, opts)
self.playerMonFainted = function(s)
for _, mon in ipairs(myParty) do
if mon.hp > 0 then
s:act(function()
s.player = mkBattler(game.data, mon, true)
s:sayNext(("Go! %s!"):format(s.player.name))
end)
s:act(function() sendOutPlayer(s, mon) end)
return
end
end
@@ -468,10 +518,7 @@ function LinkBattle.new(game, net, opts)
self.enemyMonFainted = function(s)
for _, mon in ipairs(theirParty) do
if mon.hp > 0 then
s:act(function()
s.enemy = mkBattler(game.data, mon, false)
s:sayNext(("%s sent\nout %s!"):format(theirName, s.enemy.name))
end)
s:act(function() sendOutEnemy(s, mon) end)
return
end
end
@@ -498,6 +545,19 @@ function LinkBattle.new(game, net, opts)
if not s.result then
endAsDraw(s, ("%s left the\nbattle."):format(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))
end
else
-- a tournament control message (bracket_update, the next
-- match_start, ...) can arrive while this match is still
-- finishing up; Tournament.lua drains this once it regains the
-- stack top rather than losing it to this poll loop
s.pendingTournamentMessages = s.pendingTournamentMessages or {}
table.insert(s.pendingTournamentMessages, msg)
end
end
if net.closed and not s.linkEnded and not s.result then
@@ -513,16 +573,48 @@ function LinkBattle.new(game, net, opts)
end
return
end
if opts.turnLimit and s.phase == "menu" then
if not s.turnClockActive then
s.turnClockActive = true
s.turnClock = opts.turnLimit
end
s.turnClock = s.turnClock - dt
if s.turnClock <= 0 then
s.turnClockActive = false
send({ type = "forfeit" })
endWithResult(s, "lose", "Time's up! You\nforfeit the match.")
end
elseif opts.turnLimit then
s.turnClockActive = false
end
baseUpdate(s, dt)
end
if opts.turnLimit then
local baseDraw = self.draw
self.draw = function(s, ...)
baseDraw(s, ...)
if s.phase == "menu" and s.turnClockActive then
love.graphics.setColor(1, 1, 1, 1)
Font.draw(tostring(math.max(0, math.ceil(s.turnClock))), 144, 4)
end
end
end
local baseFinish = self.finish
self.finish = function(s)
if not s.linkEnded then
s.linkEnded = true
send({ type = "bye" })
end
net:close()
-- opts.keepNetOpen: a tournament match's `net` is the caller's
-- long-lived tournament connection (still needed for the next round,
-- spectating, bracket updates, ...), not a dedicated match socket --
-- closing it here the way a plain 1v1 link battle does would sever
-- the whole tournament, not just this match.
if not opts.keepNetOpen then
net:close()
end
if game.linkNet == net then game.linkNet = nil end
baseFinish(s)
end
@@ -530,6 +622,244 @@ function LinkBattle.new(game, net, opts)
return self
end
-- A tournament spectator: reconstructs the exact same lockstep battle a
-- live match's two real participants are playing, from a copy of the
-- traffic the relay fans out to onlookers (see pokeserver's `spectate`
-- envelope). No local input drives anything here -- both sides' actions
-- arrive over the wire, tagged by which real player sent them -- so it's
-- a read-only replay, not a third participant: no hash/desync checking
-- (a spectator has nothing to verify against), no shot clock (nothing to
-- act on), and `finish` must NOT close `net`, since that's the caller's
-- long-lived tournament connection, not a dedicated match socket.
--
-- opts: { hostParty = packed, guestParty = packed, hostName, guestName,
-- seed, verdict, strict }. `self.player` is always the host's battler and
-- `self.enemy` the guest's, which is what makes TurnOrder.firstMover's
-- tie-break (below) land on the same result the host's own instance
-- already computed with invertTie=false.
function LinkBattle.newSpectator(game, net, opts)
local BattleState = require("src.battle.BattleState")
local hostName = opts.hostName or "HOST"
local guestName = opts.guestName or "GUEST"
if not Handshake.battleAllowed(opts.verdict) then
return nil, "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))
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))
end)
if not guestParty then return nil, guestErr end
if #hostParty == 0 or #guestParty == 0 then
Logger.warn("link: empty party on one side (spectator)")
end
local self = BattleState.newWild(game, guestParty[1] and guestParty[1].species
or "RATTATA", 5)
self.kind = "link" -- exact same visual treatment as a real link battle
self.spectating = true -- Tournament.lua's marker: don't report a result for this one
self.net = net
game.linkNet = net
self.rng = makeRng(opts.seed or 1)
self.player = mkBattler(game.data, hostParty[1], true)
self.enemy = mkBattler(game.data, guestParty[1], false)
self.enemyParty = guestParty
self.playerParty = hostParty
self.opponentName = guestName
self.introText = ("%s vs %s!"):format(hostName, guestName)
local function orderMove(action)
if action and action.id then return game.data.moves[action.id] end
return nil
end
local function endSpectate(s, text)
if s.linkEnded then return end
s.result = s.result or "ended"
s.afterQueue = "finish"
s.phase = "messages"
if text then s:say(text) end
end
local function sendOutHost(s, mon)
local previous = s.player
s.player = mkBattler(game.data, mon, true)
s:syncSides()
Runtime.emit("battle.battler_switched", {
battle = s, side = s.sides[1], battler = s.player, previous = previous,
})
s.sendingOut = true
s:sayNext(s:sendOutText(s.player.name))
s:animNext("POOF_ANIM", false)
s:actNext(function()
s.sendingOut = false
s:startGrowIn(s.player)
require("src.core.Sound").playCry(s.data, s.player.mon.species)
end)
end
local function sendOutGuest(s, mon)
local previous = s.enemy
s.enemy = mkBattler(game.data, mon, false)
s:syncSides()
Runtime.emit("battle.battler_switched", {
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:actNext(function()
s.enemySendingOut = false
s:startGrowIn(s.enemy)
s:actNext(function()
require("src.core.Sound").playCry(s.data, s.enemy.mon.species)
end)
end)
end
local function resolveSpecTurn(s, hostMsg, guestMsg)
if hostMsg.kind == "run" or guestMsg.kind == "run" then
endSpectate(s, "The match ended.")
return
end
s.phase = "messages"
s.afterQueue = "linkNext"
s.turnCount = (s.turnCount or 0) + 1
if hostMsg.kind == "switch" then
local idx = hostMsg.index
s:act(function() sendOutHost(s, hostParty[idx]) end)
end
if guestMsg.kind == "switch" then
local idx = guestMsg.index
s:act(function() sendOutGuest(s, guestParty[idx]) end)
end
s:act(function()
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"
and decodeWireAction(s, guestMsg, s.enemy) or nil
Runtime.emit("battle.turn_started", {
battle = s, turn = s.turnCount,
playerAction = hostAction, enemyAction = guestAction,
})
if hostAction and guestAction then
local hostMove, guestMove = orderMove(hostAction), orderMove(guestAction)
local first
if Runtime.wantsHook("battle.turn_order") then
first = Runtime.call("battle.turn_order", function(a, aMove, b, bMove, c)
return TurnOrder.firstMover(a, aMove, b, bMove, c.rng, c.invertTie)
end, s.player, hostMove, s.enemy, guestMove, { rng = s.rng, invertTie = false })
else
first = TurnOrder.firstMover(s.player, hostMove, s.enemy, guestMove, s.rng, false)
end
local order
if first then
order = { { s.player, s.enemy, hostAction }, { s.enemy, s.player, guestAction } }
else
order = { { s.enemy, s.player, guestAction }, { s.player, s.enemy, hostAction } }
end
for _, entry in ipairs(order) do
s:act(function() s:executeAction(entry[1], entry[2], entry[3]) end)
end
elseif hostAction then
s:act(function() s:executeAction(s.player, s.enemy, hostAction) end)
elseif guestAction then
s:act(function() s:executeAction(s.enemy, s.player, guestAction) end)
end
s:act(function() s:endOfTurn() end)
end)
end
self.resolveTurn = function() end -- a spectator's own input never drives anything
self.resolveSwitch = function() end
self.tryRun = function() end
self.openParty = function(s) s.phase = "waitBoth" end
self.playerMonFainted = function(s)
for _, mon in ipairs(hostParty) do
if mon.hp > 0 then
s:act(function() sendOutHost(s, mon) end)
return
end
end
s:sayNext(("%s is out of\nPOKéMON!\f%s wins!"):format(hostName, guestName))
s.result = "guestWin"
s.afterQueue = "finish"
end
self.enemyMonFainted = function(s)
for _, mon in ipairs(guestParty) do
if mon.hp > 0 then
s:act(function() sendOutGuest(s, mon) end)
return
end
end
s:sayNext(("%s is out of\nPOKéMON!\f%s wins!"):format(guestName, hostName))
s.result = "hostWin"
s.afterQueue = "finish"
end
self.hostMsg, self.guestMsg = nil, nil
local baseUpdate = self.update
self.update = function(s, dt)
net:update()
for _, msg in ipairs(net:poll()) do
if msg.type == "spectate" then
local inner = msg.msg
if inner.type == "action" then
if msg.side == "host" then s.hostMsg = inner else s.guestMsg = inner end
if s.hostMsg and s.guestMsg then
local h, g = s.hostMsg, s.guestMsg
s.hostMsg, s.guestMsg = nil, nil
resolveSpecTurn(s, h, g)
end
elseif inner.type == "bye" or inner.type == "forfeit" then
if not s.result then endSpectate(s, "The match ended.") end
end
-- "hello"/"party"/"hash" ride along too (Tournament.lua already
-- consumed hello/party before building this battle); none of them
-- need any action here
else
-- same reasoning as the real-participant loop above: don't lose a
-- bracket_update/match_start_spectate that arrives mid-match
s.pendingTournamentMessages = s.pendingTournamentMessages or {}
table.insert(s.pendingTournamentMessages, msg)
end
end
if net.closed and not s.linkEnded and not s.result then
endSpectate(s)
end
if s.phase == "messages" and s.afterQueue == "linkNext" then
if not s:updateQueue() then
s.afterQueue = "waitBoth"
s.phase = "waitBoth"
end
return
end
if s.phase == "menu" or s.phase == "waitBoth" then
return -- frozen between resolved turns; never a real decision here
end
baseUpdate(s, dt)
end
local baseFinish = self.finish
self.finish = function(s)
s.linkEnded = true
if game.linkNet == net then game.linkNet = nil end
baseFinish(s) -- deliberately doesn't touch net: it's the caller's
-- tournament connection, still needed after this match
end
return self
end
-- backwards-compatible entry points (LinkState passes role explicitly)
function LinkBattle.newHost(game, net, opts)
opts.role = "host"
+235 -8
View File
@@ -2,6 +2,8 @@
-- the other joins by typing that address in. Direct peer-to-peer over
-- lua-enet (bundled with LÖVE), no relay server.
local CodeEntry = require("src.link.CodeEntry")
local DiscordPresence = require("src.core.DiscordPresence")
local Font = require("src.render.Font")
local Handshake = require("src.link.Handshake")
local Net = require("src.link.Net")
@@ -15,6 +17,37 @@ LinkState.__index = LinkState
LinkState.isOpaque = true
local CURSOR = 0xED
local ANY = "ANY" -- sentinel: a leading nil array entry breaks ipairs under
-- LuaJIT even though # still reports the full size, so
-- the level picker cycles this string instead of nil,
-- converted to nil only on the wire (see levelForWire)
local FORCE_LEVEL_STEPS = { ANY, 50, 100 }
local function indexOf(list, value)
for i, v in ipairs(list) do
if v == value then return i end
end
return 1
end
local function levelForWire(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)
return (v == ANY or v == nil) and "ANY" or ("AUTO " .. tostring(v))
end
-- stages before any Net object is meaningfully "this session's link" --
-- .net can still be a leftover failed attempt sitting on self, so error/
-- closed checks below skip these rather than keying off self.net's
-- presence alone
local PRE_CONNECT_STAGES = { menu = true, lanMenu = true, onlineMenu = true }
-- how long the host waits for a v2 hello before deciding the peer predates
-- the handshake (a pre-mod guest sends nothing until it hears the mode)
@@ -47,7 +80,23 @@ function LinkState.new(game)
return self
end
-- entry point for a Discord "Ask to Join" click (see DiscordPresence.lua):
-- skips the whole LAN/ONLINE/TOURNAMENT menu and jumps straight to
-- "connecting with this code", same as if the player had typed it in
function LinkState.newJoinOnline(game, code)
local self = LinkState.new(game)
self.net = Net.new()
if self.net:joinOnline(nil, code) then
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 "?"))
end
return self
end
function LinkState:exitWith(message, reason)
DiscordPresence.setJoinCode(nil)
Runtime.emit("link.ended", { reason = reason or (message and "error" or "bye") })
if self.net then self.net:close() end
self.game.stack:pop()
@@ -101,7 +150,12 @@ function LinkState:decideCompat(mode, isHost)
mods = peer and peer.mods, fingerprint = peer and peer.fingerprint },
})
if self.verdict == "full" or self.verdict == "vanilla_peer" then
self:startMode(mode, isHost)
if isHost and mode == "battle" then
-- host picks the level rule now, before the parties are exchanged
self.stage = "battleOptions"
else
self:startMode(mode, isHost)
end
return
end
-- naming the difference up front is the whole point: the old behaviour
@@ -119,25 +173,54 @@ function LinkState:update(dt)
local input = self.game.input
if self.net then
self.net:update()
if self.net.error and self.stage ~= "menu" then
if self.net.error and not PRE_CONNECT_STAGES[self.stage] then
self:exitWith("Link error:\n" .. self.net.error:sub(1, 60))
return
end
-- the peer vanished without a bye (only once the inbox is drained,
-- so a final message travelling with the disconnect still counts)
if self.net.closed and #self.net.inbox == 0
and self.stage ~= "menu" and self.stage ~= "addrEntry"
and self.stage ~= "notice" and self.stage ~= "battleRunning" then
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.")
return
end
end
if self.stage == "menu" then
if input:wasPressed("down") then
self.index = self.index % 3 + 1
elseif input:wasPressed("up") then
self.index = (self.index - 2) % 3 + 1
elseif input:wasPressed("b") then
self:exitWith(nil)
elseif input:wasPressed("a") then
if self.index == 1 then
self.stage = "lanMenu"
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.")
return
end
if self.index == 2 then
self.stage = "onlineMenu"
else
local Tournament = require("src.link.Tournament")
self.game.stack:pop()
self.game.stack:push(Tournament.new(self.game))
end
self.index = 1
end
end
elseif self.stage == "lanMenu" then
if input:wasPressed("up") or input:wasPressed("down") then
self.index = self.index == 1 and 2 or 1
elseif input:wasPressed("b") then
self:exitWith(nil)
self.stage = "menu"
self.index = 1
elseif input:wasPressed("a") then
self.net = Net.new()
if self.index == 1 then
@@ -151,6 +234,67 @@ function LinkState:update(dt)
end
end
elseif self.stage == "onlineMenu" then
if input:wasPressed("up") or input:wasPressed("down") then
self.index = self.index == 1 and 2 or 1
elseif input:wasPressed("b") then
self.stage = "menu"
self.index = 2
elseif input:wasPressed("a") then
if self.index == 1 then
self.net = Net.new()
if self.net:hostOnline() then
self.stage = "onlineHosting"
else
self:exitWith("Link error:\n" .. (self.net.error or "?"))
end
else
self.stage = "codeEntry"
self.codeEntry = CodeEntry.new()
end
end
elseif self.stage == "onlineHosting" then
if not self.discordCodeSet and self.net.code then
DiscordPresence.setJoinCode(self.net.code)
self.discordCodeSet = true
end
if input:wasPressed("b") then self:exitWith(nil) return end
if self.net.paired then
DiscordPresence.setJoinCode(nil) -- someone's here now; stop advertising
self.stage = "modeSelect"
self.index = 1
end
elseif self.stage == "codeEntry" then
if input:wasPressed("b") then
self.stage = "onlineMenu"
self.index = 2
elseif input:wasPressed("up") then
CodeEntry.up(self.codeEntry)
elseif input:wasPressed("down") then
CodeEntry.down(self.codeEntry)
elseif input:wasPressed("left") then
CodeEntry.left(self.codeEntry)
elseif input:wasPressed("right") then
CodeEntry.right(self.codeEntry)
elseif input:wasPressed("a") then
local code = CodeEntry.text(self.codeEntry)
self.net = Net.new()
if self.net:joinOnline(nil, code) then
self.stage = "onlineJoining"
else
self:exitWith("Link error:\n" .. (self.net.error or "?"))
end
end
elseif self.stage == "onlineJoining" then
if input:wasPressed("b") then self:exitWith(nil) return end
if self.net.paired then
self.stage = "waitMode"
self:sendHello(nil) -- the host owns the mode; this is just who we are
end
elseif self.stage == "hosting" then
if input:wasPressed("b") then self:exitWith(nil) return end
if self.net.paired then
@@ -208,6 +352,23 @@ function LinkState:update(dt)
self:exitWith(nil)
end
elseif self.stage == "battleOptions" then -- host picks the level rule,
-- once compat is confirmed and
-- before parties are exchanged
self.levelChoice = self.levelChoice or ANY
if input:wasPressed("b") then
self:exitWith(nil)
elseif input:wasPressed("up") or input:wasPressed("down")
or input:wasPressed("left") or input:wasPressed("right") then
local delta = (input:wasPressed("down") or input:wasPressed("left")) and -1 or 1
local i = indexOf(FORCE_LEVEL_STEPS, self.levelChoice)
i = ((i - 1 + delta) % #FORCE_LEVEL_STEPS) + 1
self.levelChoice = FORCE_LEVEL_STEPS[i]
elseif input:wasPressed("a") then
self.forceLevel = levelForWire(self.levelChoice)
self:startMode(self.pendingMode, true)
end
elseif self.stage == "waitHello" then -- host waits for the peer's hello
if input:wasPressed("b") then self:exitWith(nil) return end
local got, other = self:pollHello()
@@ -251,6 +412,9 @@ function LinkState:update(dt)
local msgs = self.net:poll()
for i, msg in ipairs(msgs) do
if msg.type == "party" then
-- the host owns this rule (same as mode); the guest only learns
-- it here, off the host's own party message
if not self.isHost then self.forceLevel = msg.forceLevel end
local LinkBattle = require("src.link.LinkBattle")
local opts = {
myParty = Protocol.packParty(self.game.save.party),
@@ -259,6 +423,7 @@ function LinkState:update(dt)
seed = self.isHost and self.linkSeed or msg.seed,
verdict = self.verdict,
strict = Handshake.strict(self.verdict),
forceLevel = self.forceLevel,
}
local battle, why
if self.isHost then
@@ -309,7 +474,8 @@ function LinkState:startMode(mode, isHost)
end
self.net:send({ type = "party",
mons = Protocol.packParty(self.game.save.party),
seed = self.linkSeed })
seed = self.linkSeed,
forceLevel = isHost and self.forceLevel or nil })
end
end
@@ -332,6 +498,14 @@ function LinkState:updateTrade(input)
if t.stage == "done" then
local sent = t.party[t.myPick]
local received, evoTo = t:apply(self.game)
-- Autosave the instant the swap commits into game.save.party, matching the
-- Cable Club: pokered engine/link/cable_club.asm calls SaveSAVtoSRAM
-- (engine/menus/save.asm) right after every trade so the trade is on the
-- cartridge before the animation runs. Without this the received mon
-- lives only in memory until a manual START-menu save, so a force-quit
-- would lose it and a reset would clone the sent mon (#222). Guarded so
-- headless LinkBattle-style fake games with no writeSave are unaffected.
if self.game.writeSave then self.game:writeSave() end
local name = received.nickname or self.game.data.pokemon[received.species].name
Runtime.emit("link.ended", { reason = "done" })
self.net:close()
@@ -340,12 +514,22 @@ function LinkState:updateTrade(input)
require("src.core.Sound").play(game.data, "Trade_Machine")
Screens.push(game, "TradeAnim", {
sent = sent, received = received,
enemyName = (self.peerName or "TRAINER"),
playerOt = game.save.player.name,
playerOtId = sent.otId or game.save.player.id,
enemyOtId = received.otId,
onDone = function()
game.stack:push(TextBox.new(game,
("Trade completed!\f%s received\n%s!"):format(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,
@@ -389,12 +573,49 @@ end
function LinkState:draw()
if self.stage == "menu" then
drawTitle("LINK CABLE CLUB")
drawTitle("BOIS CLUB LIVE")
Font.draw("LINK CABLE (LAN)", 32, 44)
Font.draw("ONLINE MATCH", 32, 60)
Font.draw("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.drawCode(CURSOR, 24, self.index == 1 and 48 or 68)
Font.draw("UDP port " .. 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.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(self.net.code or "??????", 32, 68)
Font.draw("Waiting for join...", 8, 96)
elseif self.stage == "codeEntry" then
drawTitle("ENTER CODE")
for i = 1, CodeEntry.LENGTH do
local x = 16 + (i - 1) * 16
local ch = CodeEntry.CHARSET:sub(self.codeEntry.chars[i], self.codeEntry.chars[i])
Font.draw(ch, x, 64)
if i == self.codeEntry.pos then
Font.drawCode(0xEE, x, 76) -- ▼ under the active slot
end
end
Font.draw("A: connect B: back", 8, 128)
elseif self.stage == "onlineJoining" then
drawTitle("CONNECTING...")
Font.draw("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)
@@ -428,6 +649,12 @@ function LinkState:draw()
Font.draw("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(forceLevelLabel(self.levelChoice or ANY), 88, 56)
Font.draw("A: continue B: back", 8, 128)
elseif self.stage == "waitMode" or self.stage == "waitHello" then
drawTitle("CONNECTED!")
if self.stage == "waitHello" then
+170 -1
View File
@@ -14,6 +14,15 @@
-- Plain luajit (headless tests) has no enet; Net.available() reports
-- that, and Net.loopbackPair() returns two in-memory ends with the
-- same API so the protocol/battle logic stays testable offline.
--
-- A second backend, alongside the ENet one above, talks plain TCP to a
-- pokeserver relay instead of direct peer-to-peer: both sides dial out
-- to a public server (works through NAT with no hole-punching), the host
-- gets a 6-character room code instead of an IP, and the server forwards
-- messages between them. Same newline-delimited JSON on the wire, same
-- Net API (host/join/send/update/poll/.paired/.closed/.error) -- see
-- Net:hostOnline/Net:joinOnline below. LinkState/LinkBattle/Protocol don't
-- know or care which backend is in play.
local Json = require("src.link.Json")
local Logger = require("src.core.Logger")
@@ -21,10 +30,14 @@ local Logger = require("src.core.Logger")
local hasEnet, enet = pcall(require, "enet")
if not hasEnet then enet = nil end
local hasSocket, socket = pcall(require, "socket")
if not hasSocket then socket = nil end
local Net = {}
Net.__index = Net
Net.DEFAULT_PORT = 7777
Net.DEFAULT_RELAY_ADDRESS = "147.182.215.255:7778"
function Net.available()
return enet ~= nil
@@ -34,6 +47,10 @@ function Net.defaultPort()
return tonumber(os.getenv("POKEPORT_LINK_PORT") or "") or Net.DEFAULT_PORT
end
function Net.defaultRelayAddress()
return os.getenv("POKEPORT_RELAY_ADDR") or Net.DEFAULT_RELAY_ADDRESS
end
-- monotonic-ish clock for the join timeout
local function now()
if love and love.timer and love.timer.getTime then
@@ -64,13 +81,17 @@ function Net.new()
enetHost = nil, -- our enet host object (both ends have one)
peer = nil, -- the connected remote peer
inbox = {},
outbox = {}, -- messages queued before pairing completes
outbox = {}, -- messages queued before pairing completes (enet only)
paired = false,
address = nil, -- host: "ip:port" the other player types in
error = nil,
closed = false,
mode = nil,
joinTimeout = 10,
tcpSocket = nil, -- relay backend: the luasocket TCP connection
rxBuf = "", -- relay backend: bytes read but not yet a full line
txBuf = "", -- relay backend: bytes queued but not yet written
code = nil, -- relay backend, hosting: the room/tournament code
}, Net)
end
@@ -133,8 +154,56 @@ function Net:join(address)
return true
end
-- opens a TCP connection to a pokeserver relay (blocking connect with a
-- short timeout -- this runs once, from a single explicit user action, not
-- from a per-frame poll, so blocking briefly is fine). Callers then send
-- whatever control message starts their session ({type="host"},
-- {type="join",...}, {type="host_tournament",...}, ...); every reply that
-- isn't one of the four generic ones below lands in the normal inbox for
-- the caller (LinkState, or Tournament.lua) to interpret.
function Net:connectTCP(addr)
if not socket then
self.error = "online play needs luasocket (bundled with LOVE)"
return false
end
local host, port = addr:match("^(.-):(%d+)$")
host = host or addr
port = tonumber(port) or 7778
local tcp = socket.tcp()
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))
return false
end
tcp:settimeout(0)
self.tcpSocket = tcp
self.rxBuf = ""
self.txBuf = ""
return true
end
function Net:hostOnline(addr)
if not self:connectTCP(addr or Net.defaultRelayAddress()) then return false end
self.mode = "onlineHosting"
self:send({ type = "host" })
return true
end
function Net:joinOnline(addr, code)
if not self:connectTCP(addr or Net.defaultRelayAddress()) then return false end
self.mode = "onlineJoining"
self.target = code
self:send({ type = "join", code = code })
return true
end
function Net:send(msg)
if self.closed then return end
if self.tcpSocket then
self.txBuf = self.txBuf .. Json.encode(msg) .. "\n"
return
end
if self.peerEnd then -- loopback: re-encode through json like the wire
local decoded = Json.decode(Json.encode(msg))
if decoded and not self.peerEnd.closed then
@@ -155,8 +224,102 @@ function Net:send(msg)
end
end
-- one control message recognized on every relay connection, regardless of
-- what it's being used for (a 1v1 room or a tournament): "peer_gone" only
-- ever fires for a paired 1v1 room (tournaments signal disconnects through
-- bracket_update/tournament_over instead), so it's unambiguous here.
local function handleGenericRelayControl(self, msg)
if msg.type == "hosted" then
self.code = msg.code
return true
elseif msg.type == "paired" then
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.closed = true
return true
elseif msg.type == "peer_gone" then
self.closed = true
return true
end
return false
end
function Net:handleTCPLine(line)
local msg = Json.decode(line)
if not msg then
Logger.warn("link: bad relay message %q", line:sub(1, 60))
return
end
if not handleGenericRelayControl(self, msg) then
table.insert(self.inbox, msg)
end
end
-- pulls every complete "\n"-terminated line out of rxBuf (leaving a
-- trailing partial line, if any, for the next call to complete) and hands
-- each to handleTCPLine. Pure buffer manipulation, no socket -- factored
-- out of updateTCP so the framing logic is testable without a real
-- connection (see the relay-path tests in tests/run_link_tests.lua).
function Net:drainLines()
while true do
local nl = self.rxBuf:find("\n", 1, true)
if not nl then break end
local line = self.rxBuf:sub(1, nl - 1)
self.rxBuf = self.rxBuf:sub(nl + 1)
if #line > 0 then self:handleTCPLine(line) end
end
end
-- non-blocking pump for the relay TCP backend: flush queued writes, drain
-- whatever's arrived into complete lines. Uses a byte-count receive
-- (rather than the "*l" pattern) because luasocket's "*l" doesn't let a
-- non-blocking caller recover the partial line across calls -- a
-- byte-count read hands back whatever's available via the third return
-- value on timeout, which we can buffer ourselves.
function Net:updateTCP()
if self.closed then return end
local sock = self.tcpSocket
if #self.txBuf > 0 then
local sent, err, lastByte = sock:send(self.txBuf)
if sent then
self.txBuf = ""
elseif err == "timeout" then
self.txBuf = self.txBuf:sub((lastByte or 0) + 1)
else
self.error = "send failed: " .. tostring(err)
self.closed = true
return
end
end
while true do
local data, err, partial = sock:receive(8192)
local chunk = data or partial or ""
if #chunk > 0 then self.rxBuf = self.rxBuf .. chunk end
if err == "closed" then
self.closed = true
break
elseif err and err ~= "timeout" then
self.error = tostring(err)
self.closed = true
break
end
if not data then break end -- nothing more buffered this frame
end
self:drainLines()
end
-- pump enet events; decoded JSON messages are queued for poll()
function Net:update()
if self.tcpSocket then
self:updateTCP()
return
end
if self.peerEnd then return end -- loopback needs no pumping
if not self.enetHost or self.closed then return end
while true do
@@ -219,6 +382,12 @@ function Net:close()
self.closed = true
return
end
if self.tcpSocket then
pcall(function() self.tcpSocket:close() end)
self.tcpSocket = nil
self.closed = true
return
end
if self.enetHost then
if self.peer and self.paired and not self.closed then
-- graceful goodbye: disconnect_later delivers the queued
+44 -3
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,12 +78,27 @@ 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
return nil
end
local level = math.max(2, math.min(100, math.floor(packed.level or 5)))
-- "auto-level" tournaments/matches: every participant's real level is
-- 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 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
dvs[k] = math.max(0, math.min(15, math.floor((packed.dvs or {})[k] or 0)))
@@ -104,6 +126,23 @@ function Protocol.unpackMon(data, packed, opts)
if strict then return nil, "no shared moves" end
moves = { { id = "TACKLE", pp = 35 } }
end
-- a forced level scales every stat including max HP, so the real party's
-- 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 = 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,
@@ -111,9 +150,11 @@ function Protocol.unpackMon(data, packed, opts)
dvs = dvs,
statExp = statExp,
stats = stats,
hp = math.max(0, math.min(stats.hp, math.floor(packed.hp or stats.hp))),
status = packed.status,
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
+639
View File
@@ -0,0 +1,639 @@
-- Tournament play: host or join a bracket over the pokeserver relay.
-- Single-elimination, server-managed (pokeserver's `tournaments` map).
-- Matches run one at a time in bracket order; everyone not currently
-- playing -- still waiting their turn, or already eliminated -- watches
-- the live match play out via LinkBattle.newSpectator, reconstructed from
-- a copy of the real traffic the server fans out. Battle mode only (no
-- trade); vanilla only (Handshake.onlineAllowed already gated entry here
-- from LinkState). Elite Four music (Music_IndigoPlateau) loops the whole
-- time, uninterrupted by individual matches.
local CodeEntry = require("src.link.CodeEntry")
local DiscordPresence = require("src.core.DiscordPresence")
local Font = require("src.render.Font")
local Handshake = require("src.link.Handshake")
local LinkBattle = require("src.link.LinkBattle")
local Net = require("src.link.Net")
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 Tournament = {}
Tournament.__index = Tournament
Tournament.isOpaque = true
local CURSOR = 0xED
local MUSIC = "Music_IndigoPlateau"
local TURN_LIMITS = { 3, 6, 9 }
local PARTY_SIZES = { 1, 2, 3, 4, 5, 6 }
local ANY = "ANY" -- sentinel: a leading *nil* array entry breaks ipairs
-- (LuaJIT's # still reports the literal's full size, but
-- ipairs stops dead at the hole), so level bounds use
-- this string in self.settings instead of nil, converted
-- to nil only on the wire
local LEVEL_STEPS = { ANY, 5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55, 60, 65,
70, 75, 80, 85, 90, 95, 100 }
local SETTINGS_ROWS = 6 -- POKEMON, MIN LV, MAX LV, TIMER, LEVELS, PLAYING
-- LEVELS cycles through this: ANY (real levels) or a fixed level every
-- match normalizes to, regardless of each side's real party
local FORCE_LEVEL_STEPS = { ANY, 50, 100 }
-- tournaments have no real participant cap (any number can join before
-- start); this is just generous headroom so Discord's party.size never
-- reads as "full" and blocks a real invite click
local TOURNAMENT_PARTY_MAX = 16
local function indexOf(list, value)
for i, v in ipairs(list) do
if v == value then return i end
end
return 1
end
-- accepts either the internal ANY sentinel or a raw wire value (nil means
-- "any" there too, since the server never round-trips the sentinel string)
local function levelLabel(v)
return (v == ANY or v == nil) and "ANY" or tostring(v)
end
local function levelForWire(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)
return (v == ANY or v == nil) and "ANY" or ("AUTO " .. tostring(v))
end
local function partyStats(party)
local size, minLevel, maxLevel = 0, nil, nil
for _, mon in ipairs(party or {}) do
size = size + 1
local lvl = mon.level or 1
minLevel = minLevel and math.min(minLevel, lvl) or lvl
maxLevel = maxLevel and math.max(maxLevel, lvl) or lvl
end
return size, minLevel or 0, maxLevel or 0
end
function Tournament.new(game)
local self = setmetatable({}, Tournament)
self.game = game
self.stage = "menu"
self.index = 1
self.settings = { turnLimit = 6, requiredPartySize = 3, minLevel = ANY, maxLevel = ANY,
forceLevel = ANY, participating = true }
self.settingsIndex = 1
self.roster = {}
self.spectatorRoster = {}
Sound.startLoop(game.data, MUSIC)
return self
end
-- entry point for a Discord "Ask to Join" click on a tournament invite
-- (see DiscordPresence.lua): skips the HOST/JOIN menu and code entry,
-- straight to "connecting with this code"
function Tournament.newJoinOnline(game, code)
local self = Tournament.new(game)
self:startJoining(code)
return self
end
-- headcount for Discord's party.size: the roster only ever lists
-- competing players, so a non-participating (organizer-only) host isn't
-- in it even though they're right here running the thing
function Tournament:discordPartySize()
return #self.roster + (self.participating == false and 1 or 0)
end
function Tournament:exitWith(message)
DiscordPresence.setJoinCode(nil)
Sound.stopLoop(MUSIC)
Runtime.emit("link.ended", { reason = message and "error" or "bye" })
if self.net then self.net:close() end
self.game.stack:pop()
if message then
self.game.stack:push(TextBox.new(self.game, message))
end
end
-- -------------------------------------------------------------------
-- host / join
-- -------------------------------------------------------------------
function Tournament:startHosting()
self.net = Net.new()
if not self.net:connectTCP(Net.defaultRelayAddress()) then
self:exitWith("Link error:\n" .. (self.net.error or "?"))
return
end
local size, minL, maxL = partyStats(self.game.save.party)
self.isCreator = true
self.participating = self.settings.participating
self.net:send({
type = "host_tournament",
turnLimit = self.settings.turnLimit,
requiredPartySize = self.settings.requiredPartySize,
minLevel = levelForWire(self.settings.minLevel),
maxLevel = levelForWire(self.settings.maxLevel),
forceLevel = levelForWire(self.settings.forceLevel),
participating = self.settings.participating,
name = self.game.save.player.name,
partySize = size, partyMinLevel = minL, partyMaxLevel = maxL,
})
self.stage = "registering"
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 "?"))
return
end
local size, minL, maxL = partyStats(self.game.save.party)
self.isCreator = false
self.participating = true -- joining is always to compete; only hosting can opt out
self.code = code
self.net:send({
type = "join_tournament", code = code, name = self.game.save.player.name,
partySize = size, partyMinLevel = minL, partyMaxLevel = maxL,
})
self.stage = "registering"
end
-- -------------------------------------------------------------------
-- message handling (shared between "registering"/"bracket" and drained
-- again from a just-finished match's pendingTournamentMessages)
-- -------------------------------------------------------------------
local JOIN_ERROR_TEXT = {
not_found = "That code wasn't\nfound.",
already_started = "That tournament\nhas already begun.",
expired = "That code has\nexpired.",
}
function Tournament:handleMessage(msg)
if msg.type == "tournament_hosted" then
self.code = msg.code
self.settings.turnLimit = msg.turnLimit
self.settings.forceLevel = msg.forceLevel == nil and ANY or msg.forceLevel
self.participating = msg.participating
self.stage = "bracket"
-- the server already seeded t.players/spectators with the creator at
-- creation time; mirror that here so the Discord party size (and the
-- "waiting for players" list) show the host from the very first frame,
-- not just once someone else joins and a real roster broadcast arrives
if self.participating then
self.roster = { self.game.save.player.name }
else
self.spectatorRoster = { self.game.save.player.name }
end
if self.isCreator then
DiscordPresence.setJoinCode(self.code, "tournament",
self:discordPartySize(), TOURNAMENT_PARTY_MAX)
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)))
else
self:exitWith("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)))
else
self:exitWith(JOIN_ERROR_TEXT[msg.reason] or "Couldn't join\nthat tournament.")
end
elseif msg.type == "tournament_roster" then
self.roster = msg.players
self.spectatorRoster = msg.spectators or {}
self.settings.turnLimit = msg.turnLimit
self.settings.requiredPartySize = msg.requiredPartySize
self.settings.minLevel = msg.minLevel == nil and ANY or msg.minLevel
self.settings.maxLevel = msg.maxLevel == nil and ANY or msg.maxLevel
self.settings.forceLevel = msg.forceLevel == nil and ANY or msg.forceLevel
self.stage = "bracket"
if self.isCreator and self.code then
DiscordPresence.setJoinCode(self.code, "tournament",
self:discordPartySize(), TOURNAMENT_PARTY_MAX)
end
elseif msg.type == "bracket_update" then
self.bracket = msg.tournament
self.code = self.bracket.code
if self.stage == "registering" then self.stage = "bracket" end
elseif msg.type == "match_start" then
self:enterMatch(msg)
elseif msg.type == "match_start_spectate" then
self:enterSpectate(msg)
elseif msg.type == "tournament_bye" then
self.byeRound = msg.round
elseif msg.type == "tournament_over" then
self.champion = msg.champion
self.stage = "done"
end
end
-- -------------------------------------------------------------------
-- entering a match: real participant
-- -------------------------------------------------------------------
function Tournament:sendHello(mode)
self.myHello = Handshake.hello(self.game, mode)
self.net:send(self.myHello)
end
function Tournament:pollHello()
local msgs = self.net:poll()
local keep, got = {}, false
for _, msg in ipairs(msgs) do
if msg.type == "hello" and not self.peerHello then
self.peerHello = msg
got = true
else
keep[#keep + 1] = msg
end
end
for i = #keep, 1, -1 do
table.insert(self.net.inbox, 1, keep[i])
end
return got
end
function Tournament:enterMatch(msg)
self.isHost = (msg.role == "host")
self.opponentName = msg.opponent
self.matchRound = msg.round
self.matchTurnLimit = msg.turnLimit
self.peerHello = nil
self:sendHello(self.isHost and "battle" or nil)
self.stage = "matchHello"
end
function Tournament:beginMatchBattle()
local verdict = Handshake.checkCompat(self.myHello, self.peerHello)
if not (verdict == "full" or verdict == "vanilla_peer") then
-- 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.")
return
end
self.linkSeed = self.isHost and love.math.random(1, 2 ^ 30) or nil
local opts = {
myParty = Protocol.packParty(self.game.save.party),
theirName = self.opponentName or "FOE",
seed = self.isHost and self.linkSeed or nil,
verdict = verdict,
strict = Handshake.strict(verdict),
turnLimit = self.matchTurnLimit,
forceLevel = levelForWire(self.settings.forceLevel),
keepNetOpen = true, -- this is the tournament's own connection, not a
-- dedicated match socket -- don't let finish() close it
}
self.stage = "matchWaitParty"
self.pendingBattleOpts = opts
self.net:send({ type = "party",
mons = Protocol.packParty(self.game.save.party),
seed = self.linkSeed })
end
-- -------------------------------------------------------------------
-- entering a match: spectator
-- -------------------------------------------------------------------
function Tournament:enterSpectate(msg)
self.matchRound = msg.round
self.spectate = {
hostName = msg.playerHost, guestName = msg.playerGuest,
hostParty = nil, guestParty = nil, seed = nil,
}
self.stage = "spectateWait"
end
-- -------------------------------------------------------------------
-- update
-- -------------------------------------------------------------------
function Tournament:update(dt)
local input = self.game.input
if self.stage == "matchRunning" or self.stage == "spectateRunning" then
if self.game.stack:top() == self then
-- the battle popped; drain anything the network handed to it but it
-- didn't itself understand (a bracket_update, the next match...)
local battle = self.activeBattle
self.activeBattle = nil
if self.stage == "matchRunning" and battle and battle.result and self.net
and not self.net.closed then
-- a real participant reports its own outcome; the server resolves
-- the match once both sides have (or one disconnects)
self.net:send({ type = "tournament_result", result = battle.result })
end
if battle and battle.pendingTournamentMessages then
for _, msg in ipairs(battle.pendingTournamentMessages) do
self:handleMessage(msg)
end
end
if self.stage == "matchRunning" or self.stage == "spectateRunning" then
self.stage = "bracket"
end
end
return
end
if self.net then
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))
return
end
if self.net.closed and self.stage ~= "done" then
self:exitWith("The tournament\nconnection was\nlost.")
return
end
end
if self.stage == "matchHello" then
if input:wasPressed("b") then self:exitWith(nil) return end
self:pollHello()
if self.peerHello then self:beginMatchBattle() end
for _, msg in ipairs(self.net:poll()) do self:handleMessage(msg) end
return
elseif self.stage == "matchWaitParty" then
if input:wasPressed("b") then self:exitWith(nil) return end
local msgs = self.net:poll()
for i, msg in ipairs(msgs) do
if msg.type == "party" then
self.pendingBattleOpts.theirParty = msg.mons
if self.isHost then
self.pendingBattleOpts.seed = self.pendingBattleOpts.seed or self.linkSeed
else
self.pendingBattleOpts.seed = msg.seed
end
local battle, why = self.isHost
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.")
return
end
-- anything after `party` in this same batch belongs to the
-- battle now, not to Tournament -- put it back for its own poll()
for j = #msgs, i + 1, -1 do
table.insert(self.net.inbox, 1, msgs[j])
end
self.activeBattle = battle
self.game.stack:push(battle)
self.stage = "matchRunning"
return
else
self:handleMessage(msg)
end
end
return
elseif self.stage == "spectateWait" then
if input:wasPressed("b") then self:exitWith(nil) return end
local msgs = self.net:poll()
for i, msg in ipairs(msgs) do
if msg.type == "spectate" and msg.msg.type == "party" then
local inner = msg.msg
if msg.side == "host" then
self.spectate.hostParty = inner.mons
self.spectate.seed = inner.seed
else
self.spectate.guestParty = inner.mons
end
if self.spectate.hostParty and self.spectate.guestParty then
local battle, why = LinkBattle.newSpectator(self.game, self.net, {
hostParty = self.spectate.hostParty, guestParty = self.spectate.guestParty,
hostName = self.spectate.hostName, guestName = self.spectate.guestName,
seed = self.spectate.seed,
forceLevel = levelForWire(self.settings.forceLevel),
})
if not battle then
self:exitWith(why or "Can't watch this\nmatch.")
return
end
for j = #msgs, i + 1, -1 do
table.insert(self.net.inbox, 1, msgs[j])
end
self.activeBattle = battle
self.game.stack:push(battle)
self.stage = "spectateRunning"
return
end
else
self:handleMessage(msg)
end
end
return
end
if self.net then
for _, msg in ipairs(self.net:poll()) do self:handleMessage(msg) end
end
if self.stage == "menu" then
if input:wasPressed("up") or input:wasPressed("down") then
self.index = self.index == 1 and 2 or 1
elseif input:wasPressed("b") then
self:exitWith(nil)
elseif input:wasPressed("a") then
if self.index == 1 then
self.stage = "hostSettings"
self.settingsIndex = 1
else
self.stage = "codeEntry"
self.codeEntry = CodeEntry.new()
end
end
elseif self.stage == "hostSettings" then
if input:wasPressed("b") then
self.stage = "menu"
self.index = 1
elseif input:wasPressed("up") then
self.settingsIndex = self.settingsIndex == 1 and SETTINGS_ROWS or self.settingsIndex - 1
elseif input:wasPressed("down") then
self.settingsIndex = self.settingsIndex % SETTINGS_ROWS + 1
elseif input:wasPressed("left") or input:wasPressed("right") then
local delta = input:wasPressed("right") and 1 or -1
if self.settingsIndex == 1 then
local i = indexOf(PARTY_SIZES, self.settings.requiredPartySize)
i = ((i - 1 + delta) % #PARTY_SIZES) + 1
self.settings.requiredPartySize = PARTY_SIZES[i]
elseif self.settingsIndex == 2 then
local i = indexOf(LEVEL_STEPS, self.settings.minLevel)
i = ((i - 1 + delta) % #LEVEL_STEPS) + 1
self.settings.minLevel = LEVEL_STEPS[i]
elseif self.settingsIndex == 3 then
local i = indexOf(LEVEL_STEPS, self.settings.maxLevel)
i = ((i - 1 + delta) % #LEVEL_STEPS) + 1
self.settings.maxLevel = LEVEL_STEPS[i]
elseif self.settingsIndex == 4 then
local i = indexOf(TURN_LIMITS, self.settings.turnLimit)
i = ((i - 1 + delta) % #TURN_LIMITS) + 1
self.settings.turnLimit = TURN_LIMITS[i]
elseif self.settingsIndex == 5 then
local i = indexOf(FORCE_LEVEL_STEPS, self.settings.forceLevel)
i = ((i - 1 + delta) % #FORCE_LEVEL_STEPS) + 1
self.settings.forceLevel = FORCE_LEVEL_STEPS[i]
elseif self.settingsIndex == 6 then
self.settings.participating = not self.settings.participating
end
elseif input:wasPressed("a") or input:wasPressed("start") then
self:startHosting()
end
elseif self.stage == "codeEntry" then
if input:wasPressed("b") then
self.stage = "menu"
self.index = 2
elseif input:wasPressed("up") then
CodeEntry.up(self.codeEntry)
elseif input:wasPressed("down") then
CodeEntry.down(self.codeEntry)
elseif input:wasPressed("left") then
CodeEntry.left(self.codeEntry)
elseif input:wasPressed("right") then
CodeEntry.right(self.codeEntry)
elseif input:wasPressed("a") then
self:startJoining(CodeEntry.text(self.codeEntry))
end
elseif self.stage == "registering" then
if input:wasPressed("b") then self:exitWith(nil) end
elseif self.stage == "bracket" then
if input:wasPressed("b") then self:exitWith(nil) return end
if input:wasPressed("a") and self.isCreator and #self.roster >= 2 then
DiscordPresence.setJoinCode(nil) -- roster's locking in; stop advertising
self.net:send({ type = "start_tournament" })
end
elseif self.stage == "done" then
if input:wasPressed("a") or input:wasPressed("b") then
self:exitWith(nil)
end
end
end
-- -------------------------------------------------------------------
-- draw
-- -------------------------------------------------------------------
local function drawTitle(text)
love.graphics.setColor(1, 1, 1, 1)
love.graphics.rectangle("fill", 0, 0, 160, 144)
love.graphics.setColor(0, 0, 0, 1)
Font.draw(text, 8, 6)
end
local SETTINGS_LABELS = { "POKEMON", "MIN LV", "MAX LV", "TIMER", "LEVELS", "PLAYING" }
function Tournament:draw()
if self.stage == "menu" then
drawTitle("TOURNAMENT")
Font.draw("HOST", 32, 48)
Font.draw("JOIN", 32, 68)
Font.drawCode(CURSOR, 24, self.index == 1 and 48 or 68)
elseif self.stage == "hostSettings" then
drawTitle("TOURNAMENT RULES")
local values = {
tostring(self.settings.requiredPartySize),
levelLabel(self.settings.minLevel),
levelLabel(self.settings.maxLevel),
self.settings.turnLimit .. "s",
forceLevelLabel(self.settings.forceLevel),
self.settings.participating and "YES" or "NO",
}
for i, label in ipairs(SETTINGS_LABELS) do
local y = 32 + (i - 1) * 16
Font.draw(label, 16, y)
Font.draw(values[i], 96, y)
if i == self.settingsIndex then Font.drawCode(CURSOR, 8, y) end
end
Font.draw("START: create", 8, 128)
elseif self.stage == "codeEntry" then
drawTitle("ENTER CODE")
for i = 1, CodeEntry.LENGTH do
local x = 16 + (i - 1) * 16
local ch = CodeEntry.CHARSET:sub(self.codeEntry.chars[i], self.codeEntry.chars[i])
Font.draw(ch, x, 64)
if i == self.codeEntry.pos then
Font.drawCode(0xEE, x, 76)
end
end
Font.draw("A: join B: back", 8, 128)
elseif self.stage == "registering" then
drawTitle("CONNECTING...")
Font.draw("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 "??????"))
if self.bracket then
local y = 20
for _, round in ipairs(self.bracket.rounds) do
Font.draw(("ROUND %d"):format(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 "?")
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 "?",
m.b or "?", m.winner == m.b and "<" or (mark == "*" and " *" or ""))
end
Font.draw(line, 12, y)
y = y + 10
if y > 120 then break end
end
end
else
if self.participating == false then
Font.draw("(organizing --", 16, 32)
Font.draw("not playing)", 16, 42)
end
Font.draw("Waiting for", 16, 48)
Font.draw("players to join:", 16, 60)
local y = 60
for i, name in ipairs(self.roster) do
y = 60 + i * 10
Font.draw(name, 24, y)
end
for _, name in ipairs(self.spectatorRoster) do
y = y + 10
Font.draw(name .. " (watch)", 24, y)
end
end
if self.isCreator and #self.roster >= 2 and not self.bracket then
Font.draw("A: START B: cancel", 8, 132)
else
Font.draw("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)
end
Font.draw("A: continue", 8, 128)
end
love.graphics.setColor(1, 1, 1, 1)
end
return Tournament
+366
View File
@@ -0,0 +1,366 @@
-- Launcher-side mod surface (18/launcher redesign): the mods panel runs
-- BEFORE Game:load, so this NEVER loads a mod entry chunk -- it scans
-- 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,
-- installs a dropped/chosen .zip into the save-dir "mods/<id>/" tree, and
-- uninstalls a mod by removing that tree + clearing options.mods[id].
--
-- Split in two: the pure derivation (deriveList, locateRoot) has no love and
-- no filesystem, so the engine tier can table-drive it; the discovery,
-- 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 LauncherMods = {}
-- ------- pure status derivation
-- A hard-dependency / conflict / version verdict for one manifest. mods is
-- the id -> validated-manifest map resolveToggle reads (its dependencySpecs,
-- conflictSpecs, version and game_version are exactly the fields the loader's
-- Manifest.validate produced); enabledSet is the current desired enable-set.
local function statusFor(mods, id, enabledSet, enabled)
local m = mods[id]
-- conflict only bites an enabled mod: resolveToggle's conflict list is
-- bidirectional (this mod's conflicts spec vs an enabled other, and an
-- enabled other's spec vs this mod), which is exactly the launcher chip.
if enabled then
local r = ManagerState.resolveToggle(mods, id, true, enabledSet)
if #r.conflicts > 0 then
local otherId = r.conflicts[1]
local other = mods[otherId]
return "conflict",
"Conflicts with " .. ((other and other.name) or otherId)
end
end
-- warn: the engine is outside the mod's game_version range
if m.game_version
and not Semver.satisfies(Version.engine, m.game_version) then
return "warn", "Needs engine " .. m.game_version
.. " (have " .. Version.engine .. ")"
end
-- warn: a hard dependency is absent, switched off, or the wrong version.
-- resolveToggle would cascade-enable a merely-disabled dep rather than flag
-- it, so the disabled case is judged straight off the manifest here.
for _, spec in ipairs(m.dependencySpecs or {}) do
local dep = mods[spec.id]
if not dep then
return "warn", "Needs " .. spec.id .. " (not installed)"
elseif not enabledSet[spec.id] then
return "warn", "Needs " .. spec.id .. " (disabled)"
elseif spec.range
and not Semver.satisfies(dep.version, spec.range) then
return "warn", "Needs " .. spec.id .. " " .. spec.range
end
end
return "ok", "Ready"
end
-- deriveList(manifests, options) -> the panel row list, pure.
-- manifests is an array of validated manifests (Manifest.validate output);
-- options is the options table (only options.mods is read). Rows come back
-- sorted by id so the panel order is stable.
function LauncherMods.deriveList(manifests, options)
local mods = options and options.mods or {}
local ordered = {}
for _, m in ipairs(manifests) do ordered[#ordered + 1] = m end
table.sort(ordered, function(a, b) return a.id < b.id end)
local byId, enabledSet = {}, {}
for _, m in ipairs(ordered) do
byId[m.id] = m
-- missing entry means enabled, matching the loader's default
if mods[m.id] ~= false then enabledSet[m.id] = true end
end
local out = {}
for _, m in ipairs(ordered) do
local enabled = enabledSet[m.id] == true
local status, detail = statusFor(byId, m.id, enabledSet, enabled)
local raw = m.raw or {}
out[#out + 1] = {
id = m.id,
name = m.name or m.id,
version = m.version,
-- category, then profile, then a generic fallback -- uppercased
badge = tostring(raw.category or m.profile or "MOD"):upper(),
description = m.description or "",
enabled = enabled,
status = status,
statusDetail = detail,
}
end
return out
end
-- locateRoot(paths) -> the mod-root prefix inside a mounted archive, pure.
-- paths is a shallow listing: top-level file names as-is, and for a top-level
-- directory a "<dir>/manifest.json" entry when it holds one. Returns "" when
-- the manifest sits at the archive root, "<dir>" when a single top-level
-- folder holds it, or nil + a user-presentable reason.
function LauncherMods.locateRoot(paths)
for _, p in ipairs(paths) do
if p == "manifest.json" then return "" end
end
local topDirs, seen, hasManifest = {}, {}, {}
for _, p in ipairs(paths) do
local top, rest = p:match("^([^/]+)/(.+)$")
if top then
if not seen[top] then
seen[top] = true
topDirs[#topDirs + 1] = top
end
if rest == "manifest.json" then hasManifest[top] = true end
end
end
if #topDirs == 1 and hasManifest[topDirs[1]] then return topDirs[1] end
if #topDirs > 1 then
return nil, "the .zip must contain a single mod folder"
end
return nil, "no manifest.json found in the .zip"
end
-- ------- discovery (love.filesystem)
local function decodeManifest(raw, path)
local Json = require("src.link.Json")
local data, decodeErr = Json.decode(raw)
if not data then return nil, decodeErr end
local ok, manifest = pcall(Manifest.validate, data, path)
if not ok then return nil, manifest end
return manifest
end
-- Scan "mods/" one level deep for valid manifests (mirrors Loader:_discover,
-- but validates only -- no entry chunk is ever loaded). First id wins on a
-- duplicate. Returns an array of validated manifests.
local function discover()
local fs = love and love.filesystem
local out = {}
if not (fs and fs.getInfo and fs.getDirectoryItems) then return out end
if not fs.getInfo("mods") then return out end
local seen = {}
for _, name in ipairs(fs.getDirectoryItems("mods")) do
local path = "mods/" .. name
local info = fs.getInfo(path)
if info and info.type == "directory" then
local raw = fs.read(path .. "/manifest.json")
if raw then
local manifest = decodeManifest(raw, path)
if manifest and not seen[manifest.id] then
seen[manifest.id] = true
out[#out + 1] = manifest
end
end
end
end
return out
end
-- list() -> the mods-panel rows for the current install. Reads the same
-- options.mods enable-state the loader persists, so a toggle here is what the
-- game sees on its next boot.
function LauncherMods.list()
local options = SaveData.loadOptions()
return LauncherMods.deriveList(discover(), options)
end
-- setEnabled(id, enabled): persist options.mods[id] in the exact shape
-- Loader:_saveState writes (a plain boolean), so the running game and the
-- in-game ManagerState pick it up unchanged.
function LauncherMods.setEnabled(id, enabled)
local options = SaveData.loadOptions()
options.mods = options.mods or {}
options.mods[id] = enabled and true or false
SaveData.saveOptions(options)
return true
end
-- ------- install (love.filesystem)
-- Read a .zip source into bytes. A string is an external absolute path (like
-- a chosen ROM) read with io.*, falling back to a save-dir-relative
-- love.filesystem read; a love DroppedFile is opened the way RomImporter
-- ingests dropped ROMs.
local function readArchive(source)
local t = type(source)
if (t == "userdata" or t == "table") and type(source.open) == "function" then
local ok = source:open("r")
if not ok then return nil, "could not open the dropped file" end
local data = source:read(source:getSize())
source:close()
if not data then return nil, "the dropped file could not be read" end
return data
end
if t == "string" then
local f = io.open(source, "rb")
if f then
local data = f:read("*a")
f:close()
if not data then return nil, "could not read " .. source end
return data
end
if love and love.filesystem then
local data = love.filesystem.read(source)
if data then return data end
end
return nil, "could not open " .. source
end
return nil, "unsupported archive source"
end
-- Shallow listing of a mounted archive shaped for locateRoot: files by name,
-- and for each top-level directory a "<dir>/manifest.json" marker only when it
-- actually holds one (so a lone folder with no manifest still reads as empty).
local function topLevelPaths(mount)
local fs = love.filesystem
local paths = {}
for _, name in ipairs(fs.getDirectoryItems(mount)) do
local info = fs.getInfo(mount .. "/" .. name)
if info and info.type == "directory" then
if fs.getInfo(mount .. "/" .. name .. "/manifest.json", "file") then
paths[#paths + 1] = name .. "/manifest.json"
end
else
paths[#paths + 1] = name
end
end
return paths
end
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
local info = fs.getInfo(s)
if info and info.type == "directory" then
local ok, err = copyTree(s, d)
if not ok then return nil, err end
else
local data = fs.read(s)
if data == nil then return nil, "could not read " .. name end
local ok, err = fs.write(d, data)
if not ok then return nil, "could not write " .. name .. ": " .. tostring(err) end
end
end
return true
end
local function removeTree(path)
local fs = love.filesystem
local info = fs.getInfo(path)
if not info then return end
if info.type == "directory" then
for _, child in ipairs(fs.getDirectoryItems(path)) do
removeTree(path .. "/" .. child)
end
end
fs.remove(path)
end
-- installZip(source) -> true, id | nil, errString
-- source is an external path or a love DroppedFile. The archive is validated
-- BEFORE anything is copied; every path unmounts and clears the staged temp
-- file, and a failed copy rolls its partial tree back. A dropped file outside
-- the save dir is staged into a save-dir temp first, because
-- love.filesystem.mount only reaches a save-directory-relative path.
function LauncherMods.installZip(source)
if not (love and love.filesystem) then
return nil, "mod install needs LOVE"
end
local fs = love.filesystem
local data, readErr = readArchive(source)
if not data then return nil, readErr end
-- stage into a save-dir temp so mount can reach it
local tmp = ("mod_import_%d_%d.zip"):format(os.time(), math.random(0, 999999))
local ok, writeErr = fs.write(tmp, data)
if not ok then
return nil, "could not stage the .zip: " .. tostring(writeErr)
end
local mount = "mod_import_mount"
if not fs.mount(tmp, mount) then
fs.remove(tmp)
return nil, "that .zip could not be opened"
end
local function cleanup()
pcall(fs.unmount, tmp)
fs.remove(tmp)
end
local prefix, rootErr = LauncherMods.locateRoot(topLevelPaths(mount))
if not prefix then
cleanup()
return nil, rootErr
end
local root = prefix == "" and mount or (mount .. "/" .. prefix)
local raw = fs.read(root .. "/manifest.json")
if not raw then
cleanup()
return nil, "the .zip has no readable manifest.json"
end
local manifest, manifestErr = decodeManifest(raw, root)
if not manifest then
cleanup()
return nil, "invalid mod manifest: " .. tostring(manifestErr)
end
-- reject a duplicate before touching the mods tree
local dest = "mods/" .. manifest.id
if fs.getInfo(dest) then
cleanup()
return nil, "a mod named '" .. manifest.id .. "' is already installed"
end
fs.createDirectory("mods")
local copied, copyErr = copyTree(root, dest)
if not copied then
removeTree(dest)
cleanup()
return nil, copyErr or "could not copy the mod files"
end
cleanup()
return true, manifest.id
end
-- uninstall(id) -> true | nil, errString
-- Removes mods/<id>/ from the save directory and clears options.mods[id] so the
-- loader and in-game manager no longer see it. Rejects unknown / missing ids.
-- Does not touch other mods' enable state.
function LauncherMods.uninstall(id)
if type(id) ~= "string" or id == "" then
return nil, "missing mod id"
end
if id:find("[/\\]") or id == "." or id == ".." then
return nil, "invalid mod id"
end
if not (love and love.filesystem) then
return nil, "mod uninstall needs LOVE"
end
local fs = love.filesystem
local dest = "mods/" .. id
if not fs.getInfo(dest) then
return nil, "mod '" .. id .. "' is not installed"
end
removeTree(dest)
-- Drop the enable flag so a reinstall of the same id starts from the
-- loader's default (enabled) rather than a stale false.
local options = SaveData.loadOptions()
if options.mods and options.mods[id] ~= nil then
options.mods[id] = nil
SaveData.saveOptions(options)
end
return true
end
return LauncherMods
+1
View File
@@ -68,6 +68,7 @@ local devShim = { installed = false, permissions = {}, warned = {}, depth = 0 }
local SUPPORTED_REQUIRES = {
["src.mods.Semver"] = true,
["src.audio.ChipAsm"] = true,
["src.pokemon.Stats"] = true, -- Stats.isShiny / calc for indicator mods
}
local function scanRequire(name)
+6
View File
@@ -18,6 +18,12 @@ ManagerState.isOpaque = true
-- Game:keypressed recognizes a directly-pushed instance
ManagerState.screenId = "ManagerState"
-- Same as OptionsMenu: without this, title LOGO zones leak through when
-- MODS is opened from the title-screen options path.
function ManagerState:sgbPalettes(game)
return require("src.render.PaletteFX").wholeNamed(game.data, "MEWMON")
end
-- the charmap has no * ~ + < > glyphs, so the status gutter uses what it
-- does have: staged-awaiting-restart, disabled, errored, dep-unhealthy
local GLYPH = { staged = ".", disabled = "-", errored = "!", blocked = "?" }
+137 -2
View File
@@ -40,6 +40,19 @@ function f.int(min, max)
end }
end
-- a bounded float; like f.int but keeps the fractional part (scales,
-- gains). Rejects out-of-range with the "expected number a..b" message.
function f.numRange(min, max)
local desc = "number"
if min and max then desc = ("number %s..%s"):format(min, max)
elseif min then desc = ("number >= %s"):format(min) end
return { kind = "num", min = min, max = max, desc = desc,
check = function(v)
return type(v) == "number"
and (min == nil or v >= min) and (max == nil or v <= max)
end }
end
function f.enum(values)
local set = {}
for _, value in ipairs(values) do set[value] = true end
@@ -420,6 +433,12 @@ R.pokemon = {
frames = f.opt(f.int(1)) } }),
cry = f.opt(f.id("cries")), palette = f.opt(f.id("palettes")),
trueColor = f.opt(f.bool),
-- battle-pic scale overrides for this species' own pics: front is the
-- enemy pic (default 1x), back is the player pic (default 2x). An
-- image-level battle_sprite_scales entry for the same path beats these.
-- The pic stays grounded (feet pinned) at any scale; see docs/modding.md.
battleScaleFront = f.opt(f.numRange(0.25, 4.0)),
battleScaleBack = f.opt(f.numRange(0.25, 4.0)),
},
example = 'mod.content.pokemon:patch("MEW", { baseStats = { attack = 120 } })',
}
@@ -791,6 +810,118 @@ R.transitions = {
example = 'mod.content.transitions:register("dissolve", { frames = 30, draw = fn })',
}
-- ------- rendering pipelines
--
-- A pipeline is a display mode that owns part of the frame: it may replace
-- the overworld's world pass with geometry of its own (drawWorld) and/or
-- post-process the finished composite (present). Everything around that --
-- the OFF/1/2/3 ladder, its options row, its hotkey, persistence in
-- save.options.pipelines and the gating that keeps it out of battles and
-- menus -- is engine plumbing driven from this record, so a renderer mod
-- declares what it is and writes only the two draw functions.
--
-- Both callbacks are optional and independent: a present-only pipeline is a
-- post-process (bloom, tilt-shift, a CRT curve) that leaves whatever
-- rendered the frame alone, and a drawWorld-only pipeline is a world
-- renderer that composites straight. See src/render/Pipelines.lua for the
-- ctx each receives and docs/modding.md for the worked example.
R.render_pipelines = {
semantics = "record", target = "render_pipelines",
fields = {
-- shown in the options menu; the ladder labels default to OFF/ON
label = f.str,
levels = f.opt(f.list(f.str)),
-- keyboard key that cycles the ladder, checked after the engine's own
-- display hotkeys so a pipeline can never shadow one
hotkey = f.opt(f.str),
-- higher wins when two world pipelines are somehow active at once;
-- also the options-row order, so a mode and its post-process sort
-- together instead of by id
priority = f.opt(f.num),
-- hardware/driver gate, checked every frame: false keeps the vanilla
-- 2D path, which is what a headless run and a driver with no depth
-- canvas both get
available = f.opt(f.fn),
-- (top, overworld) -> boolean: whether the player may CHANGE the mode
-- right now. Defaults to the survey-zoom gate (free-roam overworld
-- only), which keeps a hotkey press from switching modes mid-warp or
-- mid-cutscene. It has no say over whether an already-on mode draws:
-- a mode that stopped rendering during a warp would flash the flat 2D
-- world every time the player walked through a door.
gate = f.opt(f.fn),
-- (dt, level): presentational tweens, ticked on real frame time
update = f.opt(f.fn),
-- (ctx) -> canvas | nil: render the world. nil falls back to the
-- vanilla flat/tilt draw for this frame.
drawWorld = f.opt(f.fn),
-- (canvas, ctx) -> canvas: post-process the WORLD image, before the UI
-- composites over it -- a depth-of-field or colour grade that must not
-- touch the dialog boxes and menus sitting on top. Only runs when some
-- pipeline rendered the world, since the vanilla world pass has no
-- single finished image to hand over.
worldPresent = f.opt(f.fn),
-- (canvas, ctx) -> canvas: post-process the whole finished composite,
-- world and UI alike (a CRT curve, a full-screen grade). Must return a
-- canvas; the input unchanged is the correct answer when the effect is
-- off.
present = f.opt(f.fn),
-- drop GPU objects (window resize, hot reload, mode switch)
invalidate = f.opt(f.fn),
},
-- a pipeline that does neither half is dead weight and would silently
-- occupy an options row and a hotkey
extra = function(_, value)
if value.drawWorld == nil and value.present == nil
and value.worldPresent == nil then
return "a render pipeline needs drawWorld, worldPresent or present"
end
end,
-- A pipeline callback fails at play time, long after the load phase has
-- handed its report to the mod manager, so the merge leaves behind who
-- wrote each record for Pipelines to name in the failure -- the same
-- provenance trick the audio registries use (Loader.stampAudioOwners).
-- Placement is otherwise the default record merge.
write = function(target, registry)
local owners, tombstones = {}, {}
for id in pairs(registry.ops) do
local value = registry:get(id)
if value == nil then
tombstones[#tombstones + 1] = id
else
target[id] = value
local owner = registry.owners[id]
if owner and owner ~= Schemas.ENGINE then owners[id] = owner end
end
end
for _, id in ipairs(tombstones) do target[id] = nil end
target._owners = owners
end,
example = 'mod.content.render_pipelines:register("voxel", ' ..
'{ label = "VOXEL", levels = { "OFF", "15", "35", "50" }, drawWorld = fn })',
}
-- ------- battle sprite scales
--
-- Per-image battle-pic scale overrides, keyed by record id and consulted
-- by asset path at draw time. Where a species' battleScaleFront /
-- battleScaleBack scales its own front/back pic, this scales ANY battle
-- pic by the path it is drawn from -- the only handle on the non-species
-- pics like the player's trainer back sprite. Image-level beats
-- species-level; both compose with the send-out grow and keep the sprite
-- grounded (feet pinned) at whatever scale. See docs/modding.md.
R.battle_sprite_scales = {
semantics = "record", target = "battle_sprite_scales",
fields = {
-- the asset path exactly as data references it, e.g.
-- "assets/generated/battle/back/abrab.png"
path = f.path,
-- 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 })',
}
-- ------- progression
R.evolution_methods = {
@@ -872,11 +1003,15 @@ R.palettes = {
}
-- keyed by species id, unlike the vanilla byDex array: a species past the
-- end of the dex gets an icon without punching a hole in the list
-- end of the dex gets an icon without punching a hole in the list. The party
-- menu (src/ui/PartyMenu.lua) reads this per-species entry before the vanilla
-- dex-indexed default. The value is a built-in icon NAME -- one of BALL, BIRD,
-- BUG, FAIRY, GRASS, HELIX, MON, QUADRUPED, SNAKE, WATER (uppercase) -- or a
-- { image = <bundled file path>, frames? } table of your own art.
R.icons = {
semantics = "record", target = "icons.bySpecies",
value = f.union{ f.str, f.rec{ image = f.path, frames = f.opt(f.int(1)) } },
example = 'mod.content.icons:register("MISSINGNO", { image = "glitch.png" })',
example = 'mod.content.icons:register("MODMON", "QUADRUPED") -- a built-in name, or { image = mod.assets:path("icon.png"), frames = 2 }',
}
-- glyph codes are not bytes: the vanilla pages sit at $60/$80 but a
+63 -8
View File
@@ -106,11 +106,55 @@ 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,
("%s learned\n%s!"):format(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"))
@@ -120,7 +164,9 @@ function Evolution.evolve(game, mon, newSpecies, onDone, via)
:format(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 +184,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
+28
View File
@@ -29,6 +29,34 @@ function Pokemon.movesAtLevel(speciesDef, level)
return moves
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.
function Pokemon.learnMovesFromDayCare(data, mon, speciesDef, startLevel, newLevel)
if not (speciesDef and speciesDef.learnset and mon) then return end
mon.moves = mon.moves or {}
for _, entry in ipairs(speciesDef.learnset) do
local moveLevel = entry.level
if moveLevel > newLevel then break end
if moveLevel > startLevel then
local known = false
for _, mv in ipairs(mon.moves) do
if mv.id == entry.move then known = true break end
end
if not known then
local mdef = data.moves[entry.move]
local slot = { id = entry.move, pp = mdef and mdef.pp or 0 }
if #mon.moves < 4 then
mon.moves[#mon.moves + 1] = slot
else
table.remove(mon.moves, 1)
mon.moves[#mon.moves + 1] = slot
end
end
end
end
end
function Pokemon.new(data, species, level, rng)
local def = data.pokemon[species]
assert(def, "unknown species " .. tostring(species))
+63
View File
@@ -0,0 +1,63 @@
-- 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.
local Runtime = require("src.mods.Runtime")
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 a party-menu icon image path for `mon`.
-- vanillaPath is the path PartyMenu already picked from icons.bySpecies /
-- def.icon / icons.byDex; the hook may replace it.
-- Returns path (possibly nil).
function Sprites.iconPath(data, mon, vanillaPath, opts)
opts = opts or {}
if not vanillaPath and not Runtime.wantsHook("pokemon.icon") then
return vanillaPath
end
local species = mon and mon.species
local ctx = {
species = species,
mon = mon,
name = opts.name,
data = data,
kind = "icon",
}
if not Runtime.wantsHook("pokemon.icon") then return vanillaPath end
local hooked = Runtime.call("pokemon.icon", samePath, vanillaPath, ctx)
if type(hooked) == "string" and hooked ~= "" then return hooked end
if hooked == nil or hooked == false then return nil end
return vanillaPath
end
return Sprites
+16
View File
@@ -57,4 +57,20 @@ function Stats.applyStage(value, stage)
return math.max(1, math.min(999, v))
end
-- Gen 2 shiny formula applied to Gen 1 DVs (the RBY "virtual shiny"):
-- Defense/Speed/Special DV == 10 and Attack DV is even-high
-- (2, 3, 6, 7, 10, 11, 14, or 15). Used by shiny-indicator mods.
local SHINY_ATK = {
[2] = true, [3] = true, [6] = true, [7] = true,
[10] = true, [11] = true, [14] = true, [15] = true,
}
function Stats.isShiny(dvs)
if type(dvs) ~= "table" then return false end
return (dvs.defense or 0) == 10
and (dvs.speed or 0) == 10
and (dvs.special or 0) == 10
and SHINY_ATK[dvs.attack or 0] == true
end
return Stats
+5
View File
@@ -230,6 +230,11 @@ function BattleTransition:draw()
love.graphics.setColor(0, 0, 0, 1)
local prog = math.min(1, self.t / self.wipeLen)
-- Cascade black 8x8 blocks across the window area *outside* the classic
-- 160x144 wipe square, in lockstep with the OG wipe progress. Renderer
-- paints them in screen space after the world blit (see endFrame).
local renderer = self.game and self.game.renderer
if renderer then renderer.battleCascadeProg = prog end
local style = self.style
-- a registered style may draw itself; the eight built-ins do not
+11
View File
@@ -148,6 +148,17 @@ function Font.advanceOf(code)
return page and page.advance or GLYPH
end
-- Pixel width of a string (glyph advances, not UTF-8 byte length).
-- Multi-byte charmap entries like "¥" are one glyph; callers that
-- right-align with `#text * 8` mis-place them.
function Font.width(text)
local w = 0
for _, code in ipairs(Font.encode(text)) do
w = w + Font.advanceOf(code)
end
return w
end
-- Draw a plain single-line string at pixel (x, y). Returns the width
-- drawn, which is #codes * 8 for every fixed-width page.
function Font.draw(text, x, y)
+29 -1
View File
@@ -22,6 +22,15 @@ GBCFX.level = 0
local shader -- false = unavailable (headless / no shader support)
-- Mobile GPUs often compile this pass but present a black frame, and the
-- level persists in options.lua -- soft-bricking the APK until a manual
-- edit (issue #136). Desktop is unchanged; Android/iOS refuse the effect.
function GBCFX.isSupported()
if not love or not love.system or not love.system.getOS then return true end
local osName = love.system.getOS()
return osName ~= "Android" and osName ~= "iOS"
end
-- GLSL 1.20-compatible (no array initializers; wavelength terms and the
-- shadow blur are unrolled by hand).
local SHADER_SRC = [[
@@ -214,6 +223,7 @@ vec4 effect(vec4 color, Image tex, vec2 tc, vec2 pc)
GBCFX.SHADER_SRC = SHADER_SRC -- exposed for the standalone compile check
function GBCFX.shader()
if not GBCFX.isSupported() then return nil end
if shader == nil then
local ok, sh = pcall(love.graphics.newShader, SHADER_SRC)
shader = ok and sh or false
@@ -222,6 +232,10 @@ function GBCFX.shader()
end
function GBCFX.setLevel(level)
if not GBCFX.isSupported() then
GBCFX.level = 0
return
end
level = math.floor(tonumber(level) or 0)
if level < 0 then level = 0 end
if level > 4 then level = 4 end
@@ -230,12 +244,26 @@ end
-- Advance OFF → 1 → 2 → 3 → 4 → OFF. Returns the new level.
function GBCFX.cycle()
if not GBCFX.isSupported() then
GBCFX.level = 0
return 0
end
GBCFX.setLevel((GBCFX.level + 1) % 5)
return GBCFX.level
end
-- Apply opts.gbcfx. On unsupported platforms force OFF and clear a
-- persisted non-zero value so boot recovers from a soft-brick. Returns
-- true when opts was sanitized (caller should persist options.lua).
function GBCFX.applyOptions(opts)
if not GBCFX.isSupported() then
local had = opts and (tonumber(opts.gbcfx) or 0) ~= 0
if opts then opts.gbcfx = 0 end
GBCFX.level = 0
return had and true or false
end
GBCFX.setLevel(opts and opts.gbcfx or 0)
return false
end
function GBCFX.levelLabel(level)
@@ -243,7 +271,7 @@ function GBCFX.levelLabel(level)
end
function GBCFX.active()
return GBCFX.level > 0 and GBCFX.shader() ~= nil
return GBCFX.isSupported() and GBCFX.level > 0 and GBCFX.shader() ~= nil
end
-- Draw `canvas` fullscreen through the GBC FX shader into the current

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