mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-15 15:51:17 +02:00
Compare commits
38 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c8f7f396ce | |||
| a94fecfec8 | |||
| 2010ba71a6 | |||
| e90dff6764 | |||
| f4658b89ae | |||
| da63163216 | |||
| fceaa2b54c | |||
| be2ef46ebc | |||
| d8e86e6878 | |||
| 7e57e3174e | |||
| dfeacc36b0 | |||
| 1586aec9f6 | |||
| 35d44efb8b | |||
| 500d8c2c07 | |||
| 09204daa28 | |||
| 01329758ff | |||
| 7b8cb127a8 | |||
| 8e4d5b6807 | |||
| 26e9e1d597 | |||
| f7f32d2786 | |||
| c5192eaea4 | |||
| c2906744ce | |||
| 8e6b5ae73d | |||
| 0b96ac0ab6 | |||
| f6a035947f | |||
| 941181d31c | |||
| 9ceb1a8940 | |||
| 37051a26b5 | |||
| b12a0dac91 | |||
| e4786a78d9 | |||
| bde606f966 | |||
| a722d24759 | |||
| 833388c235 | |||
| 6ff7dd0c38 | |||
| c0f654f8c2 | |||
| e44769a48a | |||
| d38faab03c | |||
| 83682f011d |
@@ -252,8 +252,40 @@ jobs:
|
||||
Remove-Item $env:UWP_PFX -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
# Windows Native AOT TLS dialer. The Mac release runner fuses the win64 zip
|
||||
# from LÖVE's prebuilt binaries and cannot cross-compile this DLL, so build
|
||||
# it here and inject it in the release job before scripts/build.sh win.
|
||||
native-tls-win:
|
||||
name: build Windows gen1tls.dll
|
||||
needs: version
|
||||
runs-on: windows-2022
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- name: Setup .NET 8
|
||||
uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: "8.0.x"
|
||||
- name: Publish gen1tls (win-x64 Native AOT)
|
||||
shell: pwsh
|
||||
run: |
|
||||
$out = "dist/native/win-x64"
|
||||
New-Item -ItemType Directory -Force -Path $out | Out-Null
|
||||
dotnet publish native/tls_dial/Gen1Tls.csproj `
|
||||
-c Release -r win-x64 -o $out
|
||||
if (-not (Test-Path "$out/gen1tls.dll")) {
|
||||
throw "gen1tls.dll missing after publish"
|
||||
}
|
||||
Get-Item "$out/gen1tls.dll" | Format-List Name, Length, LastWriteTime
|
||||
- name: Upload gen1tls.dll
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: gen1tls-win-x64
|
||||
path: dist/native/win-x64/gen1tls.dll
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
release:
|
||||
needs: [version, xbox-uwp, linux-arm64]
|
||||
needs: [version, xbox-uwp, linux-arm64, native-tls-win]
|
||||
runs-on: ${{ fromJSON(github.repository == 'bryanthaboi/gen1recomp' && '["self-hosted", "macOS"]' || '"macos-latest"') }}
|
||||
|
||||
steps:
|
||||
@@ -269,6 +301,12 @@ jobs:
|
||||
fetch-depth: 0
|
||||
fetch-tags: true
|
||||
|
||||
- name: Download Windows gen1tls dialer
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: gen1tls-win-x64
|
||||
path: dist/native/win-x64
|
||||
|
||||
- name: Import signing certificate into a temporary keychain
|
||||
if: github.repository == 'bryanthaboi/gen1recomp'
|
||||
run: |
|
||||
@@ -307,13 +345,21 @@ jobs:
|
||||
security find-identity -v -p codesigning "$KEYCHAIN_PATH"
|
||||
|
||||
- name: Build macOS + Windows + Linux
|
||||
env:
|
||||
GEN1TLS_DLL: ${{ github.workspace }}/dist/native/win-x64/gen1tls.dll
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Sign in-build (identity auto-detected from the temp keychain);
|
||||
# notarize separately below so it uses secret credentials, not a
|
||||
# login-keychain profile. "all" also builds the Linux AppImage,
|
||||
# which needs no signing/notarization.
|
||||
if [ ! -f "$GEN1TLS_DLL" ]; then
|
||||
echo "::error::gen1tls.dll missing at $GEN1TLS_DLL (native-tls-win job)"
|
||||
exit 1
|
||||
fi
|
||||
scripts/build.sh all --version "${{ needs.version.outputs.version }}" --no-notarize
|
||||
unzip -l dist/win/gen1recomp-win64.zip | grep -F gen1tls.dll \
|
||||
|| { echo "::error::Windows zip is missing gen1tls.dll"; exit 1; }
|
||||
|
||||
- name: Build Android
|
||||
run: |
|
||||
|
||||
+5
-1
@@ -3,8 +3,9 @@
|
||||
data/generated/
|
||||
assets/generated/
|
||||
|
||||
# LÖVE packages
|
||||
# LÖVE packages & archives
|
||||
*.love
|
||||
*.zip
|
||||
|
||||
# Local saves (LÖVE writes to its save dir, but keep the repo clean anyway)
|
||||
save/
|
||||
@@ -75,3 +76,6 @@ mobile/ios/bundle_id.local
|
||||
/dist/native/
|
||||
/dist/win/
|
||||
/.bazinga/
|
||||
|
||||
# Local options / preferences
|
||||
/options.lua*
|
||||
|
||||
+45
-3
@@ -242,10 +242,52 @@ with a `MK4xx` finding per site and an `unresolved:` note, with a file and a
|
||||
line, for every reach a static scan could not follow. Neither substitutes for a
|
||||
real Gold boot.
|
||||
|
||||
### 5. `mod.card`
|
||||
### 5. What a mod's code can reach
|
||||
|
||||
Your code runs in a sandbox (`src/mods/Sandbox.lua`), not against the
|
||||
engine's globals. Every chunk you author gets it: `main.lua`, your
|
||||
`options_schema`, and anything you `load()` yourself. What is absent:
|
||||
|
||||
| Absent | Use instead |
|
||||
| --- | --- |
|
||||
| `io`, and `require("io")` | `mod:read` for your own files, `mod.storage` to persist |
|
||||
| `os.getenv`, `os.execute`, `os.remove`, `os.rename`, `os.exit` | nothing; `os.time`/`os.date`/`os.clock` still work |
|
||||
| `package`, `dofile`, `loadfile`, `debug`, `getfenv`, `setfenv` | `require` for the supported engine modules |
|
||||
| `require("ffi")`, `require("love.*")` | the `love` table you are given |
|
||||
| `love.filesystem` | `mod.storage` (per-mod, per-playthrough) and `mod:read` |
|
||||
| `love.thread`, `love.event` | `mod.events`, `mod.hooks` |
|
||||
| `love.system` | `mod.device:powerInfo()` for battery information; `mod.steps` (with the `steps` permission) for the step bridge |
|
||||
|
||||
The rest of `love` passes through unchanged, so graphics, audio, timers and
|
||||
input work as they always have.
|
||||
|
||||
Three consequences worth knowing before you write against it:
|
||||
|
||||
- **Your globals are yours.** `_G` inside a mod is that mod's own table. Two
|
||||
mods no longer share a namespace, and neither can reach the engine's. To
|
||||
publish something to another mod, put it on `mod.exports` and let them
|
||||
`mod.find("your_id").exports` — the channel that was always the intended
|
||||
one. The same goes for the standard library: `string`, `table` and `math`
|
||||
are per-mod copies, so patching one is a local decision.
|
||||
- **Paths cannot climb.** `mod:read`, `mod.assets:path` and `mod.assets:image`
|
||||
join to your own directory, and `..`, absolute paths and drive letters are
|
||||
refused. So are `entry` and `options_schema` in your manifest.
|
||||
- **Ship source, not bytecode.** A precompiled entry file is refused.
|
||||
|
||||
`permissions` in the manifest is still a disclosure the manager shows the
|
||||
player, and `network` now gates `require("socket")` and friends. There is no
|
||||
permission that grants raw filesystem access, because no mod needs one:
|
||||
everything a mod legitimately writes is already scoped by
|
||||
`mod.storage` or the asset-transform derived root.
|
||||
|
||||
If your mod used one of the absent globals, the fix is almost always
|
||||
`mod.storage`. Open an issue if you have a case it does not cover.
|
||||
|
||||
### 6. `mod.card`
|
||||
|
||||
The manifest is the *engine's* contract: identity, load order, dependencies,
|
||||
permissions, profile. The card is the *human-facing* one: who made this,
|
||||
permissions, profile (see [Manifest specification](docs/modding.md#manifest-specification-manifestjson)).
|
||||
The card is the *human-facing* one: who made this,
|
||||
what it changes, what it does not do yet. It is never read by the loader's
|
||||
merge — only by tooling and the manager's detail pane — so an absent or
|
||||
malformed card can never break a load.
|
||||
@@ -263,7 +305,7 @@ Two fields deserve their own note:
|
||||
distributed mod never carries ROM-derived bytes, not even in its preview
|
||||
images.
|
||||
|
||||
### 6. Tags
|
||||
### 7. Tags
|
||||
|
||||
Lowercase kebab strings, open vocabulary. The showcase generator
|
||||
lowercases and de-dupes. A recommended starting set: `beginner`,
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 243 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 217 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 236 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 242 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 166 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 251 KiB |
@@ -92,7 +92,7 @@ return {
|
||||
{ "set_flag", "EVENT_GOT_EEVEE" }, -- 7
|
||||
{ "hide_object", "CELADON_MANSION_ROOF_HOUSE",
|
||||
"CELADONMANSION_ROOF_HOUSE_EEVEE_POKEBALL" }, -- 8
|
||||
{ "play_sound", "Get_Item1" }, -- 9 (GotMonText jingle)
|
||||
{ "text_sound", "Get_Item1" }, -- 9 (GotMonText jingle)
|
||||
{ "show_text", "_GotMonText", { RAM = "EEVEE" } }, -- 10
|
||||
{ "jump", 13 }, -- 11
|
||||
{ "show_text", "_BoxIsFullText" }, -- 12
|
||||
|
||||
@@ -10,10 +10,10 @@
|
||||
local function coinGiver(opts)
|
||||
return function(game, ow, npc, done)
|
||||
local TextBox = require("src.render.TextBox")
|
||||
local Sound = require("src.core.Sound")
|
||||
local t = game.data.text
|
||||
local function push(label, fallback, onDone)
|
||||
game.stack:push(TextBox.new(game, t[label] or fallback, onDone or done))
|
||||
local function push(label, fallback, onDone, popts)
|
||||
game.stack:push(TextBox.new(game, t[label] or fallback, onDone or done,
|
||||
popts))
|
||||
end
|
||||
if game.save.flags[opts.event] then
|
||||
push(opts.alreadyGotLabel, opts.alreadyGotFallback)
|
||||
@@ -30,9 +30,10 @@ local function coinGiver(opts)
|
||||
end
|
||||
game.save.coins = math.min(9999, (game.save.coins or 0) + opts.amount)
|
||||
game.save.flags[opts.event] = true
|
||||
Sound.play(game.data, "Get_Item1")
|
||||
-- the ReceivedNCoinsText strings carry sound_get_item_1
|
||||
push(opts.receivedLabel,
|
||||
("{PLAYER} received\n%d coins!"):format(opts.amount))
|
||||
("{PLAYER} received\n%d coins!"):format(opts.amount), nil,
|
||||
TextBox.soundOpts(game, "Get_Item1"))
|
||||
end)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -22,8 +22,8 @@ return {
|
||||
local TextBox = require("src.render.TextBox")
|
||||
local Commands = require("src.script.Commands")
|
||||
local t = game.data.text
|
||||
local function say(label, cb)
|
||||
game.stack:push(TextBox.new(game, t[label] or label, cb))
|
||||
local function say(label, cb, sopts)
|
||||
game.stack:push(TextBox.new(game, t[label] or label, cb, sopts))
|
||||
end
|
||||
|
||||
if game.save.flags.EVENT_GOT_OLD_AMBER then
|
||||
@@ -39,8 +39,9 @@ return {
|
||||
game.save.flags.EVENT_GOT_OLD_AMBER = true
|
||||
Commands.hide_object({ save = game.save, overworld = ow, game = game },
|
||||
"MUSEUM_1F", "MUSEUM1F_OLD_AMBER")
|
||||
require("src.core.Sound").play(game.data, "Get_Item1")
|
||||
say("_Museum1FScientist2ReceivedOldAmberText", done)
|
||||
-- .ReceivedOldAmberText carries sound_get_item_1
|
||||
say("_Museum1FScientist2ReceivedOldAmberText", done,
|
||||
TextBox.soundOpts(game, "Get_Item1"))
|
||||
end)
|
||||
end,
|
||||
|
||||
|
||||
+19
-19
@@ -38,9 +38,9 @@ local function starterBall(askText, species, choseFlag, ownBall,
|
||||
-- inside give_pokemon). Show the received text first so the
|
||||
-- nickname prompt follows "you got X", matching Gen1.
|
||||
-- The received text carries sound_get_key_item (OaksLab.asm
|
||||
-- OaksLabReceivedMonText); the jingle plays as the box opens
|
||||
-- (same beat as the Yellow port's starter, #668).
|
||||
{ "play_sound", "Get_Key_Item" }, -- 8
|
||||
-- OaksLabReceivedMonText), so the jingle fires once the box has
|
||||
-- typed out and holds it (same beat as the Yellow port's starter, #668).
|
||||
{ "text_sound", "Get_Key_Item" }, -- 8
|
||||
{ "show_text", "_OaksLabReceivedMonText", { RAM = species } }, -- 9
|
||||
{ "give_pokemon", species, 5 }, -- 10
|
||||
{ "set_flag", "EVENT_GOT_STARTER" }, -- 11
|
||||
@@ -54,7 +54,7 @@ local function starterBall(askText, species, choseFlag, ownBall,
|
||||
{ "face_object", 1, "up" }, -- 15
|
||||
{ "show_text", "_OaksLabRivalIllTakeThisOneText" }, -- 16
|
||||
{ "hide_object", "OAKS_LAB", rivalBall }, -- 17
|
||||
{ "play_sound", "Get_Key_Item" }, -- 18 (sound_get_key_item)
|
||||
{ "text_sound", "Get_Key_Item" }, -- 18 (sound_get_key_item)
|
||||
{ "show_text", "_OaksLabRivalReceivedMonText",
|
||||
{ RAM = rivalBall == "OAKSLAB_CHARMANDER_POKE_BALL" and "CHARMANDER"
|
||||
or rivalBall == "OAKSLAB_SQUIRTLE_POKE_BALL" and "SQUIRTLE"
|
||||
@@ -106,8 +106,8 @@ return {
|
||||
{ "check_item", "OAKS_PARCEL" },
|
||||
{ "jump_if_false", "raise_young" },
|
||||
-- OaksLabOak1Text.got_parcel → RivalArrives + OakGivesPokedex
|
||||
{ "text_sound", "Get_Key_Item" },
|
||||
{ "show_text", "_OaksLabOak1DeliverParcelText" },
|
||||
{ "play_sound", "Get_Key_Item" },
|
||||
{ "show_text", "_OaksLabOak1ParcelThanksText" },
|
||||
{ "take_item", "OAKS_PARCEL", 1 },
|
||||
{ "stop_music" },
|
||||
@@ -128,8 +128,8 @@ return {
|
||||
{ "face_object", 1, "up" },
|
||||
{ "face_object", 5, "down" },
|
||||
{ "show_text", "_OaksLabOakMyInventionPokedexText" },
|
||||
{ "text_sound", "Get_Key_Item" },
|
||||
{ "show_text", "_OaksLabOakGotPokedexText" },
|
||||
{ "play_sound", "Get_Key_Item" },
|
||||
{ "hide_object", "OAKS_LAB", "OAKSLAB_POKEDEX1" },
|
||||
{ "hide_object", "OAKS_LAB", "OAKSLAB_POKEDEX2" },
|
||||
{ "face_object", 1, "up" },
|
||||
@@ -246,9 +246,17 @@ return {
|
||||
-- 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
|
||||
local flags = game.save.flags or {}
|
||||
if flags.EVENT_GOT_STARTER and not flags.EVENT_BATTLED_RIVAL_IN_OAKS_LAB then
|
||||
local rival = ow:npcByIndex(1)
|
||||
if rival then
|
||||
rival.cellX = flags.EVENT_CHOSE_CHARMANDER and 7
|
||||
or flags.EVENT_CHOSE_SQUIRTLE and 8 or 6
|
||||
rival.cellY = 4
|
||||
rival.px, rival.py = rival.cellX * 16, rival.cellY * 16
|
||||
end
|
||||
end
|
||||
if not 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")
|
||||
@@ -286,6 +294,7 @@ return {
|
||||
-- fanfare for the taunt/challenge exchange, same as the Yellow port
|
||||
-- (oaks_lab_yellow.lua); it was silently dropped here (#596).
|
||||
local rows = {
|
||||
{ "face_player_dir", "up" },
|
||||
{ "stop_music" },
|
||||
{ "play_music", "Music_MeetRival" },
|
||||
{ "show_text", "_OaksLabRivalIllTakeYouOnText" }, -- 1
|
||||
@@ -309,28 +318,19 @@ return {
|
||||
local base = #rows
|
||||
local party = flags.EVENT_CHOSE_BULBASAUR and 3
|
||||
or flags.EVENT_CHOSE_SQUIRTLE and 2 or 1
|
||||
table.insert(rows, { "save_end_battle_text", "_OaksLabRivalIPickedTheWrongPokemonText" })
|
||||
table.insert(rows, { "start_battle", "trainer", "OPP_RIVAL1", party })
|
||||
-- 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" })
|
||||
-- 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" })
|
||||
-- OaksLabRivalStartsExitScript: parting shot, rival exit fanfare, then
|
||||
-- walk out past the player. The fanfare was dropped here (#683) -- the
|
||||
-- parcel scene above already plays Music_MeetRival on both arrival and
|
||||
-- departure (lines 144-146), and this exit should match (#596).
|
||||
table.insert(rows, { "stop_music" })
|
||||
table.insert(rows, { "play_music", "Music_MeetRival" })
|
||||
table.insert(rows, { "play_music", "Music_MeetRival", { start = "rival" } })
|
||||
table.insert(rows, { "move_npc_to", 1, 4, 11 })
|
||||
table.insert(rows, { "hide_object", "OAKS_LAB", "OAKSLAB_RIVAL" })
|
||||
table.insert(rows, { "play_music", "Music_OaksLab" })
|
||||
|
||||
@@ -38,8 +38,8 @@ return {
|
||||
{ "jump_if_false", "raise_young" },
|
||||
-- .DeliverParcelText: parcel handover, then the Pokédex scene
|
||||
-- (OaksLabRivalArrivesAtOaksRequestScript -> OakGivesPokedexScript)
|
||||
{ "text_sound", "Get_Key_Item" },
|
||||
{ "show_text", "_OaksLabOak1DeliverParcelText" },
|
||||
{ "play_sound", "Get_Key_Item" },
|
||||
{ "show_text", "_OaksLabOak1ParcelThanksText" },
|
||||
{ "take_item", "OAKS_PARCEL", 1 },
|
||||
{ "stop_music" },
|
||||
@@ -62,8 +62,8 @@ return {
|
||||
{ "face_object", RIVAL, "up" },
|
||||
{ "face_object", OAK1, "down" },
|
||||
{ "show_text", "_OaksLabOakMyInventionPokedexText" },
|
||||
{ "text_sound", "Get_Key_Item" },
|
||||
{ "show_text", "_OaksLabOakGotPokedexText" },
|
||||
{ "play_sound", "Get_Key_Item" },
|
||||
{ "hide_object", "OAKS_LAB", "OAKSLAB_POKEDEX1" },
|
||||
{ "hide_object", "OAKS_LAB", "OAKSLAB_POKEDEX2" },
|
||||
{ "face_object", RIVAL, "up" },
|
||||
@@ -113,8 +113,8 @@ return {
|
||||
{ "jump_if_true", "come_see" },
|
||||
{ "set_flag", "EVENT_GOT_POKEBALLS_FROM_OAK" },
|
||||
{ "give_item", "POKE_BALL", 5, false },
|
||||
{ "text_sound", "Get_Key_Item" },
|
||||
{ "show_text", "_OaksLabOak1ReceivedPokeballsText" },
|
||||
{ "play_sound", "Get_Key_Item" },
|
||||
{ "show_text", "_OaksLabGivePokeballsExplanationText" },
|
||||
{ "jump", "end" },
|
||||
|
||||
@@ -176,7 +176,7 @@ return {
|
||||
-- rival starter baseline (RIVAL_STARTER_JOLTEON) at snatch time
|
||||
rows[#rows + 1] = { "set_field", "rivalStarter", 1 }
|
||||
rows[#rows + 1] = { "show_text", "_OaksLabRivalTakesText1" }
|
||||
rows[#rows + 1] = { "play_sound", "Get_Key_Item" }
|
||||
rows[#rows + 1] = { "text_sound", "Get_Key_Item" }
|
||||
rows[#rows + 1] = { "show_text", "_OaksLabRivalTakesText2" }
|
||||
rows[#rows + 1] = { "show_text", "_OaksLabRivalTakesText3" }
|
||||
rows[#rows + 1] = { "show_text", "_OaksLabRivalTakesText4" }
|
||||
@@ -196,7 +196,7 @@ return {
|
||||
rows[#rows + 1] = { "face_object", OAK1, "down" }
|
||||
-- OaksLabPlayerReceivedMonText clears wMonDataLocation, so AskName runs (#1013)
|
||||
rows[#rows + 1] = { "show_text", "_OaksLabOakGivesText" }
|
||||
rows[#rows + 1] = { "play_sound", "Get_Key_Item" }
|
||||
rows[#rows + 1] = { "text_sound", "Get_Key_Item" }
|
||||
rows[#rows + 1] = { "show_text", "_OaksLabReceivedText", { RAM = "PIKACHU" } }
|
||||
rows[#rows + 1] = { "give_pokemon", "PIKACHU", 5 }
|
||||
-- DisablePikachuOverworldSpriteDrawing keeps it in the ball (#1009)
|
||||
@@ -226,9 +226,15 @@ return {
|
||||
},
|
||||
|
||||
onEnter = function(game, ow)
|
||||
if not (game.save.flags and game.save.flags.EVENT_GOT_POKEDEX) then
|
||||
return
|
||||
local flags = game.save.flags or {}
|
||||
if flags.EVENT_GOT_STARTER and not flags.EVENT_BATTLED_RIVAL_IN_OAKS_LAB then
|
||||
local rival = ow:npcByIndex(RIVAL)
|
||||
if rival then
|
||||
rival.cellX, rival.cellY = 7, 4
|
||||
rival.px, rival.py = 7 * 16, 4 * 16
|
||||
end
|
||||
end
|
||||
if not 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")
|
||||
@@ -296,13 +302,14 @@ return {
|
||||
table.insert(rows, { "wait", 20 })
|
||||
table.insert(rows, { "show_text", "_OaksLabRivalSmellYouLaterText" })
|
||||
table.insert(rows, { "stop_music" })
|
||||
table.insert(rows, { "play_music", "Music_MeetRival" })
|
||||
table.insert(rows, { "play_music", "Music_MeetRival", { start = "rival" } })
|
||||
table.insert(rows, { "move_npc_to", RIVAL, 4, 11 })
|
||||
table.insert(rows, { "hide_object", "OAKS_LAB", "OAKSLAB_RIVAL" })
|
||||
table.insert(rows, { "play_music", "Music_OaksLab" })
|
||||
-- OaksLabPikachuEscapesPokeballScript: the follower reaches the map (#1009)
|
||||
table.insert(rows, { "face_player_dir", "up" })
|
||||
table.insert(rows, { "set_field", "pikachuInBall", false })
|
||||
table.insert(rows, { "spawn_pikachu_follower" })
|
||||
table.insert(rows, { "play_cry", "PIKACHU" })
|
||||
table.insert(rows, { "show_text", "_OaksLabPikachuDislikesPokeballsText1" })
|
||||
table.insert(rows, { "show_text", "_OaksLabPikachuDislikesPokeballsText2" })
|
||||
|
||||
+21
-19
@@ -142,20 +142,23 @@ M.VIRIDIAN_CITY = {
|
||||
M.BLUES_HOUSE = {
|
||||
talk = {
|
||||
TEXT_BLUESHOUSE_DAISY_SITTING = {
|
||||
{ "face_player" }, -- 1
|
||||
{ "check_flag", "EVENT_GOT_TOWN_MAP" }, -- 2
|
||||
{ "jump_if_true", 10 }, -- 3
|
||||
{ "check_flag", "EVENT_GOT_STARTER" }, -- 4
|
||||
{ "jump_if_false", 12 }, -- 5
|
||||
{ "show_text", "_BluesHouseDaisyOfferMapText" }, -- 6
|
||||
{ "face_player" },
|
||||
{ "check_flag", "EVENT_GOT_TOWN_MAP" },
|
||||
{ "jump_if_true", "got_map" },
|
||||
{ "check_flag", "EVENT_GOT_POKEDEX" },
|
||||
{ "jump_if_false", "too_early" },
|
||||
{ "show_text", "_BluesHouseDaisyOfferMapText" },
|
||||
-- _GotMapText: "{PLAYER} got a\n{RAM:wStringBuffer}!" -- the
|
||||
-- buffer supplies "TOWN MAP" (scripts/BluesHouse.asm GotMapText)
|
||||
{ "give_item", "TOWN_MAP", 1, "_GotMapText" }, -- 7
|
||||
{ "set_flag", "EVENT_GOT_TOWN_MAP" }, -- 8
|
||||
{ "jump", 13 }, -- 9
|
||||
{ "show_text", "_BluesHouseDaisyUseMapText" }, -- 10
|
||||
{ "jump", 13 }, -- 11
|
||||
{ "show_text", "_BluesHouseDaisyRivalAtLabText" }, -- 12
|
||||
{ "give_item", "TOWN_MAP", 1, "_GotMapText" },
|
||||
{ "hide_object", "BLUES_HOUSE", "BLUESHOUSE_TOWN_MAP" },
|
||||
{ "set_flag", "EVENT_GOT_TOWN_MAP" },
|
||||
{ "jump", "end" },
|
||||
{ "label", "got_map" },
|
||||
{ "show_text", "_BluesHouseDaisyUseMapText" },
|
||||
{ "jump", "end" },
|
||||
{ "label", "too_early" },
|
||||
{ "show_text", "_BluesHouseDaisyRivalAtLabText" },
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -294,6 +297,7 @@ M.BILLS_HOUSE = {
|
||||
|
||||
M.ROUTE_25 = {
|
||||
onEnter = function(game, ow)
|
||||
game.save.pikachuMapScriptActive = nil
|
||||
local flags = game.save.flags
|
||||
if flags.EVENT_LEFT_BILLS_HOUSE_AFTER_HELPING then return end
|
||||
local Commands = require("src.script.Commands")
|
||||
@@ -326,6 +330,7 @@ M.VERMILION_CITY = {
|
||||
-- only read while EVENT_1ST_LOCK_OPENED is unset (the gym is only
|
||||
-- reachable through this map, so a fresh visit always re-rolls).
|
||||
onEnter = function(game, ow)
|
||||
game.save.pikachuMapScriptActive = nil
|
||||
local puz = game.save.trashPuzzle or {}
|
||||
game.save.trashPuzzle = puz
|
||||
puz.first = love.math.random(0, 7) * 2
|
||||
@@ -1058,6 +1063,7 @@ local championsRoomRivalScript = {
|
||||
-- OakCongratulatesPlayerScript: rival faces left, Oak faces down
|
||||
{ "face_object", 1, "left" }, -- 17
|
||||
{ "face_object", 2, "down" }, -- 18
|
||||
{ "load_player_starter_name" },
|
||||
{ "show_text", "_ChampionsRoomOakCongratulatesPlayerText" }, -- 19
|
||||
-- OakDisappointedWithRivalScript: Oak turns to the rival (right)
|
||||
{ "face_object", 2, "right" }, -- 20
|
||||
@@ -1067,13 +1073,8 @@ local championsRoomRivalScript = {
|
||||
{ "show_text", "_ChampionsRoomOakComeWithMeText" }, -- 23
|
||||
{ "move_npc", 2, "up", 2 }, -- 24 OakExitChampionsRoomMovement
|
||||
{ "hide_object", "CHAMPIONS_ROOM", "CHAMPIONSROOM_OAK" }, -- 25
|
||||
-- ChampionsRoomPlayerFollowsOakScript / WalkToHallOfFame_RLEMovement.
|
||||
-- The player walks out after Oak instead of the screen just fading on the
|
||||
-- spot (#704). Route one tile right before walking north so the player
|
||||
-- reaches the north-wall HALL_OF_FAME warp without sharing the rival's
|
||||
-- (4,2) cell. The original simulated movement bypasses entity collision,
|
||||
-- but this scene should not visibly walk through the defeated rival.
|
||||
{ "move_player", "right", 1 }, -- 26
|
||||
-- scripts/ChampionsRoom.asm WalkToHallOfFame_RLEMovement
|
||||
{ "move_player", "left", 1 },
|
||||
{ "move_player", "up", 3 }, -- 27
|
||||
-- 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)
|
||||
@@ -1238,6 +1239,7 @@ local function pokemonTower2FRivalScript(playerX)
|
||||
{ "jump_if_false", "end" }, -- 6 loss: stay
|
||||
{ "set_flag", "EVENT_BEAT_POKEMON_TOWER_RIVAL" }, -- 7
|
||||
{ "show_text", "_PokemonTower2FRivalDefeatedText" }, -- 8
|
||||
{ "play_music", "Music_MeetRival", { start = "rival" } },
|
||||
{ "walk_npc", 1, exitDirs }, -- 9
|
||||
{ "hide_object", "POKEMON_TOWER_2F", "POKEMONTOWER2F_RIVAL" }, -- 10
|
||||
{ "jump", "end" }, -- 11
|
||||
|
||||
@@ -413,6 +413,9 @@ M.ROUTE_8_GATE = saffronGate("TEXT_ROUTE8GATE_GUARD", { { 2, 3 }, { 2, 4 } }, tr
|
||||
-- -------------------------------------------------------------------
|
||||
|
||||
M.POKEMON_FAN_CLUB = {
|
||||
onEnter = function(game, ow)
|
||||
require("src.world.PikachuFollower").onFanClubEntered(game, ow)
|
||||
end,
|
||||
talk = {
|
||||
TEXT_POKEMONFANCLUB_CHAIRMAN = {
|
||||
{ "face_player" }, -- 1
|
||||
@@ -645,8 +648,10 @@ local function mtMoonFossil(itemId, otherName, gotFlag)
|
||||
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)
|
||||
-- MtMoonB2FReceivedFossilText: text_far, sound_get_key_item,
|
||||
-- text_waitbutton -- the jingle plays after the box has typed and
|
||||
-- the button wait comes after it
|
||||
game.stack:push(TextBox.new(game,
|
||||
t._MtMoonB2FReceivedFossilText
|
||||
or ("{PLAYER} got the\n" .. game.stringBuffer .. "!"),
|
||||
@@ -659,11 +664,11 @@ local function mtMoonFossil(itemId, otherName, gotFlag)
|
||||
ow.runner:run({
|
||||
{ "walk_npc", 1, dirs },
|
||||
{ "text_opts", { auto = true } },
|
||||
{ "text_sound", "Get_Key_Item" },
|
||||
{ "show_text", "_MtMoonB2FSuperNerdThenThisIsMineText" },
|
||||
{ "play_sound", "Get_Key_Item" },
|
||||
{ "hide_object", "MT_MOON_B2F", otherName },
|
||||
}, { onDone = done })
|
||||
end))
|
||||
end, TextBox.soundOpts(game, "Get_Key_Item")))
|
||||
end }))
|
||||
end
|
||||
end
|
||||
|
||||
+22
-21
@@ -8,9 +8,9 @@ local M = {}
|
||||
|
||||
local function text(game) return game.data.text end
|
||||
|
||||
local function push(game, s, done)
|
||||
local function push(game, s, done, opts)
|
||||
local TextBox = require("src.render.TextBox")
|
||||
game.stack:push(TextBox.new(game, s, done))
|
||||
game.stack:push(TextBox.new(game, s, done, opts))
|
||||
end
|
||||
|
||||
-- The question stays on screen under the YES/NO menu. The dojo prize
|
||||
@@ -142,7 +142,7 @@ M.MT_MOON_POKECENTER = {
|
||||
local Commands = require("src.script.Commands")
|
||||
Commands.give_pokemon({ save = game.save, game = game, overworld = ow },
|
||||
"MAGIKARP", 5)
|
||||
push(game, ("%s got a\nMAGIKARP!"):format(game.save.player.name), done)
|
||||
push(game, t._GotMonText or "{PLAYER} got\n{RAM:wNameBuffer}!", done)
|
||||
end)
|
||||
end,
|
||||
},
|
||||
@@ -263,7 +263,7 @@ M.SILPH_CO_7F = {
|
||||
{ "jump_if_false", "box_full" },
|
||||
-- flag ahead of the jingle, like the Celadon EEVEE (#426)
|
||||
{ "set_flag", "EVENT_GOT_LAPRAS" },
|
||||
{ "play_sound", "Get_Item1" },
|
||||
{ "text_sound", "Get_Item1" },
|
||||
{ "show_text", "_GotMonText", { RAM = "LAPRAS" } },
|
||||
{ "show_text", "_SilphCo7FSilphWorkerM1LaprasDescriptionText" },
|
||||
{ "jump", "end" },
|
||||
@@ -307,12 +307,11 @@ M.COPYCATS_HOUSE_2F = {
|
||||
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, t._CopycatsHouse2FCopycatReceivedTM31Text, function()
|
||||
push(game, t._CopycatsHouse2FCopycatTM31Explanation1Text, done)
|
||||
end)
|
||||
end, require("src.render.TextBox").soundOpts(game, "Get_Item1"))
|
||||
end)
|
||||
end)
|
||||
end,
|
||||
@@ -478,7 +477,6 @@ M.CELADON_MART_ROOF = {
|
||||
return
|
||||
end
|
||||
game.save.flags[g.flag] = true
|
||||
require("src.core.Sound").play(game.data, "Get_Item1")
|
||||
local subs = { player = game.save.player.name,
|
||||
ram = game.data.items[g.tm].name }
|
||||
local explain = fill(t[g.explain] or "", subs)
|
||||
@@ -490,7 +488,7 @@ M.CELADON_MART_ROOF = {
|
||||
else
|
||||
done()
|
||||
end
|
||||
end)
|
||||
end, require("src.render.TextBox").soundOpts(game, "Get_Item1"))
|
||||
end)
|
||||
end,
|
||||
onCancel = done,
|
||||
@@ -512,25 +510,28 @@ M.ROUTE_24 = {
|
||||
local flags = game.save.flags
|
||||
local function battleOrDone()
|
||||
if ow:trainerDefeated(npc) then
|
||||
push(game, "I hate this!\nMy dreams of\nTEAM ROCKET...", done)
|
||||
push(game, text(game)._Route24CooltrainerM1YouCouldBecomeATopLeaderText,
|
||||
done)
|
||||
else
|
||||
ow:engageTrainer(npc, done)
|
||||
end
|
||||
end
|
||||
if not flags.EVENT_GOT_NUGGET then
|
||||
push(game, "Congratulations!\nYou beat our 5\ncontest trainers!\f"
|
||||
.. "You just earned a\nfabulous prize!", function()
|
||||
local t = text(game)
|
||||
push(game, t._Route24CooltrainerM1YouBeatOurContestText .. "\f"
|
||||
.. t._Route24CooltrainerM1YouJustEarnedAPrizeText, function()
|
||||
if not require("src.inventory.Bag").add(game.save, "NUGGET", 1,
|
||||
game.data) then
|
||||
push(game, t._Route24CooltrainerM1NoRoomText, done)
|
||||
return
|
||||
end
|
||||
flags.EVENT_GOT_NUGGET = true
|
||||
require("src.inventory.Bag").add(game.save, "NUGGET", 1)
|
||||
push(game, ("%s received\na NUGGET!"):format(game.save.player.name),
|
||||
function()
|
||||
ask(game, "By the way, would\nyou like to join\nTEAM ROCKET?",
|
||||
function()
|
||||
push(game, "Arrgh! You are\nnot convinced?\fThen I'll show\n"
|
||||
.. "you my power!", battleOrDone)
|
||||
end)
|
||||
end)
|
||||
end)
|
||||
game.stringBuffer = game.data.items.NUGGET.name
|
||||
push(game, t._Route24CooltrainerM1ReceivedNuggetText, function()
|
||||
push(game, t._Route24CooltrainerM1JoinTeamRocketText,
|
||||
battleOrDone)
|
||||
end, require("src.render.TextBox").soundOpts(game, "Get_Item1"))
|
||||
end, require("src.render.TextBox").soundOpts(game, "Get_Item1"))
|
||||
return
|
||||
end
|
||||
battleOrDone()
|
||||
|
||||
+30
-17
@@ -4,9 +4,9 @@ local M = {}
|
||||
|
||||
local function text(game) return game.data.text end
|
||||
|
||||
local function push(game, s, done)
|
||||
local function push(game, s, done, opts)
|
||||
local TextBox = require("src.render.TextBox")
|
||||
game.stack:push(TextBox.new(game, s, done))
|
||||
game.stack:push(TextBox.new(game, s, done, opts))
|
||||
end
|
||||
|
||||
-- fill the extracted text placeholders ({RAM:...}, {PLAYER})
|
||||
@@ -25,8 +25,8 @@ local function gift(opts)
|
||||
local t = text(game)
|
||||
local itemName = game.data.items[opts.item].name
|
||||
local subs = { ram = itemName, player = game.save.player.name }
|
||||
local function say(label, fallback, cb)
|
||||
push(game, fill(t[label] or fallback, subs), cb)
|
||||
local function say(label, fallback, cb, sopts)
|
||||
push(game, fill(t[label] or fallback, subs), cb, sopts)
|
||||
end
|
||||
if game.save.flags[opts.flag] then
|
||||
say(opts.already or opts.explain, "It's a useful\nitem, isn't it?", done)
|
||||
@@ -39,15 +39,16 @@ local function gift(opts)
|
||||
end
|
||||
game.save.flags[opts.flag] = true
|
||||
local idef = game.data.items[opts.item]
|
||||
require("src.core.Sound").play(game.data,
|
||||
(idef and idef.keyItem) and "Get_Key_Item" or "Get_Item1")
|
||||
-- the received texts carry sound_get_item_1 / sound_get_key_item, so
|
||||
-- the jingle only fires once that box has typed out
|
||||
say(opts.received, "{PLAYER} received\n{RAM:}!", function()
|
||||
if opts.explain then
|
||||
say(opts.explain, "", done)
|
||||
else
|
||||
done()
|
||||
end
|
||||
end)
|
||||
end, require("src.render.TextBox").soundOpts(game,
|
||||
(idef and idef.keyItem) and "Get_Key_Item" or "Get_Item1"))
|
||||
end
|
||||
if opts.pre then say(opts.pre, opts.preFallback or "", give) else give() end
|
||||
end
|
||||
@@ -508,12 +509,12 @@ M.PEWTER_CITY = {
|
||||
-- Rival ambush: show the hidden rival, walk him up to the player, run
|
||||
-- the battle rows, march him back and hide him. On a loss the walk is
|
||||
-- skipped (the blackout rebuilds the map mid-script).
|
||||
local function runAmbush(game, ow, rows, playerFacing)
|
||||
local function runAmbush(game, ow, rows, playerFacing, musicOpts)
|
||||
if ow.runner:isRunning() then return false end
|
||||
ow.player.facing = playerFacing
|
||||
-- the rival encounter sting (MUSIC_MEET_RIVAL); the battle music
|
||||
-- takes over and the map theme returns after the victory jingle
|
||||
require("src.core.Music").play(game.data, "Music_MeetRival")
|
||||
require("src.core.Music").play(game.data, "Music_MeetRival", nil, musicOpts)
|
||||
ow.runner:run(rows)
|
||||
return true
|
||||
end
|
||||
@@ -567,10 +568,12 @@ local function route22Scene(n, objIndex, objName, oppClass, baseParty, beatFlag,
|
||||
{ "face_object", objIndex, rivalFacing }, -- 3
|
||||
{ "show_text", "_Route22RivalBeforeBattleText" .. n }, -- 4
|
||||
{ "rival_battle", oppClass, baseParty }, -- 5
|
||||
{ "jump_if_false", 11 }, -- 6
|
||||
{ "jump_if_false", 12 }, -- 6
|
||||
{ "set_flag", beatFlag }, -- 7
|
||||
{ "show_text", "_Route22Rival" .. n .. "DefeatedText" }, -- 8
|
||||
{ "show_text", "_Route22RivalAfterBattleText" .. n }, -- 9
|
||||
{ "play_music", "Music_MeetRival", { start = "rival",
|
||||
tempo = n == 2 and 100 or nil } },
|
||||
{ "walk_npc", objIndex, route22ExitDirs(n, py) }, -- 10
|
||||
{ "hide_object", "ROUTE_22", objName }, -- 11
|
||||
}
|
||||
@@ -594,7 +597,8 @@ M.ROUTE_22 = {
|
||||
if f.EVENT_BEAT_GIOVANNI and not f.EVENT_BEAT_ROUTE22_RIVAL_2ND_BATTLE then
|
||||
return runAmbush(game, ow,
|
||||
route22Scene(2, 2, "ROUTE22_RIVAL2", "OPP_RIVAL2", 10,
|
||||
"EVENT_BEAT_ROUTE22_RIVAL_2ND_BATTLE", y), playerFacing)
|
||||
"EVENT_BEAT_ROUTE22_RIVAL_2ND_BATTLE", y), playerFacing,
|
||||
{ tempo = 100 })
|
||||
end
|
||||
return false
|
||||
end,
|
||||
@@ -618,10 +622,11 @@ local function ceruleanRivalScene(px, py)
|
||||
{ "face_object", 1, "down" }, -- 3
|
||||
{ "show_text", "_CeruleanCityRivalPreBattleText" }, -- 4
|
||||
{ "rival_battle", "OPP_RIVAL1", 7 }, -- 5
|
||||
{ "jump_if_false", 11 }, -- 6
|
||||
{ "jump_if_false", 12 }, -- 6
|
||||
{ "set_flag", "EVENT_BEAT_CERULEAN_RIVAL" }, -- 7
|
||||
{ "show_text", "_CeruleanCityRivalDefeatedText" }, -- 8
|
||||
{ "show_text", "_CeruleanCityRivalIWentToBillsText" }, -- 9
|
||||
{ "play_music", "Music_MeetRival", { start = "rival" } },
|
||||
{ "walk_npc", 1, ceruleanRivalExitDirs(px) }, -- 10
|
||||
{ "hide_object", "CERULEAN_CITY", "CERULEANCITY_RIVAL" }, -- 11
|
||||
}
|
||||
@@ -730,7 +735,7 @@ local JIGGLYPUFF_SILENCE, JIGGLYPUFF_STEP, JIGGLYPUFF_TAIL = 32, 24, 48
|
||||
-- Built as a TextBox `auto` table: auto.sound fires the frame the last
|
||||
-- page has typed out (PrintText returning), and auto.tick then runs once
|
||||
-- per frame while the gate it returns still reads as playing.
|
||||
local function jigglypuffDance(game, npc)
|
||||
local function jigglypuffDance(game, npc, ow)
|
||||
local Music = require("src.core.Music")
|
||||
-- .findMatchingFacingDirectionLoop: the rotation picks up at the entry
|
||||
-- matching the sprite's current facing (showMapText has just turned it
|
||||
@@ -776,7 +781,13 @@ local function jigglypuffDance(game, npc)
|
||||
if npc then npc.facing = JIGGLYPUFF_SPIN[step] end
|
||||
return
|
||||
end
|
||||
if frames >= JIGGLYPUFF_TAIL then phase = "done" end
|
||||
if frames >= JIGGLYPUFF_TAIL then
|
||||
phase = "done"
|
||||
if require("src.core.GameVersion").isYellow()
|
||||
and require("src.world.PikachuFollower").starterInParty(game.save) then
|
||||
ow.pikachuPewterSleepScene = true
|
||||
end
|
||||
end
|
||||
end,
|
||||
}
|
||||
end
|
||||
@@ -789,7 +800,7 @@ M.PEWTER_POKECENTER = {
|
||||
local TextBox = require("src.render.TextBox")
|
||||
game.stack:push(TextBox.new(game,
|
||||
text(game)._PewterPokecenterJigglypuffText or "JIGGLYPUFF: Puu\npupuu!",
|
||||
done, { auto = jigglypuffDance(game, npc) }))
|
||||
done, { auto = jigglypuffDance(game, npc, ow) }))
|
||||
end,
|
||||
},
|
||||
}
|
||||
@@ -864,10 +875,11 @@ M.SILPH_CO_7F = {
|
||||
{ "face_object", 9, "up" }, -- 4
|
||||
{ "show_text", "_SilphCo7FRivalWaitedHereText" }, -- 5
|
||||
{ "rival_battle", "OPP_RIVAL2", 7 }, -- 6
|
||||
{ "jump_if_false", 12 }, -- 7
|
||||
{ "jump_if_false", 13 }, -- 7
|
||||
{ "set_flag", "EVENT_BEAT_SILPH_CO_RIVAL" }, -- 8
|
||||
{ "show_text", "_SilphCo7FRivalDefeatedText" }, -- 9
|
||||
{ "show_text", "_SilphCo7FRivalGoodLuckToYouText" }, -- 10
|
||||
{ "play_music", "Music_MeetRival", { start = "rival" } },
|
||||
{ "move_npc_to", 9, 5, y + 1 }, -- 11
|
||||
{ "hide_object", "SILPH_CO_7F", "SILPHCO7F_RIVAL" }, -- 12
|
||||
}, "down")
|
||||
@@ -899,10 +911,11 @@ M.SS_ANNE_2F = {
|
||||
{ "face_object", 2, onLeft and "down" or "right" }, -- 3
|
||||
{ "show_text", "_SSAnne2FRivalText" }, -- 4
|
||||
{ "rival_battle", "OPP_RIVAL2", 1 }, -- 5
|
||||
{ "jump_if_false", 11 }, -- 6
|
||||
{ "jump_if_false", 12 }, -- 6
|
||||
{ "set_flag", "EVENT_BEAT_SS_ANNE_RIVAL" }, -- 7
|
||||
{ "show_text", "_SSAnne2FRivalDefeatedText" }, -- 8
|
||||
{ "show_text", "_SSAnne2FRivalCutMasterText" }, -- 9
|
||||
{ "play_music", "Music_MeetRival", { start = "rival" } },
|
||||
{ "walk_npc", 2, ssAnne2FRivalExitDirs(onLeft) }, -- 10
|
||||
{ "hide_object", "SS_ANNE_2F", "SSANNE2F_RIVAL" }, -- 11
|
||||
}, onLeft and "up" or "left")
|
||||
|
||||
@@ -28,6 +28,14 @@
|
||||
-- is full; `gotFlag` (pokered's EVENT_GOT_TM*) is set only on a
|
||||
-- successful give, which is what makes the leader's talk script retry
|
||||
-- later (gyms.lua).
|
||||
--
|
||||
-- `badgeSound` / `tmSound` are the text sound command each gym's reward
|
||||
-- text carries right after its FIRST label -- home/text.asm TextCommand_SOUND
|
||||
-- plays it once that page has typed out and then blocks on
|
||||
-- WaitForSoundToFinish, so the jingle sits between the pages rather than
|
||||
-- under them. macros/scripts/text.asm defines sound_level_up as
|
||||
-- sound_get_item_1, so Pewter's and Viridian's badge lines are Get_Item1
|
||||
-- too. Vermilion, Celadon and Fuchsia carry no sound on the badge text.
|
||||
|
||||
local function range(prefix, first, last)
|
||||
local t = {}
|
||||
@@ -50,6 +58,8 @@ return {
|
||||
{ "PEWTER_CITY", "PEWTERCITY_YOUNGSTER" },
|
||||
{ "ROUTE_22", "ROUTE22_RIVAL1" },
|
||||
},
|
||||
badgeSound = "Get_Item1", -- sound_level_up
|
||||
tmSound = "Get_Item1",
|
||||
dialogue = {
|
||||
"_PewterGymBrockReceivedBoulderBadgeText",
|
||||
"_PewterGymBrockBoulderBadgeInfoText",
|
||||
@@ -64,6 +74,8 @@ return {
|
||||
gotFlag = "EVENT_GOT_TM11",
|
||||
noRoom = "_CeruleanGymMistyTM11NoRoomText",
|
||||
deactivate = range("EVENT_BEAT_CERULEAN_GYM_TRAINER_", 0, 1),
|
||||
badgeSound = "Get_Key_Item",
|
||||
tmSound = "Get_Item1",
|
||||
dialogue = {
|
||||
"_CeruleanGymMistyReceivedCascadeBadgeText",
|
||||
},
|
||||
@@ -76,6 +88,7 @@ return {
|
||||
gotFlag = "EVENT_GOT_TM24",
|
||||
noRoom = "_VermilionGymLTSurgeTM24NoRoomText",
|
||||
deactivate = range("EVENT_BEAT_VERMILION_GYM_TRAINER_", 0, 2),
|
||||
tmSound = "Get_Key_Item",
|
||||
dialogue = {
|
||||
"_VermilionGymLTSurgeReceivedThunderBadgeText",
|
||||
},
|
||||
@@ -89,6 +102,7 @@ return {
|
||||
gotFlag = "EVENT_GOT_TM21",
|
||||
noRoom = "_CeladonGymTM21NoRoomText",
|
||||
deactivate = range("EVENT_BEAT_CELADON_GYM_TRAINER_", 0, 6),
|
||||
tmSound = "Get_Item1",
|
||||
dialogue = {
|
||||
"_CeladonGymErikaReceivedRainbowBadgeText",
|
||||
},
|
||||
@@ -102,6 +116,7 @@ return {
|
||||
gotFlag = "EVENT_GOT_TM06",
|
||||
noRoom = "_FuchsiaGymKogaTM06NoRoomText",
|
||||
deactivate = range("EVENT_BEAT_FUCHSIA_GYM_TRAINER_", 0, 5),
|
||||
tmSound = "Get_Key_Item",
|
||||
dialogue = {
|
||||
"_FuchsiaGymKogaReceivedSoulBadgeText",
|
||||
},
|
||||
@@ -115,6 +130,8 @@ return {
|
||||
gotFlag = "EVENT_GOT_TM46",
|
||||
noRoom = "_SaffronGymSabrinaTM46NoRoomText",
|
||||
deactivate = range("EVENT_BEAT_SAFFRON_GYM_TRAINER_", 0, 6),
|
||||
badgeSound = "Get_Key_Item",
|
||||
tmSound = "Get_Item1",
|
||||
dialogue = {
|
||||
"_SaffronGymSabrinaReceivedMarshBadgeText",
|
||||
},
|
||||
@@ -128,6 +145,8 @@ return {
|
||||
gotFlag = "EVENT_GOT_TM38",
|
||||
noRoom = "_CinnabarGymBlaineTM38NoRoomText",
|
||||
deactivate = range("EVENT_BEAT_CINNABAR_GYM_TRAINER_", 0, 6),
|
||||
badgeSound = "Get_Key_Item",
|
||||
tmSound = "Get_Item1",
|
||||
dialogue = {
|
||||
"_CinnabarGymBlaineReceivedVolcanoBadgeText",
|
||||
},
|
||||
@@ -141,6 +160,8 @@ return {
|
||||
gotFlag = "EVENT_GOT_TM27",
|
||||
noRoom = "_ViridianGymGiovanniTM27NoRoomText",
|
||||
deactivate = range("EVENT_BEAT_VIRIDIAN_GYM_TRAINER_", 0, 7),
|
||||
badgeSound = "Get_Item1", -- sound_level_up
|
||||
tmSound = "Get_Item1",
|
||||
dialogue = {
|
||||
"_ViridianGymGiovanniReceivedEarthBadgeText",
|
||||
},
|
||||
|
||||
+8
-6
@@ -130,18 +130,20 @@ before `Game:load`, so **it never loads a mod's entry chunk**; only
|
||||
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).
|
||||
`"MOD"`, uppercased. `enabledByVersion` contains an answer for each game;
|
||||
missing entries default to enabled (except experimental mods), matching the
|
||||
loader. On the first run with per-game controls, legacy shared choices are
|
||||
copied to every installed game's answer.
|
||||
- `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
|
||||
- `LauncherMods.setEnabled(id, bool, version)` persists the selected game's
|
||||
answer, so the running game and the in-game `ManagerState` see the change on
|
||||
next boot. The MODS panel renders a coloured checkbox for Red, Blue, Yellow,
|
||||
and Gold on every row 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
|
||||
|
||||
+22
-16
@@ -96,14 +96,14 @@ The launcher asks the same question of a mod's dependencies: one whose hard
|
||||
dependency does not run on the selected game reads `Needs <id> (not for Gold)`,
|
||||
matching the loader's contagious skip.
|
||||
|
||||
A separate overlay, `options.modsByVersion[version][id]`, is where a per-game
|
||||
enable flag will live. It is a preview: `SaveData.PER_VERSION_MODS` is `false`,
|
||||
so `SaveData.modScope` answers nil for every caller and the launcher panel, the
|
||||
in-game manager and the loader all read *and* write the one shared
|
||||
`options.mods` flag. Nothing consults the overlay for enablement until that
|
||||
flips, which is deliberate: the overlay is plantable from an imported
|
||||
`.g1rmodlist`, and a reader scoped differently from the writers would show a
|
||||
mod set no boot would honour.
|
||||
A separate overlay, `options.modsByVersion[version][id]`, holds each game's
|
||||
enable flag. The launcher shows a coloured Red / Blue / Yellow / Gold checkbox
|
||||
for every installed mod, and the loader and in-game manager read the same
|
||||
game-specific answer on the next boot. On the first launch after this feature,
|
||||
the existing shared state is copied to every game, so a mod that was enabled
|
||||
remains enabled everywhere; after that, changing one checkbox affects only
|
||||
that game. New mods still default to enabled on every game (experimental mods
|
||||
retain their explicit opt-in default).
|
||||
|
||||
That is deliberate. Gold reimplements the battle engine, the overworld, the
|
||||
script VM and the save format, so a Gen 1 mod dropped into a Gold boot would
|
||||
@@ -118,10 +118,11 @@ Gen 2 games no longer loads on Red, Blue or Yellow. Say `["all"]` or list both
|
||||
generations if you want both.
|
||||
|
||||
Two riders. **A hard dependency that does not run here takes the dependent down
|
||||
with it**, as a skip rather than a failure and carrying the dependency's own
|
||||
wording (`depends on X, which does not run here (For Blue, not Red)`), so the
|
||||
whole chain has to cover the same games. And **the claim is yours, not the last
|
||||
word**: it is the manager's `TRY HERE ANYWAY` row that lets a player run a mod
|
||||
with it** (unless scoped to specific games, e.g.
|
||||
`dependencies: [{ id = "x", games = ["gen2"] }]`), as a skip rather than a
|
||||
failure and carrying the dependency's own wording (`depends on X, which does not
|
||||
run here (For Blue, not Red)`), so the whole chain has to cover the same games.
|
||||
And **the claim is yours, not the last word**: it is the manager's `TRY HERE ANYWAY` row that lets a player run a mod
|
||||
whose author never opted in, which is the only route for a mod written before
|
||||
the field existed. The override is per game -- `options.modsGen2[id]` is a
|
||||
`{ [version] = true }` table, so forcing a mod onto Red does not force it onto
|
||||
@@ -474,6 +475,8 @@ Generation-agnostic; nothing to adapt.
|
||||
(`src/world/gen2/WorldAPI.lua`). Two differences show through and are
|
||||
documented on the module: Gold's world is not a stack state, and Gen 2 event
|
||||
flags are numeric ids into `wEventFlags` rather than string keys.
|
||||
`mapOverview` returns the same read-only terrain, tile-shading, and marker
|
||||
shape, using Gold's live object masks and event flags to omit collected items.
|
||||
`spawnNpc` / `removeNpc` append onto the map def's own object list, the way the
|
||||
Gen 1 arm does, so a spawned actor is pooled, drawn, walked and talked to like
|
||||
an extracted one and survives a map reload; it is not serialized, so a mod
|
||||
@@ -531,8 +534,9 @@ gains a field instead of the name gaining a prefix.
|
||||
`pokemon.level_up`, `pokemon.move_learned`; hooks `battle.damage`,
|
||||
`battle.crit`, `battle.accuracy`, `battle.turn_order`,
|
||||
`battle.enemy_action`, `battle.run`, `battle.exp_award`, `exp.gain`,
|
||||
`catch.rate`, `trainer.party`, `battle.overlay`, `battle.low_health_alarm`
|
||||
and `battle.catch_exp`. One payload difference: Gen 1's vanilla
|
||||
`catch.rate`, `trainer.party`, `battle.overlay`, `battle.low_health_alarm`,
|
||||
`battle.catch_exp`, `battle.bottom_ui_visible` and
|
||||
`battle.status_hud_visible`. One payload difference: Gen 1's vanilla
|
||||
`battle.low_health_alarm` link reads `ctx.battle.data`, and Gold's battle
|
||||
screen has no `.data` field, so the Gen 2 site **adds** `ctx.data` beside the
|
||||
Gen 1 keys. A mod that calls `nextFn` is unaffected; one that reaches through
|
||||
@@ -544,11 +548,13 @@ gains a field instead of the name gaining a prefix.
|
||||
each row's decision in `evolution.check`. The hook passes `data` where Gen 1
|
||||
passes `game`; positions 2-4 (mon, row, trigger) match.
|
||||
- *The frame (`src/core/Game2.lua`):* hooks `input.step`, `input.pointer`,
|
||||
`render.zones`, `render.compose`, `render.letterbox`, `render.hud`. Each sits
|
||||
`render.zones`, `render.compose`, `render.output_enabled`, `render.output`,
|
||||
`render.letterbox`, `render.hud`. Each sits
|
||||
at the same moment `src/core/Game.lua` and `src/render/Renderer.lua` raise it
|
||||
-- the logic tick before the pad is read, a pointer the touch overlay gets
|
||||
first refusal on, the palette zone list handed to the present pass, the
|
||||
letterbox, and the finished playfield rect -- and carries the same payload.
|
||||
composed frame before GBCFX, the letterbox, and the finished playfield rect
|
||||
-- and carries the same payload.
|
||||
`render.hud`'s `gameX` / `gameY` really is where Gold's dialogue boxes and
|
||||
menus land, because `Chrome.fitScale` / `fitOrigin` and `World:fitScale`
|
||||
compute the same number. `render.zones` is handed `nil` in GBC mode (Gold
|
||||
|
||||
+125
-1
@@ -21,6 +21,76 @@ luajit tools/gen_registry_docs.lua
|
||||
luajit tools/gen_registry_docs.lua ../gen1recomp.wiki
|
||||
```
|
||||
|
||||
## Manifest specification (`manifest.json`)
|
||||
|
||||
Every mod contains a root `manifest.json` defining its metadata, supported games, and dependencies for the engine loader.
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "my_mod",
|
||||
"name": "My Cool Mod",
|
||||
"version": "1.0.0",
|
||||
"api": 2,
|
||||
"entry": "main.lua",
|
||||
"profile": "content",
|
||||
"category": "GAMEPLAY",
|
||||
"games": ["gen1", "gen2"],
|
||||
"game_version": ">=0.0.0-dev <2.0.0",
|
||||
"priority": 100,
|
||||
"dependencies": [
|
||||
"helper_lib@^1.0.0",
|
||||
{ "id": "pokegear_cards", "games": ["gen2"], "range": "^1.0.0", "github": "1jamie/pokegear_cards" }
|
||||
],
|
||||
"optional_dependencies": [
|
||||
"gen1_modern_ui"
|
||||
],
|
||||
"conflicts": [],
|
||||
"permissions": ["engine_internals"],
|
||||
"description": "A brief description of the mod.",
|
||||
"github": "author/my_mod"
|
||||
}
|
||||
```
|
||||
|
||||
### Manifest Fields
|
||||
|
||||
| Field | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| `id` | `string` | Unique identifier (lowercase alphanumeric, underscores, hyphens). |
|
||||
| `name` | `string` | Human-readable title shown in launcher and manager. |
|
||||
| `version` | `string` | Semantic version string (e.g. `"1.0.0"`). |
|
||||
| `api` | `integer` | Mod API level (`2` for current standard, `1` for legacy). |
|
||||
| `entry` | `string` | Entry Lua file path relative to mod root (usually `"main.lua"`). |
|
||||
| `profile` | `string` | Mod profile: `"content"`, `"overhaul"`, or `"total_conversion"`. |
|
||||
| `category` | `string` | Categorization chip (e.g. `"GAMEPLAY"`, `"CONTENT"`, `"UI"`, `"AUDIO"`). |
|
||||
| `games` | `array` | Supported game versions: `["gen1"]`, `["gen2"]`, `["red"]`, `["blue"]`, `["yellow"]`, `["gold"]`, or `["all"]`. |
|
||||
| `game_version`| `string` | Semver range of required engine version (e.g. `">=0.0.0-dev <2.0.0"`). |
|
||||
| `priority` | `integer` | Load priority order (lower numbers load earlier; dependencies always precede dependents regardless of priority). |
|
||||
| `dependencies` | `array` | Hard required dependencies. A mod will not load if a required dependency is missing or disabled for the active game. |
|
||||
| `optional_dependencies` | `array` | Soft dependencies. Guarantees that if the target mod is present and active, it loads *before* this mod without blocking load if absent. |
|
||||
| `conflicts` / `incompatible` | `array` | List of mod IDs that cannot run concurrently with this mod. |
|
||||
| `permissions` | `array` | Requested privileges (e.g. `["engine_internals"]`, `["network"]`, `["filesystem"]`). |
|
||||
| `github` | `string` | GitHub repository (`"owner/repo"`) used for update checks and dependency download links. |
|
||||
|
||||
### Declaring Dependencies & Scoping
|
||||
|
||||
Dependencies in `dependencies` and `optional_dependencies` can be declared in several formats:
|
||||
|
||||
1. **Simple string**: `"mod_id"`
|
||||
2. **Version-pinned string**: `"mod_id@^1.2.0"`
|
||||
3. **Repository-hinted string**: `"mod_id#owner/repo"` or `"mod_id@^1.2.0#owner/repo"`
|
||||
4. **Structured object**:
|
||||
```json
|
||||
{
|
||||
"id": "mod_id",
|
||||
"range": "^1.2.0",
|
||||
"games": ["gen2"],
|
||||
"github": "owner/repo"
|
||||
}
|
||||
```
|
||||
|
||||
#### Version-Scoped Dependencies
|
||||
When a mod supports multiple games (`"games": ["gen1", "gen2"]`), a dependency can specify `"games": ["gen2"]` to indicate it is only required when booting Gen 2. When booting Gen 1, the engine will ignore the dependency, preventing unnecessary boot blocks on games that do not need it.
|
||||
|
||||
## Mods and Gold (Gen 2)
|
||||
|
||||
The mod API is one API across both generations, but Gold runs its own battle
|
||||
@@ -66,7 +136,8 @@ optional visual `tileRows` at 2x resolution, and optional `tileDetailRows` at
|
||||
`"3"` (darkest); their matching width and height fields describe the grid.
|
||||
`markers` contains active `{ kind, x, y }` points in map-cell coordinates for
|
||||
`warp`, visible `item`, and untaken `hidden` locations. All fields are
|
||||
read-only snapshots; mods choose which layers to render.
|
||||
read-only snapshots; mods choose which layers to render. Red and Gold expose
|
||||
the same contract while applying their own object and event visibility rules.
|
||||
|
||||
## Party ordering
|
||||
|
||||
@@ -439,6 +510,20 @@ oldest queued event as `"action,x,y"` in submitted-frame coordinates, or `nil`.
|
||||
This is what lets a mod lay the two passes out as two stacked Game Boy screens,
|
||||
or push one onto a second screen, without the engine knowing the layout.
|
||||
|
||||
`render.output_enabled` and `render.output` are the later, whole-window seam
|
||||
for mods that need the engine's normal composite rather than its separate
|
||||
layers. It runs after registered present pipelines and before GBCFX,
|
||||
`render.hud`, and touch controls. A mod wraps both hooks: the first returns
|
||||
`true` only while output ownership is needed, and the second receives
|
||||
`(next, ctx)` with `canvas`, `width`, `height`, `gameX`, `gameY`, `gameWidth`,
|
||||
`gameHeight`, `scale`, `dpiX`,
|
||||
`dpiY`, and `generation`. Returning `true` from `render.output` takes over the
|
||||
window; calling `next(ctx)` keeps the normal presentation. Both hooks default
|
||||
to `false`. Enabling the seam requires a full-window canvas for that frame.
|
||||
With no `render.output` subscriber, or while `render.output_enabled` is false,
|
||||
the existing presentation path is unchanged. `render.compose` takes precedence
|
||||
when it owns the frame.
|
||||
|
||||
`screen.render_visible` receives `(next, state)` while the main screen is being
|
||||
composed. Return `false` to omit that state from drawing, opacity selection and
|
||||
palette-zone ownership. The state remains on the stack and keeps its normal
|
||||
@@ -454,6 +539,7 @@ identifiers: `pc_box_withdraw`, `pc_box_deposit`, `pc_box_release`,
|
||||
`battle.bottom_ui_visible` and `battle.status_hud_visible` independently
|
||||
control the battle text/menu layer and the HP/status panels. Both receive
|
||||
`(next, state)` and default to `true`, so vanilla rendering is unchanged.
|
||||
Both hooks apply to Gen 1 and Gen 2 battles.
|
||||
Text boxes and YES/NO prompts pushed above a battle inherit a `false` result
|
||||
for that battle, so hiding the bottom layer cannot leave their white backing
|
||||
behind under another overlay. Text boxes also pass through the hook as their
|
||||
@@ -544,3 +630,41 @@ local both = mod.datetime:dateTime(game, createdAt)
|
||||
The live `game` supplies only the current option context. Formatting never
|
||||
mutates the save, options, or timestamp, and invalid timestamps return
|
||||
`"----"`.
|
||||
|
||||
## Device power information
|
||||
|
||||
Sandboxed mods can read the host's battery state without receiving the rest
|
||||
of `love.system`:
|
||||
|
||||
```lua
|
||||
local state, percent = mod.device:powerInfo()
|
||||
```
|
||||
|
||||
`state` follows LÖVE's values: `"unknown"`, `"battery"`, `"nobattery"`,
|
||||
`"charging"`, or `"charged"`. `percent` is `0` through `100`, or `nil` when
|
||||
the platform cannot report it. The facade is read-only and does not expose
|
||||
URL launching, clipboard access, or other system operations.
|
||||
|
||||
## Real-world steps
|
||||
|
||||
On iOS and Android the game counts the player's real-world steps natively
|
||||
(HealthKit / the hardware step counter). A mod reaches that bridge through
|
||||
the `steps` permission in `manifest.json`, which the player sees in the
|
||||
mod manager like every other permission:
|
||||
|
||||
```lua
|
||||
if mod.steps:available() then
|
||||
mod.steps:sync() -- async; OS consent sheet on first use
|
||||
end
|
||||
-- later, at a quiet moment:
|
||||
local walk = mod.steps:poll() -- { steps = n, from = ?, to = ? } or nil
|
||||
```
|
||||
|
||||
`available()` is `false` on builds without the bridge (desktop) and for
|
||||
mods without the permission, so a probe is always safe. `sync()` asks the
|
||||
platform to refresh its count and returns whether there was a bridge to
|
||||
ask. `poll()` returns the next delivery for this mod — the engine consumes
|
||||
the native side's pending file itself, each permissioned mod receives its
|
||||
own copy of a delivery, and steps are anchored natively so the same walk
|
||||
is never delivered twice. Without the permission, `sync` and `poll` raise
|
||||
an error naming it.
|
||||
|
||||
@@ -19,13 +19,14 @@ Features intentionally added beyond the original Pokémon Red, Blue, and Yellow
|
||||
* **Soft reset button combination**
|
||||
* **Keyboard and controller rebinding**
|
||||
* **Mod profiles** with separate mod settings and save slots
|
||||
* **Sandboxed mods**: an installed mod can read only its own folder and write only its own storage, so it cannot reach the rest of your device
|
||||
* **Improved launcher and save editor UI**, including background downloads and update checks
|
||||
* **Direct-launch options** for shortcuts, Steam entries, and handheld frontends
|
||||
* **Custom boot branding**
|
||||
|
||||
## Pokémon Gold (Gen 2)
|
||||
|
||||
A fourth game the launcher can import and play, built from pret/pokegold the same way Red/Blue/Yellow are built from pokered. Port extras beyond the cartridge:
|
||||
A fourth game the launcher can import and play, built from pret/pokegold the same way Red/Blue/Yellow are built from pokered (and pokeyellow). Port extras beyond the cartridge:
|
||||
|
||||
* **COLOR, zoom, tilt, GBC FX, and quick save/load**
|
||||
* **UI that stays fixed while the overworld zooms**
|
||||
|
||||
@@ -258,20 +258,34 @@ row on the detail screen. The launcher's dependency verdict asks the same
|
||||
question of your dependencies: a mod whose hard dependency does not run on the
|
||||
selected game reads `Needs <id> (not for Gold)` rather than `Ready`.
|
||||
|
||||
### Scoping dependencies per game / generation
|
||||
|
||||
For mods targeting multiple generations (`"games": ["gen1", "gen2"]`), a hard
|
||||
dependency can be scoped to specific games so that it is only enforced when
|
||||
booting those games:
|
||||
|
||||
```json
|
||||
"dependencies": [
|
||||
{ "id": "pokegear_cards", "games": ["gen2"], "range": "^1.0.0", "github": "1jamie/pokegear_cards" }
|
||||
]
|
||||
```
|
||||
|
||||
When booting a Gen 1 game (Red, Blue, Yellow), the engine loader sees that
|
||||
`pokegear_cards` is scoped to `"gen2"` and will not skip or block the parent mod
|
||||
on Gen 1. When booting Gen 2 (Gold), `pokegear_cards` is strictly required.
|
||||
|
||||
For conditional integrations where the dependency is optional across the board,
|
||||
`optional_dependencies` remains the standard pattern.
|
||||
|
||||
### One limit worth knowing
|
||||
|
||||
**Per-game enable flags are still a preview.** The overlay
|
||||
`options.modsByVersion[version][id]` exists and every surface goes through
|
||||
`SaveData.modEnabled` / `SaveData.setModEnabled`, but
|
||||
`SaveData.PER_VERSION_MODS` is `false`
|
||||
(`src/core/SaveData.lua:489`). While it is false, `SaveData.modScope` answers
|
||||
nil for every caller, so the launcher panel, the in-game manager *and* the
|
||||
loader all read and write the one shared `options.mods` flag and the overlay is
|
||||
not consulted for enablement anywhere. That matters because the overlay is
|
||||
plantable from an imported `.g1rmodlist`: keeping every reader on the same
|
||||
scope as every writer is what stops a stored per-game flag from showing a mod
|
||||
set no boot would honour. Nothing about this affects a mod author; it affects
|
||||
what a player can currently express.
|
||||
**Enablement is per game.** The overlay
|
||||
`options.modsByVersion[version][id]` is read and written through
|
||||
`SaveData.modEnabled` / `SaveData.setModEnabled` by the launcher, in-game
|
||||
manager, and loader. Existing shared settings are copied to every game the
|
||||
first time this version sees the installed mods; from then on, each coloured
|
||||
game checkbox changes only that game's next boot. Nothing about this affects a
|
||||
mod author; it affects what a player can express.
|
||||
|
||||
Targeting is a different question from enablement and *is* enforced per game,
|
||||
as above. The two do not share a switch.
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
# RFC 0008 — Read-only device power information for sandboxed mods
|
||||
|
||||
## Status
|
||||
|
||||
Proposed. Engine: `Loader.lua`, `Sandbox.lua`. Test:
|
||||
`tests/modkit/cases/device_power_info.lua`.
|
||||
|
||||
## Motivation
|
||||
|
||||
A handheld UI mod can show the player's battery state and warn before power
|
||||
loss. The sandbox correctly removes `love.system` because that module also
|
||||
launches URLs and exposes other host operations, but it leaves no scoped way
|
||||
to read the harmless power values that LÖVE already provides.
|
||||
|
||||
## The decision it extends
|
||||
|
||||
Extends the mod sandbox in `src/mods/Sandbox.lua`: blocked host modules stay
|
||||
blocked while legitimate operations receive narrow engine-owned facades.
|
||||
|
||||
## The exact API delta
|
||||
|
||||
Add `mod.device:powerInfo() -> state, percent`.
|
||||
|
||||
The engine calls `love.system.getPowerInfo()` outside the mod sandbox and
|
||||
returns only its first two values. `state` is one of LÖVE's standard power
|
||||
states. `percent` is `0` through `100` or `nil`. When the platform has no
|
||||
power-information backend, the result is `"unknown", nil`.
|
||||
|
||||
No permission grants access to `love.system`; URL launching, clipboard access,
|
||||
OS identification, and the module table itself remain unavailable.
|
||||
|
||||
## Migration note for existing mods
|
||||
|
||||
Mods that used `love.system.getPowerInfo()` replace that call with
|
||||
`mod.device:powerInfo()`. No other mod changes.
|
||||
|
||||
## Parity tests
|
||||
|
||||
- **No mod:** loading no mods does not call the platform power backend.
|
||||
- **Mod API:** a fixture mod loaded through the public loader receives state
|
||||
and percentage through `mod.device`, while the existing sandbox suite keeps
|
||||
proving that direct `love.system` access is refused.
|
||||
- **Unavailable backend:** the public facade returns `"unknown", nil` rather
|
||||
than inventing battery data or failing mod load.
|
||||
|
||||
## Deprecation etiquette
|
||||
|
||||
Nothing deprecated. The facade is additive; the sandbox's `love.system` block
|
||||
remains in force.
|
||||
@@ -0,0 +1,68 @@
|
||||
# RFC 0009 — Permission-gated step bridge for sandboxed mods
|
||||
|
||||
## Status
|
||||
|
||||
Proposed. Engine: `Steps.lua` (new), `Loader.lua`, `Manifest.lua`,
|
||||
`Sandbox.lua`. Test: `tests/modkit/cases/steps_bridge.lua`. Issue: #1186.
|
||||
|
||||
## Motivation
|
||||
|
||||
The iOS and Android builds count the player's real-world steps natively
|
||||
(#452, #489), exposed to Lua as `love.system.syncHealthSteps()` and
|
||||
delivered as `steps_pending.json` in the save-directory root. The sandbox
|
||||
correctly blocks both — `love.system` also launches URLs, and the file API
|
||||
names paths — but that leaves the bridge with no consumer: the mod that
|
||||
step counting was built for (Pokéwalker, steps→EXP) can no longer be
|
||||
written.
|
||||
|
||||
## The decision it extends
|
||||
|
||||
Extends the mod sandbox in `src/mods/Sandbox.lua` (blocked host modules
|
||||
stay blocked; legitimate operations receive narrow engine-owned facades)
|
||||
and the permission model `network` established: a `manifest.json`
|
||||
permission the player sees in the mod manager that genuinely gates a
|
||||
capability.
|
||||
|
||||
## The exact API delta
|
||||
|
||||
A new manifest permission token, `steps`, and a `mod.steps` facade:
|
||||
|
||||
- `mod.steps:available() -> boolean` — whether this build carries the
|
||||
native bridge. Answers `false` without the permission, so a probe stays
|
||||
quiet.
|
||||
- `mod.steps:sync() -> boolean` — asks the platform to refresh its count
|
||||
(async; the OS consent sheet still appears on first use, exactly as
|
||||
before the sandbox). `false` when there is no bridge.
|
||||
- `mod.steps:poll() -> { steps = n, from = iso?, to = iso? } | nil` — the
|
||||
next delivery for this mod, engine-consumed from the pending file. Each
|
||||
permissioned mod receives its own copy of a delivery.
|
||||
|
||||
Without the permission, `sync` and `poll` raise an error naming the
|
||||
missing permission, the way the network gate does. The engine owns the
|
||||
pending file: mods never learn its name or location, and only the three
|
||||
contract fields travel. No new event, hook, or registry names.
|
||||
|
||||
## Migration note for existing mods
|
||||
|
||||
Mods that called `love.system.syncHealthSteps()` and read
|
||||
`steps_pending.json` themselves add `"steps"` to `permissions` and switch
|
||||
to `mod.steps:sync()` / `mod.steps:poll()`. No other mod changes.
|
||||
|
||||
## Parity tests
|
||||
|
||||
- **No mod:** with nothing installed the bridge is never called and a
|
||||
pending file on disk is left untouched.
|
||||
- **Mod API:** a fixture mod with the permission syncs and receives a
|
||||
delivery through the public loader; two permissioned mods both receive
|
||||
the same walk; a second poll returns nil.
|
||||
- **No permission:** `available()` is false and the acting calls name the
|
||||
missing permission; the sandbox suite keeps proving direct
|
||||
`love.system` access is refused.
|
||||
- **Malformed delivery:** a bad or empty pending file is dropped whole
|
||||
rather than crashing a poll (the native anchor only advances on a
|
||||
successful sync, so nothing is lost).
|
||||
|
||||
## Deprecation etiquette
|
||||
|
||||
Nothing deprecated. The facade is additive; the sandbox's `love.system`
|
||||
and `love.filesystem` blocks remain in force.
|
||||
@@ -12,6 +12,27 @@
|
||||
"tintColor": "3b5ca8",
|
||||
"category": "games",
|
||||
"versions": [
|
||||
{
|
||||
"version": "0.1.83",
|
||||
"date": "2026-08-13",
|
||||
"size": 11282300,
|
||||
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.83/gen1recomp++-0.1.83-ios.ipa",
|
||||
"localizedDescription": "Download the correct version for your computer below.\n\n## Contributors\n\n- @AverageConsumer\n- @bryanthaboi"
|
||||
},
|
||||
{
|
||||
"version": "0.1.82",
|
||||
"date": "2026-08-13",
|
||||
"size": 11281277,
|
||||
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.82/gen1recomp++-0.1.82-ios.ipa",
|
||||
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #914 Immunity steps and no encounter tiles\n- #936 Fly and teleporting allow should block interactions\n- #944 When starting a new game, the character sprite is facing down instead of up\n- #949 Save game import, save game export, \"normal\" save game\n- #964 Menu closing when trying to view Pokemon before picking starter\n- #978 Following up of #847\n- #983 Pikachu incorrectly able to be released\n- #984 EXP calculation after trading a PKMN back isnt right.\n- #987 Celadon Game Corner Display Error (L22680)\n- #988 (Yellow) Missing Pikachu Interaction - Pewter City Pokemon Centre\n- #989 (Yellow) Missing Pikachu Interaction - Vermilion City Pokemon Fan Club\n- #990 (Yellow) Pikachu Missing Hat\n- #999 Pallet Town music while player follows Prof. Oak\n- #1007 Transition into battle state for the 'Catching Pikachu' scene missing\n- #1010 Rival's position resets\n- #1011 Rival dialogue issue\n- #1029 1px black line sits under the title logo at the SGB zone boundary\n- #1035 Alt Music not playing\n- #1041 Mimic text not swoing up\n- #1047 Using a TM or HM closes the menu afterwards.\n- #1048 Cannot select items from the sell menu.\n- #1056 Cursor not jumping when switching moves\n- #1065 Player walks through statue after final battle while walking into Hall Of Fame\n- #1066 Anomalies when opponent switches out Pokemon\n- #1069 missing {RAM:wNameBuffer}\n- #1073 _GotMonText not used\n- #1089 Scripted movement missing for player and rival\n- #1101 Recoil damage calculation bug\n- #1115 Hitting attack does no damage\n- #1119 Wrongs textlines and events\n- #1120 Health is black when viewing stats\n- #1129 Errors in bowing animations of the nurse\n- #1146 [Recomp] Map music glitch\n- #1149 issue that affects tranlations\n- #1175 Pikachu visible too late\n- #1189 Town Map Item\n- #1193 Status moves have (wrong) sound effects when battle animations are turned off\n- #1206 Gen1: Cinnebar island has unfishable tiles north and east\n- #1207 Poison Flicker Visual issue\n- #1214 Title Screen with OG Red is the wrong color\n- #1219 [GOLD] UNABLE TO USE SUN STONE\n- #1220 No screen shaking for hitting moves when battle animations are off\n- #1221 [Gold] Flaafy evolution to at Lvl 30 Ampharos skipped learn thunder punch\n- #1225 Jingles for learning moves missing\n\n## Contributors\n\n- @1Jamie\n- @bryanthaboi"
|
||||
},
|
||||
{
|
||||
"version": "0.1.81",
|
||||
"date": "2026-08-13",
|
||||
"size": 9886854,
|
||||
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.81/gen1recomp++-0.1.81-ios.ipa",
|
||||
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #1091 Encounter Rate is tied to Game Speed\n- #1097 Phase through walls exiting a cave\n- #1100 Allow for control edit and vibration toggle on gold\n- #1104 wrong warp\n- #1135 update for iphone - haptic vibrations\n- #1155 Magnitude always rolls a 4\n- #1157 Sonic Boom doesn't deal set damage\n- #1158 Unable to view opponent's next Pokemon\n- #1159 Dig animation is bugged\n- #1160 64x64 battle sprite followup for mods\n- #1167 [Gold] items in ilex forest missing\n- #1208 Gold Save game not showing properly in LAUNCHER after having multiple saves\n\n## Contributors\n\n- @bryanthaboi\n- @castdrian\n- @MaxTomahawk\n- @Yukitty\n- Codex Agent"
|
||||
},
|
||||
{
|
||||
"version": "0.1.80",
|
||||
"date": "2026-08-12",
|
||||
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
/Users/bryanbassett/Documents/development/pokemon-gen1-recomp-project/.bazinga/mods/timekeepers_hut
|
||||
@@ -23,6 +23,10 @@ Ship `gen1tls.dll` (or `libgen1tls.so` / `libgen1tls.dylib`) **next to** the
|
||||
fused executable. Mods load it through LuaJIT FFI; no .NET runtime is
|
||||
required on the player's machine.
|
||||
|
||||
Official Windows release zips get `gen1tls.dll` from CI: a `windows-2022`
|
||||
job publishes this project and `scripts/build.sh`'s Windows packaging copies
|
||||
it beside `gen1recomp.exe` (see `GEN1TLS_DLL` / `dist/native/win-x64`).
|
||||
|
||||
## Android
|
||||
|
||||
On Android, the matching API is exposed as `love.system.tlsOpen` /
|
||||
|
||||
@@ -261,6 +261,21 @@ build_win() {
|
||||
cp "$love_dir"/*.dll "$out_dir"/
|
||||
cp "$love_dir"/license.txt "$out_dir"/ 2>/dev/null || true
|
||||
|
||||
# Native AOT TLS dialer for outbound wss:// (e.g. Archipelago hosted rooms).
|
||||
# Release CI builds this on windows-2022 (Native AOT can't cross-compile
|
||||
# win-x64 from the Mac runner) and either exports GEN1TLS_DLL or drops the
|
||||
# file at dist/native/win-x64/gen1tls.dll before calling build.sh.
|
||||
local tls_dll="${GEN1TLS_DLL:-}"
|
||||
if [ -z "$tls_dll" ] && [ -f "$DIST/native/win-x64/gen1tls.dll" ]; then
|
||||
tls_dll="$DIST/native/win-x64/gen1tls.dll"
|
||||
fi
|
||||
if [ -n "$tls_dll" ] && [ -f "$tls_dll" ]; then
|
||||
cp "$tls_dll" "$out_dir/gen1tls.dll"
|
||||
say "bundled gen1tls.dll for Windows TLS (wss://)"
|
||||
else
|
||||
warn "gen1tls.dll not found — Windows zip will not support wss:// (set GEN1TLS_DLL or build native/tls_dial)"
|
||||
fi
|
||||
|
||||
# The exe's icon lives in love.exe's PE resources, so it must be patched
|
||||
# BEFORE the .love is appended: peresed rewrites the whole file and would
|
||||
# drop the fused bytes. peresed (pipx install pe_tools) has no .ico input,
|
||||
@@ -400,6 +415,9 @@ EOF
|
||||
grep -q '^FUSE_PATH="\$APPDIR/game.love"$' "$appdir/AppRun" \
|
||||
|| fail "failed to enable FUSE_PATH in AppRun (upstream AppRun changed?)"
|
||||
|
||||
sed -i '' 's|^exec "\$APPDIR/bin/love"|if [ -n "$WAYLAND_DISPLAY" ] \&\& [ -z "$SDL_VIDEODRIVER" ]; then export SDL_VIDEODRIVER=x11; fi\
|
||||
exec "$APPDIR/bin/love"|' "$appdir/AppRun"
|
||||
|
||||
# Match the upstream image's compression (gzip, 128K blocks) so the
|
||||
# bundled runtime can read it.
|
||||
local sfs_out="$WORK/game.squashfs"
|
||||
|
||||
@@ -405,6 +405,12 @@ if [ -z "\$LUA_CPATH" ]; then
|
||||
fi
|
||||
export LUA_CPATH="\$APPDIR/lib/lua/5.1/?.so;\$LUA_CPATH"
|
||||
|
||||
# SDL2 on Wayland crashes during desktop drag-and-drop in certain compositors;
|
||||
# default to X11/XWayland when available to ensure rock-solid drag-drop stability.
|
||||
if [ -n "\$WAYLAND_DISPLAY" ] && [ -z "\$SDL_VIDEODRIVER" ]; then
|
||||
export SDL_VIDEODRIVER=x11
|
||||
fi
|
||||
|
||||
exec "\$APPDIR/bin/love" --fused "\$APPDIR/game.love" "\$@"
|
||||
EOF
|
||||
chmod +x "$APPDIR/AppRun"
|
||||
|
||||
@@ -35,4 +35,10 @@ find_love() {
|
||||
LOVE_BIN="$(find_love)" \
|
||||
|| fail "LÖVE not found, run scripts/setup.sh (or install from https://love2d.org)"
|
||||
|
||||
# SDL2 on Wayland crashes during desktop drag-and-drop in certain compositors;
|
||||
# default to X11/XWayland when available to ensure rock-solid drag-drop stability.
|
||||
if [ -n "${WAYLAND_DISPLAY:-}" ] && [ -z "${SDL_VIDEODRIVER:-}" ]; then
|
||||
export SDL_VIDEODRIVER=x11
|
||||
fi
|
||||
|
||||
exec "$LOVE_BIN" "$ROOT" "$@"
|
||||
|
||||
+153
-82
@@ -501,6 +501,9 @@ local function makeBattler(data, mon, isPlayer, save)
|
||||
badgeBoosts = badgeBoosts,
|
||||
statuses = data.statuses,
|
||||
shownHP = mon.hp, -- the HP the bar displays (UpdateHPBar drain)
|
||||
-- the bar's own length in GetHPBarLength pixels; it trails shownHP
|
||||
-- because UpdateHPBar_AnimateHPBar slides it one pixel at a time
|
||||
shownPx = Timing.hpBarPixels(mon.hp, math.max(1, mon.stats.hp)),
|
||||
-- HUD status label (DrawHUDsAndHPBars); mon.status can land mid-move
|
||||
-- while the tilemap still shows the prior condition until the next
|
||||
-- post-action HUD refresh (core.asm after Execute*Move)
|
||||
@@ -563,10 +566,6 @@ local function markOwned(game, species)
|
||||
local dex = game.save.pokedex
|
||||
if dex then
|
||||
dex.seen[species] = true
|
||||
if not dex.owned[species] then
|
||||
-- new dex page registered (SFX_DEX_PAGE_ADDED)
|
||||
require("src.core.Sound").play(game.data, "Dex_Page_Added")
|
||||
end
|
||||
dex.owned[species] = true
|
||||
end
|
||||
end
|
||||
@@ -613,6 +612,7 @@ local function newBattle(game)
|
||||
self.phase = "intro"
|
||||
self.menuIndex = 1
|
||||
self.moveIndex = 1
|
||||
self.playerMoveListIndex = 1
|
||||
self.frame = 0
|
||||
return self
|
||||
end
|
||||
@@ -929,6 +929,22 @@ function BattleState:sayNext(text)
|
||||
table.insert(self.queue, self.nextInsert, { text = text })
|
||||
end
|
||||
|
||||
function BattleState:sayNextWaitSfx(text, sfx)
|
||||
self.nextInsert = (self.nextInsert or 0) + 1
|
||||
table.insert(self.queue, self.nextInsert, { text = text, waitForLearningSfx = sfx })
|
||||
end
|
||||
|
||||
-- WaitForSoundToFinish for a sound an act() has already started: PlayCry
|
||||
-- ends in `jp WaitForSoundToFinish` (home/pokemon.asm), so every cry in
|
||||
-- Gen 1 holds whatever the ROM does next. `src` is the audio source, or a
|
||||
-- getter the row calls at execution time when the source is only known then.
|
||||
function BattleState:waitSfxNext(src)
|
||||
self.nextInsert = (self.nextInsert or 0) + 1
|
||||
table.insert(self.queue, self.nextInsert,
|
||||
{ waitSound = type(src) == "function" and src
|
||||
or function() return src end })
|
||||
end
|
||||
|
||||
-- sayNext for a page that ends in `text_end` (see sayAuto) (#765)
|
||||
function BattleState:sayNextAuto(text, delay)
|
||||
self.nextInsert = (self.nextInsert or 0) + 1
|
||||
@@ -1016,22 +1032,39 @@ function BattleState:stepHPDrain()
|
||||
and b.shownHP >= b.drainFloor then
|
||||
goal = b.drainFloor
|
||||
end
|
||||
local maxHP = math.max(1, b.mon.stats.hp)
|
||||
local playerSide = (b == self.player)
|
||||
local targetPx = Timing.hpBarPixels(b.shownHP, maxHP)
|
||||
if not b.shownPx then b.shownPx = targetPx end
|
||||
if (b.drainHold or 0) > 0 then
|
||||
b.drainHold = b.drainHold - 1
|
||||
busy = true
|
||||
elseif b.shownPx ~= targetPx then
|
||||
-- .barAnimationLoop redraws the bar one pixel at a time, `ld c, 2 /
|
||||
-- call DelayFrames` apiece (:141-148), so a single HP point that
|
||||
-- spans several pixels still slides instead of jumping
|
||||
b.shownPx = b.shownPx + ((b.shownPx > targetPx) and -1 or 1)
|
||||
b.drainHold = Timing.HP_BAR_PIXEL_STEP - 1
|
||||
busy = true
|
||||
elseif b.shownHP ~= goal then
|
||||
local maxHP = math.max(1, b.mon.stats.hp)
|
||||
local playerSide = (b == self.player)
|
||||
local cost = 0
|
||||
local spent = 0
|
||||
-- consume whole HP steps until this frame's budget is spent; on the
|
||||
-- enemy HUD several free steps can land in the same frame
|
||||
while b.shownHP ~= goal and cost < 1 do
|
||||
local nextHP = b.shownHP + ((b.shownHP > goal) and -1 or 1)
|
||||
cost = cost + Timing.hpDrainStepFrames(b.shownHP, nextHP,
|
||||
maxHP, playerSide)
|
||||
b.shownHP = nextHP
|
||||
repeat
|
||||
b.shownHP = b.shownHP + ((b.shownHP > goal) and -1 or 1)
|
||||
spent = spent + (playerSide and Timing.HP_BAR_HP_STEP or 0)
|
||||
targetPx = Timing.hpBarPixels(b.shownHP, maxHP)
|
||||
until b.shownHP == goal or targetPx ~= b.shownPx or spent >= 1
|
||||
if spent > 0 then
|
||||
b.drainHold = spent - 1
|
||||
elseif targetPx ~= b.shownPx then
|
||||
-- the enemy HUD printed no number, so this frame is already the
|
||||
-- first of the pixel step the crossing just asked for
|
||||
b.shownPx = b.shownPx + ((b.shownPx > targetPx) and -1 or 1)
|
||||
b.drainHold = Timing.HP_BAR_PIXEL_STEP - 1
|
||||
else
|
||||
b.drainHold = 0
|
||||
end
|
||||
b.drainHold = math.max(0, cost - 1)
|
||||
b.draining = true
|
||||
busy = true
|
||||
elseif b.draining then
|
||||
@@ -1255,13 +1288,13 @@ function BattleState:updateQueue()
|
||||
-- no subanimation player: keep the single-sound fallback (with
|
||||
-- the move's pitch/tempo modifiers; GROWL/ROAR play the
|
||||
-- attacker's cry -- GetMoveSound/IsCryMove)
|
||||
if item.anim == "GROWL" or item.anim == "ROAR" then
|
||||
if self:animationsOn() and (item.anim == "GROWL" or item.anim == "ROAR") then
|
||||
local attacker = item.attackerIsPlayer and self.player or self.enemy
|
||||
if attacker then
|
||||
require("src.core.Sound").playMoveCry(self.data, attacker.mon.species,
|
||||
anim and anim.tempo)
|
||||
end
|
||||
elseif anim and anim.sound then
|
||||
elseif self:animationsOn() and anim and anim.sound then
|
||||
local Sound = require("src.core.Sound")
|
||||
if Sound.playMove then
|
||||
Sound.playMove(self.data, anim)
|
||||
@@ -1361,6 +1394,11 @@ function BattleState:updateQueue()
|
||||
self.current = nil
|
||||
end
|
||||
elseif not (item and item.choice) then
|
||||
if item and item.waitForLearningSfx and not item.soundStarted then
|
||||
item.soundStarted = true
|
||||
self.waitingSound = item.waitForLearningSfx()
|
||||
return true
|
||||
end
|
||||
-- The page is typed out and waiting on the player: PromptText
|
||||
-- (home/text.asm:209-217) writes '▼' at (18,16) and ManualTextScroll
|
||||
-- blinks it until A/B, so the arrow belongs on a finished page and not
|
||||
@@ -1420,7 +1458,7 @@ end
|
||||
function BattleState:playEntranceCry(battler)
|
||||
local mon = battler and battler.mon
|
||||
if not mon then return end
|
||||
require("src.core.Sound").playCry(self.data, mon.species,
|
||||
return require("src.core.Sound").playCry(self.data, mon.species,
|
||||
mon.status == "SLP" and 37 or 11)
|
||||
end
|
||||
|
||||
@@ -1544,7 +1582,23 @@ function BattleState:enter()
|
||||
-- default stays opaque for every other battle and for older saves.
|
||||
self.isOpaque = self:bgMode() ~= "world"
|
||||
self.introSlide = Timing.BATTLE_SLIDE_IN_FRAMES
|
||||
self.showEnemyTrainer = self.kind == "trainer" and self.trainerPic ~= nil
|
||||
-- GetTrainerInformation .linkBattle (home/trainers2.asm:26-31): the link
|
||||
-- foe's pic is RedPicFront whatever either save looks like, and
|
||||
-- InitBattleCommon loads and tilemaps it at hlcoord 12,0 for every
|
||||
-- wIsInBattle == 2 battle (core.asm:6681-6688), the link one included.
|
||||
-- wEnemyMonSpecies2 is zeroed under it, so the pic palette is PAL_MEWMON
|
||||
-- exactly as it is for a trainer.
|
||||
if self.kind == "link" and not self.trainerPic then
|
||||
local frontPath, frontTrueColor =
|
||||
require("src.pokemon.Sprites").playerPath(self.data, "front",
|
||||
{ kind = "battle", battle = self })
|
||||
if frontPath and require("src.render.Assets").exists(frontPath) then
|
||||
self.trainerPic = getImage(frontPath, namedPalette(self.data, "MEWMON"),
|
||||
frontTrueColor)
|
||||
end
|
||||
end
|
||||
self.showEnemyTrainer = (self.kind == "trainer" or self.kind == "link")
|
||||
and self.trainerPic ~= nil
|
||||
-- DrawAllPokeballs (common_text.asm:27) puts the party ball rows AND the
|
||||
-- HUD corner/underline tiles under them (PlacePlayerHUDTiles /
|
||||
-- PlaceEnemyHUDTiles, draw_hud_pokeball_gfx.asm:119-165) on screen with
|
||||
@@ -1576,13 +1630,13 @@ function BattleState:enter()
|
||||
-- a different point in each battle kind, so queue it per branch
|
||||
local function queueEnemyCry()
|
||||
self:act(function()
|
||||
self:playEntranceCry(self.enemy)
|
||||
self:waitSfxNext(self:playEntranceCry(self.enemy))
|
||||
end)
|
||||
end
|
||||
-- PrintBeginningBattleText (engine/battle/common_text.asm:10-19): a wild
|
||||
-- battle calls PlayCry BEFORE PrintText WildMonAppearedText, so the cry
|
||||
-- sounds with the "Wild X appeared!" box instead of waiting on the A
|
||||
-- press that clears its `prompt` (#303). The Silph-Scope-less tower
|
||||
-- battle calls PlayCry BEFORE PrintText WildMonAppearedText, and PlayCry
|
||||
-- ends in WaitForSoundToFinish, so the cry runs to its end and only then
|
||||
-- does the "Wild X appeared!" box open (#303). The Silph-Scope-less tower
|
||||
-- ghost gets no cry at all (common_text.asm:43-48), and neither does the
|
||||
-- unveiled MAROWAK: .isMarowak never reaches PlayCry (#492).
|
||||
if self.kind ~= "trainer" and self.kind ~= "link"
|
||||
@@ -1600,7 +1654,10 @@ function BattleState:enter()
|
||||
-- The sfx is extracted as "Trainer_Appeared" (tools/rom_manifest.json
|
||||
-- sfxHeaders, bank 8 / $42bb -- the same header pokered names
|
||||
-- SFX_Silph_Scope); nothing had ever played it.
|
||||
if self.kind == "trainer" then
|
||||
--
|
||||
-- A link battle is wIsInBattle == 2, so PrintBeginningBattleText takes the
|
||||
-- same .trainerBattle arm and owes the sfx too (common_text.asm:20-23).
|
||||
if self.kind == "trainer" or self.kind == "link" then
|
||||
self:act(function()
|
||||
self.introSfx = require("src.core.Sound").play(self.data,
|
||||
"Trainer_Appeared")
|
||||
@@ -1618,13 +1675,17 @@ function BattleState:enter()
|
||||
-- battle -- not on a switch, and not when the beaten trainer's pic
|
||||
-- scrolls back in (#317, #282)
|
||||
self:act(function() self.introBalls = nil end)
|
||||
if self.kind == "trainer" then
|
||||
-- EnemySendOutFirstMon (core.asm:1308-1310): SlideTrainerPicOffScreen
|
||||
-- walks the foe's pic off the RIGHT edge (hlcoord 18,0, a = 8 tiles,
|
||||
-- one tile every 2 frames) BEFORE TrainerSentOutText -- the pic does
|
||||
-- not blink out under the text (#317)
|
||||
self:act(function() self:slidePic("foe", 0, 64, 4) end)
|
||||
table.insert(self.queue, { wait = 16 })
|
||||
if self.kind == "trainer" or self.kind == "link" then
|
||||
local foeName = self.trainer and self.trainer.name
|
||||
or self.opponentName or Strings("FOE")
|
||||
if self.showEnemyTrainer then
|
||||
-- EnemySendOutFirstMon (core.asm:1308-1310): SlideTrainerPicOffScreen
|
||||
-- walks the foe's pic off the RIGHT edge (hlcoord 18,0, a = 8 tiles,
|
||||
-- one tile every 2 frames) BEFORE TrainerSentOutText -- the pic does
|
||||
-- not blink out under the text (#317)
|
||||
self:act(function() self:slidePic("foe", 0, 64, 4) end)
|
||||
table.insert(self.queue, { wait = 16 })
|
||||
end
|
||||
self:act(function()
|
||||
self.showEnemyTrainer = false
|
||||
-- the slot is EMPTY from here until AnimateSendingOutMon runs below:
|
||||
@@ -1637,7 +1698,7 @@ function BattleState:enter()
|
||||
self.enemySendingOut = true
|
||||
self:slidePic("foe")
|
||||
end)
|
||||
self:say(Strings("%s sent\nout %s!", self.trainer.name, self.enemy.name))
|
||||
self:say(Strings("%s sent\nout %s!", foeName, self.enemy.name))
|
||||
self:act(function()
|
||||
-- EnemySendOutFirstMon (core.asm:1421-1434): after the text the
|
||||
-- pic grows out of the ball (AnimateSendingOutMon), then the cry
|
||||
@@ -1645,18 +1706,6 @@ function BattleState:enter()
|
||||
self:startGrowIn(self.enemy)
|
||||
end)
|
||||
queueEnemyCry()
|
||||
elseif self.kind == "link" then
|
||||
-- Colosseum has no foe trainer pic, but the enemy mon still grows
|
||||
-- out of the ball after "X sent out Y!" (not the wild "already there"
|
||||
-- intro that LinkBattle previously inherited from newWild).
|
||||
self.enemySendingOut = true
|
||||
self:say(Strings("%s sent\nout %s!", self.opponentName or Strings("FOE"),
|
||||
self.enemy.name))
|
||||
self:act(function()
|
||||
self.enemySendingOut = false
|
||||
self:startGrowIn(self.enemy)
|
||||
end)
|
||||
queueEnemyCry()
|
||||
end
|
||||
-- StartBattle .foundFirstAliveEnemyMon (core.asm:152-156): the `call nz`
|
||||
-- gates only EnemySendOutFirstMon -- the `ld c, 40 / call DelayFrames`
|
||||
@@ -1687,7 +1736,7 @@ function BattleState:enter()
|
||||
-- SendOutMon (core.asm:1757-1762): after the poof the mon grows
|
||||
-- out of the ball (AnimateSendingOutMon at hlcoord 4,11)
|
||||
self:startGrowIn(self.player)
|
||||
self:playEntranceCry(self.player)
|
||||
self:waitSfxNext(self:playEntranceCry(self.player))
|
||||
end)
|
||||
self:markParticipant()
|
||||
end
|
||||
@@ -1746,6 +1795,7 @@ end
|
||||
local function sendOutMonCursors(self)
|
||||
self.menuIndex = 1
|
||||
self.moveIndex = 1
|
||||
self.playerMoveListIndex = 1
|
||||
end
|
||||
|
||||
-- core.asm:297-300: both sides' FLINCHED bits are cleared as a turn's move
|
||||
@@ -1864,7 +1914,10 @@ function BattleState:update(dt)
|
||||
if self.phase == "menu" then
|
||||
for _, b in ipairs({ self.player, self.enemy }) do
|
||||
if b then
|
||||
if b.shownHP then b.shownHP = b.mon.hp end
|
||||
if b.shownHP then
|
||||
b.shownHP = b.mon.hp
|
||||
b.shownPx = Timing.hpBarPixels(b.mon.hp, math.max(1, b.mon.stats.hp))
|
||||
end
|
||||
b.drainFloor = nil
|
||||
b.shownStatus = b.mon.status
|
||||
end
|
||||
@@ -2044,8 +2097,10 @@ function BattleState:update(dt)
|
||||
if self.moveSwapIndex then
|
||||
self:swapMoves(self.moveSwapIndex, self.moveIndex)
|
||||
self.moveSwapIndex = nil
|
||||
self.moveIndex = math.min(self.playerMoveListIndex or 1, #moves)
|
||||
else
|
||||
self.moveSwapIndex = self.moveIndex
|
||||
self.moveIndex = math.min(self.playerMoveListIndex or 1, #moves)
|
||||
end
|
||||
elseif input:wasPressed("b") then
|
||||
require("src.core.Sound").play(self.data, "Press_AB")
|
||||
@@ -2068,6 +2123,7 @@ function BattleState:update(dt)
|
||||
self.phase = "messages"
|
||||
self.afterQueue = "menu"
|
||||
else
|
||||
self.playerMoveListIndex = self.moveIndex
|
||||
self:resolveTurn(mv)
|
||||
end
|
||||
end
|
||||
@@ -2279,10 +2335,10 @@ function BattleState:oldManThrow()
|
||||
return
|
||||
end
|
||||
self:ballChain("TOSS_ANIM", true, 3, "POKE_BALL")
|
||||
self:actNext(function()
|
||||
require("src.core.Sound").play(self.data, "Caught_Mon")
|
||||
end)
|
||||
self:sayNext(Strings("All right!\n%s was\ncaught!", self.enemy.name))
|
||||
-- ItemUseBallText05: text_far, sound_caught_mon, text_promptbutton --
|
||||
-- the fanfare follows the caught text and holds the prompt
|
||||
self:sayNextWaitSfx(Strings("All right!\n%s was\ncaught!", self.enemy.name),
|
||||
function() return require("src.core.Sound").play(self.data, "Caught_Mon") end)
|
||||
end)
|
||||
end
|
||||
|
||||
@@ -2423,11 +2479,9 @@ function BattleState:resolveTurn(playerAction)
|
||||
end
|
||||
local order
|
||||
if pFirst then
|
||||
order = { { self.player, self.enemy, playerAction },
|
||||
{ self.enemy, self.player, enemyAction } }
|
||||
order = { { true, playerAction }, { false, enemyAction } }
|
||||
else
|
||||
order = { { self.enemy, self.player, enemyAction },
|
||||
{ self.player, self.enemy, playerAction } }
|
||||
order = { { false, enemyAction }, { true, playerAction } }
|
||||
end
|
||||
|
||||
self.phase = "messages"
|
||||
@@ -2435,7 +2489,9 @@ function BattleState:resolveTurn(playerAction)
|
||||
|
||||
for _, entry in ipairs(order) do
|
||||
self:act(function()
|
||||
self:executeAction(entry[1], entry[2], entry[3])
|
||||
local user = entry[1] and self.player or self.enemy
|
||||
local target = entry[1] and self.enemy or self.player
|
||||
self:executeAction(user, target, entry[2])
|
||||
end)
|
||||
end
|
||||
self:act(function() self:endOfTurn() end)
|
||||
@@ -2466,7 +2522,7 @@ function BattleState:resolveSwitch(newMon)
|
||||
self.sendingOut = false
|
||||
-- SendOutMon (core.asm:1757-1762): poof, then the grow-in
|
||||
self:startGrowIn(self.player)
|
||||
self:playEntranceCry(self.player)
|
||||
self:waitSfxNext(self:playEntranceCry(self.player))
|
||||
end)
|
||||
end)
|
||||
self:act(function()
|
||||
@@ -2945,7 +3001,7 @@ function BattleState:applyHitFx(hit)
|
||||
Sound.play(self.data, hit.sfx)
|
||||
end
|
||||
end
|
||||
if not t or not self:animationsOn() then return end
|
||||
if not t then return end
|
||||
if t == 1 then
|
||||
-- PredefShakeScreenVertically b=8: the window drops by b for 3 frames
|
||||
-- then home for 3, b counting down
|
||||
@@ -3778,7 +3834,7 @@ function BattleState:onFaint(battler)
|
||||
if battler.isPlayer then
|
||||
-- RemoveFaintedPlayerMon (core.asm:1040-1042): the player mon's
|
||||
-- faint plays its ordinary species cry -- no Faint_Fall
|
||||
Sound.playCry(self.data, battler.mon.species)
|
||||
self.faintCry = Sound.playCry(self.data, battler.mon.species)
|
||||
elseif self.kind ~= "wild" then
|
||||
-- FaintEnemyPokemon (core.asm:732-771): the enemy faint plays no
|
||||
-- species cry; trainer battles get SFX_FAINT_FALL, then SFX_FAINT_THUD
|
||||
@@ -3793,6 +3849,11 @@ function BattleState:onFaint(battler)
|
||||
end)
|
||||
self.nextInsert = (self.nextInsert or 0) + 1
|
||||
table.insert(self.queue, self.nextInsert, { wait = Timing.FAINT_SLIDE })
|
||||
if battler.isPlayer then
|
||||
-- RemoveFaintedPlayerMon ends `call PlayCry / jp PrintText`, and PlayCry
|
||||
-- is `jp WaitForSoundToFinish`, so "X fainted!" waits out the cry
|
||||
self:waitSfxNext(function() return self.faintCry end)
|
||||
end
|
||||
if not battler.isPlayer and self.kind ~= "wild" then
|
||||
-- FaintEnemyPokemon's SFX_FAINT_THUD lands as the slide does (after
|
||||
-- Faint_Fall, before EnemyMonFaintedText)
|
||||
@@ -3839,9 +3900,12 @@ function BattleState:awardExp()
|
||||
participants, alive = 1, { self.player.mon }
|
||||
end
|
||||
local function applyShare(mon, split, announce)
|
||||
local playerId = self.game.save.player and self.game.save.player.id
|
||||
local traded = mon.otId ~= nil and playerId ~= nil
|
||||
and mon.otId ~= playerId or mon.traded == true and mon.otId == nil
|
||||
local levels, gained = Experience.apply(self.data, mon, self.enemy.def,
|
||||
self.enemy.mon.level, self.kind == "trainer",
|
||||
split, mon.traded)
|
||||
split, traded)
|
||||
-- Track level-ups for EvolveAfterBattle (OverworldState:afterBattle ->
|
||||
-- Evolution.checkParty). B-cancel leaves the mon at/above threshold;
|
||||
-- without this gate it re-triggers after every later fight (#213).
|
||||
@@ -3865,7 +3929,7 @@ function BattleState:awardExp()
|
||||
local text = Strings.source("%s gained\n%d EXP. Points!")
|
||||
if announce == "expAll" then
|
||||
text = Strings.source("%s gained\nwith EXP.ALL,\v%d EXP. Points!")
|
||||
elseif mon.traded then
|
||||
elseif traded then
|
||||
text = Strings.source("%s gained\na boosted\v%d EXP. Points!")
|
||||
end
|
||||
self:sayNext(Strings(text, name, gained))
|
||||
@@ -3877,9 +3941,11 @@ function BattleState:awardExp()
|
||||
-- experience.asm:248 fires per grew-level text
|
||||
require("src.world.PikachuFollower")
|
||||
.modifyHappiness(game.save, "LEVELUP", mon)
|
||||
self:sayNext(Strings("%s grew\nto level %d!", name, lv))
|
||||
-- GrewLevelText: text_far, sound_level_up, text_end (experience.asm:
|
||||
-- 369-372); PrintStatsBox only runs once PrintText has returned
|
||||
self:sayNextWaitSfx(Strings("%s grew\nto level %d!", name, lv),
|
||||
function() return require("src.core.Sound").play(game.data, "Level_Up") end)
|
||||
self:uiNext(function()
|
||||
require("src.core.Sound").play(game.data, "Level_Up")
|
||||
return StatBox.new(game, mon)
|
||||
end)
|
||||
-- After PrintStatsBox, experience.asm reloads the active battler's
|
||||
@@ -4024,7 +4090,7 @@ function BattleState:enemyMonFainted()
|
||||
self.enemySendingOut = false
|
||||
self:startGrowIn(self.enemy)
|
||||
self:actNext(function()
|
||||
self:playEntranceCry(self.enemy)
|
||||
self:waitSfxNext(self:playEntranceCry(self.enemy))
|
||||
end)
|
||||
end)
|
||||
end)
|
||||
@@ -4063,7 +4129,7 @@ function BattleState:enemyMonFainted()
|
||||
self:actNext(function()
|
||||
self.sendingOut = false
|
||||
self:startGrowIn(self.player)
|
||||
self:playEntranceCry(self.player)
|
||||
self:waitSfxNext(self:playEntranceCry(self.player))
|
||||
end)
|
||||
end)
|
||||
return
|
||||
@@ -4129,15 +4195,17 @@ function BattleState:learnMove(mon, moveId)
|
||||
if #mon.moves < 4 then
|
||||
table.insert(mon.moves, { id = moveId, pp = mdef.pp })
|
||||
Runtime.emit("pokemon.move_learned", { mon = mon, moveId = moveId })
|
||||
self:sayNext(self:romText("_MimicLearnedMoveText", "%s learned\n%s!", mon.nickname or self.data.pokemon[mon.species].name,
|
||||
mdef.name))
|
||||
self:sayNextWaitSfx(self:romText("_MimicLearnedMoveText", "%s learned\n%s!", mon.nickname or self.data.pokemon[mon.species].name,
|
||||
mdef.name), function()
|
||||
return require("src.core.Sound").play(self.data, "Level_Up")
|
||||
end)
|
||||
return
|
||||
end
|
||||
-- the "trying to learn" preamble lives inside MoveLearnMenu:enter;
|
||||
-- ordered insert so multi-level gains keep each level's checks
|
||||
-- between its own stat box and the next "grew to level" text
|
||||
self:uiNext(function()
|
||||
return self:buildScreen("MoveLearnMenu", mon, moveId)
|
||||
return self:buildScreen("MoveLearnMenu", mon, moveId, nil, "Level_Up")
|
||||
end)
|
||||
end
|
||||
|
||||
@@ -4257,7 +4325,7 @@ function BattleState:openReplacementMenu()
|
||||
self.sendingOut = false
|
||||
-- SendOutMon (core.asm:1757-1762): poof, then the grow-in
|
||||
self:startGrowIn(self.player)
|
||||
self:playEntranceCry(self.player)
|
||||
self:waitSfxNext(self:playEntranceCry(self.player))
|
||||
end)
|
||||
end,
|
||||
})
|
||||
@@ -4301,11 +4369,10 @@ function BattleState:safariAction(choice)
|
||||
-- above DoBallTossSpecialEffects's <= ULTRA_BALL check)
|
||||
self:ballChain(self:tossAnimFor("SAFARI_BALL"), caught, shakes, "SAFARI_BALL")
|
||||
if caught then
|
||||
-- ItemUseBallText05's sound_caught_mon: fanfare with the text
|
||||
self:actNext(function()
|
||||
require("src.core.Sound").play(self.data, "Caught_Mon")
|
||||
end)
|
||||
self:sayNext(Strings("All right!\n%s was\ncaught!", self.enemy.name))
|
||||
-- ItemUseBallText05: text_far, sound_caught_mon, text_promptbutton --
|
||||
-- the fanfare follows the caught text and holds the prompt
|
||||
self:sayNextWaitSfx(Strings("All right!\n%s was\ncaught!", self.enemy.name),
|
||||
function() return require("src.core.Sound").play(self.data, "Caught_Mon") end)
|
||||
-- same ItemUseBall .captured flow as a regular ball
|
||||
self:act(function() self:storeCaughtMon() end)
|
||||
else
|
||||
@@ -4533,8 +4600,12 @@ function BattleState:storeCaughtMon()
|
||||
markOwned(game, species)
|
||||
stampOT(game.save, self.enemy.mon)
|
||||
if isNew then
|
||||
-- _ItemUseBallText06 + ShowPokedexData
|
||||
self:sayNext(Strings("New POKéDEX data\nwill be added for\n%s!", self.enemy.name))
|
||||
-- _ItemUseBallText06 + ShowPokedexData: text_far, sound_dex_page_added,
|
||||
-- text_promptbutton (item_effects.asm:624-629), so the fanfare follows
|
||||
-- the box rather than firing when the dex bit is set
|
||||
self:sayNextWaitSfx(
|
||||
Strings("New POKéDEX data\nwill be added for\n%s!", self.enemy.name),
|
||||
function() return require("src.core.Sound").play(self.data, "Dex_Page_Added") end)
|
||||
self:uiNext(function()
|
||||
return self:buildScreen("DexEntryMenu", species)
|
||||
end)
|
||||
@@ -4684,12 +4755,10 @@ function BattleState:throwBall(ball)
|
||||
self:ballChain(self:tossAnimFor(ball), caught, shakes, ball)
|
||||
if caught then
|
||||
-- ItemUseBallText05 carries sound_caught_mon (item_effects.asm:
|
||||
-- 608-614): the fanfare sounds with the caught message, before
|
||||
-- the prompt, not after the text is dismissed
|
||||
self:actNext(function()
|
||||
require("src.core.Sound").play(self.data, "Caught_Mon")
|
||||
end)
|
||||
self:sayNext(Strings("All right!\n%s was\ncaught!", self.enemy.name))
|
||||
-- 608-614): text_far, sound_caught_mon, text_promptbutton -- the
|
||||
-- fanfare follows the caught message and holds the prompt
|
||||
self:sayNextWaitSfx(Strings("All right!\n%s was\ncaught!", self.enemy.name),
|
||||
function() return require("src.core.Sound").play(self.data, "Caught_Mon") end)
|
||||
self:act(function() self:storeCaughtMon() end)
|
||||
else
|
||||
self:sayNext(self:ballMissMessage(shakes))
|
||||
@@ -5176,7 +5245,8 @@ function BattleState:sgbBattlePals()
|
||||
local function bar(b)
|
||||
if not b then return pals.GREENBAR end
|
||||
local hp = b.shownHP or b.mon.hp
|
||||
return pals[PaletteFX.barPalName(hp, b.mon.stats.hp)] or pals.GREENBAR
|
||||
return pals[PaletteFX.barPalName(hp, b.mon.stats.hp, b.shownPx)]
|
||||
or pals.GREENBAR
|
||||
end
|
||||
local function mon(b, placeholder)
|
||||
if placeholder or not b then return pals.MEWMON or pals.GREENBAR end
|
||||
@@ -5574,7 +5644,7 @@ function BattleState:drawHUDs(slide)
|
||||
hudTile(0x73, 8, 16)
|
||||
drawHPBar(barData, 2, 2,
|
||||
{ hp = shownHP(self.enemy), stats = self.enemy.mon.stats },
|
||||
nil, grayFill)
|
||||
nil, grayFill, nil, self.enemy.shownPx)
|
||||
hudTile(0x74, 8, 24)
|
||||
for i = 2, 9 do hudTile(0x76, i * 8, 24) end
|
||||
hudTile(0x78, 80, 24)
|
||||
@@ -5655,7 +5725,7 @@ function BattleState:drawHUDs(slide)
|
||||
end
|
||||
drawHPBar(barData, 10, 9,
|
||||
{ hp = shownHP(self.player), stats = self.player.mon.stats },
|
||||
1, grayFill) -- wHPBarType 1: the $6D cap
|
||||
1, grayFill, nil, self.player.shownPx) -- wHPBarType 1: the $6D cap
|
||||
Font.draw(("%3d/%3d"):format(shownHP(self.player), self.player.mon.stats.hp), 88, 80)
|
||||
hudTile(0x73, 144, 80)
|
||||
hudTile(0x77, 144, 88)
|
||||
@@ -5791,6 +5861,7 @@ function BattleState:drawTextArea()
|
||||
Font.draw(self.data.moves[m.id].name, 16, (7 + i) * 8)
|
||||
end
|
||||
Font.drawCode(0xED, 8, (7 + self.mimicIndex) * 8)
|
||||
Font.draw(Strings("WHICH TECHNIQUE?"), 8, 112)
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -310,8 +310,7 @@ function EffectRegistry.runDamaging(battle, ctx, record)
|
||||
if record and record.afterDamage then
|
||||
record.afterDamage(ctx, totalDealt)
|
||||
elseif moveInst.struggle then
|
||||
-- struggle recoils even when its effect id resolves to no record
|
||||
local recoil = math.max(1, math.floor(dmg / 2))
|
||||
local recoil = math.max(1, math.floor(totalDealt / 2))
|
||||
battle:sayNext(romText(battle.data, "_HitWithRecoilText", "%s's\nhit with recoil!", displayName(user)))
|
||||
battle:applyDamage(user, recoil)
|
||||
end
|
||||
|
||||
@@ -348,8 +348,9 @@ MoveEffects.primary = {
|
||||
target.disabledTurns = battle.rng(1, 8)
|
||||
local id = target.curMoves[slot].id
|
||||
-- _MoveWasDisabledText: "X's / MOVE was / disabled!"
|
||||
return { romText(battle.data, "_MoveWasDisabledText", "%s's\n%s was\ndisabled!", displayName(target),
|
||||
battle.data.moves[id].name) }
|
||||
return { romText(battle.data, "_MoveWasDisabledText", "%s's\n%s was\ndisabled!",
|
||||
{ TARGET = displayName(target),
|
||||
["RAM:wNameBuffer"] = battle.data.moves[id].name }) }
|
||||
end,
|
||||
|
||||
SPLASH_EFFECT = function(battle)
|
||||
@@ -441,11 +442,10 @@ local function hitsFrom(dist, ctx)
|
||||
return dist[r + 1]
|
||||
end
|
||||
|
||||
-- drain_hp.asm halves the RAW wDamage IN PLACE (minimum 1) and heals
|
||||
-- that amount, so Counter would see the halved value
|
||||
-- engine/battle/core.asm ApplyDamageToEnemyPokemon
|
||||
local function drainHalf(label, text)
|
||||
return function(ctx)
|
||||
local heal = math.max(1, math.floor(ctx.rawDamage / 2))
|
||||
local heal = math.max(1, math.floor(ctx.totalDealt / 2))
|
||||
ctx.battle.lastDamage = heal
|
||||
local mon = ctx.user.mon
|
||||
mon.hp = math.min(mon.stats.hp, mon.hp + heal)
|
||||
@@ -529,9 +529,8 @@ MoveEffects.full = {
|
||||
|
||||
RECOIL_EFFECT = {
|
||||
afterDamage = function(ctx)
|
||||
-- recoil.asm reads the RAW computed wDamage (not the HP actually
|
||||
-- removed): overkill and substitute hits recoil at full strength
|
||||
local recoil = math.max(1, math.floor(ctx.rawDamage
|
||||
-- engine/battle/move_effects/recoil.asm
|
||||
local recoil = math.max(1, math.floor(ctx.totalDealt
|
||||
/ (ctx.moveInst.struggle and 2 or 4)))
|
||||
ctx.say(romText(ctx.battle.data, "_HitWithRecoilText", "%s's\nhit with recoil!", displayName(ctx.user)))
|
||||
ctx.battle:applyDamage(ctx.user, recoil)
|
||||
|
||||
@@ -243,7 +243,9 @@ function Status.beforeMove(battler, rng, battle, selectedMoveId)
|
||||
local shown = moves and moves[selectedMoveId] and moves[selectedMoveId].name
|
||||
or tostring(selectedMoveId)
|
||||
table.insert(msgs, romText(battle and battle.data, "_MoveIsDisabledText",
|
||||
"%s's\n%s is\ndisabled!", name(battler), shown))
|
||||
"%s's\n%s is\ndisabled!", {
|
||||
USER = name(battler), ["RAM:wNameBuffer"] = shown,
|
||||
}))
|
||||
return false, msgs
|
||||
end
|
||||
end
|
||||
|
||||
@@ -108,7 +108,7 @@ local function drawStatusPanel(battle, battler, x, y, player)
|
||||
HudTiles.drawHPBar(battle.data, tx + 1, ty + 2, {
|
||||
hp = shownHP(battler),
|
||||
stats = battler.mon.stats,
|
||||
}, nil, monoMode(), tw - 5)
|
||||
}, nil, monoMode(), tw - 5, battler.shownPx)
|
||||
|
||||
if player then
|
||||
Font.draw(("%3d/%3d"):format(shownHP(battler), battler.mon.stats.hp),
|
||||
|
||||
@@ -37,8 +37,10 @@ local Prize = require("src.battle.gen2.Prize")
|
||||
-- * Gen 1's `user` / `target` / `battler` are battler wrappers around a mon
|
||||
-- ({ mon = , name = , isPlayer = }); Gen 2's engine works on the party mon
|
||||
-- table directly, so that is what these payloads carry.
|
||||
-- * Gen 1's `rng` is love.math.random (1..n); Gen 2's injected `random` is
|
||||
-- the cart's BattleRandom convention (0..n-1). Both keys are present.
|
||||
-- * Gen 1's `rng` is love.math.random (rng(n) → 1..n, rng(lo,hi) → lo..hi).
|
||||
-- Gen 2's cart BattleRandom is random(n) → 0..n-1. Both live on the
|
||||
-- battle: `random` / `roller()` are BattleRandom for damage, accuracy,
|
||||
-- Magnitude, etc.; `rng` is the Gen 1 / love-style view of the same stream.
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
-- The two battle lines that carry the cart's own `line` break: a marker-bearing
|
||||
-- literal has to stay reachable from a translation mod (#186, #245), which is
|
||||
@@ -88,6 +90,19 @@ local function rand(random, n)
|
||||
return math.random(n) - 1
|
||||
end
|
||||
|
||||
-- Gen 1 / love.math view of BattleRandom: rng(n) → 1..n, rng(lo,hi) → lo..hi.
|
||||
local function loveStyleRng(random)
|
||||
return function(lo, hi)
|
||||
if hi == nil then
|
||||
local n = lo or 1
|
||||
if n < 1 then n = 1 end
|
||||
return (rand(random, n) or 0) + 1
|
||||
end
|
||||
if hi < lo then lo, hi = hi, lo end
|
||||
return lo + (rand(random, hi - lo + 1) or 0)
|
||||
end
|
||||
end
|
||||
|
||||
-- data/trainers/leaders.asm. The two lists are ONE array in the ROM: only
|
||||
-- KantoGymLeaders carries the -1 terminator, and GymLeaders falls through into
|
||||
-- it, so IsGymLeader matches all twenty-two classes while IsKantoGymLeader
|
||||
@@ -212,12 +227,15 @@ Battle.KANTO_BADGE_ORDER = {
|
||||
-- this wild battle is BATTLETYPE_ROAMING; the caller built `wild`
|
||||
-- through Roamers.beginBattle and reads Battle.roaming back to
|
||||
-- bank the beast's HP afterwards
|
||||
-- random(n) 0..n-1, injected so a test is deterministic
|
||||
-- random(n) 0..n-1, injected so a test is deterministic (BattleRandom)
|
||||
-- rng(lo,hi) / rng(n) Gen 1 / love.math contract; defaults over `random`
|
||||
function Battle.new(opts)
|
||||
opts = opts or {}
|
||||
local self = setmetatable({}, Battle)
|
||||
self.data = opts.data or {}
|
||||
self.random = opts.random or function(n) return rand(nil, n) end
|
||||
-- Same stream as `random`, Gen 1 / love.math calling convention.
|
||||
self.rng = opts.rng or loveStyleRng(self.random)
|
||||
self.party = opts.party or {}
|
||||
self.trainer = opts.trainer
|
||||
self.save = opts.save
|
||||
@@ -403,11 +421,8 @@ function Battle:endBattle(outcome)
|
||||
Runtime.emit("battle.ended", { battle = self, result = outcome })
|
||||
end
|
||||
|
||||
-- A never-nil 0..n-1 roller for the hook contexts. `random` is optional on the
|
||||
-- constructor (a headless test injects one, the game leaves it to love.math),
|
||||
-- and a mod reaching for ctx.rng must not have to know that. Note the
|
||||
-- convention: this is the cart's BattleRandom byte (0..n-1), NOT Gen 1's
|
||||
-- love.math.random (1..n).
|
||||
-- Never-nil BattleRandom (0..n-1) for call sites that want the cart byte.
|
||||
-- `battle.rng` is the Gen 1 / love.math view of the same stream.
|
||||
function Battle:roller()
|
||||
if not self.rollerFn then
|
||||
self.rollerFn = function(n) return rand(self.random, n) end
|
||||
|
||||
@@ -265,8 +265,18 @@ Effects.MAGNITUDE_POWER = {
|
||||
-- is what damagecalc reads as the move's power -- data/moves/moves.asm stores
|
||||
-- MAGNITUDE at power 1 precisely because this overwrites it. Returns the
|
||||
-- power and the magnitude number the text prints.
|
||||
--
|
||||
-- `random` is BattleRandom (0..n-1). If none is supplied, roll via love.math
|
||||
-- / math.random — never hard-code 0 (that always yields Magnitude 4).
|
||||
function Effects.magnitudePower(random)
|
||||
local roll = random and random(256) or 0
|
||||
local roll
|
||||
if type(random) == "function" then
|
||||
roll = random(256) or 0
|
||||
elseif love and love.math and love.math.random then
|
||||
roll = love.math.random(256) - 1
|
||||
else
|
||||
roll = math.random(256) - 1
|
||||
end
|
||||
for _, row in ipairs(Effects.MAGNITUDE_POWER) do
|
||||
if row[1] >= roll then return row[2], row[3] end
|
||||
end
|
||||
|
||||
@@ -334,6 +334,12 @@ function BattleCheckpoint.restore(game, checkpoint, copy)
|
||||
battle.player.mon.hp, battle.player.mon.status
|
||||
battle.enemy.shownHP, battle.enemy.shownStatus =
|
||||
battle.enemy.mon.hp, battle.enemy.mon.status
|
||||
-- the bar's own pixel length is derived HUD state, not captured: it
|
||||
-- settles with shownHP above (UpdateHPBar_AnimateHPBar's end position)
|
||||
local Timing = require("src.core.Timing")
|
||||
for _, b in ipairs({ battle.player, battle.enemy }) do
|
||||
b.shownPx = Timing.hpBarPixels(b.mon.hp, math.max(1, b.mon.stats.hp))
|
||||
end
|
||||
|
||||
local ow = game.overworld
|
||||
if not ow or type(ow.restoreBattleContinuation) ~= "function"
|
||||
|
||||
+11
-2
@@ -1230,8 +1230,17 @@ function Engine.new(data, header, options)
|
||||
engine.tempo = header.tempo
|
||||
engine.tempoLocked = true
|
||||
end
|
||||
for _, spec in ipairs(chip and chip.channels
|
||||
or headerChannels(banks, header)) do
|
||||
local channels = chip and chip.channels or headerChannels(banks, header)
|
||||
if header.startChannels then
|
||||
local byNumber = {}
|
||||
for _, start in ipairs(header.startChannels) do
|
||||
byNumber[start.number] = start.address
|
||||
end
|
||||
for _, spec in ipairs(channels) do
|
||||
spec.address = byNumber[spec.number] or spec.address
|
||||
end
|
||||
end
|
||||
for _, spec in ipairs(channels) do
|
||||
local frameTicks = options.frameTicks
|
||||
local hardware = (spec.number - 1) % 4 + 1
|
||||
if hardware == 4 then
|
||||
|
||||
+44
-15
@@ -73,7 +73,7 @@ end
|
||||
--
|
||||
-- Gold composites its own frame (Game2:draw / drawScene) and pumps its own pad
|
||||
-- (the FixedStep callback in Game2:load), so none of it goes through
|
||||
-- src/render/Renderer.lua or src/core/Game.lua. That explains why the six
|
||||
-- src/render/Renderer.lua or src/core/Game.lua. That explains why the eight
|
||||
-- hooks below never used to fire here; it is not a reason they should not. A
|
||||
-- hook is a contract about a MOMENT in the frame, and Gold has every one of
|
||||
-- these moments -- so each is raised under the Gen 1 NAME with the Gen 1
|
||||
@@ -83,6 +83,7 @@ end
|
||||
-- input.pointer uncaptured pointer events (src/core/Game.lua:887)
|
||||
-- render.zones the palette pass, pre-blit (src/core/Game.lua:505)
|
||||
-- render.compose the whole-window composite (Renderer.lua:759)
|
||||
-- render.output* the normal composed frame (Renderer.lua:1063)
|
||||
-- render.letterbox the void around the 160x144 blit (Renderer.lua:840)
|
||||
-- render.hud screen-space UI over the frame (src/core/Game.lua:521)
|
||||
--
|
||||
@@ -689,10 +690,26 @@ function Game2:usePartyItem(itemId)
|
||||
self:say(result.text)
|
||||
return
|
||||
end
|
||||
self:consumeItem(itemId)
|
||||
if action == "candy" then
|
||||
if action == "stone" then
|
||||
local party = (self.save and self.save.party) or {}
|
||||
local index
|
||||
for i, member in ipairs(party) do
|
||||
if member == mon then index = i break end
|
||||
end
|
||||
Screens.push(self, "Gen2EvolutionAnim", {
|
||||
mon = mon, entry = result.evolution, index = index,
|
||||
party = party, save = self.save,
|
||||
force = true,
|
||||
onDone = function(evolution)
|
||||
if evolution and evolution.evolved then self:consumeItem(itemId) end
|
||||
self.stack:pop()
|
||||
end,
|
||||
})
|
||||
elseif action == "candy" then
|
||||
self:consumeItem(itemId)
|
||||
self:say(result.text, function() self:afterRareCandy(mon, result) end)
|
||||
else
|
||||
self:consumeItem(itemId)
|
||||
self:say(result.text)
|
||||
end
|
||||
end
|
||||
@@ -1366,12 +1383,14 @@ function Game2:draw()
|
||||
local zoned = type(zones) == "table" and zones[1] ~= nil
|
||||
|
||||
-- A present canvas is paid for only when something reads it: the zone pass,
|
||||
-- GBC FX, a mod post-process, or a render.compose subscriber about to be
|
||||
-- handed the finished frame. With none of them the frame draws straight to
|
||||
-- the screen exactly as it always did.
|
||||
-- GBC FX, a mod post-process, render.compose, or an enabled render.output
|
||||
-- subscriber. With none of them the frame draws straight to the screen
|
||||
-- exactly as it always did.
|
||||
local composing = ModRuntime.wantsHook("render.compose")
|
||||
local hasOutputHook = ModRuntime.wantsHook("render.output")
|
||||
and ModRuntime.call("render.output_enabled", function() return false end) == true
|
||||
local scene = nil
|
||||
if zoned or fx or composing or Pipelines.wantsPresent() then
|
||||
if zoned or fx or composing or Pipelines.wantsPresent() or hasOutputHook then
|
||||
scene = self:presentCanvas(1, w, h)
|
||||
end
|
||||
if not scene then
|
||||
@@ -1403,7 +1422,7 @@ function Game2:draw()
|
||||
-- untinted one. On its own the tint rides the final blit and no second
|
||||
-- canvas is paid for.
|
||||
local source = scene
|
||||
local reread = fx or Pipelines.wantsPresent()
|
||||
local reread = fx or Pipelines.wantsPresent() or hasOutputHook
|
||||
if zoned and reread then
|
||||
local tinted = self:presentCanvas(2, w, h)
|
||||
if tinted then
|
||||
@@ -1423,15 +1442,25 @@ function Game2:draw()
|
||||
-- Post-process pipelines run over the finished composite and before GBC
|
||||
-- FX. Each hands back a canvas; with none registered this returns `source`
|
||||
-- unchanged and the frame is byte-identical (Renderer.lua:1058).
|
||||
local scale, _, _, dpi = self:frameFit(w, h)
|
||||
local scale, ox, oy, dpi = self:frameFit(w, h)
|
||||
source = Pipelines.present(source, { width = w, height = h, scale = scale,
|
||||
dpi = dpi, dpiX = dpi, dpiY = dpi }) or source
|
||||
if fx then
|
||||
GBCFX.present(source, self:pixelScale(w, h))
|
||||
else
|
||||
G.setColor(1, 1, 1, 1)
|
||||
G.draw(source, 0, 0)
|
||||
G.setShader()
|
||||
local outputHandled = hasOutputHook
|
||||
and ModRuntime.call("render.output", function() return false end, {
|
||||
canvas = source, width = w, height = h,
|
||||
gameX = ox, gameY = oy,
|
||||
gameWidth = 160 * scale, gameHeight = 144 * scale,
|
||||
scale = scale, dpiX = dpi, dpiY = dpi,
|
||||
generation = 2,
|
||||
}) == true
|
||||
if not outputHandled then
|
||||
if fx then
|
||||
GBCFX.present(source, self:pixelScale(w, h))
|
||||
else
|
||||
G.setColor(1, 1, 1, 1)
|
||||
G.draw(source, 0, 0)
|
||||
G.setShader()
|
||||
end
|
||||
end
|
||||
end
|
||||
G.pop()
|
||||
|
||||
+17
-2
@@ -64,6 +64,7 @@ state = {
|
||||
fanfareResume = false, -- start/resume state.source when the fanfare ends
|
||||
fade = nil, -- active volume-ramp fade-out (see Music.fadeOut)
|
||||
tempo = nil, -- alternate-tempo override in force for `current`
|
||||
start = nil,
|
||||
failed = {}, -- labels whose def could not be started; logged once
|
||||
}
|
||||
|
||||
@@ -215,9 +216,11 @@ function Music.play(data, song, loop, ctx)
|
||||
song = selectSong(song, ctx)
|
||||
|
||||
local tempo = ctx and ctx.tempo or nil
|
||||
local start = ctx and ctx.start or nil
|
||||
-- a hook may silence the cue outright, or swap in a label the dedupe
|
||||
-- below has to compare against
|
||||
if not song or (song == state.current and tempo == state.tempo) then return end
|
||||
if not song or (song == state.current and tempo == state.tempo
|
||||
and start == state.start) then return end
|
||||
local def = songDef(data, song)
|
||||
if not def or state.failed[song] then return end
|
||||
|
||||
@@ -240,6 +243,17 @@ function Music.play(data, song, loop, ctx)
|
||||
slowed.tempo = tempo
|
||||
def = slowed
|
||||
end
|
||||
if start == "rival" and song == "Music_MeetRival"
|
||||
and def.bank == 2 and def.address == 17050 then
|
||||
local started = {}
|
||||
for key, value in pairs(def) do started[key] = value end
|
||||
started.startChannels = {
|
||||
{ number = 1, address = 0x71a2 },
|
||||
{ number = 2, address = 0x721d },
|
||||
{ number = 3, address = 0x72b5 },
|
||||
}
|
||||
def = started
|
||||
end
|
||||
local wantLoop = loop ~= false
|
||||
local src, loopSrc, isChip, err = startSong(data, def, wantLoop)
|
||||
if not src then
|
||||
@@ -276,6 +290,7 @@ function Music.play(data, song, loop, ctx)
|
||||
state.source, state.loopSource, state.chip = src, loopSrc, isChip
|
||||
state.current = song
|
||||
state.tempo = tempo
|
||||
state.start = start
|
||||
if Runtime.wants("music.started") then
|
||||
Runtime.emit("music.started", {
|
||||
song = song, previous = previous, chip = isChip,
|
||||
@@ -290,7 +305,7 @@ function Music.stop()
|
||||
stopSource(state.loopSource)
|
||||
require("src.core.ChipAudio").stopMusic()
|
||||
state.current, state.source, state.loopSource, state.fade = nil, nil, nil, nil
|
||||
state.tempo = nil
|
||||
state.tempo, state.start = nil, nil
|
||||
state.chip = false
|
||||
state.pendingRestore = nil
|
||||
if previous and Runtime.wants("music.stopped") then
|
||||
|
||||
@@ -33,6 +33,14 @@ return function(data, label, fallback, ...)
|
||||
local args = { ... }
|
||||
if #args == 0 then return text end
|
||||
|
||||
if #args == 1 and type(args[1]) == "table" then
|
||||
local values = args[1]
|
||||
return (text:gsub("%b{}", function(token)
|
||||
local value = values[token] or values[token:sub(2, -2)]
|
||||
return value == nil and token or tostring(value)
|
||||
end))
|
||||
end
|
||||
|
||||
local slots, named = 0, 0
|
||||
for token in text:gmatch("%b{}") do
|
||||
slots = slots + 1
|
||||
|
||||
+63
-8
@@ -304,6 +304,10 @@ function SaveData.defaultOptions()
|
||||
-- options.lua written before this key keeps its exact meaning. Read and
|
||||
-- written through SaveData.modEnabled / SaveData.setModEnabled.
|
||||
modsByVersion = {},
|
||||
-- Set after the first per-game enablement migration. Older options files
|
||||
-- have only `mods`, so the migration copies each installed mod's current
|
||||
-- answer to every game before game-specific toggles begin changing it.
|
||||
modsByVersionMigrated = false,
|
||||
-- Named setups the player can switch between (#593; src/mods/ModProfile.lua
|
||||
-- owns the shape, src/mods/ManagerState.lua the UI): each row is
|
||||
-- { name, enabled = {id=bool}, options = {id={k=v}}, slots = {version=slotId} }.
|
||||
@@ -563,19 +567,68 @@ end
|
||||
-- only holds the games the player actually chose for, so a mod set can differ
|
||||
-- between Red and Gold without either one owning the other's flags.
|
||||
|
||||
-- Whether a per-game answer is honoured at boot. The loader reads the enable
|
||||
-- flags once, before any entry chunk (src/mods/Loader.lua _loadState), so this
|
||||
-- flips on with that read and not before: until then every writer keeps to the
|
||||
-- shared flag and no surface promises what the boot does not do.
|
||||
SaveData.PER_VERSION_MODS = false
|
||||
-- Per-game answers are live. Every reader and writer goes through modScope,
|
||||
-- so a choice made in the launcher is the choice the next boot loads.
|
||||
SaveData.PER_VERSION_MODS = true
|
||||
|
||||
-- The version a write should be scoped to: the game asked for once per-game
|
||||
-- flags are live, nil (the shared flag) while they are only a preview.
|
||||
-- The version a write is scoped to: per-game controls name one game; a nil
|
||||
-- caller still addresses the legacy shared fallback.
|
||||
function SaveData.modScope(version)
|
||||
if SaveData.PER_VERSION_MODS then return version end
|
||||
return nil
|
||||
end
|
||||
|
||||
-- Promote an installation that predates per-game flags. `mods` may contain
|
||||
-- manifest rows ({ id, experimental }) or bare ids. A mod with no old entry
|
||||
-- had the loader default: on, except for experimental mods. Copy that answer
|
||||
-- to every game once, preserving any per-game overlay somebody imported before
|
||||
-- this feature shipped. New installs need no rows here: an absent answer
|
||||
-- still defaults to enabled for every game.
|
||||
function SaveData.migrateModEnablement(options, mods)
|
||||
if type(options) ~= "table" or options.modsByVersionMigrated then return false end
|
||||
options.mods = type(options.mods) == "table" and options.mods or {}
|
||||
options.modsByVersion = type(options.modsByVersion) == "table"
|
||||
and options.modsByVersion or {}
|
||||
|
||||
local known = {}
|
||||
for id in pairs(options.mods) do
|
||||
if type(id) == "string" and id ~= "" then known[id] = { id = id } end
|
||||
end
|
||||
for version, bucket in pairs(options.modsByVersion) do
|
||||
if GameVersion.VERSIONS[version] and type(bucket) == "table" then
|
||||
for id in pairs(bucket) do
|
||||
if type(id) == "string" and id ~= "" then known[id] = known[id] or { id = id } end
|
||||
end
|
||||
end
|
||||
end
|
||||
for _, mod in ipairs(mods or {}) do
|
||||
local id = type(mod) == "table" and mod.id or mod
|
||||
if type(id) == "string" and id ~= "" then
|
||||
known[id] = type(mod) == "table" and mod or (known[id] or { id = id })
|
||||
end
|
||||
end
|
||||
|
||||
-- Do not create options.lua just to record an empty migration on a fresh
|
||||
-- no-mod boot. Keep it pending until there is a real installed or saved
|
||||
-- mod answer to preserve.
|
||||
if next(known) == nil then return false end
|
||||
|
||||
for id, mod in pairs(known) do
|
||||
local shared = options.mods[id]
|
||||
if type(shared) ~= "boolean" then shared = not (mod.experimental == true) end
|
||||
for _, version in ipairs(GameVersion.ORDER) do
|
||||
local bucket = options.modsByVersion[version]
|
||||
if type(bucket) ~= "table" then
|
||||
bucket = {}
|
||||
options.modsByVersion[version] = bucket
|
||||
end
|
||||
if type(bucket[id]) ~= "boolean" then bucket[id] = shared end
|
||||
end
|
||||
end
|
||||
options.modsByVersionMigrated = true
|
||||
return true
|
||||
end
|
||||
|
||||
-- true/false as chosen for `version`, else the shared flag, else nil -- the
|
||||
-- caller owns the default (the loader enables, the launcher keeps
|
||||
-- experimental mods off until asked).
|
||||
@@ -1834,6 +1887,8 @@ function SaveData.newGame(boot)
|
||||
boot = type(boot) == "table" and boot or {}
|
||||
local map = boot.startMap or "REDS_HOUSE_2F"
|
||||
local x, y = boot.startX or 3, boot.startY or 6
|
||||
local facing = boot.startFacing or "down"
|
||||
if map == "REDS_HOUSE_2F" and boot.version ~= "yellow" then facing = "up" end
|
||||
local heal = SaveData.defaultHeal(boot)
|
||||
local save = {
|
||||
meta = { format = Version.saveFormat, mods = {} },
|
||||
@@ -1844,7 +1899,7 @@ function SaveData.newGame(boot)
|
||||
map = map,
|
||||
x = x,
|
||||
y = y,
|
||||
facing = boot.startFacing or "down",
|
||||
facing = facing,
|
||||
name = boot.playerName or "RED",
|
||||
rival = boot.rivalName or "BLUE",
|
||||
-- 16-bit trainer ID rolled at new game (wPlayerID, filled from
|
||||
|
||||
@@ -450,6 +450,20 @@ record("RARE_CANDY", "candy", function(ctx)
|
||||
return rareCandy(ctx.mon, ctx.data)
|
||||
end)
|
||||
|
||||
for _, itemId in ipairs({ "SUN_STONE", "MOON_STONE", "FIRE_STONE",
|
||||
"THUNDERSTONE", "WATER_STONE", "LEAF_STONE" }) do
|
||||
record(itemId, "stone", function(ctx)
|
||||
if ctx.mon.item == "EVERSTONE" then
|
||||
return { used = false, text = ItemEffects.TEXT_NO_EFFECT }
|
||||
end
|
||||
local Evolution = require("src.core.gen2.Evolution")
|
||||
local entry = Evolution.checkMon(ctx.data, ctx.mon,
|
||||
{ force = true, item = ctx.item })
|
||||
if not entry then return { used = false, text = ItemEffects.TEXT_NO_EFFECT } end
|
||||
return { used = true, evolution = entry }
|
||||
end)
|
||||
end
|
||||
|
||||
for itemId in pairs(ItemEffects.REVIVE) do
|
||||
record(itemId, "revive", function(ctx) return revive(ctx.item, ctx.mon) end)
|
||||
end
|
||||
|
||||
@@ -116,6 +116,8 @@ local physfsMountFn = nil
|
||||
local function resolveMount()
|
||||
if physfsMountFn ~= nil then return physfsMountFn end
|
||||
physfsMountFn = false
|
||||
if Platform.isUWP() then return physfsMountFn end
|
||||
if love and love.filesystem and love.filesystem._mounts then return physfsMountFn end
|
||||
local ok, ffi = pcall(require, "ffi")
|
||||
if not ok then return physfsMountFn end
|
||||
pcall(ffi.cdef,
|
||||
@@ -159,6 +161,8 @@ local physfsUnmountFn = nil
|
||||
local function resolveUnmount()
|
||||
if physfsUnmountFn ~= nil then return physfsUnmountFn end
|
||||
physfsUnmountFn = false
|
||||
if Platform.isUWP() then return physfsUnmountFn end
|
||||
if love and love.filesystem and love.filesystem._mounts then return physfsUnmountFn end
|
||||
local ok, ffi = pcall(require, "ffi")
|
||||
if not ok then return physfsUnmountFn end
|
||||
pcall(ffi.cdef, "int PHYSFS_unmount(const char *oldDir);")
|
||||
|
||||
+1136
-193
File diff suppressed because it is too large
Load Diff
+156
-11
@@ -1228,8 +1228,8 @@ function RomImporter.new(onComplete, opts)
|
||||
saveNotice = {},
|
||||
-- MODS panel state (pass 3): mods is the cached LauncherMods.list() array
|
||||
-- (refreshed lazily on first draw and after any toggle/install/delete);
|
||||
-- modScroll is the list scroll offset (px, clamped in draw); modNotice is
|
||||
-- the last install/delete result { ok, text } shown as a line above the list.
|
||||
-- modScroll is the current paged list's inner scroll offset (px, clamped
|
||||
-- in draw); modNotice is the last install/delete result { ok, text }.
|
||||
mods = nil, modScroll = 0, modNotice = nil,
|
||||
-- Which game the MODS panel is answering for (a GameVersion id, nil =
|
||||
-- every game). Rows resolve their enable-state and their "runs here"
|
||||
@@ -1649,7 +1649,7 @@ end
|
||||
function RomImporter:_installMod(source)
|
||||
if self.workState == "working" then return end
|
||||
self.tab = "mods"
|
||||
local ok, installed, res = pcall(function()
|
||||
local ok, installed, res, manifest = pcall(function()
|
||||
local LauncherMods = require("src.mods.LauncherMods")
|
||||
return LauncherMods.installZip(source)
|
||||
end)
|
||||
@@ -1661,6 +1661,17 @@ function RomImporter:_installMod(source)
|
||||
if installed then
|
||||
pcall(self._refreshMods, self)
|
||||
self.modNotice = { ok = true, text = "Installed " .. tostring(res) }
|
||||
local LauncherMods = require("src.mods.LauncherMods")
|
||||
local checkTarget = manifest
|
||||
if not checkTarget and type(res) == "string" then
|
||||
checkTarget = { id = res }
|
||||
end
|
||||
if checkTarget and LauncherMods.checkDependencies then
|
||||
local depCheck = LauncherMods.checkDependencies(checkTarget)
|
||||
if depCheck and depCheck.hasIssues then
|
||||
self._modDepResolver = depCheck
|
||||
end
|
||||
end
|
||||
else
|
||||
self.modNotice = { ok = false, text = tostring(res) }
|
||||
end
|
||||
@@ -2048,6 +2059,15 @@ end
|
||||
|
||||
function RomImporter:update(dt)
|
||||
self.pulse = self.pulse + dt
|
||||
if self._launchFade then
|
||||
self._launchFade.elapsed = self._launchFade.elapsed + dt
|
||||
if self._launchFade.elapsed >= self._launchFade.duration then
|
||||
local version = self._launchFade.version
|
||||
self._launchFade = nil
|
||||
self:play(version)
|
||||
return
|
||||
end
|
||||
end
|
||||
self:_updatePadCursor(dt)
|
||||
self:_stepBaseRomScan()
|
||||
-- Pump the FlexLove view (input polling + the queued click actions). The
|
||||
@@ -2436,9 +2456,15 @@ function RomImporter:joystickhat(joystick, hat, direction)
|
||||
end
|
||||
|
||||
-- Player pressed Play on a game whose ROM is imported: hand off to boot.
|
||||
function RomImporter:play(version)
|
||||
function RomImporter:play(version, fade)
|
||||
if self.workState == "working" then return end
|
||||
if not self.ready[version] then return end
|
||||
if fade then
|
||||
if not self._launchFade then
|
||||
self._launchFade = { version = version, elapsed = 0, duration = 0.24 }
|
||||
end
|
||||
return
|
||||
end
|
||||
self._handedOff = true
|
||||
-- #835: remember the game being launched so the next launcher start opens on
|
||||
-- its column (_applyLastVersionTab). It rides options.lua rather than a file
|
||||
@@ -2701,6 +2727,43 @@ function RomImporter:fileUrl(path)
|
||||
end
|
||||
|
||||
function RomImporter:keypressed(key)
|
||||
if self._profileSavePrompt then
|
||||
if key == "backspace" then
|
||||
self._profileSavePrompt.text = utf8Back(self._profileSavePrompt.text or "")
|
||||
elseif key == "return" or key == "kpenter" then
|
||||
local txt = self._profileSavePrompt and self._profileSavePrompt.text
|
||||
if txt and txt ~= "" then
|
||||
local LauncherMods = require("src.mods.LauncherMods")
|
||||
LauncherMods.saveProfile(txt)
|
||||
self._profileSavePrompt = nil
|
||||
self:_disarmTextInput()
|
||||
if self._refreshMods then self:_refreshMods() end
|
||||
end
|
||||
elseif key == "escape" then
|
||||
self._profileSavePrompt = nil
|
||||
self:_disarmTextInput()
|
||||
end
|
||||
return
|
||||
end
|
||||
if self._profileRenamePrompt then
|
||||
if key == "backspace" then
|
||||
self._profileRenamePrompt.text = utf8Back(self._profileRenamePrompt.text or "")
|
||||
elseif key == "return" or key == "kpenter" then
|
||||
local txt = self._profileRenamePrompt and self._profileRenamePrompt.text
|
||||
local old = self._profileRenamePrompt and self._profileRenamePrompt.oldName
|
||||
if txt and txt ~= "" and old then
|
||||
local LauncherMods = require("src.mods.LauncherMods")
|
||||
LauncherMods.renameProfile(old, txt)
|
||||
self._profileRenamePrompt = nil
|
||||
self:_disarmTextInput()
|
||||
if self._refreshMods then self:_refreshMods() end
|
||||
end
|
||||
elseif key == "escape" then
|
||||
self._profileRenamePrompt = nil
|
||||
self:_disarmTextInput()
|
||||
end
|
||||
return
|
||||
end
|
||||
if self._settingsText then
|
||||
if key == "backspace" then
|
||||
self._settingsText.text = utf8Back(self._settingsText.text)
|
||||
@@ -2870,6 +2933,14 @@ function RomImporter:_commitRename()
|
||||
end
|
||||
|
||||
function RomImporter:textinput(text)
|
||||
if self._profileSavePrompt then
|
||||
self._profileSavePrompt.text = utf8Cap((self._profileSavePrompt.text or "") .. text, MAX_SLOT_LABEL)
|
||||
return
|
||||
end
|
||||
if self._profileRenamePrompt then
|
||||
self._profileRenamePrompt.text = utf8Cap((self._profileRenamePrompt.text or "") .. text, MAX_SLOT_LABEL)
|
||||
return
|
||||
end
|
||||
if self._settingsText then
|
||||
local st = self._settingsText
|
||||
st.text = utf8Cap(st.text .. text, st.maxLen or MAX_SLOT_LABEL)
|
||||
@@ -3064,15 +3135,19 @@ function RomImporter:_modUpdateInfo(id)
|
||||
return self.modUpdateInfo and self.modUpdateInfo[id] or nil
|
||||
end
|
||||
|
||||
-- Flip a mod's enabled flag (persisted via LauncherMods.setEnabled) and relist
|
||||
-- so the toggle, count, and every status chip reflect the new resolution.
|
||||
-- Enabling an experimental mod arms a confirm first.
|
||||
function RomImporter:_toggleMod(id, confirmed)
|
||||
-- Flip one game's mod flag (persisted via LauncherMods.setEnabled) and relist
|
||||
-- so that game's checkbox and status chips reflect the new resolution.
|
||||
-- Enabling an experimental mod arms a confirmation for that same game.
|
||||
function RomImporter:_toggleMod(id, confirmed, version)
|
||||
local LauncherMods = require("src.mods.LauncherMods")
|
||||
local cur, experimental = false, false
|
||||
for _, m in ipairs(self.mods or {}) do
|
||||
if m.id == id then
|
||||
cur = m.enabled
|
||||
if version and m.enabledByVersion then
|
||||
cur = m.enabledByVersion[version] == true
|
||||
else
|
||||
cur = m.enabled
|
||||
end
|
||||
experimental = m.experimental == true
|
||||
break
|
||||
end
|
||||
@@ -3080,7 +3155,7 @@ function RomImporter:_toggleMod(id, confirmed)
|
||||
local want = not cur
|
||||
if want and experimental and not confirmed then
|
||||
self._modConfirm = {
|
||||
kind = "experimental", id = id,
|
||||
kind = "experimental", id = id, version = version,
|
||||
title = "Experimental mod",
|
||||
yesLabel = "Enable",
|
||||
lines = {
|
||||
@@ -3092,7 +3167,7 @@ function RomImporter:_toggleMod(id, confirmed)
|
||||
return
|
||||
end
|
||||
self._modConfirm = nil
|
||||
LauncherMods.setEnabled(id, want, self.modScope)
|
||||
LauncherMods.setEnabled(id, want, version or self.modScope)
|
||||
self:_refreshMods()
|
||||
end
|
||||
|
||||
@@ -3352,6 +3427,76 @@ function RomImporter:_pumpModInstall()
|
||||
else
|
||||
self.modNotice = { ok = true, text = text }
|
||||
end
|
||||
local LauncherMods = require("src.mods.LauncherMods")
|
||||
local depCheck = LauncherMods.checkDependencies({ id = spec.modId })
|
||||
if depCheck and depCheck.hasIssues then
|
||||
self._modDepResolver = depCheck
|
||||
end
|
||||
end
|
||||
|
||||
-- Start an async pull for a single dependency
|
||||
function RomImporter:_startDepPull(dep)
|
||||
if not dep or not dep.github then return end
|
||||
self._depPullState = self._depPullState or {}
|
||||
local hFetch = require("src.mods.ModUpdate").beginFetchReleases(dep.github, dep.id, { force = true })
|
||||
self._depPullState[dep.id] = {
|
||||
dep = dep,
|
||||
stage = "fetching",
|
||||
fetchHandle = hFetch,
|
||||
}
|
||||
end
|
||||
|
||||
-- Pump all in-flight dependency pulls
|
||||
function RomImporter:_pumpDepPulls()
|
||||
if not self._depPullState then return end
|
||||
local ModUpdate = require("src.mods.ModUpdate")
|
||||
local LauncherMods = require("src.mods.LauncherMods")
|
||||
|
||||
for depId, state in pairs(self._depPullState) do
|
||||
if state.stage == "fetching" then
|
||||
local done, releases, err = ModUpdate.pumpFetchReleases(state.fetchHandle)
|
||||
if done then
|
||||
if err or not releases or #releases == 0 then
|
||||
state.stage = "error"
|
||||
state.err = err or "No downloadable releases found on GitHub"
|
||||
else
|
||||
local rel = releases[1]
|
||||
if not rel or not rel.zip or not rel.zip.url then
|
||||
state.stage = "error"
|
||||
state.err = "Latest release has no downloadable .zip asset"
|
||||
else
|
||||
local tmpName = ("dep_%s_%s.zip"):format(depId, tostring(rel.version or os.time()))
|
||||
state.dlHandle = ModUpdate.beginDownloadZip(rel.zip.url, tmpName, rel.zip.size)
|
||||
state.stage = "downloading"
|
||||
state.targetVersion = rel.version
|
||||
end
|
||||
end
|
||||
end
|
||||
elseif state.stage == "downloading" then
|
||||
local done, localPath, err, progress = ModUpdate.pumpDownloadZip(state.dlHandle)
|
||||
state.progress = progress
|
||||
if done then
|
||||
if err or not localPath then
|
||||
state.stage = "error"
|
||||
state.err = err or "Download failed"
|
||||
else
|
||||
state.stage = "installing"
|
||||
local okInst, versionRes = LauncherMods.installDownloadedZip(depId, localPath, state.targetVersion)
|
||||
if okInst then
|
||||
state.stage = "done"
|
||||
pcall(self._refreshMods, self)
|
||||
if self._modDepResolver and self._modDepResolver.targetMod then
|
||||
local updated = LauncherMods.checkDependencies(self._modDepResolver.targetMod)
|
||||
self._modDepResolver = updated
|
||||
end
|
||||
else
|
||||
state.stage = "error"
|
||||
state.err = tostring(versionRes or "Installation failed")
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
function RomImporter:_confirmModUpdate(modId, release)
|
||||
|
||||
@@ -210,6 +210,17 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
|
||||
-- (ItemUsePokeFlute, engine/items/item_effects.asm); never consumed.
|
||||
if itemId == "POKE_FLUTE" then
|
||||
if not battle then
|
||||
if ow and ow.map and ow.map.id == "PEWTER_POKECENTER"
|
||||
and ow.pikachuPewterSleepScene then
|
||||
local Follower = require("src.world.PikachuFollower")
|
||||
local pika = Follower.current(ow)
|
||||
local player = ow.player
|
||||
if pika and player
|
||||
and math.abs(pika.cellX - player.cellX) + math.abs(pika.cellY - player.cellY) == 1 then
|
||||
return "flute_wake_pikachu", { romText(data, "_PlayedFluteHadEffectText",
|
||||
"{PLAYER} played the\nPOKé FLUTE.") }
|
||||
end
|
||||
end
|
||||
-- standing next to a not-yet-beaten Snorlax: this is the ONLY way
|
||||
-- Snorlax wakes -- using the flute from the item-use menu, never
|
||||
-- just talking to it with the flute in the bag (see
|
||||
|
||||
+120
-11
@@ -9,15 +9,16 @@
|
||||
-- cable pull.
|
||||
--
|
||||
-- Cable rules: no experience, no money, no items; either side may RUN
|
||||
-- (a draw); a fainted mon is auto-replaced by the next healthy party
|
||||
-- member (the original prompts; documented divergence). Badge stat
|
||||
-- boosts don't apply on either side (divergence: Gen 1 famously kept
|
||||
-- them in link battles).
|
||||
-- (a draw); a fainted mon is replaced from the party menu and the chosen
|
||||
-- slot rides the wire, the way ChooseNextMon hands it to
|
||||
-- LinkBattleExchangeData. Badge stat boosts don't apply on either side
|
||||
-- (divergence: Gen 1 famously kept 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 Party = require("src.pokemon.Party")
|
||||
local Protocol = require("src.link.Protocol")
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
local TurnOrder = require("src.battle.TurnOrder")
|
||||
@@ -257,7 +258,9 @@ function LinkBattle.new(game, net, opts)
|
||||
self.enemyParty = theirParty
|
||||
self.playerParty = myParty -- intro ball row uses the clamped copies
|
||||
self.opponentName = theirName
|
||||
self.introText = Strings("%s wants\nto battle!", theirName)
|
||||
-- _TrainerWantsToFightText (data/text/text_2.asm:1257): wIsInBattle == 2
|
||||
-- takes PrintBeginningBattleText's .trainerBattle arm, link included
|
||||
self.introText = Strings("%s wants\nto fight!", theirName)
|
||||
self.remoteHashes = {}
|
||||
self.localHashes = {}
|
||||
self.remoteParts = {}
|
||||
@@ -327,6 +330,68 @@ function LinkBattle.new(game, net, opts)
|
||||
end)
|
||||
end
|
||||
|
||||
-- ChooseNextMon (engine/battle/core.asm:1086-1103): the replacement after
|
||||
-- a faint is a free party-menu pick in a link battle too -- DisplayPartyMenu
|
||||
-- runs first, a fainted pick or a cancel goes back to it
|
||||
-- (.goBackToPartyMenu), and only the chosen slot rides
|
||||
-- LinkBattleExchangeData.
|
||||
local function chooseReplacement(s)
|
||||
s.linkReplacement = nil
|
||||
s:uiNext(function()
|
||||
return s:buildScreen("PartyMenu", {
|
||||
battle = s,
|
||||
party = myParty,
|
||||
forceSwitch = true,
|
||||
onSwitch = function(mon)
|
||||
if mon.hp > 0 then s.linkReplacement = mon end
|
||||
end,
|
||||
})
|
||||
end)
|
||||
s:actNext(function()
|
||||
local mon = s.linkReplacement
|
||||
s.linkReplacement = nil
|
||||
if s.result then return end
|
||||
if not mon then
|
||||
chooseReplacement(s)
|
||||
return
|
||||
end
|
||||
for i, m in ipairs(myParty) do
|
||||
if m == mon then send({ type = "replace", index = i }) break end
|
||||
end
|
||||
sendOutPlayer(s, mon)
|
||||
end)
|
||||
end
|
||||
|
||||
-- ReplaceFaintedEnemyMon (core.asm:892-905) reads the peer's slot back out
|
||||
-- of the exchange, and EnemySendOutFirstMon (core.asm:1315-1320) decodes it
|
||||
-- as wSerialExchangeNybbleReceiveData - 4. HandlePlayerMonFainted
|
||||
-- (core.asm:989-996) always runs ChooseNextMon BEFORE that read, so on a
|
||||
-- double faint both machines commit their own slot before they block on the
|
||||
-- peer's; our queue can reach the enemy's handler first, so the wait
|
||||
-- pre-empts itself with the local pick rather than deadlocking on a message
|
||||
-- neither side is going to send.
|
||||
local function awaitReplacement(s)
|
||||
s:actNext(function()
|
||||
if s.result then return end
|
||||
if s.player.mon.hp <= 0 and not s.linkChoiceQueued
|
||||
and Party.firstHealthy(myParty) then
|
||||
s.linkChoiceQueued = true
|
||||
chooseReplacement(s)
|
||||
awaitReplacement(s)
|
||||
return
|
||||
end
|
||||
local idx = s.remoteReplace
|
||||
if not idx then
|
||||
awaitReplacement(s)
|
||||
return
|
||||
end
|
||||
s.remoteReplace = nil
|
||||
local mon = theirParty[idx]
|
||||
if not mon or mon.hp <= 0 then mon = Party.firstHealthy(theirParty) end
|
||||
sendOutEnemy(s, mon)
|
||||
end)
|
||||
end
|
||||
|
||||
-- decode a remote action message against the enemy battler
|
||||
local function decodeTheirAction(s, msg)
|
||||
return decodeWireAction(s, msg, s.enemy)
|
||||
@@ -540,12 +605,17 @@ function LinkBattle.new(game, net, opts)
|
||||
submit(s, { type = "action", kind = "run" }, nil)
|
||||
end
|
||||
|
||||
-- fainted mons auto-replace with the next healthy teammate, in party
|
||||
-- order, identically on both machines
|
||||
-- linkChoiceQueued: awaitReplacement already pre-empted itself with this
|
||||
-- side's ChooseNextMon and put the slot on the wire, so the handler the
|
||||
-- faint queued has nothing left to do but drop the flag
|
||||
self.playerMonFainted = function(s)
|
||||
if s.linkChoiceQueued then
|
||||
s.linkChoiceQueued = nil
|
||||
return
|
||||
end
|
||||
for _, mon in ipairs(myParty) do
|
||||
if mon.hp > 0 then
|
||||
s:act(function() sendOutPlayer(s, mon) end)
|
||||
if not s.result then chooseReplacement(s) end
|
||||
return
|
||||
end
|
||||
end
|
||||
@@ -558,7 +628,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() sendOutEnemy(s, mon) end)
|
||||
if not s.result then awaitReplacement(s) end
|
||||
return
|
||||
end
|
||||
end
|
||||
@@ -579,6 +649,9 @@ function LinkBattle.new(game, net, opts)
|
||||
s.remoteHashes[msg.turn or 0] = msg.value
|
||||
s.remoteParts[msg.turn or 0] = msg.parts
|
||||
checkHashes(s)
|
||||
elseif msg.type == "replace" then
|
||||
local idx = math.floor(tonumber(msg.index) or 1)
|
||||
s.remoteReplace = math.max(1, math.min(#theirParty, idx))
|
||||
elseif msg.type == "bye" then
|
||||
-- only a draw if our own simulation hasn't already decided
|
||||
-- (the winner's bye can arrive while we're still animating)
|
||||
@@ -770,6 +843,34 @@ function LinkBattle.newSpectator(game, net, opts)
|
||||
end)
|
||||
end
|
||||
|
||||
local function awaitHostReplacement(s)
|
||||
s:actNext(function()
|
||||
if s.result then return end
|
||||
local idx = table.remove(s.hostReplace, 1)
|
||||
if not idx then
|
||||
awaitHostReplacement(s)
|
||||
return
|
||||
end
|
||||
local mon = hostParty[idx]
|
||||
if not mon or mon.hp <= 0 then mon = Party.firstHealthy(hostParty) end
|
||||
sendOutHost(s, mon)
|
||||
end)
|
||||
end
|
||||
|
||||
local function awaitGuestReplacement(s)
|
||||
s:actNext(function()
|
||||
if s.result then return end
|
||||
local idx = table.remove(s.guestReplace, 1)
|
||||
if not idx then
|
||||
awaitGuestReplacement(s)
|
||||
return
|
||||
end
|
||||
local mon = guestParty[idx]
|
||||
if not mon or mon.hp <= 0 then mon = Party.firstHealthy(guestParty) end
|
||||
sendOutGuest(s, mon)
|
||||
end)
|
||||
end
|
||||
|
||||
local function resolveSpecTurn(s, hostMsg, guestMsg)
|
||||
if hostMsg.kind == "run" or guestMsg.kind == "run" then
|
||||
endSpectate(s, "The match ended.")
|
||||
@@ -840,7 +941,7 @@ function LinkBattle.newSpectator(game, net, opts)
|
||||
self.playerMonFainted = function(s)
|
||||
for _, mon in ipairs(hostParty) do
|
||||
if mon.hp > 0 then
|
||||
s:act(function() sendOutHost(s, mon) end)
|
||||
if not s.result then awaitHostReplacement(s) end
|
||||
return
|
||||
end
|
||||
end
|
||||
@@ -852,7 +953,7 @@ function LinkBattle.newSpectator(game, net, opts)
|
||||
self.enemyMonFainted = function(s)
|
||||
for _, mon in ipairs(guestParty) do
|
||||
if mon.hp > 0 then
|
||||
s:act(function() sendOutGuest(s, mon) end)
|
||||
if not s.result then awaitGuestReplacement(s) end
|
||||
return
|
||||
end
|
||||
end
|
||||
@@ -862,6 +963,7 @@ function LinkBattle.newSpectator(game, net, opts)
|
||||
end
|
||||
|
||||
self.hostMsg, self.guestMsg = nil, nil
|
||||
self.hostReplace, self.guestReplace = {}, {}
|
||||
local baseUpdate = self.update
|
||||
self.update = function(s, dt)
|
||||
net:update()
|
||||
@@ -875,6 +977,13 @@ function LinkBattle.newSpectator(game, net, opts)
|
||||
s.hostMsg, s.guestMsg = nil, nil
|
||||
resolveSpecTurn(s, h, g)
|
||||
end
|
||||
elseif inner.type == "replace" then
|
||||
local idx = math.floor(tonumber(inner.index) or 1)
|
||||
if msg.side == "host" then
|
||||
table.insert(s.hostReplace, math.max(1, math.min(#hostParty, idx)))
|
||||
else
|
||||
table.insert(s.guestReplace, math.max(1, math.min(#guestParty, idx)))
|
||||
end
|
||||
elseif inner.type == "bye" or inner.type == "forfeit" then
|
||||
if not s.result then endSpectate(s, "The match ended.") end
|
||||
end
|
||||
|
||||
+87
-17
@@ -19,6 +19,7 @@ LinkState.__index = LinkState
|
||||
LinkState.isOpaque = true
|
||||
|
||||
local CURSOR = 0xED
|
||||
local CURSOR_HOLLOW = 0xEC
|
||||
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,
|
||||
@@ -114,6 +115,7 @@ end
|
||||
function LinkState:exitWith(message, reason)
|
||||
DiscordPresence.setJoinCode(nil)
|
||||
self.game.linkSession = nil -- back to the player's own GAME SPEED
|
||||
if self.game.linkNet == self.net then self.game.linkNet = nil end
|
||||
Runtime.emit("link.ended", { reason = reason or (message and "error" or "bye") })
|
||||
if self.net then self.net:close() end
|
||||
self.game.stack:pop()
|
||||
@@ -519,6 +521,9 @@ function LinkState:startMode(mode, isHost)
|
||||
})
|
||||
self.net:send(self.trade:opening())
|
||||
self.index = 1
|
||||
self.theirIndex = 1
|
||||
self.side = "mine"
|
||||
self.pickChoice = nil
|
||||
else
|
||||
self.stage = "battleWait"
|
||||
-- the host deals the shared RNG seed for the lockstep simulation
|
||||
@@ -536,7 +541,14 @@ end
|
||||
-- trade flow
|
||||
-- -------------------------------------------------------------------
|
||||
|
||||
function LinkState:openStats(mon)
|
||||
if not mon then return end
|
||||
self.game.linkNet = self.net
|
||||
Screens.push(self.game, "SummaryMenu", mon)
|
||||
end
|
||||
|
||||
function LinkState:updateTrade(input)
|
||||
if self.game.linkNet == self.net then self.game.linkNet = nil end
|
||||
for _, msg in ipairs(self.net:poll()) do
|
||||
local reply = self.trade:handle(msg)
|
||||
if reply then self.net:send(reply) end
|
||||
@@ -591,10 +603,51 @@ function LinkState:updateTrade(input)
|
||||
return
|
||||
end
|
||||
|
||||
if t.stage == "picking" and input:wasPressed("up") then
|
||||
self.index = math.max(1, self.index - 1)
|
||||
-- pokered engine/link/cable_club.asm TradeCenter_SelectMon: A on one of
|
||||
-- your own mons opens the "STATS TRADE" row (.displayStatsTradeMenu)
|
||||
-- and only TRADE commits the pick, while the enemy list carries its own
|
||||
-- cursor whose A shows that mon's status pages (.displayEnemyMonStats).
|
||||
-- The cart's enemy path sets hl but never wMonDataLocation, the way the
|
||||
-- battle menu's STATS (engine/battle/core.asm) does, so LoadMonData_ reads
|
||||
-- the player's party and it draws YOUR mon at that slot -- an omission,
|
||||
-- not behaviour, so we show the peer's mon.
|
||||
if t.stage == "picking" and self.pickChoice then
|
||||
if input:wasPressed("left") then
|
||||
self.pickChoice = 1
|
||||
elseif input:wasPressed("right") then
|
||||
self.pickChoice = 2
|
||||
elseif input:wasPressed("b") then
|
||||
self.pickChoice = nil -- .cancelPlayerMonChoice: back to the list, not
|
||||
-- out of the trade
|
||||
elseif input:wasPressed("a") then
|
||||
if self.pickChoice == 1 then
|
||||
self.pickChoice = nil
|
||||
self:openStats(self.game.save.party[self.index])
|
||||
elseif t:canPick(self.index) then
|
||||
self.pickChoice = nil
|
||||
self.side = "mine"
|
||||
self.net:send(t:pick(self.index))
|
||||
end
|
||||
end
|
||||
elseif t.stage == "picking" and input:wasPressed("up") then
|
||||
if self.side == "theirs" then
|
||||
self.theirIndex = math.max(1, self.theirIndex - 1)
|
||||
else
|
||||
self.index = math.max(1, self.index - 1)
|
||||
end
|
||||
elseif t.stage == "picking" and input:wasPressed("down") then
|
||||
self.index = math.min(#self.game.save.party, self.index + 1)
|
||||
if self.side == "theirs" then
|
||||
self.theirIndex = math.min(#(t.theirParty or {}), self.theirIndex + 1)
|
||||
else
|
||||
self.index = math.min(#self.game.save.party, self.index + 1)
|
||||
end
|
||||
elseif t.stage == "picking" and input:wasPressed("right") then
|
||||
if t.theirParty and #t.theirParty > 0 then
|
||||
self.side = "theirs"
|
||||
self.theirIndex = math.min(self.theirIndex, #t.theirParty)
|
||||
end
|
||||
elseif t.stage == "picking" and input:wasPressed("left") then
|
||||
self.side = "mine"
|
||||
elseif self.confirmed == nil and input:wasPressed("b") then
|
||||
-- once confirm=true has been sent to the peer, backing out here
|
||||
-- would desync the two sides (the peer may already be committing
|
||||
@@ -603,8 +656,10 @@ function LinkState:updateTrade(input)
|
||||
self.net:send({ type = "bye" })
|
||||
self:exitWith(Strings("The trade was\ncancelled."))
|
||||
elseif t.stage == "picking" and input:wasPressed("a") then
|
||||
if t:canPick(self.index) then
|
||||
self.net:send(t:pick(self.index))
|
||||
if self.side == "theirs" then
|
||||
self:openStats((t.theirParty or {})[self.theirIndex])
|
||||
else
|
||||
self.pickChoice = 1
|
||||
end
|
||||
elseif t.stage == "confirming" and self.confirmed == nil then
|
||||
if input:wasPressed("a") then
|
||||
@@ -738,25 +793,40 @@ function LinkState:draw()
|
||||
local label = (mon.nickname or def.name):sub(1, 8)
|
||||
if not t:canPick(i) then label = label .. "X" end
|
||||
Font.draw(label, 16, 20 + i * 12)
|
||||
if i == self.index then Font.drawCode(CURSOR, 8, 20 + i * 12) end
|
||||
if i == self.index and self.side ~= "theirs" then
|
||||
Font.drawCode(CURSOR, 8, 20 + i * 12)
|
||||
end
|
||||
end
|
||||
Font.draw(Strings("THEIRS"), 84, 20)
|
||||
for i, mon in ipairs(t.theirParty or {}) do
|
||||
local def = self.game.data.pokemon[mon.species]
|
||||
Font.draw((mon.nickname or def.name):sub(1, 8), 92, 20 + i * 12)
|
||||
if t.theirPick == i then Font.drawCode(CURSOR, 84, 20 + i * 12) end
|
||||
if self.side == "theirs" and i == self.theirIndex then
|
||||
Font.drawCode(CURSOR, 84, 20 + i * 12)
|
||||
elseif t.theirPick == i then
|
||||
Font.drawCode(CURSOR_HOLLOW, 84, 20 + i * 12)
|
||||
end
|
||||
end
|
||||
local hint
|
||||
if t.stage == "waitRecords" then hint = "Comparing games..."
|
||||
elseif t.stage == "waitParty" then hint = "Exchanging data..."
|
||||
elseif t.stage == "picking" then
|
||||
hint = t:canPick(self.index) and "Pick one to trade"
|
||||
or Strings("X: not on theirs")
|
||||
elseif t.stage == "waitPick" then hint = "Waiting for them..."
|
||||
elseif t.stage == "confirming" then
|
||||
hint = self.confirmed and "Waiting..." or Strings("A: trade B: cancel")
|
||||
if self.pickChoice then
|
||||
Font.draw(Strings("STATS"), 16, 128)
|
||||
Font.draw(Strings("TRADE"), 96, 128)
|
||||
Font.drawCode(CURSOR, self.pickChoice == 1 and 8 or 88, 128)
|
||||
else
|
||||
local hint
|
||||
if t.stage == "waitRecords" then hint = "Comparing games..."
|
||||
elseif t.stage == "waitParty" then hint = "Exchanging data..."
|
||||
elseif t.stage == "picking" then
|
||||
if self.side == "theirs" then hint = Strings("A: stats")
|
||||
else
|
||||
hint = t:canPick(self.index) and "Pick one to trade"
|
||||
or Strings("X: not on theirs")
|
||||
end
|
||||
elseif t.stage == "waitPick" then hint = "Waiting for them..."
|
||||
elseif t.stage == "confirming" then
|
||||
hint = self.confirmed and "Waiting..." or Strings("A: trade B: cancel")
|
||||
end
|
||||
Font.draw(hint or "", 8, 132)
|
||||
end
|
||||
Font.draw(hint or "", 8, 132)
|
||||
|
||||
elseif self.stage == "battleWait" or self.stage == "battleRunning" then
|
||||
drawTitle("LINK BATTLE")
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
|
||||
local Logger = require("src.core.Logger")
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
local SafePath = require("src.mods.SafePath")
|
||||
|
||||
local unpack = table.unpack or unpack
|
||||
local loadstring = loadstring or load
|
||||
@@ -31,25 +32,8 @@ AssetTransform.SOURCE_ROOT = SOURCE_ROOT
|
||||
AssetTransform.DERIVED_ROOT = DERIVED_ROOT
|
||||
|
||||
-- ------- path sandbox
|
||||
|
||||
-- a relative path that cannot climb out of the root it is joined to
|
||||
local function safeRelative(rel)
|
||||
if type(rel) ~= "string" or rel == "" then return nil end
|
||||
if rel:sub(1, 1) == "/" then return nil end
|
||||
if rel:find("\\", 1, true) then return nil end
|
||||
for segment in rel:gmatch("[^/]+") do
|
||||
if segment == ".." or segment == "." then return nil end
|
||||
end
|
||||
return rel
|
||||
end
|
||||
|
||||
local function requireRelative(rel, what)
|
||||
local safe = safeRelative(rel)
|
||||
if not safe then
|
||||
error(("%s must stay inside its root, got %q"):format(what, tostring(rel)), 0)
|
||||
end
|
||||
return safe
|
||||
end
|
||||
-- shared with mod:read and the manifest's own paths (src/mods/SafePath.lua)
|
||||
local requireRelative = SafePath.require
|
||||
|
||||
-- ------- the restricted context
|
||||
|
||||
|
||||
@@ -736,7 +736,7 @@ COVERAGE["src.pokemon.Boxes"] = {
|
||||
|
||||
COVERAGE["src.world.WorldAPI"] = {
|
||||
kind = "alias", target = "src.world.gen2.WorldAPI",
|
||||
backed = "new __index overworld current warpTo toggleObject replaceBlock "
|
||||
backed = "new __index overworld current mapOverview warpTo toggleObject replaceBlock "
|
||||
.. "spawnNpc removeNpc npc queueScript invalidateMap",
|
||||
warned = "setFlag getFlag",
|
||||
absent = "",
|
||||
|
||||
+378
-30
@@ -36,10 +36,13 @@ local ModTargets = require("src.mods.ModTargets")
|
||||
local Semver = require("src.mods.Semver")
|
||||
local Version = require("src.core.Version")
|
||||
local SaveData = require("src.core.SaveData")
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
local CacheFs = require("src.import.CacheFs")
|
||||
|
||||
local LauncherMods = {}
|
||||
|
||||
local discover -- forward declaration for helper functions above line 343
|
||||
|
||||
-- ------- pure status derivation
|
||||
|
||||
-- A hard-dependency / conflict / version verdict for one manifest. mods is
|
||||
@@ -84,31 +87,196 @@ local function statusFor(mods, id, enabledSet, enabled, version, forcedFor)
|
||||
-- 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)"
|
||||
-- installed and on, but not for THIS game: the loader skips the
|
||||
-- dependency and the skip is contagious (Loader:_enforceDependencies),
|
||||
-- so a mod that runs everywhere still does not run here
|
||||
elseif version
|
||||
and not ModTargets.runsHere(dep, version, nil, forcedFor(spec.id)) then
|
||||
return "warn", "Needs " .. spec.id .. " (not for "
|
||||
.. ModTargets.gameLabel(version) .. ")"
|
||||
elseif spec.range
|
||||
and not Semver.satisfies(dep.version, spec.range) then
|
||||
return "warn", "Needs " .. spec.id .. " " .. spec.range
|
||||
if ModTargets.specApplies(spec, version) then
|
||||
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)"
|
||||
-- installed and on, but not for THIS game: the loader skips the
|
||||
-- dependency and the skip is contagious (Loader:_enforceDependencies),
|
||||
-- so a mod that runs everywhere still does not run here
|
||||
elseif version
|
||||
and not ModTargets.runsHere(dep, version, nil, forcedFor(spec.id)) then
|
||||
return "warn", "Needs " .. spec.id .. " (not for "
|
||||
.. ModTargets.gameLabel(version) .. ")"
|
||||
elseif spec.range
|
||||
and not Semver.satisfies(dep.version, spec.range) then
|
||||
return "warn", "Needs " .. spec.id .. " " .. spec.range
|
||||
end
|
||||
end
|
||||
end
|
||||
return "ok", "Ready"
|
||||
end
|
||||
|
||||
-- Resolves the best-known GitHub owner/repo string for a dependency spec, if any.
|
||||
function LauncherMods.resolveDependencyRepo(depId, parentManifest, installedDep)
|
||||
if not depId or depId == "" then return nil end
|
||||
-- 1. Check spec hint if parentManifest dependencySpecs carries it
|
||||
if parentManifest and parentManifest.dependencySpecs then
|
||||
for _, spec in ipairs(parentManifest.dependencySpecs) do
|
||||
if spec.id == depId and spec.github then
|
||||
return spec.github
|
||||
end
|
||||
end
|
||||
end
|
||||
-- 2. Check parentManifest raw dependency_sources
|
||||
if parentManifest and parentManifest.raw and type(parentManifest.raw.dependency_sources) == "table" then
|
||||
local src = parentManifest.raw.dependency_sources[depId]
|
||||
if src then
|
||||
local ok, clean = pcall(Manifest.parseGithub, src)
|
||||
if ok and clean then return clean end
|
||||
end
|
||||
end
|
||||
-- 3. Check installed dependency manifest
|
||||
if installedDep and installedDep.github then
|
||||
return installedDep.github
|
||||
end
|
||||
-- 4. Check ModIndex entries if available
|
||||
local okModIndex, ModIndex = pcall(require, "src.mods.ModIndex")
|
||||
if okModIndex and ModIndex and type(ModIndex.sources) == "function" then
|
||||
for _, src in ipairs(ModIndex.sources() or {}) do
|
||||
local cached = ModIndex.readCache and ModIndex.readCache(src.feed)
|
||||
if cached and type(cached.index) == "table" then
|
||||
for _, entry in ipairs(cached.index) do
|
||||
if type(entry) == "table" and entry.id == depId and entry.github then
|
||||
local ok, clean = pcall(Manifest.parseGithub, entry.github)
|
||||
if ok and clean then return clean end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
-- Inspect manifest dependencies and conflicts against installed mods.
|
||||
-- Returns: { hasIssues = bool, targetMod = {...}, deps = [ { id, name, range, status, kind, installedVersion, github, safeUrl }, ... ] }
|
||||
function LauncherMods.checkDependencies(manifest, options, version, installedManifests)
|
||||
if not manifest then
|
||||
return { hasIssues = false, targetMod = { name = "Unknown" }, deps = {} }
|
||||
end
|
||||
|
||||
local SaveData = require("src.core.SaveData")
|
||||
local manifests = installedManifests or discover()
|
||||
local installedMap = {}
|
||||
for _, m in ipairs(manifests) do
|
||||
installedMap[m.id] = m
|
||||
end
|
||||
|
||||
local depsResult = {}
|
||||
local hasIssues = false
|
||||
|
||||
-- 1. Hard Dependencies (dependencySpecs)
|
||||
if type(manifest.dependencySpecs) == "table" then
|
||||
for _, spec in ipairs(manifest.dependencySpecs) do
|
||||
if not version or ModTargets.specApplies(spec, version) then
|
||||
local depId = spec.id
|
||||
local range = spec.range
|
||||
local installedDep = installedMap[depId]
|
||||
local status = "satisfied"
|
||||
local installedVersion = installedDep and installedDep.version or nil
|
||||
|
||||
if not installedDep then
|
||||
status = "missing"
|
||||
hasIssues = true
|
||||
elseif range and not Semver.satisfies(installedDep.version, range) then
|
||||
status = "incompatible"
|
||||
hasIssues = true
|
||||
end
|
||||
|
||||
local ghRepo = LauncherMods.resolveDependencyRepo(depId, manifest, installedDep)
|
||||
local safeUrl = ghRepo and ("https://github.com/" .. ghRepo) or nil
|
||||
|
||||
depsResult[#depsResult + 1] = {
|
||||
id = depId,
|
||||
name = (installedDep and installedDep.name) or depId,
|
||||
range = range,
|
||||
status = status,
|
||||
kind = "dependency",
|
||||
installedVersion = installedVersion,
|
||||
github = ghRepo,
|
||||
safeUrl = safeUrl,
|
||||
}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- 2. Conflicts / Incompatible Mods (conflictSpecs)
|
||||
local conflictIdsSeen = {}
|
||||
local scope = SaveData.modScope and SaveData.modScope(version) or version
|
||||
|
||||
local isEnabled = function(modId)
|
||||
if not options then return true end
|
||||
local dec = SaveData.modEnabled(options, modId, scope)
|
||||
if dec ~= nil then return dec == true end
|
||||
local m = installedMap[modId]
|
||||
return m and not m.experimental
|
||||
end
|
||||
|
||||
-- (a) Conflicts declared by target manifest
|
||||
if type(manifest.conflictSpecs) == "table" then
|
||||
for _, spec in ipairs(manifest.conflictSpecs) do
|
||||
local conflictId = spec.id
|
||||
local installedOther = installedMap[conflictId]
|
||||
if installedOther and isEnabled(conflictId) and not conflictIdsSeen[conflictId] then
|
||||
conflictIdsSeen[conflictId] = true
|
||||
hasIssues = true
|
||||
depsResult[#depsResult + 1] = {
|
||||
id = conflictId,
|
||||
name = installedOther.name or conflictId,
|
||||
status = "conflict",
|
||||
kind = "conflict",
|
||||
installedVersion = installedOther.version or "?",
|
||||
github = nil,
|
||||
safeUrl = nil,
|
||||
}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- (b) Reverse conflicts declared by installed mods against target manifest
|
||||
if manifest.id then
|
||||
for _, other in ipairs(manifests) do
|
||||
if other.id ~= manifest.id and isEnabled(other.id) and not conflictIdsSeen[other.id] then
|
||||
local conflicts = other.conflictSpecs or {}
|
||||
for _, spec in ipairs(conflicts) do
|
||||
if spec.id == manifest.id then
|
||||
conflictIdsSeen[other.id] = true
|
||||
hasIssues = true
|
||||
depsResult[#depsResult + 1] = {
|
||||
id = other.id,
|
||||
name = other.name or other.id,
|
||||
status = "conflict",
|
||||
kind = "conflict",
|
||||
installedVersion = other.version or "?",
|
||||
github = nil,
|
||||
safeUrl = nil,
|
||||
}
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
return {
|
||||
hasIssues = hasIssues,
|
||||
targetMod = {
|
||||
id = manifest.id,
|
||||
name = manifest.name or manifest.id,
|
||||
version = manifest.version or "?",
|
||||
},
|
||||
deps = depsResult,
|
||||
}
|
||||
end
|
||||
|
||||
-- deriveList(manifests, options [, version]) -> the panel row list, pure.
|
||||
-- manifests is an array of validated manifests (Manifest.validate output);
|
||||
-- options is the options table (options.mods, options.modsByVersion and
|
||||
-- options.modsGen2 are read). `version` is the game the panel is showing:
|
||||
-- nil keeps the pre-per-game view, where the shared flag is the whole answer.
|
||||
-- options.modsGen2 are read). `version` is the game the panel is showing;
|
||||
-- each row also carries its answer for every game so the launcher can render
|
||||
-- the coloured game checkboxes together.
|
||||
-- Rows come back sorted by id so the panel order is stable.
|
||||
function LauncherMods.deriveList(manifests, options, version)
|
||||
local ordered = {}
|
||||
@@ -126,8 +294,7 @@ function LauncherMods.deriveList(manifests, options, version)
|
||||
-- this game's choice, then the shared flag, then the default: enabled,
|
||||
-- matching the loader -- except experimental mods, which stay off until
|
||||
-- the player opts in. Scoped through modScope, so this reads exactly what
|
||||
-- setEnabled writes and the loader loads: while per-game flags are a
|
||||
-- preview the shared flag is the whole answer on every surface.
|
||||
-- setEnabled writes and the loader loads for the selected game.
|
||||
local decided = SaveData.modEnabled(options, m.id, SaveData.modScope(version))
|
||||
if decided == nil then decided = not m.experimental end
|
||||
if decided then enabledSet[m.id] = true end
|
||||
@@ -152,10 +319,20 @@ function LauncherMods.deriveList(manifests, options, version)
|
||||
badge = badge,
|
||||
description = m.description or "",
|
||||
enabled = enabled,
|
||||
enabledByVersion = (function()
|
||||
local answers = {}
|
||||
for _, game in ipairs(GameVersion.ORDER) do
|
||||
local answer = SaveData.modEnabled(options, m.id, game)
|
||||
answers[game] = answer == true or (answer == nil and not m.experimental)
|
||||
end
|
||||
return answers
|
||||
end)(),
|
||||
status = status,
|
||||
statusDetail = detail,
|
||||
github = m.github,
|
||||
experimental = m.experimental == true,
|
||||
dependencySpecs = m.dependencySpecs,
|
||||
manifest = m,
|
||||
-- what game this mod is for, and whether it will run on the one the
|
||||
-- panel is showing (src/mods/ModTargets.lua)
|
||||
targets = ModTargets.chip(m),
|
||||
@@ -243,7 +420,7 @@ 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()
|
||||
discover = function()
|
||||
local fs = love and love.filesystem
|
||||
local out = {}
|
||||
if not (fs and fs.getInfo and fs.getDirectoryItems) then return out end
|
||||
@@ -281,7 +458,14 @@ end
|
||||
function LauncherMods.list(version)
|
||||
local ok, result = pcall(function()
|
||||
local options = SaveData.loadOptions()
|
||||
return LauncherMods.deriveList(discover(), options, version)
|
||||
local manifests = discover()
|
||||
-- The first build containing game-specific switches turns the old shared
|
||||
-- state into one explicit answer per installed mod and game. Saving here
|
||||
-- means users who only visit the launcher still receive the migration.
|
||||
if SaveData.migrateModEnablement(options, manifests) then
|
||||
SaveData.saveOptions(options)
|
||||
end
|
||||
return LauncherMods.deriveList(manifests, options, version)
|
||||
end)
|
||||
if not ok then
|
||||
-- a single bad options/mod file must not blank the launcher
|
||||
@@ -364,14 +548,13 @@ function LauncherMods.translationStrings()
|
||||
return merged
|
||||
end
|
||||
|
||||
-- setEnabled(id, enabled [, version]): 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. With `version` the choice
|
||||
-- lands in that game's overlay instead and no other game moves.
|
||||
-- setEnabled(id, enabled [, version]): with a game, persist just that game's
|
||||
-- answer. The loader and the in-game manager use the same scope on next boot.
|
||||
function LauncherMods.setEnabled(id, enabled, version)
|
||||
local options = SaveData.loadOptions()
|
||||
SaveData.setModEnabled(options, id, enabled, SaveData.modScope(version))
|
||||
SaveData.saveOptions(options)
|
||||
LauncherMods.syncActiveProfile(options)
|
||||
return true
|
||||
end
|
||||
|
||||
@@ -384,9 +567,18 @@ function LauncherMods.setAllEnabled(ids, enabled, version)
|
||||
local options = SaveData.loadOptions()
|
||||
local scope = SaveData.modScope(version)
|
||||
for _, id in ipairs(ids or {}) do
|
||||
SaveData.setModEnabled(options, id, enabled, scope)
|
||||
if scope then
|
||||
SaveData.setModEnabled(options, id, enabled, scope)
|
||||
elseif SaveData.PER_VERSION_MODS then
|
||||
for _, game in ipairs(GameVersion.ORDER) do
|
||||
SaveData.setModEnabled(options, id, enabled, game)
|
||||
end
|
||||
else
|
||||
SaveData.setModEnabled(options, id, enabled)
|
||||
end
|
||||
end
|
||||
SaveData.saveOptions(options)
|
||||
LauncherMods.syncActiveProfile(options)
|
||||
return true
|
||||
end
|
||||
|
||||
@@ -652,9 +844,9 @@ function LauncherMods.adoptStrays() return scanStrays(true) end
|
||||
-- opts.replace = true uninstalls an existing same-id mod first (updates /
|
||||
-- rollbacks). opts.expectId, when set, refuses a zip whose manifest id differs.
|
||||
function LauncherMods.installZip(source, opts)
|
||||
local ok, result, err = pcall(LauncherMods._installZipInner, source, opts)
|
||||
local ok, result, res2, res3 = pcall(LauncherMods._installZipInner, source, opts)
|
||||
if not ok then return nil, "import failed: " .. tostring(result) end
|
||||
return result, err
|
||||
return result, res2, res3
|
||||
end
|
||||
|
||||
function LauncherMods._installZipInner(source, opts)
|
||||
@@ -764,7 +956,7 @@ function LauncherMods._installZipInner(source, opts)
|
||||
return nil, copyErr or "could not copy the mod files"
|
||||
end
|
||||
cleanup()
|
||||
return true, manifest.id
|
||||
return true, manifest.id, manifest
|
||||
end
|
||||
|
||||
-- Install (or replace) a mod from a GitHub release zip URL.
|
||||
@@ -843,7 +1035,7 @@ end
|
||||
|
||||
-- uninstall(id) -> true | nil, errString
|
||||
-- Removes mods/<id>/ from wherever it was installed (the portable game folder
|
||||
-- or the save directory, CacheFs decides -- #330) and clears options.mods[id]
|
||||
-- or the save directory, CacheFs decides -- #330) and clears every enable flag
|
||||
-- so the loader and in-game manager no longer see it. Rejects missing ids.
|
||||
-- Does not touch other mods' enable state.
|
||||
function LauncherMods.uninstall(id)
|
||||
@@ -872,8 +1064,164 @@ function LauncherMods.uninstall(id)
|
||||
-- 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()
|
||||
local changed = false
|
||||
if options.mods and options.mods[id] ~= nil then
|
||||
options.mods[id] = nil
|
||||
changed = true
|
||||
end
|
||||
for _, version in ipairs(GameVersion.ORDER) do
|
||||
local bucket = options.modsByVersion and options.modsByVersion[version]
|
||||
if type(bucket) == "table" and bucket[id] ~= nil then
|
||||
bucket[id] = nil
|
||||
changed = true
|
||||
end
|
||||
end
|
||||
if changed then
|
||||
SaveData.saveOptions(options)
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
-- ----------------------------------------------------------- Mod Profiles (#593)
|
||||
local ModProfile = require("src.mods.ModProfile")
|
||||
|
||||
function LauncherMods.getProfiles(options)
|
||||
options = options or SaveData.loadOptions()
|
||||
local manifests = discover()
|
||||
ModProfile.ensureFirst(options, manifests, options.modOptions)
|
||||
return options.modProfiles or {}, options.activeProfile or "PROFILE 1"
|
||||
end
|
||||
|
||||
function LauncherMods.applyProfile(profileName, options)
|
||||
options = options or SaveData.loadOptions()
|
||||
local profiles = options.modProfiles or {}
|
||||
local targetProfile
|
||||
for _, p in ipairs(profiles) do
|
||||
if p.name == profileName then targetProfile = p; break end
|
||||
end
|
||||
if not targetProfile then return false end
|
||||
ModProfile.restoreVersions(targetProfile, options)
|
||||
options.activeProfile = profileName
|
||||
SaveData.saveOptions(options)
|
||||
return true
|
||||
end
|
||||
|
||||
function LauncherMods.saveProfile(profileName, options)
|
||||
options = options or SaveData.loadOptions()
|
||||
local manifests = discover()
|
||||
options.modProfiles = options.modProfiles or {}
|
||||
local snap = ModProfile.capture(manifests, options.modOptions, options.modsByVersion)
|
||||
snap.name = profileName
|
||||
local existingIdx
|
||||
for i, p in ipairs(options.modProfiles) do
|
||||
if p.name == profileName then existingIdx = i; break end
|
||||
end
|
||||
if existingIdx then
|
||||
options.modProfiles[existingIdx] = snap
|
||||
else
|
||||
options.modProfiles[#options.modProfiles + 1] = snap
|
||||
end
|
||||
options.activeProfile = profileName
|
||||
SaveData.saveOptions(options)
|
||||
return snap
|
||||
end
|
||||
|
||||
local function copyTable(tbl)
|
||||
if type(tbl) ~= "table" then return tbl end
|
||||
local copy = {}
|
||||
for k, v in pairs(tbl) do
|
||||
copy[k] = type(v) == "table" and copyTable(v) or v
|
||||
end
|
||||
return copy
|
||||
end
|
||||
|
||||
function LauncherMods.syncActiveProfile(options)
|
||||
options = options or SaveData.loadOptions()
|
||||
local activeName = options.activeProfile or "PROFILE 1"
|
||||
local profiles = options.modProfiles or {}
|
||||
local manifests = discover()
|
||||
local snap = ModProfile.capture(manifests, options.modOptions, options.modsByVersion)
|
||||
snap.name = activeName
|
||||
|
||||
local found = false
|
||||
for i, p in ipairs(profiles) do
|
||||
if p.name == activeName then
|
||||
profiles[i] = snap
|
||||
found = true
|
||||
break
|
||||
end
|
||||
end
|
||||
if not found then
|
||||
profiles[#profiles + 1] = snap
|
||||
end
|
||||
options.modProfiles = profiles
|
||||
options.activeProfile = activeName
|
||||
SaveData.saveOptions(options)
|
||||
return snap
|
||||
end
|
||||
|
||||
function LauncherMods.duplicateProfile(sourceName, options)
|
||||
options = options or SaveData.loadOptions()
|
||||
local profiles = options.modProfiles or {}
|
||||
local sourceProfile
|
||||
for _, p in ipairs(profiles) do
|
||||
if p.name == sourceName then sourceProfile = p; break end
|
||||
end
|
||||
if not sourceProfile then return nil end
|
||||
|
||||
local baseName = sourceName .. " (Copy)"
|
||||
local newName = baseName
|
||||
local n = 1
|
||||
local taken = {}
|
||||
for _, p in ipairs(profiles) do taken[p.name] = true end
|
||||
while taken[newName] do
|
||||
n = n + 1
|
||||
newName = sourceName .. " (" .. n .. ")"
|
||||
end
|
||||
|
||||
local snap = {
|
||||
name = newName,
|
||||
enabled = copyTable(sourceProfile.enabled),
|
||||
options = copyTable(sourceProfile.options),
|
||||
slots = copyTable(sourceProfile.slots),
|
||||
enabledByVersion = copyTable(sourceProfile.enabledByVersion),
|
||||
}
|
||||
profiles[#profiles + 1] = snap
|
||||
options.modProfiles = profiles
|
||||
options.activeProfile = newName
|
||||
SaveData.saveOptions(options)
|
||||
return snap
|
||||
end
|
||||
|
||||
function LauncherMods.renameProfile(oldName, newName, options)
|
||||
options = options or SaveData.loadOptions()
|
||||
if not newName or newName == "" then return false end
|
||||
local profiles = options.modProfiles or {}
|
||||
for i, p in ipairs(profiles) do
|
||||
if p.name == oldName then
|
||||
p.name = newName
|
||||
if options.activeProfile == oldName then
|
||||
options.activeProfile = newName
|
||||
end
|
||||
SaveData.saveOptions(options)
|
||||
return true
|
||||
end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
function LauncherMods.deleteProfile(profileName, options)
|
||||
options = options or SaveData.loadOptions()
|
||||
options.modProfiles = options.modProfiles or {}
|
||||
local newProfiles = {}
|
||||
for _, p in ipairs(options.modProfiles) do
|
||||
if p.name ~= profileName then newProfiles[#newProfiles + 1] = p end
|
||||
end
|
||||
options.modProfiles = newProfiles
|
||||
if options.activeProfile == profileName then
|
||||
local fallback = newProfiles[1] and newProfiles[1].name or "PROFILE 1"
|
||||
LauncherMods.applyProfile(fallback, options)
|
||||
else
|
||||
SaveData.saveOptions(options)
|
||||
end
|
||||
return true
|
||||
|
||||
+171
-66
@@ -12,12 +12,15 @@ local Manifest = require("src.mods.Manifest")
|
||||
local Merge = require("src.mods.Merge")
|
||||
local ModTargets = require("src.mods.ModTargets")
|
||||
local Registry = require("src.mods.Registry")
|
||||
local SafePath = require("src.mods.SafePath")
|
||||
local Sandbox = require("src.mods.Sandbox")
|
||||
local Schemas = require("src.mods.Schemas")
|
||||
local Semver = require("src.mods.Semver")
|
||||
local Events = require("src.mods.Events")
|
||||
local Gen2Compat = require("src.mods.Gen2Compat")
|
||||
local Hooks = require("src.mods.Hooks")
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
local Steps = require("src.mods.Steps")
|
||||
|
||||
local Loader = {}
|
||||
Loader.__index = Loader
|
||||
@@ -72,10 +75,12 @@ local function orderedIds(mods, filter)
|
||||
return ids
|
||||
end
|
||||
|
||||
-- ------- dev-mode permissions tripwire
|
||||
-- Attribution only: the shim delegates unconditionally and blocks nothing.
|
||||
-- Installed once per process and only when the loader runs in dev mode, so a
|
||||
-- player build has zero interposition.
|
||||
-- ------- the require gate
|
||||
-- Two jobs in one interposition. The engine_internals/network scan is
|
||||
-- attribution only and stays dev-mode: it warns and delegates. The
|
||||
-- Sandbox.moduleDenial check is not -- require("io") would hand back
|
||||
-- package.loaded.io and undo the whole mod environment -- so it is installed
|
||||
-- in player builds too, for any boot that has mods on it.
|
||||
|
||||
local devShim = { installed = false, permissions = {}, warned = {}, depth = 0 }
|
||||
|
||||
@@ -135,7 +140,8 @@ end
|
||||
|
||||
local function scanRequire(name)
|
||||
local modId = Runtime.currentMod
|
||||
if not modId or type(name) ~= "string" then return end
|
||||
if type(modId) ~= "string" then modId = Runtime.modRequire end
|
||||
if type(modId) ~= "string" or type(name) ~= "string" then return end
|
||||
local granted = devShim.permissions[modId] or {}
|
||||
local function warnOnce(permission)
|
||||
local key = modId .. "|" .. permission .. "|" .. name
|
||||
@@ -188,6 +194,7 @@ function Loader:_installDevShim()
|
||||
for id, mod in pairs(self.mods) do
|
||||
devShim.permissions[id] = mod.manifest.permissionSet
|
||||
end
|
||||
devShim.dev = self.dev
|
||||
if devShim.installed then return end
|
||||
devShim.installed = true
|
||||
local delegate = require
|
||||
@@ -195,12 +202,22 @@ function Loader:_installDevShim()
|
||||
-- only the mod's own call is the mod's doing; whatever that module
|
||||
-- requires in turn is the engine wiring itself up
|
||||
if devShim.depth == 0 then
|
||||
scanRequire(name)
|
||||
-- Backstop for the deny list Sandbox.envFor's require already applies:
|
||||
-- an engine module requiring io is the engine wiring itself up, a mod
|
||||
-- doing it is the hole this closes, and any future path that runs mod
|
||||
-- code without a sandbox env still lands here.
|
||||
local owner = Runtime.currentMod or Runtime.modRequire
|
||||
if owner or callerIsMod(3) then
|
||||
local id = type(owner) == "string" and owner or nil
|
||||
local denial = Sandbox.moduleDenial(name, devShim.permissions[id])
|
||||
if denial then error(("[%s] %s"):format(id or "mod", denial), 0) end
|
||||
end
|
||||
if devShim.dev or devShim.generation ~= 1 then scanRequire(name) end
|
||||
-- The Gen 1 name a mod asked for, answered by the Gen 2 arm behind it.
|
||||
-- Engine code keeps the real module: src/render/PaletteFX.lua:776
|
||||
-- requires src.core.Game on both generations and means it.
|
||||
if devShim.generation ~= 1 and Gen2Compat.serves(name)
|
||||
and callerIsMod(3) then
|
||||
and (owner or callerIsMod(3)) then
|
||||
local adapter = Gen2Compat.resolve(name, Runtime.currentMod)
|
||||
if adapter then
|
||||
local key = "adapter|" .. name
|
||||
@@ -235,7 +252,7 @@ function Loader.new(opts)
|
||||
events = Events.new(), hooks = Hooks.new(), content = {}, assets = {},
|
||||
exports = {}, migrations = {}, order = {},
|
||||
modSave = {}, modOptions = {}, optionSchemas = {}, imageCache = {},
|
||||
modInput = {},
|
||||
modInput = {}, modEnv = {}, stepsQueues = {},
|
||||
fs = (opts and opts.fs) or (love and love.filesystem),
|
||||
dev = dev,
|
||||
-- Which generation this boot is (1 or 2). Fixed at construction: the
|
||||
@@ -270,8 +287,7 @@ function Loader:_targetVersion()
|
||||
return version
|
||||
end
|
||||
|
||||
-- The version an enable flag is read and written under: this game once
|
||||
-- per-game flags are live, nil (the shared flag) while they are a preview.
|
||||
-- The version an enable flag is read and written under: this running game.
|
||||
-- Reads and writes go through the same answer so the two can never drift.
|
||||
function Loader:_enableScope()
|
||||
return SaveData.modScope(self:_targetVersion())
|
||||
@@ -358,11 +374,13 @@ function Loader:_writeOptionSchemas()
|
||||
-- demand; using it here means older mods do not need to migrate to
|
||||
-- mod.options:define just to appear in a launcher settings screen.
|
||||
if schema == nil and mod.manifest.options_schema and self.fs.load then
|
||||
local chunk = self.fs.load(mod.path .. "/" .. mod.manifest.options_schema)
|
||||
if chunk then
|
||||
local ok, rows = pcall(chunk)
|
||||
if ok and type(rows) == "table" then schema = rows end
|
||||
end
|
||||
local ok, rows = pcall(function()
|
||||
local path = SafePath.join(mod.path, mod.manifest.options_schema,
|
||||
"options_schema")
|
||||
local chunk = Sandbox.loadFile(self.fs, path, self:_modEnv(mod))
|
||||
return chunk and chunk()
|
||||
end)
|
||||
if ok and type(rows) == "table" then schema = rows end
|
||||
end
|
||||
if schema ~= nil then
|
||||
mods[id] = schema
|
||||
@@ -562,42 +580,46 @@ end
|
||||
-- hard dependencies must exist, be enabled, have survived, and satisfy their
|
||||
-- range; run to a fixpoint so failures propagate to dependents transitively
|
||||
function Loader:_enforceDependencies()
|
||||
local targetVersion = self:_targetVersion()
|
||||
local generation = self.generation
|
||||
local changed = true
|
||||
while changed do
|
||||
changed = false
|
||||
for _, id in ipairs(orderedIds(self.mods, isActive)) do
|
||||
local mod = self.mods[id]
|
||||
for _, spec in ipairs(mod.manifest.dependencySpecs) do
|
||||
local dep = self.mods[spec.id]
|
||||
local reason, skip
|
||||
if not dep then
|
||||
reason = "missing dependency: " .. spec.id
|
||||
elseif not dep.enabled then
|
||||
reason = ("dependency %s is disabled"):format(spec.id)
|
||||
elseif dep.state == "wrong_generation" then
|
||||
-- the gate's skip is contagious as a SKIP, not as a failure: the
|
||||
-- dependency has no bug to report and neither does this mod, so
|
||||
-- nothing here lands on the boot error list
|
||||
skip = true
|
||||
-- carry the dependency's own reason: it names the game or the
|
||||
-- missing gen2compat, and a guess here would name the wrong one
|
||||
reason = ("depends on %s, which does not run here (%s)")
|
||||
:format(spec.id, dep.skipReason or "not made for this game")
|
||||
elseif dep.failed then
|
||||
reason = ("dependency %s failed to load"):format(spec.id)
|
||||
elseif spec.range
|
||||
and not Semver.satisfies(dep.manifest.version, spec.range) then
|
||||
reason = ("needs %s@%s, found %s")
|
||||
:format(spec.id, spec.range, dep.manifest.version)
|
||||
end
|
||||
if reason then
|
||||
if skip then
|
||||
self:_skip(mod, "wrong_generation", reason)
|
||||
else
|
||||
self:_fail(mod, "blocked_dependency", reason)
|
||||
if ModTargets.specApplies(spec, targetVersion, generation) then
|
||||
local dep = self.mods[spec.id]
|
||||
local reason, skip
|
||||
if not dep then
|
||||
reason = "missing dependency: " .. spec.id
|
||||
elseif not dep.enabled then
|
||||
reason = ("dependency %s is disabled"):format(spec.id)
|
||||
elseif dep.state == "wrong_generation" then
|
||||
-- the gate's skip is contagious as a SKIP, not as a failure: the
|
||||
-- dependency has no bug to report and neither does this mod, so
|
||||
-- nothing here lands on the boot error list
|
||||
skip = true
|
||||
-- carry the dependency's own reason: it names the game or the
|
||||
-- missing gen2compat, and a guess here would name the wrong one
|
||||
reason = ("depends on %s, which does not run here (%s)")
|
||||
:format(spec.id, dep.skipReason or "not made for this game")
|
||||
elseif dep.failed then
|
||||
reason = ("dependency %s failed to load"):format(spec.id)
|
||||
elseif spec.range
|
||||
and not Semver.satisfies(dep.manifest.version, spec.range) then
|
||||
reason = ("needs %s@%s, found %s")
|
||||
:format(spec.id, spec.range, dep.manifest.version)
|
||||
end
|
||||
if reason then
|
||||
if skip then
|
||||
self:_skip(mod, "wrong_generation", reason)
|
||||
else
|
||||
self:_fail(mod, "blocked_dependency", reason)
|
||||
end
|
||||
changed = true
|
||||
break
|
||||
end
|
||||
changed = true
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -607,6 +629,8 @@ end
|
||||
-- Tarjan SCC over the hard-dependency graph: only a cycle's own members
|
||||
-- fail, so an unrelated mod beside a cycle still loads
|
||||
function Loader:_failCycles()
|
||||
local targetVersion = self:_targetVersion()
|
||||
local generation = self.generation
|
||||
local mods = self.mods
|
||||
local counter, stack, onStack, index, low = 0, {}, {}, {}, {}
|
||||
local cycles = {}
|
||||
@@ -617,14 +641,16 @@ function Loader:_failCycles()
|
||||
onStack[id] = true
|
||||
local selfEdge = false
|
||||
for _, spec in ipairs(mods[id].manifest.dependencySpecs) do
|
||||
local dep = mods[spec.id]
|
||||
if spec.id == id then selfEdge = true end
|
||||
if dep and isActive(dep) and spec.id ~= id then
|
||||
if not index[spec.id] then
|
||||
connect(spec.id)
|
||||
if low[spec.id] < low[id] then low[id] = low[spec.id] end
|
||||
elseif onStack[spec.id] and index[spec.id] < low[id] then
|
||||
low[id] = index[spec.id]
|
||||
if ModTargets.specApplies(spec, targetVersion, generation) then
|
||||
local dep = mods[spec.id]
|
||||
if spec.id == id then selfEdge = true end
|
||||
if dep and isActive(dep) and spec.id ~= id then
|
||||
if not index[spec.id] then
|
||||
connect(spec.id)
|
||||
if low[spec.id] < low[id] then low[id] = low[spec.id] end
|
||||
elseif onStack[spec.id] and index[spec.id] < low[id] then
|
||||
low[id] = index[spec.id]
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -675,6 +701,8 @@ end
|
||||
-- Kahn over the surviving graph with the ready set kept in (priority, id)
|
||||
-- order, so dependencies come first and the rest matches the v1 contract
|
||||
function Loader:_order()
|
||||
local targetVersion = self:_targetVersion()
|
||||
local generation = self.generation
|
||||
local pending, indegree, dependents = {}, {}, {}
|
||||
for _, id in ipairs(orderedIds(self.mods, isActive)) do
|
||||
pending[id], indegree[id] = true, 0
|
||||
@@ -687,9 +715,17 @@ function Loader:_order()
|
||||
dependents[depId][#dependents[depId] + 1] = id
|
||||
indegree[id] = indegree[id] + 1
|
||||
end
|
||||
for _, spec in ipairs(manifest.dependencySpecs) do edge(spec.id) end
|
||||
for _, spec in ipairs(manifest.dependencySpecs) do
|
||||
if ModTargets.specApplies(spec, targetVersion, generation) then
|
||||
edge(spec.id)
|
||||
end
|
||||
end
|
||||
-- optional dependencies order without requiring anything
|
||||
for _, spec in ipairs(manifest.optionalSpecs) do edge(spec.id) end
|
||||
for _, spec in ipairs(manifest.optionalSpecs) do
|
||||
if ModTargets.specApplies(spec, targetVersion, generation) then
|
||||
edge(spec.id)
|
||||
end
|
||||
end
|
||||
end
|
||||
local ordered = {}
|
||||
local function nextId()
|
||||
@@ -981,6 +1017,40 @@ function Loader:_api(mod)
|
||||
return DateTime.dateTime(game, timestamp)
|
||||
end,
|
||||
},
|
||||
-- The read-only part of love.system that device UIs legitimately need.
|
||||
-- Do not expose the module: openURL and clipboard access stay sandboxed.
|
||||
device = {
|
||||
powerInfo = function()
|
||||
local getPowerInfo = love and love.system and love.system.getPowerInfo
|
||||
if not getPowerInfo then return "unknown", nil end
|
||||
local state, percent = getPowerInfo()
|
||||
return state, percent
|
||||
end,
|
||||
},
|
||||
-- The native step bridge (#1186), behind the "steps" permission the
|
||||
-- player sees in the mod manager: sync asks the platform to refresh
|
||||
-- its count, poll hands this mod its copy of what the bridge
|
||||
-- delivered. The engine owns the pending file -- a mod never names a
|
||||
-- path, it only receives { steps, from, to }. available() answers
|
||||
-- false without the permission (a probe stays quiet); the calls that
|
||||
-- would do something name the missing permission instead, the way the
|
||||
-- network gate does.
|
||||
steps = (function()
|
||||
if mod.manifest.permissionSet.steps then
|
||||
loader.stepsQueues[modId] = loader.stepsQueues[modId] or {}
|
||||
return {
|
||||
available = function() return Steps.available() end,
|
||||
sync = function() return Steps.sync() end,
|
||||
poll = function() return Steps.poll(loader, modId) end,
|
||||
}
|
||||
end
|
||||
local function refuse()
|
||||
error(('[%s] mod.steps needs the "steps" permission in '
|
||||
.. "manifest.json"):format(modId), 2)
|
||||
end
|
||||
return { available = function() return false end,
|
||||
sync = refuse, poll = refuse }
|
||||
end)(),
|
||||
-- namespaced per mod; M11 backs these with save.modData /
|
||||
-- options.modOptions, the shape mods compile against is already final
|
||||
save = {
|
||||
@@ -1091,9 +1161,11 @@ function Loader:_api(mod)
|
||||
-- assets keeps the v1 alias to the content accessors and adds the file
|
||||
-- helpers on top, so mod.assets.pokemon and mod.assets:image both resolve
|
||||
api.assets = setmetatable({
|
||||
path = function(_, relative) return mod.path .. "/" .. relative end,
|
||||
path = function(_, relative)
|
||||
return SafePath.join(mod.path, relative, "mod.assets:path")
|
||||
end,
|
||||
image = function(_, relative)
|
||||
local full = mod.path .. "/" .. relative
|
||||
local full = SafePath.join(mod.path, relative, "mod.assets:image")
|
||||
local cached = loader.imageCache[full]
|
||||
if cached then return cached end
|
||||
assert(love and love.graphics,
|
||||
@@ -1103,9 +1175,10 @@ function Loader:_api(mod)
|
||||
return image
|
||||
end,
|
||||
}, { __index = api.content })
|
||||
-- the mod's own directory and nothing above it: PhysFS already refuses a
|
||||
-- climb, but loader.fs is injectable and has no such floor
|
||||
function api:read(relative)
|
||||
local path = self.path .. "/" .. relative
|
||||
return loader.fs.read(path)
|
||||
return loader.fs.read(SafePath.join(self.path, relative, "mod:read"))
|
||||
end
|
||||
-- mod.world materializes on first touch, like the image helper above: a
|
||||
-- headless load must not drag the world stack in, and the Game the facade
|
||||
@@ -1149,9 +1222,21 @@ function Loader:_game()
|
||||
return engineRequire("src.core.Game")
|
||||
end
|
||||
|
||||
-- The environment every chunk this mod authors runs in, built once per mod so
|
||||
-- its entry file and its options_schema share one globals table.
|
||||
function Loader:_modEnv(mod)
|
||||
local id = mod.manifest.id
|
||||
local env = self.modEnv[id]
|
||||
if not env then
|
||||
env = Sandbox.envFor({ modId = id, permissions = mod.manifest.permissionSet })
|
||||
self.modEnv[id] = env
|
||||
end
|
||||
return env
|
||||
end
|
||||
|
||||
function Loader:_loadMod(mod)
|
||||
local path = mod.path .. "/" .. mod.manifest.entry
|
||||
local chunk, err = self.fs.load(path)
|
||||
local path = SafePath.join(mod.path, mod.manifest.entry, "manifest entry")
|
||||
local chunk, err = Sandbox.loadFile(self.fs, path, self:_modEnv(mod))
|
||||
if not chunk then error(err or ("unable to load " .. path)) end
|
||||
local api = self:_api(mod)
|
||||
local result = chunk(api)
|
||||
@@ -1183,6 +1268,7 @@ function Loader:_rollback(modId)
|
||||
self.optionSchemas[modId] = nil
|
||||
self.migrations[modId] = nil
|
||||
self.modSave[modId] = nil
|
||||
self.stepsQueues[modId] = nil
|
||||
end
|
||||
|
||||
-- a mod that explicitly swears it stays link-compatible while writing into a
|
||||
@@ -1306,13 +1392,30 @@ function Loader:load(data)
|
||||
require("src.mods.Builtins").install(self.content, data, self.generation)
|
||||
self:_loadState()
|
||||
self:_discover()
|
||||
-- Existing installs stored one shared answer. Once their manifests are
|
||||
-- known, split that answer across every game before the next launcher/game
|
||||
-- toggle can change one independently. _loadState already used the same
|
||||
-- fallback, so this write cannot change the current boot's result.
|
||||
do
|
||||
local options = SaveData.loadOptions(self.fs)
|
||||
local installed = {}
|
||||
for id, mod in pairs(self.mods) do
|
||||
installed[#installed + 1] = {
|
||||
id = id,
|
||||
experimental = mod.manifest and mod.manifest.experimental == true,
|
||||
}
|
||||
end
|
||||
if SaveData.migrateModEnablement(options, installed) and self.fs.write then
|
||||
SaveData.saveOptions(options, self.fs)
|
||||
end
|
||||
end
|
||||
-- Experimental mods stay off until the player opts in: a missing
|
||||
-- options.mods entry normally means enabled, but experimental flips that.
|
||||
do
|
||||
local options = SaveData.loadOptions(self.fs)
|
||||
local modsOpt = options.mods or {}
|
||||
local scope = self:_enableScope()
|
||||
for id, mod in pairs(self.mods) do
|
||||
if not self.disabled[id] and modsOpt[id] == nil
|
||||
if not self.disabled[id] and SaveData.modEnabled(options, id, scope) == nil
|
||||
and mod.manifest.experimental then
|
||||
self.disabled[id] = true
|
||||
end
|
||||
@@ -1349,10 +1452,12 @@ function Loader:load(data)
|
||||
-- every touch: a mod captures the facade at file scope, before Game2 has a
|
||||
-- save or a world (src/mods/Gen2Compat.lua).
|
||||
Gen2Compat.bind(function() return self:_game() end)
|
||||
-- Dev mode wants the permissions tripwire; a Gold boot with mods on it wants
|
||||
-- the Gen 1-only require report, which is the difference between "the mod
|
||||
-- does nothing" and knowing why. A Gold boot with no mods pays nothing.
|
||||
if self.dev or (self.generation ~= 1 and next(self.mods) ~= nil) then
|
||||
-- Any boot with mods on it needs the gate, because require("io") is how a
|
||||
-- mod would walk out of Sandbox.envFor. Dev mode adds the permissions
|
||||
-- tripwire on top, and a Gold boot the Gen 1-only require report -- the
|
||||
-- difference between "the mod does nothing" and knowing why. A boot with no
|
||||
-- mods pays nothing.
|
||||
if self.dev or next(self.mods) ~= nil then
|
||||
self:_installDevShim()
|
||||
end
|
||||
for _, mod in ipairs(ordered) do
|
||||
|
||||
@@ -8,6 +8,8 @@ local Font = require("src.render.Font")
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
local ModTargets = require("src.mods.ModTargets")
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
local SafePath = require("src.mods.SafePath")
|
||||
local Sandbox = require("src.mods.Sandbox")
|
||||
local SaveData = require("src.core.SaveData")
|
||||
local Semver = require("src.mods.Semver")
|
||||
local Version = require("src.core.Version")
|
||||
@@ -735,8 +737,7 @@ function ManagerState:toggleGen2Force(m)
|
||||
}, apply)
|
||||
end
|
||||
|
||||
-- Where the loader persisted an enable flag: this game's slot once it keeps
|
||||
-- them per game, the shared flag until then (SaveData.modScope).
|
||||
-- Where the loader persists an enable flag: this running game's slot.
|
||||
function ManagerState:enableScope()
|
||||
return SaveData.modScope((self:targetGame()))
|
||||
end
|
||||
@@ -936,13 +937,18 @@ function ManagerState:schemaFor(m)
|
||||
local schema = loader.optionSchemas and loader.optionSchemas[m.id]
|
||||
if schema == nil and m.options_schema and m.path
|
||||
and loader.fs and loader.fs.load then
|
||||
local chunk = loader.fs.load(m.path .. "/" .. m.options_schema)
|
||||
if chunk then
|
||||
local ok, rows = pcall(chunk)
|
||||
if ok and type(rows) == "table" then
|
||||
schema = rows
|
||||
if loader.optionSchemas then loader.optionSchemas[m.id] = schema end
|
||||
end
|
||||
-- mod-authored code, so it runs in the same sandbox the entry chunk does
|
||||
local ok, rows = pcall(function()
|
||||
local path = SafePath.join(m.path, m.options_schema, "options_schema")
|
||||
local mod = loader.mods and loader.mods[m.id]
|
||||
local env = mod and loader._modEnv and loader:_modEnv(mod)
|
||||
or Sandbox.envFor()
|
||||
local chunk = Sandbox.loadFile(loader.fs, path, env)
|
||||
return chunk and chunk()
|
||||
end)
|
||||
if ok and type(rows) == "table" then
|
||||
schema = rows
|
||||
if loader.optionSchemas then loader.optionSchemas[m.id] = schema end
|
||||
end
|
||||
end
|
||||
return schema
|
||||
|
||||
+80
-26
@@ -3,13 +3,15 @@
|
||||
-- that need to stat a file, this owns shape, vocabulary and range grammar.
|
||||
local Logger = require("src.core.Logger")
|
||||
local ModTargets = require("src.mods.ModTargets")
|
||||
local SafePath = require("src.mods.SafePath")
|
||||
local Semver = require("src.mods.Semver")
|
||||
local Version = require("src.core.Version")
|
||||
|
||||
local Manifest = {}
|
||||
|
||||
Manifest.PROFILES = { content = true, overhaul = true, total_conversion = true }
|
||||
Manifest.PERMISSIONS = { network = true, filesystem = true, engine_internals = true }
|
||||
Manifest.PERMISSIONS = { network = true, filesystem = true,
|
||||
engine_internals = true, steps = true }
|
||||
|
||||
-- link-relevant registries; a mod that writes into one of these while
|
||||
-- declaring affects_link = false gets an attributed warning from the loader
|
||||
@@ -31,26 +33,6 @@ local function violation(strict, id, message)
|
||||
Logger.warn("[%s] %s", tostring(id), message)
|
||||
end
|
||||
|
||||
-- "id" or "id@<range>"; a malformed id or range fails for every api level
|
||||
-- because there is no sane fallback reading for it
|
||||
local function parseSpecs(list, field)
|
||||
local specs = {}
|
||||
for _, entry in ipairs(list) do
|
||||
assert(type(entry) == "string" and entry ~= "",
|
||||
field .. " entries must be non-empty strings")
|
||||
local id, range = entry:match("^([%w_%-]+)@(.+)$")
|
||||
if not id then
|
||||
id = entry:match("^([%w_%-]+)$")
|
||||
assert(id, ("malformed %s entry %q"):format(field, entry))
|
||||
range = nil
|
||||
end
|
||||
local ok, err = Semver.validRange(range)
|
||||
assert(ok, ("malformed %s range in %q: %s"):format(field, entry, tostring(err)))
|
||||
specs[#specs + 1] = { id = id, range = range }
|
||||
end
|
||||
return specs
|
||||
end
|
||||
|
||||
-- Optional GitHub repo for launcher auto-update / other-versions.
|
||||
-- Accepts "owner/repo" or a github.com URL; empty/absent means no updates.
|
||||
function Manifest.parseGithub(value)
|
||||
@@ -73,6 +55,69 @@ function Manifest.parseGithub(value)
|
||||
return owner .. "/" .. repo
|
||||
end
|
||||
|
||||
-- "id", "id@<range>", "id@<range>#<github>", "id#<github>", or table entry
|
||||
local function parseSpecs(list, field, sources)
|
||||
local specs = {}
|
||||
sources = type(sources) == "table" and sources or {}
|
||||
for _, entry in ipairs(list) do
|
||||
local id, range, ghHint, gamesRaw, gameVersion
|
||||
if type(entry) == "table" then
|
||||
id = entry.id
|
||||
range = entry.range or entry.version
|
||||
ghHint = entry.github or entry.repo
|
||||
gamesRaw = entry.games or entry.game
|
||||
gameVersion = entry.game_version
|
||||
elseif type(entry) == "string" and entry ~= "" then
|
||||
local main, hashRepo = entry:match("^([^#]+)#(.*)$")
|
||||
if main then
|
||||
entry = main
|
||||
ghHint = hashRepo
|
||||
end
|
||||
id, range = entry:match("^([%w_%-]+)@(.+)$")
|
||||
if not id then
|
||||
id = entry:match("^([%w_%-]+)$")
|
||||
assert(id, ("malformed %s entry %q"):format(field, entry))
|
||||
range = nil
|
||||
end
|
||||
else
|
||||
error(field .. " entries must be non-empty strings or tables")
|
||||
end
|
||||
assert(id, ("malformed %s entry"):format(field))
|
||||
local ok, err = Semver.validRange(range)
|
||||
assert(ok, ("malformed %s range in %q: %s"):format(field, tostring(entry), tostring(err)))
|
||||
|
||||
if gameVersion then
|
||||
local okV, errV = Semver.validRange(gameVersion)
|
||||
assert(okV, ("malformed %s game_version range in %q: %s"):format(field, tostring(entry), tostring(errV)))
|
||||
end
|
||||
|
||||
local parsedGames = nil
|
||||
if gamesRaw ~= nil then
|
||||
if type(gamesRaw) == "string" then gamesRaw = { gamesRaw } end
|
||||
assert(type(gamesRaw) == "table", "dependency games must be a string or table")
|
||||
local normalized, unknown = ModTargets.normalize(gamesRaw)
|
||||
assert(#unknown == 0, ("unknown game in %s: %s"):format(field, table.concat(unknown, ", ")))
|
||||
parsedGames = normalized
|
||||
end
|
||||
|
||||
local parsedGh = nil
|
||||
local rawGh = ghHint or sources[id]
|
||||
if rawGh then
|
||||
local okGh, cleanGh = pcall(Manifest.parseGithub, rawGh)
|
||||
if okGh and cleanGh then parsedGh = cleanGh end
|
||||
end
|
||||
|
||||
specs[#specs + 1] = {
|
||||
id = id,
|
||||
range = range,
|
||||
github = parsedGh,
|
||||
games = parsedGames,
|
||||
game_version = gameVersion,
|
||||
}
|
||||
end
|
||||
return specs
|
||||
end
|
||||
|
||||
-- conflicts + incompatible (alias) merged, first-wins on duplicate ids
|
||||
local function mergeConflictLists(conflicts, incompatible)
|
||||
local seen, out = {}, {}
|
||||
@@ -144,6 +189,9 @@ function Manifest.validate(raw, path)
|
||||
assert(type(raw.name) == "string" and raw.name ~= "", "manifest name is required")
|
||||
assert(type(raw.version) == "string" and raw.version ~= "", "manifest version is required")
|
||||
assert(type(raw.entry) == "string" and raw.entry ~= "", "manifest entry is required")
|
||||
-- every manifest path is joined to the mod's own directory, so none of them
|
||||
-- may climb out of it (src/mods/SafePath.lua)
|
||||
local entry = SafePath.require(raw.entry, "manifest entry")
|
||||
|
||||
-- absent means 1: full v1 compat, schema violations downgrade to warnings
|
||||
assert(raw.api == nil or tonumber(raw.api) ~= nil, "manifest api must be a number")
|
||||
@@ -229,27 +277,32 @@ function Manifest.validate(raw, path)
|
||||
local affectsLink = profile ~= "content" and not language
|
||||
if type(raw.affects_link) == "boolean" then affectsLink = raw.affects_link end
|
||||
|
||||
local function optionalFile(value, field)
|
||||
local function optionalString(value, field)
|
||||
if value == nil then return nil end
|
||||
assert(type(value) == "string" and value ~= "", field .. " must be a file path")
|
||||
return value
|
||||
end
|
||||
|
||||
local function optionalFile(value, field)
|
||||
local text = optionalString(value, field)
|
||||
return text and SafePath.require(text, field)
|
||||
end
|
||||
|
||||
local conflicts = mergeConflictLists(raw.conflicts, raw.incompatible)
|
||||
|
||||
return {
|
||||
id = raw.id,
|
||||
name = raw.name,
|
||||
version = raw.version,
|
||||
entry = raw.entry,
|
||||
entry = entry,
|
||||
api = api,
|
||||
priority = tonumber(raw.priority) or 0,
|
||||
dependencies = array(raw.dependencies),
|
||||
optional_dependencies = array(raw.optional_dependencies),
|
||||
conflicts = conflicts,
|
||||
incompatible = array(raw.incompatible),
|
||||
dependencySpecs = parseSpecs(array(raw.dependencies), "dependencies"),
|
||||
optionalSpecs = parseSpecs(array(raw.optional_dependencies), "optional_dependencies"),
|
||||
dependencySpecs = parseSpecs(array(raw.dependencies), "dependencies", raw.dependency_sources),
|
||||
optionalSpecs = parseSpecs(array(raw.optional_dependencies), "optional_dependencies", raw.dependency_sources),
|
||||
conflictSpecs = parseSpecs(conflicts, "conflicts"),
|
||||
category = raw.category or "OTHER",
|
||||
game_version = raw.game_version,
|
||||
@@ -265,7 +318,8 @@ function Manifest.validate(raw, path)
|
||||
permissionSet = permissionSet,
|
||||
options_schema = optionalFile(raw.options_schema, "options_schema"),
|
||||
assets_transforms = optionalFile(raw.assets_transforms, "assets_transforms"),
|
||||
force_enable_env = optionalFile(raw.force_enable_env, "force_enable_env"),
|
||||
-- an env var name, not a path, so it keeps the plain string check
|
||||
force_enable_env = optionalString(raw.force_enable_env, "force_enable_env"),
|
||||
path = path,
|
||||
raw = raw,
|
||||
}
|
||||
|
||||
@@ -334,9 +334,15 @@ function ModIndex.compatIssues(entry, ctx)
|
||||
local function eachSpec(spec, fn)
|
||||
if type(spec) ~= "table" then return end
|
||||
for k, v in pairs(spec) do
|
||||
if type(k) == "number" and type(v) == "string" then
|
||||
local id, range = v:match("^([^@]+)@(.+)$")
|
||||
fn(id or v, range)
|
||||
if type(k) == "number" then
|
||||
if type(v) == "table" and type(v.id) == "string" then
|
||||
fn(v.id, v.range or v.version, v.github or v.repo)
|
||||
elseif type(v) == "string" then
|
||||
local main, hashRepo = v:match("^([^#]+)#(.*)$")
|
||||
if main then v = main end
|
||||
local id, range = v:match("^([^@]+)@(.+)$")
|
||||
fn(id or v, range, hashRepo)
|
||||
end
|
||||
elseif type(k) == "string" then
|
||||
fn(k, type(v) == "string" and v or nil)
|
||||
end
|
||||
|
||||
@@ -168,4 +168,39 @@ function ModTargets.detail(manifest, version)
|
||||
ModTargets.gameLabel(version))
|
||||
end
|
||||
|
||||
-- Does a dependency spec apply to this game / version?
|
||||
-- If spec.games is provided, it must match version / generation.
|
||||
-- If spec.game_version is provided, the engine version must satisfy it.
|
||||
function ModTargets.specApplies(spec, version, generation)
|
||||
if not spec then return false end
|
||||
if type(spec.games) == "table" and #spec.games > 0 then
|
||||
if version or generation then
|
||||
local match = false
|
||||
for _, id in ipairs(spec.games) do
|
||||
if version and id == version then
|
||||
match = true
|
||||
break
|
||||
end
|
||||
if generation and GameVersion.generation(id) == generation then
|
||||
match = true
|
||||
break
|
||||
end
|
||||
end
|
||||
if not match then return false end
|
||||
end
|
||||
end
|
||||
if spec.game_version then
|
||||
local ok = pcall(function()
|
||||
local Semver = require("src.mods.Semver")
|
||||
local Version = require("src.core.Version")
|
||||
if Version and Version.engine and Version.engine:match("^0%.0%.0%-") == nil then
|
||||
return Semver.satisfies(Version.engine, spec.game_version)
|
||||
end
|
||||
return true
|
||||
end)
|
||||
if not ok then return false end
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
return ModTargets
|
||||
|
||||
@@ -28,6 +28,11 @@ Runtime.errors = nil
|
||||
-- permissions tripwire knows there is nobody to attribute to
|
||||
Runtime.currentMod = nil
|
||||
|
||||
-- set by the sandbox's require for the duration of one mod-initiated require,
|
||||
-- so the loader's gate can still attribute a lazy one made long after
|
||||
-- currentMod went back to nil (src/mods/Sandbox.lua)
|
||||
Runtime.modRequire = nil
|
||||
|
||||
function Runtime.install(events, hooks, errors)
|
||||
Runtime.events, Runtime.hooks = events, hooks
|
||||
Runtime.errors = errors
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
-- One relative-path grammar for every path a mod supplies: a mod names files
|
||||
-- inside its own directory and nowhere else. love.filesystem (PhysFS) already
|
||||
-- refuses "..", absolute paths and backslashes, but Loader.new takes an
|
||||
-- injected fs that has no such floor, so the rule lives here and not in
|
||||
-- whichever filesystem happens to be underneath.
|
||||
local SafePath = {}
|
||||
|
||||
-- The normalized path, or nil when it could climb out of the root it is about
|
||||
-- to be joined to. "." segments are dropped rather than rejected so a
|
||||
-- manifest that says "./main.lua" still loads.
|
||||
function SafePath.safe(rel)
|
||||
if type(rel) ~= "string" or rel == "" then return nil end
|
||||
if rel:sub(1, 1) == "/" then return nil end
|
||||
if rel:find("\\", 1, true) then return nil end
|
||||
if rel:match("^%a:") then return nil end -- windows drive-relative
|
||||
local parts = {}
|
||||
for segment in rel:gmatch("[^/]+") do
|
||||
if segment == ".." then return nil end
|
||||
if segment ~= "." then parts[#parts + 1] = segment end
|
||||
end
|
||||
if #parts == 0 then return nil end
|
||||
return table.concat(parts, "/")
|
||||
end
|
||||
|
||||
function SafePath.require(rel, what)
|
||||
local safe = SafePath.safe(rel)
|
||||
if not safe then
|
||||
error(("%s must stay inside its root, got %q"):format(what, tostring(rel)), 0)
|
||||
end
|
||||
return safe
|
||||
end
|
||||
|
||||
-- root .. "/" .. rel, with the traversal check in between
|
||||
function SafePath.join(root, rel, what)
|
||||
return root .. "/" .. SafePath.require(rel, what or "path")
|
||||
end
|
||||
|
||||
return SafePath
|
||||
@@ -0,0 +1,228 @@
|
||||
-- The environment a mod's own code runs in. Every chunk a mod authors -- the
|
||||
-- entry file, an options_schema, anything it hands to load() -- runs against
|
||||
-- this table instead of _G, so the only paths it can name are the ones the
|
||||
-- engine hands it (mod:read, mod.storage, mod.assets).
|
||||
--
|
||||
-- What this is and is not: raw io/os/ffi are the only way to name a file
|
||||
-- outside the game tree at all, and they are absent here, so the reported
|
||||
-- "any mod can rewrite anything in your home directory" hole closes by
|
||||
-- construction. Inside the LÖVE tree this is defense in depth, not a security
|
||||
-- boundary: an engine module reached through require, or ImageData:encode,
|
||||
-- still writes in the save directory.
|
||||
--
|
||||
-- Lua 5.1/LuaJIT is the target, so setfenv is the mechanism; the 5.2+ arm
|
||||
-- exists because AssetTransform's sandbox needed it and getting this wrong
|
||||
-- silently hands the chunk the real globals.
|
||||
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
local SafePath = require("src.mods.SafePath")
|
||||
|
||||
local Sandbox = {}
|
||||
|
||||
-- Modules that hand a mod the disk, a raw socket or a fresh Lua state no
|
||||
-- matter what this file removes from the environment. package.loaded.io is
|
||||
-- the one call that would undo every other rule here.
|
||||
local DENIED = {
|
||||
io = "the filesystem", os = "the filesystem", debug = "the debug library",
|
||||
package = "the module loader", ffi = "arbitrary C calls",
|
||||
}
|
||||
|
||||
-- Same idea one level up: love.filesystem is reachable by name, and
|
||||
-- love.thread starts a Lua state this sandbox has no say over.
|
||||
local DENIED_PREFIX = { ["love"] = true, ["ffi"] = true }
|
||||
|
||||
-- The wire, which is what the network permission governs.
|
||||
local NETWORK = { socket = true, enet = true, http = true, https = true,
|
||||
ssl = true, mime = true, ltn12 = true }
|
||||
|
||||
local function head(name)
|
||||
return (name:match("^([^%.]+)")) or name
|
||||
end
|
||||
|
||||
-- nil when the require is allowed, else the message to fail it with.
|
||||
function Sandbox.moduleDenial(name, permissionSet)
|
||||
if type(name) ~= "string" then return nil end
|
||||
local root = head(name)
|
||||
local reason = DENIED[root]
|
||||
if reason then
|
||||
return ("%s is not available to mods (it grants %s); use mod.storage, "
|
||||
.. "mod:read and the engine API instead"):format(name, reason)
|
||||
end
|
||||
if DENIED_PREFIX[root] and name ~= root then
|
||||
return ("%s is not available to mods; use mod.storage, mod:read and the "
|
||||
.. "engine API instead"):format(name)
|
||||
end
|
||||
if NETWORK[root] and not (permissionSet or {}).network then
|
||||
return ("%s needs the \"network\" permission in manifest.json"):format(name)
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
-- ------- the love facade
|
||||
|
||||
-- Dropped, not narrowed: filesystem writes anywhere in the save directory
|
||||
-- (including another mod's storage), thread opens a Lua state with a full
|
||||
-- standard library, system.openURL launches whatever it is handed, and event
|
||||
-- lets a mod quit the game out from under the player. Everything else LÖVE
|
||||
-- exposes passes through, so a new module in a future LÖVE is available
|
||||
-- without an edit here.
|
||||
-- value is the replacement to name in the error, or true when there is none
|
||||
local BLOCKED_LOVE = {
|
||||
filesystem = "mod.storage and mod:read", thread = true,
|
||||
system = "mod.device:powerInfo() for battery information, mod.steps for "
|
||||
.. "the step bridge", event = true,
|
||||
}
|
||||
|
||||
local loveProxy
|
||||
local function loveFacade()
|
||||
if loveProxy or not _G.love then return loveProxy end
|
||||
loveProxy = setmetatable({}, {
|
||||
__index = function(_, key)
|
||||
local hint = BLOCKED_LOVE[key]
|
||||
if hint then
|
||||
error(("love.%s is not available to mods%s"):format(key,
|
||||
type(hint) == "string" and (", use " .. hint) or ""), 2)
|
||||
end
|
||||
return _G.love[key]
|
||||
end,
|
||||
__newindex = function(_, key)
|
||||
error(("mods cannot assign love.%s"):format(tostring(key)), 2)
|
||||
end,
|
||||
})
|
||||
return loveProxy
|
||||
end
|
||||
|
||||
-- ------- the environment
|
||||
|
||||
-- Absent on purpose: io, package, dofile, loadfile, getfenv, setfenv, debug,
|
||||
-- newproxy, module. os keeps only the clock -- getenv is how the reported
|
||||
-- exploit found the user's home directory.
|
||||
local SAFE_OS = { time = true, date = true, clock = true, difftime = true }
|
||||
|
||||
-- Per-mod copies, not the shared tables: a mod that assigns string.trim or
|
||||
-- replaces table.insert changes its own view and nobody else's. The functions
|
||||
-- are the same objects, so state behind them (math.randomseed's RNG) is
|
||||
-- unaffected -- only the namespace is private.
|
||||
local function copy(source)
|
||||
if type(source) ~= "table" then return source end
|
||||
local out = {}
|
||||
for key, value in pairs(source) do out[key] = value end
|
||||
return out
|
||||
end
|
||||
|
||||
local function baseGlobals()
|
||||
local safeOs = {}
|
||||
for key in pairs(SAFE_OS) do safeOs[key] = os[key] end
|
||||
return {
|
||||
assert = assert, error = error, ipairs = ipairs, next = next,
|
||||
pairs = pairs, pcall = pcall, xpcall = xpcall, select = select,
|
||||
tonumber = tonumber, tostring = tostring, type = type, unpack = unpack,
|
||||
rawequal = rawequal, rawget = rawget, rawset = rawset, rawlen = rawlen,
|
||||
setmetatable = setmetatable, getmetatable = getmetatable, print = print,
|
||||
collectgarbage = collectgarbage, _VERSION = _VERSION,
|
||||
coroutine = copy(coroutine), math = copy(math), string = copy(string),
|
||||
table = copy(table), bit = copy(bit), jit = jit, os = safeOs,
|
||||
}
|
||||
end
|
||||
|
||||
-- setfenv on 5.1/LuaJIT; on 5.2+ the env has to be handed to load itself, so
|
||||
-- a caller there compiles through Sandbox.compile instead.
|
||||
function Sandbox.bind(chunk, env)
|
||||
if setfenv then setfenv(chunk, env) end
|
||||
return chunk
|
||||
end
|
||||
|
||||
-- Bytecode is unreviewable and, on LuaJIT, a way out of any sandbox built out
|
||||
-- of environments. Mods ship source.
|
||||
local function rejectBytecode(source, what)
|
||||
if type(source) == "string" and source:sub(1, 1) == "\27" then
|
||||
return nil, (what or "chunk") .. ": mods must ship Lua source, not bytecode"
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
function Sandbox.compile(source, chunkname, env)
|
||||
local ok, err = rejectBytecode(source, chunkname)
|
||||
if not ok then return nil, err end
|
||||
if setfenv then
|
||||
local chunk, compileErr = loadstring(source, chunkname)
|
||||
if not chunk then return nil, compileErr end
|
||||
return setfenv(chunk, env)
|
||||
end
|
||||
return load(source, chunkname, "t", env)
|
||||
end
|
||||
|
||||
-- The load() a mod sees. Lua 5.1 gives a loaded chunk the GLOBAL environment
|
||||
-- rather than the caller's, so without this every sandboxed mod is one
|
||||
-- load(mod:read(...)) away from the real _G -- which is exactly how the
|
||||
-- multi-file mods in mods/ are written.
|
||||
local function sandboxedLoad(env)
|
||||
return function(chunk, chunkname)
|
||||
if type(chunk) == "function" then
|
||||
local parts = {}
|
||||
while true do
|
||||
local piece = chunk()
|
||||
if piece == nil or piece == "" then break end
|
||||
parts[#parts + 1] = piece
|
||||
end
|
||||
chunk = table.concat(parts)
|
||||
end
|
||||
if type(chunk) ~= "string" then return nil, "load expects a string or reader" end
|
||||
return Sandbox.compile(chunk, chunkname or "=(load)", env)
|
||||
end
|
||||
end
|
||||
|
||||
-- The require a mod sees: the deny list lives here rather than on a stack
|
||||
-- walk, because pcall(require, "io") puts a C frame where the walk would look.
|
||||
-- Runtime.modRequire is how the loader's gate identifies the caller for the
|
||||
-- Gen 2 facade once Runtime.currentMod has gone back to nil (a mod requiring
|
||||
-- lazily from an event handler).
|
||||
local function sandboxedRequire(modId, permissionSet)
|
||||
return function(name, ...)
|
||||
local denial = Sandbox.moduleDenial(name, permissionSet)
|
||||
if denial then error(("[%s] %s"):format(modId or "mod", denial), 2) end
|
||||
local previous = Runtime.modRequire
|
||||
Runtime.modRequire = modId or true
|
||||
local ok, result = pcall(_G.require, name, ...)
|
||||
Runtime.modRequire = previous
|
||||
if not ok then error(result, 0) end
|
||||
return result
|
||||
end
|
||||
end
|
||||
|
||||
function Sandbox.envFor(opts)
|
||||
opts = opts or {}
|
||||
local env = baseGlobals()
|
||||
env.love = loveFacade()
|
||||
env.require = sandboxedRequire(opts.modId, opts.permissions)
|
||||
local loader = sandboxedLoad(env)
|
||||
env.load = loader
|
||||
env.loadstring = loader
|
||||
-- a mod's globals are its own: two mods no longer share a namespace, and
|
||||
-- neither can reach the engine's
|
||||
env._G = env
|
||||
return env
|
||||
end
|
||||
|
||||
-- fs.load keeps the real filesystem's handling of the file; the environment is
|
||||
-- swapped after the fact. The 5.2+ arm has to go back to source, which is the
|
||||
-- only reason fs.read is touched here.
|
||||
function Sandbox.loadFile(fs, path, env)
|
||||
if fs.read then
|
||||
local ok, err = rejectBytecode(fs.read(path), path)
|
||||
if not ok then return nil, err end
|
||||
end
|
||||
if setfenv then
|
||||
local chunk, err = fs.load(path)
|
||||
if not chunk then return nil, err end
|
||||
return setfenv(chunk, env)
|
||||
end
|
||||
local source = fs.read and fs.read(path)
|
||||
if not source then return nil, "unable to read " .. path end
|
||||
return Sandbox.compile(source, "@" .. path, env)
|
||||
end
|
||||
|
||||
Sandbox.safePath = SafePath.safe
|
||||
Sandbox.requirePath = SafePath.require
|
||||
|
||||
return Sandbox
|
||||
@@ -0,0 +1,84 @@
|
||||
-- The scoped seam for the native step bridge (#1186).
|
||||
--
|
||||
-- The iOS/Android builds count the player's real-world steps natively
|
||||
-- (#452, #489) and deliver them by writing steps_pending.json into the
|
||||
-- save-directory root. Before the sandbox, the Pokéwalker mod called
|
||||
-- love.system.syncHealthSteps() and consumed that file itself; the sandbox
|
||||
-- blocks both, which is correct -- love.system launches URLs and the file
|
||||
-- API names paths -- but it left the bridge with no consumer at all.
|
||||
--
|
||||
-- This module is the narrow replacement, gated by the "steps" permission
|
||||
-- in manifest.json (the network model: a permission the player sees that
|
||||
-- genuinely gates a capability). The engine owns the file: mods never
|
||||
-- learn its name or location, they receive only the three contract fields
|
||||
-- ({ steps, from, to }), each permissioned mod gets its own copy, and the
|
||||
-- merge-don't-overwrite anchor semantics stay on the native side where
|
||||
-- they always lived.
|
||||
--
|
||||
-- No frame pump: the file is looked for lazily when a mod polls, so a
|
||||
-- build with no permissioned mod installed never touches the bridge or
|
||||
-- the disk.
|
||||
|
||||
local Json = require("src.link.Json")
|
||||
|
||||
local Steps = {}
|
||||
|
||||
-- The native contract's drop point, in the save-directory root (see
|
||||
-- mobile/ios and mobile/android step bridges).
|
||||
Steps.PENDING = "steps_pending.json"
|
||||
|
||||
local function bridge()
|
||||
return _G.love and _G.love.system and _G.love.system.syncHealthSteps
|
||||
end
|
||||
|
||||
-- Whether this build carries the native bridge. Desktop builds do not;
|
||||
-- a mod uses this to stay dormant without probing love.system.
|
||||
function Steps.available()
|
||||
return bridge() ~= nil
|
||||
end
|
||||
|
||||
-- Ask the native side to refresh its count. Async: the result lands in
|
||||
-- the pending file and comes back through a later poll. The platform's
|
||||
-- own consent sheet (HealthKit / ACTIVITY_RECOGNITION) still appears on
|
||||
-- first use, exactly as it did pre-sandbox. false when there is no
|
||||
-- bridge to ask.
|
||||
function Steps.sync()
|
||||
local fn = bridge()
|
||||
if not fn then return false end
|
||||
fn()
|
||||
return true
|
||||
end
|
||||
|
||||
-- Consume the pending file, if one has appeared, and fan its payload out
|
||||
-- to every permissioned mod's queue. Only the contract fields travel;
|
||||
-- anything else in the file stays in the file's grave. A malformed or
|
||||
-- empty delivery is dropped whole -- the native anchor only advances on a
|
||||
-- successful sync, so nothing is lost to a bad write.
|
||||
function Steps.pump(loader)
|
||||
local fs = _G.love and _G.love.filesystem
|
||||
if not (fs and fs.getInfo(Steps.PENDING, "file")) then return end
|
||||
local raw = fs.read(Steps.PENDING)
|
||||
fs.remove(Steps.PENDING)
|
||||
if not raw then return end
|
||||
local ok, decoded = pcall(Json.decode, raw)
|
||||
if not ok or type(decoded) ~= "table" then return end
|
||||
local steps = tonumber(decoded.steps)
|
||||
if not steps or steps <= 0 then return end
|
||||
local payload = { steps = steps, from = decoded.from, to = decoded.to }
|
||||
for _, queue in pairs(loader.stepsQueues) do
|
||||
queue[#queue + 1] = { steps = payload.steps, from = payload.from,
|
||||
to = payload.to }
|
||||
end
|
||||
end
|
||||
|
||||
-- The next delivery for this mod, or nil. Each permissioned mod consumes
|
||||
-- its own queue, so two mods both see the same walk (pre-sandbox, whoever
|
||||
-- read the file first won).
|
||||
function Steps.poll(loader, modId)
|
||||
Steps.pump(loader)
|
||||
local queue = loader.stepsQueues[modId]
|
||||
if not queue then return nil end
|
||||
return table.remove(queue, 1)
|
||||
end
|
||||
|
||||
return Steps
|
||||
@@ -140,9 +140,12 @@ function Evolution.learnEvolutionMoves(game, mon, onDone)
|
||||
if #mon.moves < 4 then
|
||||
table.insert(mon.moves, { id = moveId, pp = mdef.pp })
|
||||
Runtime.emit("pokemon.move_learned", { mon = mon, moveId = moveId })
|
||||
-- LearnedMove1Text: text_far, sound_get_item_1, text_promptbutton
|
||||
-- (learn_move.asm), so the jingle rides the box
|
||||
game.stack:push(TextBox.new(game,
|
||||
romText(game.data, "_LearnedMove1Text",
|
||||
"%s learned\n%s!", name, mdef.name), nextStep))
|
||||
"%s learned\n%s!", name, mdef.name), nextStep,
|
||||
TextBox.soundOpts(game, "Get_Item1")))
|
||||
else
|
||||
-- LearnMoveFromLevelUp with a full moveset: the forget UI
|
||||
Screens.push(game, "MoveLearnMenu", mon, moveId, nextStep)
|
||||
|
||||
@@ -141,13 +141,20 @@ end
|
||||
-- Tinting first would double-apply the color: GREENBAR's fill {0,189,0} has
|
||||
-- red channel 0, so the tint zeroes the whole bar's red and the zone's
|
||||
-- red-channel-keyed shade shader then maps every pixel to color 3 = black.
|
||||
function HudTiles.drawHPBar(data, tx, ty, mon, barType, grayFill, segments)
|
||||
--
|
||||
-- pixels: an explicit 0..48 bar length on GetHPBarLength's scale, for a
|
||||
-- caller that is animating the bar between two HP values
|
||||
-- (UpdateHPBar_AnimateHPBar); it scales with `segments` like the color
|
||||
-- thresholds do. Without it the length comes from mon.hp as before.
|
||||
function HudTiles.drawHPBar(data, tx, ty, mon, barType, grayFill, segments, pixels)
|
||||
local x, y = tx * 8, ty * 8
|
||||
segments = math.max(1, math.floor(segments or 6))
|
||||
HudTiles.tile(0x71, x, y)
|
||||
HudTiles.tile(0x62, x + 8, y)
|
||||
local px = 0
|
||||
if mon.stats.hp > 0 and mon.hp > 0 then
|
||||
if pixels then
|
||||
px = math.max(0, math.floor(pixels * segments / 6))
|
||||
elseif mon.stats.hp > 0 and mon.hp > 0 then
|
||||
px = math.max(1, math.floor(mon.hp * segments * 8 / mon.stats.hp))
|
||||
end
|
||||
local tint
|
||||
|
||||
@@ -691,10 +691,12 @@ function PaletteFX.spriteObp(spriteDef, seed)
|
||||
return PaletteFX.darkObp(w.spritePalettes[group], group)
|
||||
end
|
||||
|
||||
-- GetHealthBarColor (home/palettes.asm) on the standard 48px bar
|
||||
function PaletteFX.barPalName(hp, maxHp)
|
||||
local px = maxHp > 0 and math.floor(hp * 48 / maxHp) or 0
|
||||
if hp > 0 and px < 1 then px = 1 end
|
||||
-- GetHealthBarColor (home/palettes.asm) on the standard 48px bar. It reads
|
||||
-- the bar's own length, so a caller mid-drain passes the animated `pixels`
|
||||
-- rather than let it be re-derived from hp.
|
||||
function PaletteFX.barPalName(hp, maxHp, pixels)
|
||||
local px = pixels or (maxHp > 0 and math.floor(hp * 48 / maxHp) or 0)
|
||||
if not pixels and hp > 0 and px < 1 then px = 1 end
|
||||
return px >= 27 and "GREENBAR" or px >= 10 and "YELLOWBAR" or "REDBAR"
|
||||
end
|
||||
|
||||
|
||||
+25
-13
@@ -768,11 +768,12 @@ function Renderer:endFrame(zones, worldZones)
|
||||
end
|
||||
end
|
||||
|
||||
-- A post-process pipeline needs the whole composite in a canvas for the
|
||||
-- same reason GBC FX does, so either one alone is enough to take the
|
||||
-- present path; with neither, the frame draws straight to the screen
|
||||
-- exactly as it always did.
|
||||
local needPresent = GBCFX.active() or Pipelines.wantsPresent()
|
||||
-- Post-process pipelines, GBC FX and an enabled final-output owner need the
|
||||
-- whole composite in a canvas. With none of them, the frame draws straight
|
||||
-- to the screen exactly as it always did.
|
||||
local hasOutputHook = Runtime.wantsHook("render.output")
|
||||
and Runtime.call("render.output_enabled", function() return false end) == true
|
||||
local needPresent = GBCFX.active() or Pipelines.wantsPresent() or hasOutputHook
|
||||
local present = nil
|
||||
if needPresent then
|
||||
if not self.presentCanvas or self.presentCanvas:getWidth() ~= ww
|
||||
@@ -1057,14 +1058,25 @@ function Renderer:endFrame(zones, worldZones)
|
||||
-- this returns `present` unchanged and the frame is byte-identical.
|
||||
local composed = Pipelines.present(present,
|
||||
{ width = ww, height = wh, scale = Sp, dpi = dpiY, dpiX = dpiX, dpiY = dpiY }) or present
|
||||
if GBCFX.active() then
|
||||
-- shader grid/shadow math is in framebuffer pixels
|
||||
GBCFX.present(composed, Sp)
|
||||
else
|
||||
-- the present canvas only existed for the post-process, so put the
|
||||
-- result on the screen at the same 1:1 unit mapping it was built at
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
love.graphics.draw(composed, 0, 0)
|
||||
local outputHandled = hasOutputHook
|
||||
and Runtime.call("render.output", function() return false end, {
|
||||
canvas = composed,
|
||||
width = ww, height = wh,
|
||||
gameX = ox, gameY = oy,
|
||||
gameWidth = vpw, gameHeight = vph,
|
||||
scale = Sp, dpiX = dpiX, dpiY = dpiY,
|
||||
generation = 1,
|
||||
}) == true
|
||||
if not outputHandled then
|
||||
if GBCFX.active() then
|
||||
-- shader grid/shadow math is in framebuffer pixels
|
||||
GBCFX.present(composed, Sp)
|
||||
else
|
||||
-- the present canvas only existed for the post-process, so put the
|
||||
-- result on the screen at the same 1:1 unit mapping it was built at
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
love.graphics.draw(composed, 0, 0)
|
||||
end
|
||||
end
|
||||
end
|
||||
self.worldActive = false
|
||||
|
||||
@@ -62,6 +62,8 @@ local function getObpImage(path, colors, group)
|
||||
return obpCache[key]
|
||||
end
|
||||
|
||||
SpriteRenderer.obpImage = getObpImage
|
||||
|
||||
-- hot reload drops the sheets; live instances hold their own image, so
|
||||
-- the world rebuilds them (MapLoader.invalidateAll) rather than this
|
||||
function SpriteRenderer.invalidate()
|
||||
@@ -281,7 +283,7 @@ end
|
||||
-- swapped and OAM_XFLIP on each (data/sprites/facings.asm:192-197). Optional
|
||||
-- and trailing, so every existing call site is unchanged.
|
||||
function SpriteRenderer:draw(px, py, camX, camY, facing, walkPhase, stepFlip,
|
||||
topHalf, forceFlip)
|
||||
topHalf, forceFlip, frameOverride)
|
||||
local x, y = self:getScreenOrigin(px, py, camX, camY)
|
||||
local image = self.image
|
||||
local redraw = false
|
||||
@@ -329,6 +331,9 @@ function SpriteRenderer:draw(px, py, camX, camY, facing, walkPhase, stepFlip,
|
||||
-- still 3-frame sprites turn to face (the nurse at her machine,
|
||||
-- facePlayer on STAY NPCs) but never show walk frames.
|
||||
local frame, flip = pose(self, facing, walkPhase, stepFlip)
|
||||
if frameOverride and self.frames[frameOverride] then
|
||||
frame, flip = frameOverride, false
|
||||
end
|
||||
if forceFlip then flip = true end
|
||||
local quad = self.frames[frame]
|
||||
local drawHeight = self.frameHeight
|
||||
|
||||
@@ -100,6 +100,26 @@ function TextBox.new(game, text, onDone, opts)
|
||||
return self
|
||||
end
|
||||
|
||||
-- soundOpts: a jingle carried at the end of the string as a trailing text
|
||||
-- command (sound_get_item_1 and friends -> home/text.asm TextCommand_SOUND),
|
||||
-- which runs PlaySound then WaitForSoundToFinish once the last page has
|
||||
-- typed, so the box holds until the fanfare is over. Merges into a caller's
|
||||
-- opts table; auto.wait keeps the trailing button press the plain A/B path
|
||||
-- gives every other box.
|
||||
function TextBox.soundOpts(game, sound, opts)
|
||||
opts = opts or {}
|
||||
local auto = opts.auto
|
||||
-- auto = true is the no-button-wait arming (text_opts); keep that choice
|
||||
if auto == true then auto = { wait = false } end
|
||||
auto = auto or {}
|
||||
if auto.wait == nil then auto.wait = true end
|
||||
auto.delay = auto.delay or 0
|
||||
auto.sound = type(sound) == "function" and sound
|
||||
or function() return require("src.core.Sound").play(game.data, sound) end
|
||||
opts.auto = auto
|
||||
return opts
|
||||
end
|
||||
|
||||
-- The runtime tokens substitute() knows, as handlers the tokens registry
|
||||
-- serves. Each is fn(game, arg) -> replacement, or nil to drop the token.
|
||||
-- RAM keeps pokered's stale-buffer semantics: give_item copies the item
|
||||
@@ -130,6 +150,7 @@ TextBox.TOKENS = {
|
||||
STRBUF = function(game) return game.stringBuffer end,
|
||||
RAM = function(game, arg)
|
||||
if arg == "wStringBuffer" then return game.stringBuffer end
|
||||
if arg == "wNameBuffer" then return game.stringBuffer end
|
||||
if arg == "wBoxNumString" then return game.boxNumString end
|
||||
-- SendNewMonToBox / _SentToBoxText reads the deposited nick here
|
||||
if arg == "wBoxMonNicks" then return game.boxMonNicks end
|
||||
|
||||
@@ -100,6 +100,7 @@ O.tradeFlags = O.townVisited + 44 -- 2B (flag_array NUM_
|
||||
-- Sits 2 bytes (wPlayerCoins) past O.coins per the walk above; absolute
|
||||
-- 0x2852 (#763, #857).
|
||||
O.toggleObjectFlags = O.coins + 2 -- 32B
|
||||
O.hiddenItemFlags = O.townVisited - 27
|
||||
-- Play time (wPlayTimeHours/Maxed/Minutes/Seconds/Frames) lives INSIDE the
|
||||
-- sMainData window (wMainDataStart..wMainDataEnd is copied verbatim into
|
||||
-- SRAM), 1866 bytes past wMainDataStart -- reached from the checksum-verified
|
||||
@@ -770,6 +771,15 @@ function GenSave.decode(bytes, data, opts)
|
||||
end
|
||||
end
|
||||
|
||||
if data.hiddenItems then
|
||||
save.hiddenTaken = {}
|
||||
for i, row in ipairs(data.hiddenItems) do
|
||||
if bitGet(bytes, O.hiddenItemFlags, i - 1) then
|
||||
save.hiddenTaken[row[1] .. "_" .. row[2] .. "_" .. row[3]] = true
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- FLY destinations. wTownVisitedFlag's bit index IS the town's map index:
|
||||
-- engine/items/town_map.asm BuildFlyLocationsList loads the 16-bit value
|
||||
-- into de and rotates it right one bit per iteration with b counting up
|
||||
@@ -953,6 +963,14 @@ function GenSave.encode(save, data, template)
|
||||
end
|
||||
end
|
||||
|
||||
if data.hiddenItems then
|
||||
local taken = save.hiddenTaken or {}
|
||||
for i, row in ipairs(data.hiddenItems) do
|
||||
local key = row[1] .. "_" .. row[2] .. "_" .. row[3]
|
||||
bitSet(buf, O.hiddenItemFlags, i - 1, taken[key] and true or false)
|
||||
end
|
||||
end
|
||||
|
||||
-- FLY destinations back into wTownVisitedFlag (see the decode note), so a
|
||||
-- save exported from this port is flyable on hardware (#263). A save
|
||||
-- table with no `visited` key at all says nothing about the set, so leave
|
||||
|
||||
@@ -49,6 +49,7 @@ local DATA_MODULES = {
|
||||
charmap = { "src.save_convert.data.charmap", "src/save_convert/data/charmap.lua" },
|
||||
eventFlags = { "src.save_convert.data.event_flags", "src/save_convert/data/event_flags.lua" },
|
||||
toggleObjects = { "src.save_convert.data.toggle_objects", "src/save_convert/data/toggle_objects.lua" },
|
||||
hiddenItems = { "src.save_convert.data.hidden_items", "src/save_convert/data/hidden_items.lua" },
|
||||
}
|
||||
|
||||
local OPTIONAL_MODULES = { tilesets = true, audio = true }
|
||||
@@ -64,6 +65,10 @@ local YELLOW_EVENT_FLAGS = {
|
||||
"src.save_convert.data.event_flags_yellow",
|
||||
"src/save_convert/data/event_flags_yellow.lua",
|
||||
}
|
||||
local YELLOW_HIDDEN_ITEMS = {
|
||||
"src.save_convert.data.hidden_items_yellow",
|
||||
"src/save_convert/data/hidden_items_yellow.lua",
|
||||
}
|
||||
|
||||
local function loadTable(requirePath, filePath)
|
||||
local ok, mod = pcall(require, requirePath)
|
||||
@@ -133,6 +138,8 @@ local function ensureData(gameVersion)
|
||||
if name ~= "charmap" then
|
||||
if name == "eventFlags" and gameVersion == "yellow" then
|
||||
spec = YELLOW_EVENT_FLAGS -- Yellow's bit numbering differs (#838)
|
||||
elseif name == "hiddenItems" and gameVersion == "yellow" then
|
||||
spec = YELLOW_HIDDEN_ITEMS
|
||||
end
|
||||
local mod = loadCacheTable(gameVersion, spec[2])
|
||||
if not mod then
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
return {
|
||||
{ "VIRIDIAN_FOREST", 1, 18 }, { "VIRIDIAN_FOREST", 16, 42 },
|
||||
{ "MT_MOON_B2F", 18, 12 }, { "ROUTE_25", 38, 3 },
|
||||
{ "ROUTE_9", 14, 7 }, { "SS_ANNE_KITCHEN", 13, 9 },
|
||||
{ "SS_ANNE_B1F_ROOMS", 3, 1 }, { "ROUTE_10", 9, 17 },
|
||||
{ "ROUTE_10", 16, 53 }, { "ROCKET_HIDEOUT_B1F", 21, 15 },
|
||||
{ "ROCKET_HIDEOUT_B3F", 27, 17 }, { "ROCKET_HIDEOUT_B4F", 25, 1 },
|
||||
{ "POKEMON_TOWER_5F", 4, 12 }, { "ROUTE_13", 1, 14 },
|
||||
{ "ROUTE_13", 16, 13 }, { "POKEMON_MANSION_B1F", 1, 9 },
|
||||
{ "SAFARI_ZONE_GATE", 10, 1 }, { "SAFARI_ZONE_WEST", 6, 5 },
|
||||
{ "SILPH_CO_5F", 12, 3 }, { "SILPH_CO_9F", 2, 15 },
|
||||
{ "COPYCATS_HOUSE_2F", 1, 1 }, { "CERULEAN_CAVE_1F", 14, 11 },
|
||||
{ "CERULEAN_CAVE_B1F", 27, 3 }, { "POWER_PLANT", 17, 16 },
|
||||
{ "POWER_PLANT", 12, 1 }, { "SEAFOAM_ISLANDS_B2F", 15, 15 },
|
||||
{ "SEAFOAM_ISLANDS_B4F", 25, 17 }, { "POKEMON_MANSION_1F", 8, 16 },
|
||||
{ "POKEMON_MANSION_3F", 1, 9 }, { "ROUTE_23", 9, 44 },
|
||||
{ "ROUTE_23", 19, 70 }, { "ROUTE_23", 8, 90 },
|
||||
{ "VICTORY_ROAD_2F", 5, 2 }, { "VICTORY_ROAD_2F", 26, 7 },
|
||||
{ "UNUSED_MAP_6F", 14, 11 }, { "VIRIDIAN_CITY", 14, 4 },
|
||||
{ "ROUTE_11", 48, 5 }, { "ROUTE_12", 2, 63 },
|
||||
{ "ROUTE_17", 15, 14 }, { "ROUTE_17", 8, 45 },
|
||||
{ "ROUTE_17", 17, 72 }, { "ROUTE_17", 4, 91 },
|
||||
{ "ROUTE_17", 8, 121 }, { "UNDERGROUND_PATH_NORTH_SOUTH", 3, 4 },
|
||||
{ "UNDERGROUND_PATH_NORTH_SOUTH", 4, 34 },
|
||||
{ "UNDERGROUND_PATH_WEST_EAST", 12, 2 },
|
||||
{ "UNDERGROUND_PATH_WEST_EAST", 21, 5 }, { "CELADON_CITY", 48, 15 },
|
||||
{ "ROUTE_25", 10, 1 }, { "MT_MOON_B2F", 33, 9 },
|
||||
{ "SEAFOAM_ISLANDS_B3F", 9, 16 }, { "VERMILION_CITY", 14, 11 },
|
||||
{ "CERULEAN_CITY", 15, 8 }, { "ROUTE_4", 40, 3 },
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
return {
|
||||
{ "SILPH_CO_5F", 12, 3 }, { "SILPH_CO_9F", 2, 15 },
|
||||
{ "POKEMON_MANSION_3F", 1, 9 }, { "POKEMON_MANSION_B1F", 1, 9 },
|
||||
{ "SAFARI_ZONE_WEST", 6, 5 }, { "CERULEAN_CAVE_2F", 16, 13 },
|
||||
{ "CERULEAN_CAVE_B1F", 8, 14 }, { "UNUSED_MAP_6F", 14, 11 },
|
||||
{ "SEAFOAM_ISLANDS_B2F", 15, 15 }, { "SEAFOAM_ISLANDS_B3F", 9, 16 },
|
||||
{ "SEAFOAM_ISLANDS_B4F", 25, 17 }, { "VIRIDIAN_FOREST", 1, 18 },
|
||||
{ "VIRIDIAN_FOREST", 16, 42 }, { "MT_MOON_B2F", 18, 12 },
|
||||
{ "MT_MOON_B2F", 33, 9 }, { "SS_ANNE_B1F_ROOMS", 3, 1 },
|
||||
{ "SS_ANNE_KITCHEN", 13, 9 }, { "UNDERGROUND_PATH_NORTH_SOUTH", 3, 4 },
|
||||
{ "UNDERGROUND_PATH_NORTH_SOUTH", 4, 34 },
|
||||
{ "UNDERGROUND_PATH_WEST_EAST", 12, 2 },
|
||||
{ "UNDERGROUND_PATH_WEST_EAST", 21, 5 }, { "ROCKET_HIDEOUT_B1F", 21, 15 },
|
||||
{ "ROCKET_HIDEOUT_B3F", 27, 17 }, { "ROCKET_HIDEOUT_B4F", 25, 1 },
|
||||
{ "ROUTE_10", 9, 17 }, { "ROUTE_10", 16, 53 },
|
||||
{ "POWER_PLANT", 17, 16 }, { "POWER_PLANT", 12, 1 },
|
||||
{ "ROUTE_11", 48, 5 }, { "ROUTE_12", 2, 63 },
|
||||
{ "ROUTE_13", 1, 14 }, { "ROUTE_13", 16, 13 },
|
||||
{ "ROUTE_17", 15, 14 }, { "ROUTE_17", 8, 45 },
|
||||
{ "ROUTE_17", 17, 72 }, { "ROUTE_17", 4, 91 },
|
||||
{ "ROUTE_17", 8, 121 }, { "ROUTE_23", 9, 44 },
|
||||
{ "ROUTE_23", 19, 70 }, { "ROUTE_23", 8, 90 },
|
||||
{ "VICTORY_ROAD_2F", 5, 2 }, { "VICTORY_ROAD_2F", 26, 7 },
|
||||
{ "ROUTE_25", 38, 3 }, { "ROUTE_25", 10, 1 },
|
||||
{ "ROUTE_4", 40, 3 }, { "ROUTE_9", 14, 7 },
|
||||
{ "COPYCATS_HOUSE_2F", 1, 1 }, { "VIRIDIAN_CITY", 14, 4 },
|
||||
{ "CERULEAN_CITY", 15, 8 }, { "CERULEAN_CAVE_1F", 18, 7 },
|
||||
{ "POKEMON_TOWER_5F", 4, 12 }, { "VERMILION_CITY", 14, 11 },
|
||||
{ "CELADON_CITY", 48, 15 }, { "SAFARI_ZONE_GATE", 10, 1 },
|
||||
{ "POKEMON_MANSION_1F", 8, 16 },
|
||||
}
|
||||
+32
-5
@@ -526,6 +526,22 @@ function Commands.set_field(ctx, key, value)
|
||||
ctx.save[key] = value
|
||||
end
|
||||
|
||||
function Commands.load_player_starter_name(ctx)
|
||||
local flags = ctx.save.flags or {}
|
||||
local species = flags.EVENT_CHOSE_PIKACHU and "PIKACHU"
|
||||
or flags.EVENT_CHOSE_CHARMANDER and "CHARMANDER"
|
||||
or flags.EVENT_CHOSE_SQUIRTLE and "SQUIRTLE"
|
||||
or flags.EVENT_CHOSE_BULBASAUR and "BULBASAUR"
|
||||
or (ctx.save.party and ctx.save.party[1] and ctx.save.party[1].species)
|
||||
local def = species and ctx.game.data.pokemon[species]
|
||||
ctx.game.stringBuffer = def and def.name or species or ""
|
||||
end
|
||||
|
||||
function Commands.spawn_pikachu_follower(ctx)
|
||||
require("src.world.PikachuFollower").onMapEntered(
|
||||
ctx.game, ctx.overworld, nil, false)
|
||||
end
|
||||
|
||||
local function toggleObject(ctx, mapId, objName, visible)
|
||||
local save = ctx.save
|
||||
save.objectToggles = save.objectToggles or {}
|
||||
@@ -576,6 +592,17 @@ function Commands.play_sound(ctx, soundId)
|
||||
require("src.core.Sound").play(ctx.game.data, soundId)
|
||||
end
|
||||
|
||||
-- text_sound <soundId>: the jingle the ROM parks at the END of a string as
|
||||
-- a trailing text command (sound_get_item_1, sound_get_key_item ->
|
||||
-- home/text.asm TextCommand_SOUND). It arms the NEXT show_text the same
|
||||
-- way play_cry arms ctx.pendingCry, so the fanfare fires once the last page
|
||||
-- has typed and the box holds on WaitForSoundToFinish before the button
|
||||
-- wait. play_sound stays the bare PlaySound used for the non-blocking
|
||||
-- beats (Bill's teleporter, the S.S. Anne horn).
|
||||
function Commands.text_sound(ctx, soundId)
|
||||
ctx.textOpts = TextBox.soundOpts(ctx.game, soundId, ctx.textOpts)
|
||||
end
|
||||
|
||||
-- play_once <songId>: one-shot jingle (Music_PkmnHealed, etc.); blocks
|
||||
-- until it finishes so heal-rest scripts (Mom, captain text_asm) match
|
||||
-- Gen1's wait-on-channel loop. The map theme resumes when it ends
|
||||
@@ -1034,8 +1061,10 @@ function Commands.trade(ctx, tradeIndex, doneFlag)
|
||||
onDone = function() runner:resume() end,
|
||||
})
|
||||
runner:yield()
|
||||
-- TradedForText (sound_get_key_item) then the dialogset's thanks
|
||||
require("src.core.Sound").play(data, "Get_Key_Item")
|
||||
-- TradedForText carries sound_get_key_item after the text, so the jingle
|
||||
-- rides the box and blocks it (home/text.asm TextCommand_SOUND), then the
|
||||
-- dialogset's thanks
|
||||
Commands.text_sound(ctx, "Get_Key_Item")
|
||||
say(texts.tradedFor or "_TradedForText")
|
||||
say(texts.thanks or "_Thanks" .. dialogset .. "Text")
|
||||
end
|
||||
@@ -1131,9 +1160,7 @@ end
|
||||
-- opts.tempo is the Music_*AlternateTempo override (audio/alternate_tempo.asm
|
||||
-- re-points channel 1 at a stub that only changes the song's `tempo`) (#847).
|
||||
function Commands.play_music(ctx, songId, opts)
|
||||
local tempo = opts and opts.tempo
|
||||
require("src.core.Music").play(ctx.game.data, songId, nil,
|
||||
tempo and { tempo = tempo } or nil)
|
||||
require("src.core.Music").play(ctx.game.data, songId, nil, opts)
|
||||
if opts and opts.keep and ctx.overworld then
|
||||
ctx.overworld.keepMusicOnce = true
|
||||
end
|
||||
|
||||
+24
-11
@@ -33,12 +33,12 @@ local function save_name(game)
|
||||
return game.save.player.name
|
||||
end
|
||||
|
||||
local function showMessages(game, msgs, onDone)
|
||||
local function showMessages(game, msgs, onDone, opts)
|
||||
if not msgs or #msgs == 0 then
|
||||
if onDone then onDone() end
|
||||
return
|
||||
end
|
||||
game.stack:push(TextBox.new(game, table.concat(msgs, "\f"), onDone))
|
||||
game.stack:push(TextBox.new(game, table.concat(msgs, "\f"), onDone, opts))
|
||||
end
|
||||
|
||||
-- run the use-flow for an item on a chosen target. `picker` is the party
|
||||
@@ -60,6 +60,14 @@ local function useOn(game, battle, id, target, list, moveIndex, picker)
|
||||
return
|
||||
end
|
||||
|
||||
if result == "flute_wake_pikachu" then
|
||||
require("src.core.Sound").play(game.data, "Pokeflute")
|
||||
showMessages(game, payload, function()
|
||||
game.overworld.pikachuPewterSleepScene = nil
|
||||
end)
|
||||
return
|
||||
end
|
||||
|
||||
-- field POKé FLUTE next to a not-yet-beaten Snorlax: "had effect" text,
|
||||
-- then the woke-up/battle sequence (data/scripts/story.lua snorlaxWake)
|
||||
if result == "flute_wake" then
|
||||
@@ -142,12 +150,9 @@ local function useOn(game, battle, id, target, list, moveIndex, picker)
|
||||
list:close()
|
||||
local ow = game.overworld
|
||||
local p = ow and ow.player
|
||||
if ow and p then
|
||||
local fx, fy = p:facingCell()
|
||||
if ow.map:inBounds(fx, fy) and ow.map:isWaterCell(fx, fy) then
|
||||
ow:goFishing(id)
|
||||
return
|
||||
end
|
||||
if ow and p and ow:facingIsShoreOrWater() then
|
||||
ow:goFishing(id)
|
||||
return
|
||||
end
|
||||
showMessages(game, { Strings("No good! It's not\neven near water.") })
|
||||
return
|
||||
@@ -176,19 +181,27 @@ local function useOn(game, battle, id, target, list, moveIndex, picker)
|
||||
end
|
||||
if #target.moves < 4 then
|
||||
table.insert(target.moves, { id = moveId, pp = mdef.pp })
|
||||
-- LearnedMove1Text: text_far, sound_get_item_1, text_promptbutton
|
||||
-- (learn_move.asm), so the jingle rides the box
|
||||
showMessages(game, { Strings("%s learned\n%s!", target.nickname or
|
||||
game.data.pokemon[target.species].name, mdef.name) })
|
||||
game.data.pokemon[target.species].name, mdef.name) }, nil,
|
||||
TextBox.soundOpts(game, "Get_Item1"))
|
||||
if result == "learn" then consume(game, id) end
|
||||
list.items = buildItems(game)
|
||||
list.index = math.min(list.index, math.max(1, #list.items))
|
||||
taught()
|
||||
else
|
||||
require("src.ui.Screens").push(game, "MoveLearnMenu", target, moveId,
|
||||
function(learned)
|
||||
if learned and result == "learn" then consume(game, id) end
|
||||
if learned then
|
||||
list.items = buildItems(game)
|
||||
list.index = math.min(list.index, math.max(1, #list.items))
|
||||
end
|
||||
if learned then taught() end
|
||||
end)
|
||||
end
|
||||
end
|
||||
list:close()
|
||||
teach()
|
||||
return
|
||||
end
|
||||
@@ -324,7 +337,7 @@ local function useOn(game, battle, id, target, list, moveIndex, picker)
|
||||
table.insert(target.moves, { id = moveId, pp = mdef.pp })
|
||||
local name = target.nickname or def.name
|
||||
showMessages(game, { Strings("%s learned\n%s!", name, mdef.name) },
|
||||
nextStep)
|
||||
nextStep, TextBox.soundOpts(game, "Get_Item1"))
|
||||
else
|
||||
require("src.ui.Screens").push(game, "MoveLearnMenu",
|
||||
target, moveId, nextStep)
|
||||
|
||||
@@ -119,6 +119,13 @@ local function deposit(game)
|
||||
onChoose = function(item, list)
|
||||
local mon = game.save.party[item.value]
|
||||
if not mon then return end
|
||||
local Follower = require("src.world.PikachuFollower")
|
||||
if Follower.isFollowingDisabled(game.overworld)
|
||||
and Follower.isStarterPikachu(game.save, mon) then
|
||||
game.stack:push(TextBox.new(game, t._SleepingPikachuText2
|
||||
or Strings("There isn't any\nresponse...")))
|
||||
return
|
||||
end
|
||||
monSubmenu(game, "DEPOSIT", mon, function()
|
||||
if #game.save.party <= 1 then
|
||||
list.footer = Strings("You need at least\none POKéMON!")
|
||||
@@ -167,6 +174,16 @@ local function release(game)
|
||||
local mon = box[list.index]
|
||||
if not mon then return end
|
||||
local name = monName(game, mon)
|
||||
if require("src.core.GameVersion").isYellow()
|
||||
and mon.species == "PIKACHU"
|
||||
and mon.otId == game.save.player.id
|
||||
and mon.ot == game.save.player.name then
|
||||
require("src.core.Sound").playCry(game.data, mon.species)
|
||||
game.stack:push(TextBox.new(game,
|
||||
(t._PikachuUnhappyText or Strings("%s looks\nunhappy about it!", name))
|
||||
:gsub("{RAM:wNameBuffer}", name)))
|
||||
return
|
||||
end
|
||||
game.stack:push(TextBox.new(game,
|
||||
Strings("Once released,\n%s is\ngone forever. OK?", name), nil, {
|
||||
defaultNo = true, noSound = true,
|
||||
|
||||
@@ -46,9 +46,27 @@ function EvolutionState:sgbPalettes(game)
|
||||
return P.wholeNamed(game.data, "MEWMON")
|
||||
end
|
||||
|
||||
local FLASH_FRAMES = 220
|
||||
-- evolution.asm EvolveMon delays 80 frames before .animLoop, polling nothing (#968, #1031)
|
||||
local CANCEL_GRACE_FRAMES = 80
|
||||
-- .animLoop starts at `lb bc, $1, $10` and runs 8 iterations: iteration k holds
|
||||
-- the old pic for c = 18-2k frames inside Evolution_CheckForCancel (72 in all),
|
||||
-- then Evolution_BackAndForthAnim swaps b = k times, each swap a pair of
|
||||
-- Evolution_ChangeMonPic that end in Delay3 (6 frames a swap, 216 in all).
|
||||
local ANIM_LOOP_FRAMES = 288
|
||||
local FLASH_FRAMES = CANCEL_GRACE_FRAMES + ANIM_LOOP_FRAMES
|
||||
|
||||
-- which pic is on screen t frames into .animLoop
|
||||
local function evoShowsNew(t)
|
||||
for b = 1, 8 do
|
||||
local hold = 18 - 2 * b
|
||||
if t < hold then return false end
|
||||
t = t - hold
|
||||
local swap = b * 6
|
||||
if t < swap then return t % 6 < 3 end
|
||||
t = t - swap
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
local function frontSprite(game, species, mon)
|
||||
local path, trueColor = require("src.pokemon.Sprites").path(
|
||||
@@ -140,14 +158,10 @@ function EvolutionState:draw()
|
||||
else
|
||||
sprite, spriteTrueColor = self.newSprite, self.newSpriteTrueColor
|
||||
end
|
||||
elseif evoShowsNew(self.t - CANCEL_GRACE_FRAMES) then
|
||||
sprite, spriteTrueColor = self.newSprite, self.newSpriteTrueColor
|
||||
else
|
||||
local period = math.max(4, 28 - math.floor(self.t / 40) * 6)
|
||||
local showNew = math.floor(self.t / period) % 2 == 1
|
||||
if showNew then
|
||||
sprite, spriteTrueColor = self.newSprite, self.newSpriteTrueColor
|
||||
else
|
||||
sprite, spriteTrueColor = self.oldSprite, self.oldSpriteTrueColor
|
||||
end
|
||||
sprite, spriteTrueColor = self.oldSprite, self.oldSpriteTrueColor
|
||||
end
|
||||
if sprite then
|
||||
local x = math.floor((160 - sprite:getWidth()) / 2)
|
||||
|
||||
+16
-3
@@ -6,6 +6,7 @@ local GameVersion = require("src.core.GameVersion")
|
||||
local Music = require("src.core.Music")
|
||||
local Sound = require("src.core.Sound")
|
||||
local Strings = require("src.core.Strings")
|
||||
local Timing = require("src.core.Timing")
|
||||
|
||||
local IntroMovie = {}
|
||||
IntroMovie.__index = IntroMovie
|
||||
@@ -232,6 +233,10 @@ function IntroMovie:fightStep()
|
||||
self:finish()
|
||||
return
|
||||
end
|
||||
-- an op that consumed frames ends ON its last frame; only the instant
|
||||
-- ops (sfx / pose / frame, which are plain writes between DelayFrames
|
||||
-- calls in PlayIntroScene) chain into the next op the same frame
|
||||
local timed = false
|
||||
if op.sfx then
|
||||
Sound.play(self.game.data, op.sfx)
|
||||
elseif op.pose then
|
||||
@@ -250,6 +255,7 @@ function IntroMovie:fightStep()
|
||||
end
|
||||
self.opTimer = self.opTimer + 1
|
||||
if self.opTimer < (op.px or math.abs(op.dx)) then return end
|
||||
timed = true
|
||||
elseif op.anim then
|
||||
-- one {dy,dx} delta per 5 frames (AnimateIntroNidorino: DelayFrames 5)
|
||||
if self.opTimer % 5 == 0 then
|
||||
@@ -259,9 +265,11 @@ function IntroMovie:fightStep()
|
||||
end
|
||||
self.opTimer = self.opTimer + 1
|
||||
if self.opTimer < #ANIM[op.anim] * 5 then return end
|
||||
timed = true
|
||||
elseif op.wait then
|
||||
self.opTimer = self.opTimer + 1
|
||||
if self.opTimer < op.wait then return end
|
||||
timed = true
|
||||
elseif op.fade then
|
||||
self.opTimer = self.opTimer + 1
|
||||
self.fade = self.opTimer / op.fade
|
||||
@@ -270,6 +278,7 @@ function IntroMovie:fightStep()
|
||||
end
|
||||
self.opIndex = self.opIndex + 1
|
||||
self.opTimer = 0
|
||||
if timed then return end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -284,8 +293,9 @@ function IntroMovie:update(dt)
|
||||
return
|
||||
end
|
||||
local input = self.game.input
|
||||
if input:wasPressed("a") or input:wasPressed("b")
|
||||
or input:wasPressed("start") then
|
||||
if input:wasPressed("a") or input:wasPressed("start") then
|
||||
-- CheckForUserInterruption (home/overworld.asm:2395) returns carry only
|
||||
-- on a fresh START or A -- B alone never skips the intro.
|
||||
-- PlayIntro still GBFadeOutToWhite's after an interrupted scene; the
|
||||
-- white hold stands in for that beat before the title is built.
|
||||
self:exitToTitle()
|
||||
@@ -300,7 +310,10 @@ function IntroMovie:update(dt)
|
||||
end
|
||||
if self.timer >= SPLASH_FRAMES then self:startPhase(3) end
|
||||
else
|
||||
self:fightStep()
|
||||
-- PlayShootingStar ends `jp Delay3` once Music_IntroBattle is playing
|
||||
-- (intro.asm:337), so PlayIntroScene's first op is not on the music's
|
||||
-- own frame
|
||||
if self.timer > Timing.DELAY3 then self:fightStep() end
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -17,12 +17,13 @@ local HM_MOVES = {
|
||||
CUT = true, FLY = true, SURF = true, STRENGTH = true, FLASH = true,
|
||||
}
|
||||
|
||||
function MoveLearnMenu.new(game, mon, newMoveId, onDone)
|
||||
function MoveLearnMenu.new(game, mon, newMoveId, onDone, learnedSound)
|
||||
local self = setmetatable({}, MoveLearnMenu)
|
||||
self.game = game
|
||||
self.mon = mon
|
||||
self.newMoveId = newMoveId
|
||||
self.onDone = onDone
|
||||
self.learnedSound = learnedSound or "Get_Item1"
|
||||
self.index = 1
|
||||
-- forget-list UI only after TryingToLearn YES (learn_move.asm .loop)
|
||||
self.selecting = false
|
||||
@@ -118,6 +119,7 @@ function MoveLearnMenu:finish(learned)
|
||||
self.selecting = false
|
||||
game.stack:pop()
|
||||
local msg
|
||||
local opts
|
||||
if learned then
|
||||
-- pokered pages this as four texts in a row; _ForgotAndText carries
|
||||
-- the "And..." tail
|
||||
@@ -127,13 +129,16 @@ function MoveLearnMenu:finish(learned)
|
||||
"\f%s forgot\n%s!\fAnd...", name, self.forgot)
|
||||
.. "\f" .. romText(game.data, "_LearnedMove1Text",
|
||||
"%s learned\n%s!", name, mdef.name)
|
||||
opts = { auto = { sound = function()
|
||||
return require("src.core.Sound").play(game.data, self.learnedSound)
|
||||
end, wait = true } }
|
||||
else
|
||||
msg = romText(game.data, "_DidNotLearnText",
|
||||
"%s\ndid not learn\v%s!", name, mdef.name)
|
||||
end
|
||||
game.stack:push(TextBox.new(game, msg, function()
|
||||
if self.onDone then self.onDone(learned) end
|
||||
end))
|
||||
end, opts))
|
||||
end
|
||||
|
||||
function MoveLearnMenu:draw()
|
||||
|
||||
+111
-35
@@ -72,6 +72,30 @@ end
|
||||
|
||||
local function sameItems(_, items) return items end
|
||||
|
||||
local function followerUnavailable(game, mon)
|
||||
local ow = game.overworld
|
||||
local Follower = require("src.world.PikachuFollower")
|
||||
return Follower.isFollowingDisabled(ow)
|
||||
and Follower.isStarterPikachu(game.save, mon)
|
||||
end
|
||||
|
||||
local function refuseUnavailable(self)
|
||||
self.swapFrom = nil
|
||||
local TextBox = require("src.render.TextBox")
|
||||
local t = self.game.data and self.game.data.text or {}
|
||||
self.game.stack:push(TextBox.new(self.game,
|
||||
t._SleepingPikachuText1 or Strings("There isn't any\nresponse...")))
|
||||
end
|
||||
|
||||
-- .newBadgeRequired (start_sub_menus.asm): every badge-gated arm of
|
||||
-- .outOfBattleMovePointers prints this and jumps back to the open submenu
|
||||
local function refuseBadge(self)
|
||||
local TextBox = require("src.render.TextBox")
|
||||
local t = self.game.data and self.game.data.text or {}
|
||||
self.game.stack:push(TextBox.new(self.game,
|
||||
t._NewBadgeRequiredText or Strings("No! A new BADGE\nis required.")))
|
||||
end
|
||||
|
||||
-- where DIG escapes work: escape_rope_tilesets.asm (Agatha's room is
|
||||
-- excluded by map id in ItemUseEscapeRope)
|
||||
local DIG_TILESETS = { FOREST = true, CEMETERY = true, CAVERN = true,
|
||||
@@ -367,6 +391,10 @@ function PartyMenu:update(dt)
|
||||
self.submenu = nil
|
||||
elseif input:wasPressed("a") then
|
||||
local mon = party[self.index]
|
||||
if followerUnavailable(self.game, mon) then
|
||||
refuseUnavailable(self)
|
||||
return
|
||||
end
|
||||
local entry = self.subItems[self.subIndex]
|
||||
local action = entry.action
|
||||
if not action and entry.onSelect then
|
||||
@@ -392,6 +420,23 @@ function PartyMenu:update(dt)
|
||||
-- flyTo (OverworldController) validates the fly-warp + runs the
|
||||
-- departure/warp, so we just hand it the chosen mapId (#195).
|
||||
local ow = self.game.overworld
|
||||
-- .fly checks THUNDERBADGE first, then CheckIfInOutsideMap
|
||||
-- (OVERWORLD + PLATEAU -- Route 23 / Indigo Plateau outdoor -- not
|
||||
-- OVERWORLD alone, #83); both refusals loop back to the submenu
|
||||
if ow and not ow:partyKnows("FLY") then
|
||||
refuseBadge(self)
|
||||
return
|
||||
end
|
||||
if ow and not Map.isOutside(ow.map.def,
|
||||
FieldDefaults.field(self.game.data, "outsideTilesets")) then
|
||||
local TextBox = require("src.render.TextBox")
|
||||
local def = self.game.data.pokemon[mon.species]
|
||||
local txt = (self.game.data.text._CannotFlyHereText
|
||||
or Strings("{RAM:wNameBuffer} can't\nFLY here."))
|
||||
:gsub("{RAM:wNameBuffer}", mon.nickname or def.name)
|
||||
self.game.stack:push(TextBox.new(self.game, txt))
|
||||
return -- .loop: submenu stays open behind the message
|
||||
end
|
||||
self.game.stack:pop() -- close the party menu
|
||||
Screens.push(self.game, "TownMap", { fly = true, onFly = function(mapId)
|
||||
if ow then ow:flyTo(mapId) end
|
||||
@@ -404,9 +449,17 @@ function PartyMenu:update(dt)
|
||||
-- over the menu, and the cave is lit when the blink hands the
|
||||
-- screen back, never under the text (#385).
|
||||
local ow = self.game.overworld
|
||||
if ow and not ow:partyKnows("FLASH") then
|
||||
refuseBadge(self)
|
||||
return
|
||||
end
|
||||
local TextBox = require("src.render.TextBox")
|
||||
local Transition = require("src.render.Transition")
|
||||
self.game.save.flashLit = true
|
||||
-- .flash prints on any map, but the light it records is this map's:
|
||||
-- home/overworld.asm re-arms wMapPalOffset on the next dark map, so
|
||||
-- a FLASH used in daylight must not carry into Rock Tunnel
|
||||
local wasDark = ow and ow.dark
|
||||
if wasDark then self.game.save.flashLit = true end
|
||||
self.game.stack:push(TextBox.new(self.game,
|
||||
self.game.data.text._FlashLightsAreaText
|
||||
or Strings("A blinding FLASH\nlights the area!"), function()
|
||||
@@ -421,13 +474,13 @@ function PartyMenu:update(dt)
|
||||
-- seconds of per-pixel atlas baking on a phone -- on screen as a
|
||||
-- solid white frame with nothing under it, which reads as a
|
||||
-- lockup (#610).
|
||||
ow:setDark(false)
|
||||
if wasDark then ow:setDark(false) end
|
||||
self.game.stack:push(Transition.whiteFlash(self.game))
|
||||
end))
|
||||
return
|
||||
elseif action == "surf" then
|
||||
-- start_sub_menus.asm .surf: SOULBADGE-gated (checked at list time
|
||||
-- above), then IsSurfingAllowed (the Cycling Road / Seafoam B4F
|
||||
-- start_sub_menus.asm .surf: SOULBADGE-gated (useSurfFieldMove),
|
||||
-- then IsSurfingAllowed (the Cycling Road / Seafoam B4F
|
||||
-- current refusals, both of which loop back to the submenu), then
|
||||
-- ItemUseSurfboard: while surfing it tries to dismount instead;
|
||||
-- otherwise it mounts only if the FACING tile is water, else
|
||||
@@ -484,8 +537,8 @@ function PartyMenu:update(dt)
|
||||
return -- .loop: submenu stays open behind the message
|
||||
elseif action == "cut" then
|
||||
-- start_sub_menus.asm .cut -> predef UsedCut (engine/overworld/cut.asm):
|
||||
-- CASCADEBADGE-gated (list time); _NothingToCutText loops back to the
|
||||
-- submenu when the FACING tile isn't a cuttable tree.
|
||||
-- CASCADEBADGE-gated (useCutFieldMove); _NothingToCutText loops back
|
||||
-- to the submenu when the FACING tile isn't a cuttable tree.
|
||||
local ow = self.game.overworld
|
||||
local reason = ow:useCutFieldMove()
|
||||
if reason == "ok" then
|
||||
@@ -503,7 +556,7 @@ function PartyMenu:update(dt)
|
||||
self.game.stack:push(TextBox.new(self.game, txt))
|
||||
return -- .loop: submenu stays open behind the message
|
||||
elseif action == "strength" then
|
||||
-- start_sub_menus.asm .strength: RAINBOWBADGE-gated (list time);
|
||||
-- start_sub_menus.asm .strength: RAINBOWBADGE-gated;
|
||||
-- predef PrintStrengthText (field_move_messages.asm) sets
|
||||
-- BIT_STRENGTH_ACTIVE of wStatusFlags1 -- the sole gate
|
||||
-- push_boulder.asm reads -- then prints _UsedStrengthText (no
|
||||
@@ -513,6 +566,10 @@ function PartyMenu:update(dt)
|
||||
-- .strength, GBPalWhiteOutWithDelay3 blinks the screen white
|
||||
-- before CloseTextDisplay returns to the map.
|
||||
local ow = self.game.overworld
|
||||
if ow and not ow:partyKnows("STRENGTH") then
|
||||
refuseBadge(self)
|
||||
return
|
||||
end
|
||||
local TextBox = require("src.render.TextBox")
|
||||
local Transition = require("src.render.Transition")
|
||||
local def = self.game.data.pokemon[mon.species]
|
||||
@@ -546,6 +603,33 @@ function PartyMenu:update(dt)
|
||||
-- centralizes the spin -> fade -> warp so BagMenu's ESCAPE ROPE shares
|
||||
-- the exact departure; the fade + warp fire when the spin ends.
|
||||
local ow = self.game.overworld
|
||||
if entry.move == "TELEPORT" then
|
||||
-- .teleport: TELEPORT works only OUTDOORS (CheckIfInOutsideMap --
|
||||
-- OVERWORLD + PLATEAU, #83); dark maps don't block it
|
||||
if ow and not Map.isOutside(ow.map.def,
|
||||
FieldDefaults.field(self.game.data, "outsideTilesets")) then
|
||||
local TextBox = require("src.render.TextBox")
|
||||
local def = self.game.data.pokemon[mon.species]
|
||||
local txt = (self.game.data.text._CannotUseTeleportNowText
|
||||
or Strings("{RAM:wNameBuffer} can't\nuse TELEPORT now."))
|
||||
:gsub("{RAM:wNameBuffer}", mon.nickname or def.name)
|
||||
self.game.stack:push(TextBox.new(self.game, txt))
|
||||
return -- .loop: submenu stays open behind the message
|
||||
end
|
||||
elseif ow and not (DIG_TILESETS[ow.map.def.tileset]
|
||||
and ow.map.id ~= "AGATHAS_ROOM") then
|
||||
-- .dig runs ItemUseEscapeRope (it sets wCurItem = ESCAPE_ROPE):
|
||||
-- usable in the dungeon tilesets of escape_rope_tilesets.asm minus
|
||||
-- Agatha's room, even in the dark (Rock Tunnel); anywhere else
|
||||
-- .notUsable -> ItemUseNotTime, the same line BagMenu prints for a
|
||||
-- bagged ESCAPE ROPE
|
||||
local TextBox = require("src.render.TextBox")
|
||||
self.game.stack:push(TextBox.new(self.game,
|
||||
self.game.data.text._ItemUseNotTimeText
|
||||
or Strings("OAK: %s!\nThis isn't the\ntime to use that!",
|
||||
self.game.save.player.name)))
|
||||
return -- .loop: submenu stays open behind the message
|
||||
end
|
||||
self.game.stack:pop()
|
||||
if ow then ow:beginTeleportOut() end
|
||||
return
|
||||
@@ -577,6 +661,10 @@ function PartyMenu:update(dt)
|
||||
if self.onCancel then self.onCancel() end
|
||||
elseif input:wasPressed("a") and #party > 0 then
|
||||
local mon = party[self.index]
|
||||
if followerUnavailable(self.game, mon) then
|
||||
refuseUnavailable(self)
|
||||
return
|
||||
end
|
||||
if self.softboiledFrom then
|
||||
local user = party[self.softboiledFrom]
|
||||
local heal = math.floor(user.stats.hp / 5)
|
||||
@@ -635,42 +723,30 @@ function PartyMenu:update(dt)
|
||||
-- Battle still excludes this list via `not self.battle`. Softboiled
|
||||
-- can appear for a fainted user; its heal transfer then no-ops.
|
||||
if not self.battle and ow then
|
||||
-- FLY/TELEPORT: CheckIfInOutsideMap (OVERWORLD + PLATEAU --
|
||||
-- Route 23 / Indigo Plateau outdoor), not OVERWORLD alone (#83)
|
||||
local outside = Map.isOutside(ow.map.def,
|
||||
FieldDefaults.field(self.game.data, "outsideTilesets"))
|
||||
-- GetMonFieldMoves (engine/menus/text_box.asm) matches the mon's
|
||||
-- four moves against FieldMoveDisplayData and nothing else -- no
|
||||
-- badge, no map, no tileset test. Every one of those lives in
|
||||
-- .outOfBattleMovePointers, i.e. on selection, where the refusal
|
||||
-- prints and .loop returns to this still-open submenu (#1022).
|
||||
for _, mv in ipairs(mon.moves) do
|
||||
if mv.id == "FLY" and outside
|
||||
and self.game.save.inventory.THUNDERBADGE then
|
||||
if mv.id == "FLY" then
|
||||
table.insert(items, { label = Strings("FLY"), action = "fly" })
|
||||
elseif mv.id == "FLASH" and ow.dark
|
||||
and self.game.save.inventory.BOULDERBADGE then
|
||||
elseif mv.id == "FLASH" then
|
||||
table.insert(items, { label = Strings("FLASH"), action = "flash" })
|
||||
elseif mv.id == "CUT" and self.game.save.inventory.CASCADEBADGE then
|
||||
-- CUT/SURF/STRENGTH are party-menu field moves too
|
||||
-- (start_sub_menus.asm .outOfBattleMovePointers); listed here
|
||||
-- with the same list-time badge filter this file already uses
|
||||
-- for FLY/FLASH. The facing-tile/activation check happens on
|
||||
-- selection (useCutFieldMove/useSurfFieldMove).
|
||||
elseif mv.id == "CUT" then
|
||||
table.insert(items, { label = Strings("CUT"), action = "cut" })
|
||||
elseif mv.id == "SURF" and self.game.save.inventory.SOULBADGE then
|
||||
elseif mv.id == "SURF" then
|
||||
table.insert(items, { label = Strings("SURF"), action = "surf" })
|
||||
elseif mv.id == "STRENGTH" and self.game.save.inventory.RAINBOWBADGE then
|
||||
elseif mv.id == "STRENGTH" then
|
||||
table.insert(items, { label = Strings("STRENGTH"), action = "strength" })
|
||||
elseif mv.id == "SOFTBOILED" then
|
||||
table.insert(items, { label = Strings("SOFTBOILED"), action = "softboiled" })
|
||||
elseif mv.id == "TELEPORT" and outside then
|
||||
-- TELEPORT works only OUTDOORS (start_sub_menus.asm
|
||||
-- .teleport -> CheckIfInOutsideMap); dark maps don't
|
||||
-- block it
|
||||
table.insert(items, { label = Strings("TELEPORT"), action = "escape" })
|
||||
elseif mv.id == "DIG" and DIG_TILESETS[ow.map.def.tileset]
|
||||
and ow.map.id ~= "AGATHAS_ROOM" then
|
||||
-- DIG runs ItemUseEscapeRope (.dig sets wCurItem =
|
||||
-- ESCAPE_ROPE): usable in the dungeon tilesets of
|
||||
-- escape_rope_tilesets.asm minus Agatha's room, even in
|
||||
-- the dark (Rock Tunnel)
|
||||
table.insert(items, { label = Strings("DIG"), action = "escape" })
|
||||
elseif mv.id == "TELEPORT" then
|
||||
table.insert(items, { label = Strings("TELEPORT"),
|
||||
action = "escape", move = "TELEPORT" })
|
||||
elseif mv.id == "DIG" then
|
||||
table.insert(items, { label = Strings("DIG"),
|
||||
action = "escape", move = "DIG" })
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -104,6 +104,27 @@ local function sell(game)
|
||||
dialogue = true,
|
||||
money = function() return game.save.money end,
|
||||
footer = greet,
|
||||
onSelectKey = function(item, l)
|
||||
if not item then return end
|
||||
if not l.swapIndex then
|
||||
l.swapIndex = l.index
|
||||
return
|
||||
end
|
||||
local order = Bag.order(game.save)
|
||||
order[l.swapIndex], order[l.index] = order[l.index], order[l.swapIndex]
|
||||
l.swapIndex = nil
|
||||
require("src.core.Sound").play(game.data, "Swap")
|
||||
local rebuilt = {}
|
||||
for _, id in ipairs(order) do
|
||||
local def = game.data.items[id]
|
||||
rebuilt[#rebuilt + 1] = {
|
||||
value = id,
|
||||
label = def and def.name or id,
|
||||
right = "x" .. game.save.inventory[id],
|
||||
}
|
||||
end
|
||||
l.items = rebuilt
|
||||
end,
|
||||
onChoose = function(item)
|
||||
local def = game.data.items[item.value]
|
||||
-- only key items and HMs are unsellable (pokemart.asm IsKeyItem /
|
||||
|
||||
@@ -34,7 +34,8 @@ function StartMenu.new(game)
|
||||
|
||||
-- POKéMON is always listed (draw_start_menu.asm prints it even with
|
||||
-- an empty party; selecting it then just no-ops)
|
||||
table.insert(items, { label = Strings("POKéMON"), onSelect = function()
|
||||
table.insert(items, { label = Strings("POKéMON"),
|
||||
keepOpen = #game.save.party == 0, onSelect = function()
|
||||
if #game.save.party == 0 then return end
|
||||
Screens.push(game, "PartyMenu", { onCancel = reopen })
|
||||
end })
|
||||
|
||||
+15
-8
@@ -200,8 +200,9 @@ function TitleState.new(game, opts)
|
||||
self.versionFull = imagePath(title.versionRibbon) ~= nil
|
||||
self.version = tryImage(imagePath(title.versionRibbon or title.version)
|
||||
or "assets/generated/title/red_version.png")
|
||||
self.player = tryImage(imagePath(title.player)
|
||||
or "assets/generated/title/player.png")
|
||||
self.playerPath = imagePath(title.player)
|
||||
or "assets/generated/title/player.png"
|
||||
self.player = tryImage(self.playerPath)
|
||||
-- ..(engine/movie/title2.asm ln 85)
|
||||
if self.player then
|
||||
local pw, ph = self.player:getDimensions()
|
||||
@@ -597,6 +598,12 @@ end
|
||||
|
||||
-- ..(engine/movie/title.asm ln 28)
|
||||
function TitleState:draw()
|
||||
local PaletteFX = require("src.render.PaletteFX")
|
||||
local playerImage = self.player
|
||||
if playerImage and PaletteFX.usesSpriteObp() then
|
||||
playerImage = require("src.render.SpriteRenderer").obpImage(
|
||||
self.playerPath, PaletteFX.ogObj())
|
||||
end
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
love.graphics.rectangle("fill", 0, 0, 160, 144)
|
||||
local scrollY = -(self.scy or 0)
|
||||
@@ -660,8 +667,8 @@ function TitleState:draw()
|
||||
-- layout has no cycling mon and no Red art (title_yellow.asm).
|
||||
if spriteTrueColor then
|
||||
local cover
|
||||
if self.player then
|
||||
local pw, ph = self.player:getDimensions()
|
||||
if playerImage then
|
||||
local pw, ph = playerImage:getDimensions()
|
||||
cover = { 82, 80, pw, ph }
|
||||
end
|
||||
markVisibleTrueColor(x, y, w, h, cover)
|
||||
@@ -670,11 +677,11 @@ function TitleState:draw()
|
||||
-- Red is OAM in the original: he draws over the mon's box edge
|
||||
if self.playerQuads then
|
||||
for _, part in ipairs(self.playerQuads) do
|
||||
love.graphics.draw(self.player, part[1], 82 + part[2], 80 + part[3])
|
||||
love.graphics.draw(playerImage, part[1], 82 + part[2], 80 + part[3])
|
||||
end
|
||||
love.graphics.draw(self.player, self.ballQuad, 82, self.ballY)
|
||||
elseif self.player then
|
||||
love.graphics.draw(self.player, 82, 80)
|
||||
love.graphics.draw(playerImage, self.ballQuad, 82, self.ballY)
|
||||
elseif playerImage then
|
||||
love.graphics.draw(playerImage, 82, 80)
|
||||
end
|
||||
end
|
||||
self:drawCopyright(136 + (preRibbon and 0 or scrollY))
|
||||
|
||||
@@ -173,6 +173,18 @@ end
|
||||
function BattleState:wantsFillScale() return true end
|
||||
function BattleState:drawsWidescreen() return true end
|
||||
|
||||
function BattleState:bottomUIVisible()
|
||||
if not Runtime.wantsHook("battle.bottom_ui_visible") then return true end
|
||||
return Runtime.call("battle.bottom_ui_visible", function() return true end,
|
||||
self) ~= false
|
||||
end
|
||||
|
||||
function BattleState:statusHUDVisible()
|
||||
if not Runtime.wantsHook("battle.status_hud_visible") then return true end
|
||||
return Runtime.call("battle.status_hud_visible", function() return true end,
|
||||
self) ~= false
|
||||
end
|
||||
|
||||
-- opts: battle (a Battle), onDone(outcome), save
|
||||
function BattleState.new(game, opts)
|
||||
opts = opts or {}
|
||||
@@ -3079,6 +3091,7 @@ end
|
||||
function BattleState:drawHud()
|
||||
local wasBattle = Font.useBattleExtra(true)
|
||||
local enemy, player = self:activeMon("enemy"), self:activeMon("player")
|
||||
local showStatus = self:statusHUDVisible()
|
||||
|
||||
-- Enemy HUD (DrawEnemyHUD clears (1,0) 4 rows x 11 cols):
|
||||
-- name at (1,0); PrintLevel at (6,1) with the gender symbol at (9,1);
|
||||
@@ -3087,7 +3100,7 @@ function BattleState:drawHud()
|
||||
-- runs, so a shake or a slide does not drag the HP bar with it.
|
||||
-- And nothing at all before UpdateEnemyHUD has ever run: the intro bands
|
||||
-- slide in over a blanked tilemap (core.asm:8554/8564).
|
||||
if self.showEnemyHud and not self:hudCleared("enemy") then
|
||||
if showStatus and self.showEnemyHud and not self:hudCleared("enemy") then
|
||||
Chrome.print(self:name(enemy), 1, 0)
|
||||
-- PrintLevel writes <LV> at the coordinate it is given and then LEFT-aligns
|
||||
-- the digits after it, so the glyph is pinned to column 6 whether the level
|
||||
@@ -3127,7 +3140,8 @@ function BattleState:drawHud()
|
||||
-- BattleMenu's own tutorial arm skips UpdateBattleHuds as well. The DUDE's
|
||||
-- half of the screen is his back-pic and nothing more.
|
||||
-- Nor before SendOutPlayerMon's own UpdatePlayerHUD (core.asm:3838).
|
||||
if not player or not self.showPlayerHud or self:hudCleared("player") then
|
||||
if not showStatus or not player or not self.showPlayerHud
|
||||
or self:hudCleared("player") then
|
||||
Font.useBattleExtra(wasBattle)
|
||||
return
|
||||
end
|
||||
@@ -3226,6 +3240,11 @@ function BattleState:drawPanel()
|
||||
end
|
||||
self:drawHud()
|
||||
|
||||
if not self:bottomUIVisible() then
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
return
|
||||
end
|
||||
|
||||
-- Message box across the bottom, with the menu window over its right half --
|
||||
-- the cart draws the prompt into the full-width box and then opens the menu
|
||||
-- on top, so the tail of a long name is simply covered.
|
||||
|
||||
@@ -272,9 +272,6 @@ function EvolutionAnim:commit()
|
||||
evolved.level, evolved)
|
||||
end
|
||||
|
||||
-- One LearnMove call per move the new species picks up at this level. There
|
||||
-- is no forget screen in the Gold port yet, so a full moveset reports the way
|
||||
-- src/battle/gen2/Battle.lua's own level-up path does instead of prompting.
|
||||
function EvolutionAnim:nextLearn()
|
||||
self.learnIndex = (self.learnIndex or 0) + 1
|
||||
local moveId = self.pending and self.pending[self.learnIndex]
|
||||
@@ -289,6 +286,17 @@ function EvolutionAnim:nextLearn()
|
||||
self.learned[#self.learned + 1] = moveId
|
||||
self.lines = { self.nick .. " learned", moveName .. "!" }
|
||||
elseif reason == "full" then
|
||||
if self.game and self.game.learnMoveOn then
|
||||
self.phase = "waitingLearn"
|
||||
return self.game:learnMoveOn(self.evolved, moveId, function(learned)
|
||||
if learned then
|
||||
self.learned[#self.learned + 1] = moveId
|
||||
else
|
||||
self.full[#self.full + 1] = moveId
|
||||
end
|
||||
self:nextLearn()
|
||||
end)
|
||||
end
|
||||
self.full[#self.full + 1] = moveId
|
||||
self.lines = { self.nick .. " wants to", "learn " .. moveName .. "!" }
|
||||
else
|
||||
|
||||
@@ -780,6 +780,11 @@ function MartMenu:offerToSell(itemId, count)
|
||||
end
|
||||
-- ScrollingMenu's .a_button copies the row's own quantity into
|
||||
-- wItemQuantity, so the selector's ceiling is how many you hold.
|
||||
-- The unit price is the item's own ItemAttributes price: GetItemPrice is
|
||||
-- what SelectQuantityToSell halves AND what the buy list's
|
||||
-- GetMartItemPrice charges, so a TM sells for exactly half of what the
|
||||
-- Goldenrod/Celadon TM shelves ask for it, and half of its hidden price
|
||||
-- (usually ¥1,500) anywhere else (issue #1243).
|
||||
self.qtyItem = { id = itemId, name = def.name or itemId,
|
||||
price = def.price or 0 }
|
||||
self.qty = 1
|
||||
|
||||
+15
-12
@@ -354,20 +354,23 @@ function PackMenu:useSelected()
|
||||
end
|
||||
return
|
||||
end
|
||||
-- engine/items/tmhm.asm:73
|
||||
local def = self.items and self.items[row.id]
|
||||
if def and def.teaches then
|
||||
self:openTeachParty(row)
|
||||
return
|
||||
end
|
||||
-- UseItem's jumptable runs off ITEMATTR's field-menu nibble, and the first
|
||||
-- four entries are all .Oak -- an X ATTACK or a POKé DOLL used from the
|
||||
-- field PACK prints OakThisIsntTheTimeText and goes nowhere. Only the
|
||||
-- FIELD pack owns that refusal: the battle pack returned above, and the
|
||||
-- catch tutorial's DUDE pack carries a stub world with no useFieldItem at
|
||||
-- all -- its POKE BALL is field-NOUSE and must still reach the throw.
|
||||
-- UseItem's FIELD-pack tail (engine/items/tmhm.asm:73): a TM/HM row opens
|
||||
-- the party to teach, and a field-NOUSE item -- an X ATTACK or a POKé DOLL
|
||||
-- used from the field PACK -- prints OakThisIsntTheTimeText and goes
|
||||
-- nowhere (UseItem's jumptable's first four entries are all .Oak). Both
|
||||
-- checks live behind `world.useFieldItem` on purpose: DepositSellPack (the
|
||||
-- mart's SELL, the item PC's DEPOSIT) is a chooser whose jumptable is four
|
||||
-- ScrollingMenus and never reaches tmhm.asm, so a TM picked there hands
|
||||
-- its row to onChoose like every other item instead of opening the teach
|
||||
-- party. The battle pack returned above, and the catch tutorial's DUDE
|
||||
-- pack carries a stub world with no useFieldItem at all -- its POKE BALL
|
||||
-- is field-NOUSE and must still reach the throw.
|
||||
if world and world.useFieldItem then
|
||||
local def = self.items and self.items[row.id]
|
||||
if def and def.teaches then
|
||||
self:openTeachParty(row)
|
||||
return
|
||||
end
|
||||
if def and def.fieldMenu == "ITEMMENU_NOUSE" then
|
||||
self.message = OAK_THIS_ISNT_THE_TIME
|
||||
return
|
||||
|
||||
+128
-78
@@ -368,6 +368,7 @@ Kit._navPrevN = 0
|
||||
Kit._navSeen = {}
|
||||
Kit._navQueue = nil
|
||||
Kit._activateId = nil
|
||||
Kit._ringShown = false
|
||||
|
||||
-- Register a focusable. Returns true when it currently holds the ring.
|
||||
-- Shielded widgets do not register: while a modal owns the frame the ring
|
||||
@@ -383,11 +384,12 @@ function Kit.focusable(id, x, y, w, h)
|
||||
-- First focusable ever drawn adopts the ring, so keyboard users start
|
||||
-- somewhere rather than nowhere.
|
||||
if Kit.focusId == nil then Kit.focusId = id end
|
||||
return Kit.focusId == id
|
||||
return Kit._ringShown and Kit.focusId == id
|
||||
end
|
||||
|
||||
function Kit.navigate(dir)
|
||||
Kit._navQueue = dir
|
||||
Kit._ringShown = true
|
||||
end
|
||||
|
||||
function Kit.activateFocused()
|
||||
@@ -396,6 +398,7 @@ end
|
||||
|
||||
function Kit.setFocus(id)
|
||||
Kit.focusId = id
|
||||
Kit._ringShown = id ~= nil
|
||||
end
|
||||
|
||||
-- Pick the nearest focusable in `dir` from the current one. Candidates must
|
||||
@@ -530,13 +533,10 @@ Kit._audit = audit
|
||||
function Kit.tapMin() return math.floor(30 * Kit.scale) end
|
||||
|
||||
-- ---------------------------------------------------------------- surfaces
|
||||
function Kit.card(x, y, w, h, emphasis)
|
||||
Theme.card(x, y, w, h, emphasis)
|
||||
function Kit.card(x, y, w, h, variant)
|
||||
Theme.card(x, y, w, h, variant)
|
||||
end
|
||||
|
||||
-- A list row. `id` opts it into the focus ring; pass nil for decorative
|
||||
-- rows. Returns (clicked, inkColor) -- a selected row fills white, so the
|
||||
-- caller must print with the returned ink or it will draw white on white.
|
||||
function Kit.row(x, y, w, h, selected, id)
|
||||
audit("row", x, y, w, h, id or "row")
|
||||
local focused = id and Kit.focusable(id, x, y, w, h) or false
|
||||
@@ -560,7 +560,7 @@ end
|
||||
-- hairline says the same thing for one rect.)
|
||||
function Kit.emptyBox(x, y, w, h, message)
|
||||
if not G then return end
|
||||
Theme.strokeRounded(x, y, w, h, PAL.line, 0.22, 1, Theme.radius())
|
||||
Theme.card(x, y, w, h, "empty")
|
||||
Kit.textCenter("button", Kit.ellipsize("button", message, w - 24 * Kit.scale),
|
||||
x, y + (h - Kit.textHeight("button")) / 2, w, PAL.muted)
|
||||
end
|
||||
@@ -598,61 +598,130 @@ local KINDS = {
|
||||
disabled = { fill = PAL.steel, ink = PAL.inverse, flat = true },
|
||||
}
|
||||
Kit.KINDS = KINDS
|
||||
local NO_OPTS = {}
|
||||
|
||||
-- opts: { kind, font, enabled, align, id, glow, fill, ink }
|
||||
-- id -- opts into the focus ring (give every real control one)
|
||||
-- glow -- a pulsing outline for "something is waiting for you" (the
|
||||
-- update button). No blend-mode change: the alpha of the
|
||||
-- existing outline is animated instead.
|
||||
-- fill/ink -- override the kind's colours. The ONE caller is the
|
||||
-- launcher's Play button, which wears its cartridge colour
|
||||
-- (red/blue/gold) rather than a semantic one: on that screen
|
||||
-- "which game am I launching" outranks "what kind of verb is
|
||||
-- this", and the colour is already the tab's identity.
|
||||
-- Returns true when activated, by click OR by the focus ring's Enter/A.
|
||||
function Kit.button(x, y, w, h, label, opts)
|
||||
opts = opts or {}
|
||||
opts = opts or NO_OPTS
|
||||
local enabled = opts.enabled ~= false
|
||||
-- Disabled buttons audit too: they stay visible, so they still must not
|
||||
-- paint over a neighbour.
|
||||
audit("control", x, y, w, h, label)
|
||||
local focused = enabled and opts.id
|
||||
and Kit.focusable(opts.id, x, y, w, h) or false
|
||||
local kind = KINDS[enabled and (opts.kind or "ghost") or "disabled"]
|
||||
if enabled and opts.fill then
|
||||
kind = { fill = opts.fill, ink = opts.ink or PAL.inverse }
|
||||
end
|
||||
local hot = enabled and Kit.hover(x, y, w, h)
|
||||
|
||||
if G then
|
||||
-- The fill IS the control: a rounded, embossed, colour-coded key. A
|
||||
-- disabled button keeps its shape in a dead grey rather than
|
||||
-- disappearing, so a layout never reflows on state.
|
||||
Theme.fillRounded(x, y, w, h, kind.fill, enabled and 1 or 0.45)
|
||||
Theme.emboss(x, y, w, h, enabled and (hot and 1.3 or 1) or 0.4)
|
||||
if hot or focused then
|
||||
-- White ring outside the fill: legible on green, blue, yellow, red and
|
||||
-- white alike, which one darker/lighter shade per colour would not be.
|
||||
Theme.strokeRounded(x - 2, y - 2, w + 4, h + 4, PAL.lineStrong,
|
||||
Theme.A.focus, 2, Theme.radius() + 2)
|
||||
elseif opts.glow and enabled then
|
||||
-- "Something is waiting for you" (the update button): a pulsing ring.
|
||||
-- Pure alpha on one existing stroke -- no extra draw calls, no blend
|
||||
-- mode change.
|
||||
local a = 0.25 + 0.75 * (0.5 + 0.5 * math.sin(Kit.time * 3))
|
||||
Theme.strokeRounded(x - 2, y - 2, w + 4, h + 4, PAL.lineStrong, a, 2,
|
||||
Theme.radius() + 2)
|
||||
local face = opts.face or "fill"
|
||||
local B = Theme.BUTTON
|
||||
local radius = opts.radius or B.radius
|
||||
local active = opts.active or opts.on
|
||||
local invert = false
|
||||
local fill, ink, stroke, strokeA, doEmboss, doRing, glowA
|
||||
if face == "invert" then
|
||||
invert = hot
|
||||
fill = invert and (opts.hotFill or PAL.ink) or (opts.fill or PAL.surface)
|
||||
ink = invert and (opts.hotInk or PAL.inverse) or (opts.ink or PAL.heading)
|
||||
stroke = opts.stroke or PAL.line
|
||||
strokeA = invert and Theme.A.focus or Theme.A.hairline
|
||||
doRing = focused and not hot
|
||||
elseif face == "tab" then
|
||||
invert = active or focused or hot
|
||||
local tint = opts.color or opts.fill or PAL.ink
|
||||
fill = invert and tint or PAL.surface
|
||||
ink = invert and PAL.inverse or (opts.color or PAL.text)
|
||||
if not invert then
|
||||
stroke = tint
|
||||
strokeA = opts.color and Theme.A.hover or Theme.A.hairline
|
||||
end
|
||||
local fname = opts.font or "button"
|
||||
local ink = enabled and kind.ink or PAL.inverse
|
||||
local ty = y + (h - Kit.textHeight(fname)) / 2
|
||||
local shown = Kit.ellipsize(fname, label, w - 16 * Kit.scale)
|
||||
-- Button labels are bold: they are the shortest, most-scanned text on
|
||||
-- screen and sit on a saturated fill.
|
||||
if opts.align == "left" then
|
||||
Kit.textBold(fname, shown, x + 10 * Kit.scale, ty, ink)
|
||||
elseif face == "chip" then
|
||||
local c = opts.color or PAL.line
|
||||
invert = active and true or false
|
||||
if active then
|
||||
fill = c
|
||||
ink = PAL.inverse
|
||||
doEmboss = true
|
||||
else
|
||||
Kit.textCenterBold(fname, shown, x, ty, w, ink)
|
||||
fill = PAL.bg
|
||||
ink = c
|
||||
stroke = c
|
||||
strokeA = (focused or hot) and Theme.A.focus or Theme.A.hover
|
||||
end
|
||||
doRing = focused or hot
|
||||
else
|
||||
local kind = KINDS[enabled and (opts.kind or "ghost") or "disabled"]
|
||||
fill = (enabled and opts.fill) or kind.fill
|
||||
ink = (enabled and opts.ink) or kind.ink
|
||||
doEmboss = true
|
||||
doRing = hot or focused
|
||||
if opts.glow and enabled and not doRing then
|
||||
glowA = B.glowBase + B.glowAmp * (0.5 + 0.5 * math.sin(Kit.time * B.glowHz))
|
||||
end
|
||||
end
|
||||
if opts.emboss ~= nil then doEmboss = opts.emboss end
|
||||
if opts.ring ~= nil then doRing = opts.ring end
|
||||
if G then
|
||||
Theme.fillRounded(x, y, w, h, fill, enabled and 1 or B.disabledA, radius)
|
||||
if doEmboss then
|
||||
local es = enabled and ((hot or focused) and B.embossHot or B.embossRest)
|
||||
or B.embossDisabled
|
||||
Theme.emboss(x, y, w, h, es)
|
||||
end
|
||||
if strokeA then
|
||||
Theme.strokeRounded(x, y, w, h, stroke, strokeA, 1, radius)
|
||||
end
|
||||
if doRing then
|
||||
Theme.strokeRounded(x - B.ringPad, y - B.ringPad,
|
||||
w + 2 * B.ringPad, h + 2 * B.ringPad, PAL.lineStrong,
|
||||
Theme.A.focus, B.ringWidth, radius + B.ringPad)
|
||||
elseif glowA then
|
||||
Theme.strokeRounded(x - B.ringPad, y - B.ringPad,
|
||||
w + 2 * B.ringPad, h + 2 * B.ringPad, PAL.lineStrong,
|
||||
glowA, B.ringWidth, radius + B.ringPad)
|
||||
end
|
||||
local fname = opts.font or ((face == "chip") and "micro" or "button")
|
||||
local ty = y + (h - Kit.textHeight(fname)) / 2
|
||||
local image = opts.image
|
||||
local drawFn = opts.drawFn
|
||||
local letter = opts.letter
|
||||
local hasLabel = label and label ~= ""
|
||||
local bold = opts.bold
|
||||
if bold == nil then bold = face ~= "tab" end
|
||||
if image then
|
||||
local box = h
|
||||
local boxX, boxY = x, y
|
||||
if not hasLabel then
|
||||
box = math.min(w, h)
|
||||
boxX = x + (w - box) / 2
|
||||
boxY = y + (h - box) / 2
|
||||
end
|
||||
local iw, ih = image:getDimensions()
|
||||
local pad = math.floor(box * (opts.iconPad or B.iconPad))
|
||||
local s = math.min((box - 2 * pad) / iw, (box - 2 * pad) / ih)
|
||||
if invert then Theme.col(PAL.inverse, 1)
|
||||
else Theme.col(PAL.ink, B.iconRestA) end
|
||||
G.draw(image, Theme.snap(boxX + (box - iw * s) / 2),
|
||||
Theme.snap(boxY + (box - ih * s) / 2), 0, s, s)
|
||||
if hasLabel then
|
||||
local lx = x + h + B.letterGap * Kit.scale
|
||||
if bold then Kit.textBold(fname, label, lx, ty, ink)
|
||||
else Kit.text(fname, label, lx, ty, ink) end
|
||||
end
|
||||
elseif drawFn then
|
||||
drawFn(x, y, w, h, invert or hot or focused)
|
||||
elseif letter then
|
||||
Kit.textCenter(fname, letter, x, ty, h, ink)
|
||||
if hasLabel then
|
||||
local lx = x + h + B.letterGap * Kit.scale
|
||||
if bold then Kit.textBold(fname, label, lx, ty, ink)
|
||||
else Kit.text(fname, label, lx, ty, ink) end
|
||||
end
|
||||
elseif hasLabel then
|
||||
local shown = Kit.ellipsize(fname, label, w - B.labelInset * Kit.scale)
|
||||
if opts.align == "left" then
|
||||
local lx = x + B.labelPad * Kit.scale
|
||||
if bold then Kit.textBold(fname, shown, lx, ty, ink)
|
||||
else Kit.text(fname, shown, lx, ty, ink) end
|
||||
elseif bold then
|
||||
Kit.textCenterBold(fname, shown, x, ty, w, ink)
|
||||
else
|
||||
Kit.textCenter(fname, shown, x, ty, w, ink)
|
||||
end
|
||||
end
|
||||
end
|
||||
if not enabled then return false end
|
||||
@@ -661,7 +730,6 @@ function Kit.button(x, y, w, h, label, opts)
|
||||
and Kit._activateId == opts.id)
|
||||
end
|
||||
|
||||
-- A small square control: +/- steppers, arrow cyclers, the row X.
|
||||
function Kit.stepper(x, y, w, h, glyph, opts)
|
||||
opts = opts or {}
|
||||
opts.kind = opts.kind or "ghost"
|
||||
@@ -669,31 +737,13 @@ function Kit.stepper(x, y, w, h, glyph, opts)
|
||||
return Kit.button(x, y, w, h, glyph, opts)
|
||||
end
|
||||
|
||||
-- A pill toggle (badges, dex SEEN/OWN, sub-tabs). `on` inverts it.
|
||||
local CHIP_OPTS = { face = "chip", font = "micro" }
|
||||
|
||||
function Kit.chip(x, y, w, h, label, on, color, id)
|
||||
audit("control", x, y, w, h, label)
|
||||
local focused = id and Kit.focusable(id, x, y, w, h) or false
|
||||
local c = color or PAL.line
|
||||
if G then
|
||||
local hot = focused or Kit.hover(x, y, w, h)
|
||||
if on then
|
||||
Theme.fillRounded(x, y, w, h, c, 1)
|
||||
Theme.emboss(x, y, w, h, 1)
|
||||
Kit.textCenterBold("micro", label, x,
|
||||
y + (h - Kit.textHeight("micro")) / 2, w, PAL.inverse)
|
||||
else
|
||||
Theme.fillRounded(x, y, w, h, PAL.bg, 1)
|
||||
Theme.strokeRounded(x, y, w, h, c,
|
||||
hot and Theme.A.focus or Theme.A.hover, 1)
|
||||
Kit.textCenterBold("micro", label, x,
|
||||
y + (h - Kit.textHeight("micro")) / 2, w, c)
|
||||
end
|
||||
if hot then
|
||||
Theme.strokeRounded(x - 2, y - 2, w + 4, h + 4, PAL.lineStrong,
|
||||
Theme.A.focus, 2, Theme.radius() + 2)
|
||||
end
|
||||
end
|
||||
return Kit.press(x, y, w, h) or (id ~= nil and Kit._activateId == id)
|
||||
CHIP_OPTS.active = on
|
||||
CHIP_OPTS.color = color
|
||||
CHIP_OPTS.id = id
|
||||
return Kit.button(x, y, w, h, label, CHIP_OPTS)
|
||||
end
|
||||
|
||||
-- A status label with no interaction: outlined text, the "INSTALLED"/"UPDATE"
|
||||
|
||||
+95
-20
@@ -35,8 +35,8 @@ local PAL = {
|
||||
-- it in the same hue, so a card reads as a raised object rather than as an
|
||||
-- outline drawn on the page. These are still flat fills -- the depth comes
|
||||
-- from the value step plus Theme.shadow, not from a gradient.
|
||||
field = { 16, 8, 10 }, -- the page BEHIND the cards
|
||||
bg = { 0, 0, 0 }, -- true black: button rests, field interiors
|
||||
field = { 20, 20, 20 }, -- the page BEHIND the cards
|
||||
bg = { 20, 20, 20 }, -- light black
|
||||
surface = { 28, 21, 24 }, -- card interiors
|
||||
rowBg = { 20, 14, 17 }, -- rows inside a card, one step below it
|
||||
raised = { 44, 34, 38 }, -- hover feedback
|
||||
@@ -82,6 +82,67 @@ Theme.A = {
|
||||
disabled = 0.30,
|
||||
}
|
||||
|
||||
Theme.BUTTON = {
|
||||
radius = 8,
|
||||
ringPad = 2,
|
||||
ringWidth = 2,
|
||||
labelInset = 16,
|
||||
labelPad = 10,
|
||||
iconPad = 0.24,
|
||||
letterGap = 4,
|
||||
disabledA = 0.45,
|
||||
embossHot = 1.3,
|
||||
embossRest = 1,
|
||||
embossDisabled = 0.4,
|
||||
glowHz = 3,
|
||||
glowBase = 0.25,
|
||||
glowAmp = 0.75,
|
||||
iconRestA = 0.85,
|
||||
}
|
||||
|
||||
Theme.CARD = {
|
||||
radius = 14,
|
||||
shadow = true,
|
||||
fill = PAL.surface,
|
||||
fillA = 1,
|
||||
stroke = PAL.line,
|
||||
strokeA = Theme.A.hairline,
|
||||
}
|
||||
|
||||
Theme.CARD_VARIANT = {
|
||||
emphasis = { strokeA = Theme.A.focus },
|
||||
muted = {
|
||||
fill = PAL.bg, fillA = 0.8, stroke = PAL.muted, strokeA = 0.25, shadow = false,
|
||||
},
|
||||
mutedHot = {
|
||||
fill = PAL.bg, fillA = 0.8, stroke = PAL.muted, strokeA = Theme.A.hover, shadow = false,
|
||||
},
|
||||
empty = { fillA = 0, strokeA = 0.22, shadow = false, radius = "ctl" },
|
||||
warn = {
|
||||
fill = PAL.rowBg, stroke = PAL.yellow, strokeA = Theme.A.hover,
|
||||
shadow = false, radius = "ctl",
|
||||
},
|
||||
row = {
|
||||
fill = PAL.rowBg, stroke = PAL.line, strokeA = Theme.A.hairline,
|
||||
shadow = false, radius = "ctl",
|
||||
},
|
||||
rowHover = {
|
||||
fill = PAL.raised, stroke = PAL.line, strokeA = Theme.A.hover,
|
||||
shadow = false, radius = "ctl",
|
||||
},
|
||||
rowSelected = {
|
||||
fill = PAL.ink, strokeA = 0, shadow = false, radius = "ctl",
|
||||
},
|
||||
hairline = {
|
||||
fillA = 0, stroke = PAL.line, strokeA = Theme.A.hairline,
|
||||
shadow = false, radius = "ctl",
|
||||
},
|
||||
badge = {
|
||||
fill = PAL.bg, stroke = PAL.yellow, strokeA = Theme.A.hover,
|
||||
shadow = false, radius = 0,
|
||||
},
|
||||
}
|
||||
|
||||
local G = love and love.graphics or nil
|
||||
|
||||
local has = {}
|
||||
@@ -123,11 +184,11 @@ end
|
||||
-- Controls get the smaller one, containers the larger, so a button never
|
||||
-- looks like a card and a card never looks like a button.
|
||||
function Theme.radius()
|
||||
return 8
|
||||
return Theme.BUTTON.radius
|
||||
end
|
||||
|
||||
function Theme.cardRadius()
|
||||
return 14
|
||||
return Theme.CARD.radius
|
||||
end
|
||||
|
||||
-- DROP SHADOW. Three stacked rounded rects at low alpha, each one step wider
|
||||
@@ -206,28 +267,42 @@ end
|
||||
-- The design's only container: a rounded surface a few values above the
|
||||
-- field, its own drop shadow, and a white hairline. `emphasis` raises the
|
||||
-- outline to full white (used for the focused/active card).
|
||||
function Theme.card(x, y, w, h, emphasis)
|
||||
local r = Theme.cardRadius()
|
||||
Theme.shadow(x, y, w, h, r)
|
||||
Theme.fillRounded(x, y, w, h, PAL.surface, 1, r)
|
||||
Theme.strokeRounded(x, y, w, h, PAL.line,
|
||||
emphasis and Theme.A.focus or Theme.A.hairline, 1, r)
|
||||
function Theme.card(x, y, w, h, variant)
|
||||
local spec = Theme.CARD
|
||||
local v
|
||||
if variant == true then
|
||||
v = Theme.CARD_VARIANT.emphasis
|
||||
elseif type(variant) == "string" then
|
||||
v = Theme.CARD_VARIANT[variant]
|
||||
elseif type(variant) == "table" then
|
||||
v = variant
|
||||
end
|
||||
local radius = (v and v.radius) or spec.radius
|
||||
if radius == "ctl" then radius = Theme.BUTTON.radius end
|
||||
local fill = (v and v.fill) or spec.fill
|
||||
local fillA = spec.fillA
|
||||
if v and v.fillA ~= nil then fillA = v.fillA end
|
||||
local stroke = (v and v.stroke) or spec.stroke
|
||||
local strokeA = spec.strokeA
|
||||
if v and v.strokeA ~= nil then strokeA = v.strokeA end
|
||||
local shadow = spec.shadow
|
||||
if v and v.shadow ~= nil then shadow = v.shadow end
|
||||
local strokeW = (v and v.strokeW) or 1
|
||||
if fillA > 0 and fill then
|
||||
if shadow then Theme.shadow(x, y, w, h, radius) end
|
||||
Theme.fillRounded(x, y, w, h, fill, fillA, radius)
|
||||
end
|
||||
if strokeA > 0 and stroke then
|
||||
Theme.strokeRounded(x, y, w, h, stroke, strokeA, strokeW, radius)
|
||||
end
|
||||
end
|
||||
|
||||
-- A list row. Three states, each one rect plus one outline:
|
||||
-- normal one value below the card it sits in, hairline
|
||||
-- hover lifted fill, brighter hairline
|
||||
-- selected WHITE fill (callers print ink = PAL.inverse over it)
|
||||
function Theme.row(x, y, w, h, state)
|
||||
local r = Theme.radius()
|
||||
if state == "selected" then
|
||||
Theme.fillRounded(x, y, w, h, PAL.ink, 1, r)
|
||||
Theme.card(x, y, w, h, "rowSelected")
|
||||
return PAL.inverse
|
||||
end
|
||||
Theme.fillRounded(x, y, w, h,
|
||||
state == "hover" and PAL.raised or PAL.rowBg, 1, r)
|
||||
Theme.strokeRounded(x, y, w, h, PAL.line,
|
||||
state == "hover" and Theme.A.hover or Theme.A.hairline, 1, r)
|
||||
Theme.card(x, y, w, h, state == "hover" and "rowHover" or "row")
|
||||
return PAL.text
|
||||
end
|
||||
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
-- Generation-neutral minimap rasterization. WorldAPI arms own semantic
|
||||
-- markers because object/event visibility differs; terrain and tile shading
|
||||
-- share one output contract.
|
||||
|
||||
local Assets = require("src.render.Assets")
|
||||
|
||||
local MapOverview = {}
|
||||
local overviewShades = {}
|
||||
|
||||
Assets.register(function() overviewShades = {} end)
|
||||
|
||||
local function shadeDigit(sum, pixelCount)
|
||||
return tostring(math.max(0, math.min(3,
|
||||
math.floor((1 - sum / pixelCount) * 3 + 0.5))))
|
||||
end
|
||||
|
||||
local function tileRows(map)
|
||||
local tileset = map.tileset
|
||||
if not (tileset and tileset.image and tileset.tilesPerRow) then return nil end
|
||||
local cached = overviewShades[tileset.image]
|
||||
if not cached then
|
||||
local ok, pixels = pcall(Assets.imageData, tileset.image)
|
||||
if not ok then return nil end
|
||||
cached = { pixels = pixels, shades = {} }
|
||||
overviewShades[tileset.image] = cached
|
||||
end
|
||||
local rows, detailRows, perRow = {}, {}, tileset.tilesPerRow
|
||||
for ty = 0, map.heightCells * 2 - 1 do
|
||||
local row, detailTop, detailBottom = {}, {}, {}
|
||||
for tx = 0, map.widthCells * 2 - 1 do
|
||||
local tile = map:tileAt(tx, ty)
|
||||
local shades = cached.shades[tile]
|
||||
if shades == nil then
|
||||
local sums = { 0, 0, 0, 0 }
|
||||
local ox, oy = (tile % perRow) * 8, math.floor(tile / perRow) * 8
|
||||
for py = 0, 7 do
|
||||
for px = 0, 7 do
|
||||
local r, g, b = cached.pixels:getPixel(ox + px, oy + py)
|
||||
local quadrant = math.floor(py / 4) * 2 + math.floor(px / 4) + 1
|
||||
sums[quadrant] = sums[quadrant]
|
||||
+ r * 0.2126 + g * 0.7152 + b * 0.0722
|
||||
end
|
||||
end
|
||||
shades = {
|
||||
shadeDigit(sums[1] + sums[2] + sums[3] + sums[4], 64),
|
||||
shadeDigit(sums[1], 16), shadeDigit(sums[2], 16),
|
||||
shadeDigit(sums[3], 16), shadeDigit(sums[4], 16),
|
||||
}
|
||||
cached.shades[tile] = shades
|
||||
end
|
||||
row[#row + 1] = shades[1]
|
||||
detailTop[#detailTop + 1] = shades[2] .. shades[3]
|
||||
detailBottom[#detailBottom + 1] = shades[4] .. shades[5]
|
||||
end
|
||||
rows[#rows + 1] = table.concat(row)
|
||||
detailRows[#detailRows + 1] = table.concat(detailTop)
|
||||
detailRows[#detailRows + 1] = table.concat(detailBottom)
|
||||
end
|
||||
return rows, detailRows
|
||||
end
|
||||
|
||||
function MapOverview.build(map, markers)
|
||||
local rows = {}
|
||||
for y = 0, map.heightCells - 1 do
|
||||
local row = {}
|
||||
for x = 0, map.widthCells - 1 do
|
||||
row[#row + 1] = map:isWarpTileCell(x, y) and "+"
|
||||
or map:isWaterCell(x, y) and "~"
|
||||
or map:isWalkableCell(x, y) and "." or " "
|
||||
end
|
||||
rows[#rows + 1] = table.concat(row)
|
||||
end
|
||||
local tiles, detail = tileRows(map)
|
||||
return { mapId = map.id, width = map.widthCells,
|
||||
height = map.heightCells, rows = rows, markers = markers,
|
||||
tileRows = tiles,
|
||||
tileWidth = tiles and map.widthCells * 2,
|
||||
tileHeight = tiles and map.heightCells * 2,
|
||||
tileDetailRows = detail,
|
||||
tileDetailWidth = detail and map.widthCells * 4,
|
||||
tileDetailHeight = detail and map.heightCells * 4 }
|
||||
end
|
||||
|
||||
return MapOverview
|
||||
+17
-6
@@ -8,7 +8,7 @@ local SpriteRenderer = require("src.render.SpriteRenderer")
|
||||
local NPC = {}
|
||||
NPC.__index = NPC
|
||||
|
||||
local STEP_FRAMES = 16
|
||||
local STEP_FRAMES = 32
|
||||
|
||||
local FACING_FROM_RANGE = {
|
||||
DOWN = "down", UP = "up", LEFT = "left", RIGHT = "right",
|
||||
@@ -52,7 +52,16 @@ function NPC:facePlayer(player)
|
||||
end
|
||||
|
||||
function NPC:update(map, entities)
|
||||
-- self.stepFrames overrides the shared 16-frame walk for an object whose
|
||||
-- An NPC tile is 32 frames, half the player's rate: TryWalking loads
|
||||
-- WALKANIMATIONCOUNTER with $10 and UpdateSpriteInWalkingAnimation adds
|
||||
-- the 1px step vector once per call (engine/overworld/movement.asm), but
|
||||
-- UpdateSprites runs once per OverworldLoop pass and every pass opens
|
||||
-- with two DelayFrame calls (home/overworld.asm) -- so those 16 ticks
|
||||
-- cost 32 frames for one 16px cell, against AdvancePlayerSprite's 8
|
||||
-- ticks of 2px. That halving is why pokeyellow's NormalPikachuFollow
|
||||
-- needs TryDoubleAddPikachuStepVectorToScreenPixelCoords to keep up.
|
||||
--
|
||||
-- self.stepFrames overrides the shared walk for an object whose
|
||||
-- step has to stay in phase with something else: Yellow's follower
|
||||
-- Pikachu takes the player's own step length, halved while it is more
|
||||
-- than a cell behind (FastPikachuFollow, engine/pikachu/
|
||||
@@ -78,7 +87,7 @@ function NPC:update(map, entities)
|
||||
return
|
||||
end
|
||||
local d = Collision.DELTA[self.facing]
|
||||
-- 1px per frame at the default length; a shortened step scales instead,
|
||||
-- 1px per 2 frames at the default length; a shortened step scales instead,
|
||||
-- so the cell still lands on a 16px boundary (Player:update does the
|
||||
-- same for the bicycle)
|
||||
local moved = math.floor(self.progress * 16 * span / stepLen)
|
||||
@@ -113,8 +122,9 @@ end
|
||||
|
||||
function NPC:walkPhase()
|
||||
if not self.moving then return 0 end
|
||||
local p = self.progress % 16
|
||||
return (p >= 4 and p < 12) and 1 or 0
|
||||
local stepLen = self.stepFrames or STEP_FRAMES
|
||||
local p = self.progress % stepLen
|
||||
return (p >= stepLen / 4 and p < stepLen * 3 / 4) and 1 or 0
|
||||
end
|
||||
|
||||
-- Same contract as Player:pose -- the sheet, position, facing and step
|
||||
@@ -128,7 +138,8 @@ end
|
||||
|
||||
function NPC:draw(camX, camY)
|
||||
local sprite, px, py, facing, phase, flip = self:pose()
|
||||
sprite:draw(px, py, camX, camY, facing, phase, flip)
|
||||
sprite:draw(px, py, camX, camY, facing, phase, flip, nil, nil,
|
||||
self.frameOverride)
|
||||
end
|
||||
|
||||
return NPC
|
||||
|
||||
@@ -384,12 +384,21 @@ function OverworldState:setMap(mapId, x, y, facing, opts)
|
||||
-- NPC instances persist across connection crossings in self.npcPool
|
||||
-- (keyed by NPC.id): a neighbor map's wandering ghosts ARE the
|
||||
-- objects that become the real NPCs when the player crosses the
|
||||
-- seam, so nothing snaps back to its spawn point in view of the
|
||||
-- survey zoom. Warps rebuild from scratch, like the original's
|
||||
-- per-entry sprite init (home/overworld.asm LoadMapHeader
|
||||
-- .loadSpriteData).
|
||||
-- seam, so a map the player is not entering keeps its ghosts alive
|
||||
-- in view of the survey zoom. The map being entered does not --
|
||||
-- crossing a seam runs .loadNewMap -> LoadMapHeader, whose
|
||||
-- .loadSpriteData zeroes the sprite state data and re-seeds every
|
||||
-- SPRITESTATEDATA2_MAPY/MAPX from the map header's object data
|
||||
-- (home/overworld.asm), so an NPC who walked up to the player stands
|
||||
-- on her spawn cell again the next time that map loads (#1028). Only
|
||||
-- the save-side spawn flags survive, and those live in Game.save, not
|
||||
-- here. Warps rebuild the whole pool from scratch.
|
||||
if not (opts and opts.seamless and self.npcPool) then
|
||||
self.npcPool = {}
|
||||
elseif fromMapId ~= mapId then
|
||||
for _, obj in ipairs(self.map.def.objects or {}) do
|
||||
self.npcPool[mapId .. "_obj_" .. obj.index] = nil
|
||||
end
|
||||
end
|
||||
self.npcs = {}
|
||||
for _, obj in ipairs(self.map.def.objects or {}) do
|
||||
@@ -1087,6 +1096,7 @@ function OverworldState:update(dt)
|
||||
-- the player lands on desk Oak.
|
||||
local scripted = self.runner:isRunning() or #self.scriptMoves > 0
|
||||
or self.engaging or self.emote or self.teleportOut
|
||||
or self.flyAnim or self.flyArrive
|
||||
if not scripted and not self.transitioning then
|
||||
self:checkTrainerSight()
|
||||
-- CheckFightingMapTrainers (home/trainers.asm) zeroes hJoyHeld and
|
||||
@@ -1095,6 +1105,7 @@ function OverworldState:update(dt)
|
||||
-- the player can never start another step after being spotted.
|
||||
scripted = self.runner:isRunning() or #self.scriptMoves > 0
|
||||
or self.engaging or self.emote or self.teleportOut
|
||||
or self.flyAnim or self.flyArrive
|
||||
end
|
||||
if not scripted and not self.transitioning then
|
||||
self:handleInput()
|
||||
@@ -1664,6 +1675,10 @@ end
|
||||
-- Goldeen/Poliwag L10; Super Rod uses the map's extracted fishing group
|
||||
-- (no group means "Not even a nibble!").
|
||||
function OverworldState:goFishing(rod)
|
||||
if GameVersion.isYellow() then
|
||||
Game.save.pikachuEmotionModifier = 2
|
||||
Game.save.pikachuMood = 0x81
|
||||
end
|
||||
local pool, always = fishingPool(Game.data, rod, self.map.id)
|
||||
local enc
|
||||
if Runtime.wantsHook("encounter.fishing") then
|
||||
@@ -1980,10 +1995,12 @@ function OverworldState:tryHiddenObject(fx, fy)
|
||||
end
|
||||
save.hiddenTaken[key] = true
|
||||
local name = Game.data.items[h.item] and Game.data.items[h.item].name or h.item
|
||||
-- hidden items always play SFX_GET_ITEM_2 (hidden_items.asm)
|
||||
require("src.core.Sound").play(Game.data, "Get_Item2")
|
||||
-- hidden items always play SFX_GET_ITEM_2, and FoundHiddenItemText's
|
||||
-- text_asm tail runs it as PlaySoundWaitForCurrent +
|
||||
-- WaitForSoundToFinish once the box has printed (hidden_items.asm)
|
||||
Game.stack:push(TextBox.new(Game,
|
||||
Strings("%s found\n%s!", save.player.name, name)))
|
||||
Strings("%s found\n%s!", save.player.name, name),
|
||||
nil, TextBox.soundOpts(Game, "Get_Item2")))
|
||||
return true
|
||||
end
|
||||
end
|
||||
@@ -1995,9 +2012,9 @@ function OverworldState:tryHiddenObject(fx, fy)
|
||||
if not save.inventory.COIN_CASE then return false end
|
||||
save.hiddenTaken[key] = true
|
||||
save.coins = math.min(9999, (save.coins or 0) + h.coins)
|
||||
require("src.core.Sound").play(Game.data, "Get_Item2")
|
||||
Game.stack:push(TextBox.new(Game,
|
||||
Strings("%s found\n%d coins!", save.player.name, h.coins)))
|
||||
Strings("%s found\n%d coins!", save.player.name, h.coins),
|
||||
nil, TextBox.soundOpts(Game, "Get_Item2")))
|
||||
return true
|
||||
end
|
||||
end
|
||||
@@ -2644,10 +2661,11 @@ function OverworldState:talkTo(npc)
|
||||
end
|
||||
local name = Game.data.items[d.item] and Game.data.items[d.item].name or d.item
|
||||
local ddef = Game.data.items[d.item]
|
||||
require("src.core.Sound").play(Game.data,
|
||||
(ddef and ddef.keyItem) and "Get_Key_Item" or "Get_Item1")
|
||||
-- FoundItemText: text_far, sound_get_item_1, text_end (pick_up_item.asm)
|
||||
Game.stack:push(TextBox.new(Game,
|
||||
Strings("%s found\n%s!", Game.save.player.name, name)))
|
||||
Strings("%s found\n%s!", Game.save.player.name, name), nil,
|
||||
TextBox.soundOpts(Game,
|
||||
(ddef and ddef.keyItem) and "Get_Key_Item" or "Get_Item1")))
|
||||
return
|
||||
end
|
||||
|
||||
@@ -2905,6 +2923,11 @@ end
|
||||
-- POKéMON" and "fighting fit".
|
||||
function OverworldState:nurseHeal(onDone, npc)
|
||||
local t = Game.data.text
|
||||
if self.map.id == "PEWTER_POKECENTER" and self.pikachuPewterSleepScene then
|
||||
Game.stack:push(TextBox.new(Game,
|
||||
t._LooksContentText or Strings("PIKACHU looks\ncontent."), onDone))
|
||||
return
|
||||
end
|
||||
local bye = t._PokemonCenterFarewellText or romText(Game.data, "_PokemonCenterFarewellText", "We hope to see\nyou again!")
|
||||
local hello = t._PokemonCenterWelcomeText
|
||||
or Strings("Welcome to our\nPOKéMON CENTER!")
|
||||
@@ -2978,9 +3001,13 @@ function OverworldState:finishNurseHeal(bye, onDone, npc)
|
||||
end))
|
||||
end
|
||||
if not npc then farewell() return end
|
||||
npc.facing = "up"
|
||||
npc.frameOverride = 3
|
||||
-- bubble = false is the silent world hold, this port's DelayFrames
|
||||
self.emote = { npc = npc, frames = 20, bubble = false, onDone = farewell }
|
||||
self.emote = { npc = npc, frames = 20, bubble = false, onDone = function()
|
||||
npc.frameOverride = nil
|
||||
npc:facePlayer(self.player)
|
||||
farewell()
|
||||
end }
|
||||
end))
|
||||
end
|
||||
|
||||
@@ -2992,6 +3019,11 @@ end
|
||||
-- for the original serial handshake; declining prints "Please come again!"
|
||||
function OverworldState:cableClubReceptionist(onDone)
|
||||
local t = Game.data.text
|
||||
if self.map.id == "PEWTER_POKECENTER" and self.pikachuPewterSleepScene then
|
||||
Game.stack:push(TextBox.new(Game,
|
||||
t._LooksContentText or Strings("PIKACHU looks\ncontent."), onDone))
|
||||
return
|
||||
end
|
||||
local welcome = t._CableClubNPCWelcomeText or romText(Game.data, "_CableClubNPCWelcomeText", "Welcome to the\nCable Club!")
|
||||
if not Game.save.flags.EVENT_GOT_POKEDEX then
|
||||
-- CableClubNPC .didNotConnect path before the pokedex
|
||||
@@ -3162,6 +3194,58 @@ local function giveVictoryItem(reward)
|
||||
return true
|
||||
end
|
||||
|
||||
-- A gym's reward text is not one box. Every gym script carries a sound
|
||||
-- command right after the FIRST label of each reward group
|
||||
-- (scripts/PewterGym.asm PewterGymBrockReceivedBoulderBadgeText's
|
||||
-- sound_level_up, PewterGymReceivedTM34Text's sound_get_item_1, and the
|
||||
-- equivalents in the other seven), and home/text.asm TextCommand_SOUND
|
||||
-- plays it only once that page has typed out, then blocks on
|
||||
-- WaitForSoundToFinish before the next page prints. So the pages
|
||||
-- accumulate and split into separate boxes at the sound points, each box
|
||||
-- chained off the previous one's button press.
|
||||
local function rewardChain()
|
||||
local chain = { boxes = {}, pending = {} }
|
||||
function chain.flush(sound)
|
||||
if #chain.pending > 0 then
|
||||
table.insert(chain.boxes,
|
||||
{ text = table.concat(chain.pending, "\f"), sound = sound })
|
||||
chain.pending = {}
|
||||
end
|
||||
end
|
||||
-- one reward group; `sound` rides its first page, where the scripts put it
|
||||
function chain.add(labels, sound)
|
||||
local text = Game.data.text or {}
|
||||
local n = 0
|
||||
for _, label in ipairs(labels or {}) do
|
||||
if text[label] and text[label] ~= "" then
|
||||
table.insert(chain.pending, text[label])
|
||||
n = n + 1
|
||||
if n == 1 and sound then chain.flush(sound) end
|
||||
end
|
||||
end
|
||||
end
|
||||
-- the synthetic stand-in line a reward with no `dialogue` shows
|
||||
function chain.line(str, sound)
|
||||
table.insert(chain.pending, str)
|
||||
if sound then chain.flush(sound) end
|
||||
end
|
||||
function chain.push(done)
|
||||
chain.flush()
|
||||
local function step(i)
|
||||
local box = chain.boxes[i]
|
||||
if not box then
|
||||
if done then done() end
|
||||
return
|
||||
end
|
||||
local opts = box.sound and TextBox.soundOpts(Game, box.sound) or nil
|
||||
Game.stack:push(TextBox.new(Game, box.text,
|
||||
function() step(i + 1) end, opts))
|
||||
end
|
||||
step(1)
|
||||
end
|
||||
return chain
|
||||
end
|
||||
|
||||
-- Badges/items awarded after specific battles (data/scripts/victories.lua).
|
||||
-- `deactivate` retires unfought gym/dojo trainers the way the originals'
|
||||
-- SetEvent / SetEventRange do after the leader victory.
|
||||
@@ -3198,44 +3282,31 @@ function OverworldState:checkVictoryRewards(trainerClass, partyIndex)
|
||||
-- retries the hand-over later (offerGymTm via gyms.lua)
|
||||
tmGiven = giveVictoryItem(reward)
|
||||
end
|
||||
local lines = {}
|
||||
local chain = rewardChain()
|
||||
if reward.dialogue then
|
||||
local text = Game.data.text or {}
|
||||
for _, label in ipairs(reward.dialogue) do
|
||||
if text[label] and text[label] ~= "" then
|
||||
table.insert(lines, text[label])
|
||||
end
|
||||
end
|
||||
chain.add(reward.dialogue, reward.badgeSound)
|
||||
if reward.item then
|
||||
for _, label in ipairs(reward.tmPre or {}) do
|
||||
if text[label] and text[label] ~= "" then
|
||||
table.insert(lines, text[label])
|
||||
end
|
||||
end
|
||||
chain.add(reward.tmPre)
|
||||
if tmGiven then
|
||||
for _, label in ipairs(reward.tmDialogue or {}) do
|
||||
if text[label] and text[label] ~= "" then
|
||||
table.insert(lines, text[label])
|
||||
end
|
||||
end
|
||||
elseif reward.noRoom and text[reward.noRoom] and text[reward.noRoom] ~= "" then
|
||||
table.insert(lines, text[reward.noRoom])
|
||||
chain.add(reward.tmDialogue, reward.tmSound)
|
||||
else
|
||||
chain.add({ reward.noRoom })
|
||||
end
|
||||
end
|
||||
elseif reward.badge or reward.item then
|
||||
if reward.badge then
|
||||
local name = Game.data.items[reward.badge] and Game.data.items[reward.badge].name
|
||||
or reward.badge
|
||||
table.insert(lines, Strings("%s received\nthe %s!", Game.save.player.name, name))
|
||||
chain.line(Strings("%s received\nthe %s!", Game.save.player.name, name),
|
||||
reward.badgeSound)
|
||||
end
|
||||
if tmGiven then
|
||||
local name = Game.stringBuffer or reward.item
|
||||
table.insert(lines, Strings("%s received\n%s!", Game.save.player.name, name))
|
||||
chain.line(Strings("%s received\n%s!", Game.save.player.name, name),
|
||||
reward.tmSound)
|
||||
end
|
||||
end
|
||||
if #lines > 0 then
|
||||
Game.stack:push(TextBox.new(Game, table.concat(lines, "\f")))
|
||||
end
|
||||
chain.push()
|
||||
self:runVictoryHook()
|
||||
end
|
||||
|
||||
@@ -3246,24 +3317,14 @@ end
|
||||
-- show again, then the same GiveItem check decides between the received
|
||||
-- lines and the "make room" text.
|
||||
function OverworldState:offerGymTm(reward, done)
|
||||
local text = Game.data.text or {}
|
||||
local lines = {}
|
||||
local function addLine(label)
|
||||
if label and text[label] and text[label] ~= "" then
|
||||
table.insert(lines, text[label])
|
||||
end
|
||||
end
|
||||
for _, label in ipairs(reward.tmPre or {}) do addLine(label) end
|
||||
local chain = rewardChain()
|
||||
chain.add(reward.tmPre)
|
||||
if giveVictoryItem(reward) then
|
||||
for _, label in ipairs(reward.tmDialogue or {}) do addLine(label) end
|
||||
chain.add(reward.tmDialogue, reward.tmSound)
|
||||
else
|
||||
addLine(reward.noRoom)
|
||||
end
|
||||
if #lines > 0 then
|
||||
Game.stack:push(TextBox.new(Game, table.concat(lines, "\f"), done))
|
||||
elseif done then
|
||||
done()
|
||||
chain.add({ reward.noRoom })
|
||||
end
|
||||
chain.push(done)
|
||||
end
|
||||
|
||||
-- pokered reloads the map after every battle, re-running the map
|
||||
@@ -3641,8 +3702,9 @@ function OverworldState:onStepComplete()
|
||||
end
|
||||
end
|
||||
|
||||
-- wild encounters in grass, on water while surfing, or -- on indoor
|
||||
-- maps whose tileset is not FOREST -- on EVERY tile
|
||||
-- wild encounters in grass, on water while surfing (at the map's water
|
||||
-- rate, which is 0 on every indoor map), or -- on indoor maps whose
|
||||
-- tileset is not FOREST -- on every other tile
|
||||
-- (wild_encounters.asm: caves, towers, the Mansion, Power Plant)
|
||||
-- The cooldown is checked after all other step processing so repel and
|
||||
-- movement systems continue to advance during the protected steps.
|
||||
@@ -3650,10 +3712,10 @@ function OverworldState:onStepComplete()
|
||||
local encDef = Game.data.encounters[self.map.id]
|
||||
local enc
|
||||
local indoor = Game.data.field.indoorEncounters
|
||||
if p.surfing and encDef and encDef.water and self.map:isWaterCell(p.cellX, p.cellY) then
|
||||
enc = self:rollEncounter({ grass = encDef.water }, "water")
|
||||
elseif self.map:isGrassCell(p.cellX, p.cellY) then
|
||||
if self.map:isGrassCell(p.cellX, p.cellY) then
|
||||
enc = self:rollEncounter(encDef, "grass")
|
||||
elseif p.surfing and self.map:isWaterCell(p.cellX, p.cellY) then
|
||||
enc = self:rollEncounter({ grass = encDef and encDef.water }, "water")
|
||||
elseif indoor and self.map.def.index >= indoor.firstIndoorMap
|
||||
and self.map.def.tileset ~= indoor.excludedTileset then
|
||||
enc = self:rollEncounter(encDef, "indoor")
|
||||
@@ -3774,10 +3836,10 @@ function OverworldState:checkBadgeGate()
|
||||
if Game.save.inventory[g.badge] then
|
||||
if not Game.save.flags[passedFlag] then
|
||||
Game.save.flags[passedFlag] = true
|
||||
-- Route22GateGuardGoRightAheadText plays sound_get_item_1
|
||||
require("src.core.Sound").play(Game.data, "Get_Item1")
|
||||
-- Route22GateGuardGoRightAheadText carries sound_get_item_1
|
||||
Game.stack:push(TextBox.new(Game,
|
||||
t["_" .. g.passText] or Strings("Go right ahead!")))
|
||||
t["_" .. g.passText] or Strings("Go right ahead!"),
|
||||
nil, TextBox.soundOpts(Game, "Get_Item1")))
|
||||
end
|
||||
return false
|
||||
end
|
||||
@@ -3802,11 +3864,11 @@ function OverworldState:checkBadgeGate()
|
||||
and Game.data.items[guard.badge].name or guard.badge
|
||||
if Game.save.inventory[guard.badge] then
|
||||
Game.save.flags[guard.event] = true
|
||||
-- Route23OhThatIsTheBadgeText plays sound_get_item_1
|
||||
require("src.core.Sound").play(Game.data, "Get_Item1")
|
||||
-- Route23OhThatIsTheBadgeText carries sound_get_item_1
|
||||
local text = (t["_" .. g.passText] or
|
||||
Strings("Oh! That is the\n{RAM}!")):gsub("{RAM:wNameBuffer}", badgeName)
|
||||
Game.stack:push(TextBox.new(Game, text))
|
||||
Game.stack:push(TextBox.new(Game, text,
|
||||
nil, TextBox.soundOpts(Game, "Get_Item1")))
|
||||
return false
|
||||
end
|
||||
-- Route23YouDontHaveTheBadgeYetText plays SFX_DENIED
|
||||
@@ -4450,7 +4512,7 @@ function OverworldState:scriptMove(entity, dir, tiles, onDone)
|
||||
})
|
||||
end
|
||||
|
||||
-- A step-in-place beat: the entity plays one walk-cycle animation (16
|
||||
-- A step-in-place beat: the entity plays one walk-cycle animation (32
|
||||
-- frames) without translating, keeping its current facing. Ports the
|
||||
-- NPC_CHANGE_FACING movement byte (engine/overworld/movement.asm
|
||||
-- ChangeFacingDirection -> zero-delta TryWalking), used for Oak marching
|
||||
@@ -4463,7 +4525,7 @@ end
|
||||
|
||||
-- Advance scripted moves in two phases so a chained step (a new move
|
||||
-- queued by a completing move's onDone) begins the SAME frame the
|
||||
-- previous one ends -- back-to-back 16-frame tiles like the GB's
|
||||
-- previous one ends -- back-to-back 32-frame tiles like the GB's
|
||||
-- simulated-joypad / NPC scripted movement, with no idle frame between
|
||||
-- tiles. Phase 1 retires finished moves (which may chain new ones);
|
||||
-- phase 2 then starts every not-yet-moving move.
|
||||
|
||||
@@ -65,6 +65,17 @@ function PikachuFollower.starterInParty(save, needHealthy)
|
||||
return nil
|
||||
end
|
||||
|
||||
function PikachuFollower.isStarterPikachu(save, mon)
|
||||
if not (mon and mon.species == "PIKACHU") then return false end
|
||||
local player = save.player or {}
|
||||
return mon.otId == player.id and mon.ot == player.name
|
||||
end
|
||||
|
||||
function PikachuFollower.isFollowingDisabled(ow)
|
||||
return ow and (ow.pikachuBillsScene or ow.pikachuFanClubScene
|
||||
or ow.pikachuPewterSleepScene) and true or false
|
||||
end
|
||||
|
||||
-- ModifyPikachuHappiness. mon is the party mon the event applied to for
|
||||
-- the per-mon reasons (IsThisPartyMonStarterPikachu); GYMLEADER and
|
||||
-- WALKING instead require any healthy starter in the party
|
||||
@@ -199,6 +210,7 @@ function PikachuFollower.onMapEntered(game, ow, opts, viaMapLoad)
|
||||
-- Bill's House owns a short scripted scene that deliberately keeps
|
||||
-- Pikachu off the normal trailing loop. A new map instance ends it.
|
||||
ow.pikachuBillsScene = nil
|
||||
ow.pikachuFanClubScene = nil
|
||||
remove(ow)
|
||||
if not shouldSpawn(game, ow) then return end
|
||||
-- opts.keepPikachu is the follower a connection crossing kept alive:
|
||||
@@ -410,7 +422,8 @@ end
|
||||
-- (pikachu_follow.asm keeps it one walk step behind)
|
||||
function PikachuFollower.update(game, ow)
|
||||
if ow.pikaHop then return end -- the counter hop owns the follower (#417)
|
||||
if ow.pikachuBillsScene then return end
|
||||
if ow.pikachuBillsScene or ow.pikachuFanClubScene
|
||||
or ow.pikachuPewterSleepScene then return end
|
||||
local npc = findFollower(ow)
|
||||
if not npc then
|
||||
if shouldSpawn(game, ow) then PikachuFollower.onMapEntered(game, ow) end
|
||||
@@ -713,6 +726,13 @@ function PikachuFollower.talk(game, ow, npc, done)
|
||||
ow.player.facing = OPPOSITE[npc.facing] or ow.player.facing
|
||||
local save = game.save
|
||||
local emotion = selectEmotion(game, ow, save)
|
||||
if ow.pikachuPewterSleepScene then
|
||||
local finish = done
|
||||
done = function()
|
||||
ow.pikachuPewterSleepScene = nil
|
||||
if finish then finish() end
|
||||
end
|
||||
end
|
||||
local e = EMOTIONS[emotion] or EMOTIONS[1]
|
||||
if e.turnAway then
|
||||
npc.facing = ow.player.facing -- pikaemotion_9: back to the player
|
||||
@@ -798,10 +818,41 @@ local function movePikachu(ow, npc, steps, onDone)
|
||||
nextStep(1)
|
||||
end
|
||||
|
||||
function PikachuFollower.onFanClubEntered(game, ow)
|
||||
if not (GameVersion.isYellow() and ow.map
|
||||
and ow.map.id == "POKEMON_FAN_CLUB") then return end
|
||||
local active = game.save.pikachuMapScriptActive
|
||||
game.save.pikachuMapScriptActive = true
|
||||
if active then return end
|
||||
local starter = PikachuFollower.starterInParty(game.save)
|
||||
local npc = findFollower(ow)
|
||||
if not npc or (starter and starter.status) then return end
|
||||
ow.pikachuFanClubScene = true
|
||||
ow.player.facing = "down"
|
||||
for _, other in ipairs(ow.npcs or {}) do
|
||||
if other.def and other.def.name == "POKEMONFANCLUB_SEEL" then
|
||||
other.movementStatus = 2
|
||||
other.facing = "down"
|
||||
break
|
||||
end
|
||||
end
|
||||
billsHouseEmotion(game, ow, npc, "EXCLAMATION_BUBBLE")
|
||||
movePikachu(ow, npc, { { "up", 1 }, { "right", 3 }, { "up", 1 } }, function()
|
||||
npc.facing = "up"
|
||||
end)
|
||||
end
|
||||
|
||||
function PikachuFollower.onBillsHouseEnter(game, ow)
|
||||
if not (GameVersion.isYellow() and ow.map and ow.map.id == "BILLS_HOUSE") then
|
||||
return
|
||||
end
|
||||
-- BillsHouse_CheckMetBill (scripts/BillsHouse.asm:22-40) sets
|
||||
-- BIT_PIKACHU_MAP_SCRIPT_ACTIVE and rets nz before it looks at
|
||||
-- EVENT_MET_BILL_2; the bit rides sMainData, so a reload inside the house
|
||||
-- resumes at BillsHouseScript1's bare ret (#919).
|
||||
local active = game.save.pikachuMapScriptActive
|
||||
game.save.pikachuMapScriptActive = true
|
||||
if active then return end
|
||||
if game.save.flags.EVENT_MET_BILL_2 then return end
|
||||
-- BillsHouseScript0 (scripts/BillsHouse.asm:41-47) only runs the confused
|
||||
-- walk while CheckPikachuStatusCondition comes back clear
|
||||
|
||||
+3
-73
@@ -6,8 +6,8 @@
|
||||
-- stays unsupported; anything a mod legitimately needs belongs here.
|
||||
|
||||
local Logger = require("src.core.Logger")
|
||||
local Assets = require("src.render.Assets")
|
||||
local MapLoader = require("src.world.MapLoader")
|
||||
local MapOverview = require("src.world.MapOverview")
|
||||
local Party = require("src.pokemon.Party")
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
|
||||
@@ -15,59 +15,6 @@ local WorldAPI = {}
|
||||
WorldAPI.__index = WorldAPI
|
||||
|
||||
local NO_OVERWORLD = "no overworld"
|
||||
local overviewShades = {}
|
||||
|
||||
Assets.register(function() overviewShades = {} end)
|
||||
|
||||
local function shadeDigit(sum, pixelCount)
|
||||
return tostring(math.max(0, math.min(3,
|
||||
math.floor((1 - sum / pixelCount) * 3 + 0.5))))
|
||||
end
|
||||
|
||||
local function mapTileRows(map)
|
||||
local tileset = map.tileset
|
||||
if not (tileset and tileset.image and tileset.tilesPerRow) then return nil end
|
||||
local cached = overviewShades[tileset.image]
|
||||
if not cached then
|
||||
local ok, pixels = pcall(Assets.imageData, tileset.image)
|
||||
if not ok then return nil end
|
||||
cached = { pixels = pixels, shades = {} }
|
||||
overviewShades[tileset.image] = cached
|
||||
end
|
||||
local rows, detailRows, perRow = {}, {}, tileset.tilesPerRow
|
||||
for ty = 0, map.heightCells * 2 - 1 do
|
||||
local row, detailTop, detailBottom = {}, {}, {}
|
||||
for tx = 0, map.widthCells * 2 - 1 do
|
||||
local tile = map:tileAt(tx, ty)
|
||||
local shades = cached.shades[tile]
|
||||
if shades == nil then
|
||||
local sums = { 0, 0, 0, 0 }
|
||||
local ox, oy = (tile % perRow) * 8, math.floor(tile / perRow) * 8
|
||||
for py = 0, 7 do
|
||||
for px = 0, 7 do
|
||||
local r, g, b = cached.pixels:getPixel(ox + px, oy + py)
|
||||
local quadrant = math.floor(py / 4) * 2 + math.floor(px / 4) + 1
|
||||
sums[quadrant] = sums[quadrant]
|
||||
+ r * 0.2126 + g * 0.7152 + b * 0.0722
|
||||
end
|
||||
end
|
||||
shades = {
|
||||
shadeDigit(sums[1] + sums[2] + sums[3] + sums[4], 64),
|
||||
shadeDigit(sums[1], 16), shadeDigit(sums[2], 16),
|
||||
shadeDigit(sums[3], 16), shadeDigit(sums[4], 16),
|
||||
}
|
||||
cached.shades[tile] = shades
|
||||
end
|
||||
row[#row + 1] = shades[1]
|
||||
detailTop[#detailTop + 1] = shades[2] .. shades[3]
|
||||
detailBottom[#detailBottom + 1] = shades[4] .. shades[5]
|
||||
end
|
||||
rows[#rows + 1] = table.concat(row)
|
||||
detailRows[#detailRows + 1] = table.concat(detailTop)
|
||||
detailRows[#detailRows + 1] = table.concat(detailBottom)
|
||||
end
|
||||
return rows, detailRows
|
||||
end
|
||||
|
||||
local function acceptsMenuInput(game, ow)
|
||||
local stack = game and game.stack
|
||||
@@ -147,16 +94,7 @@ end
|
||||
function WorldAPI:mapOverview()
|
||||
local ow = self:overworld()
|
||||
if not ow or not ow.map then return nil, NO_OVERWORLD end
|
||||
local map, rows, markers = ow.map, {}, {}
|
||||
for y = 0, map.heightCells - 1 do
|
||||
local row = {}
|
||||
for x = 0, map.widthCells - 1 do
|
||||
row[#row + 1] = map:isWarpTileCell(x, y) and "+"
|
||||
or map:isWaterCell(x, y) and "~"
|
||||
or map:isWalkableCell(x, y) and "." or " "
|
||||
end
|
||||
rows[#rows + 1] = table.concat(row)
|
||||
end
|
||||
local map, markers = ow.map, {}
|
||||
local def = map.def or {}
|
||||
for _, warp in ipairs(def.warps or {}) do
|
||||
markers[#markers + 1] = { kind = "warp", x = warp.x, y = warp.y }
|
||||
@@ -175,15 +113,7 @@ function WorldAPI:mapOverview()
|
||||
markers[#markers + 1] = { kind = "hidden", x = item.x, y = item.y }
|
||||
end
|
||||
end
|
||||
local tileRows, tileDetailRows = mapTileRows(map)
|
||||
return { mapId = map.id, width = map.widthCells,
|
||||
height = map.heightCells, rows = rows, markers = markers,
|
||||
tileRows = tileRows,
|
||||
tileWidth = tileRows and map.widthCells * 2,
|
||||
tileHeight = tileRows and map.heightCells * 2,
|
||||
tileDetailRows = tileDetailRows,
|
||||
tileDetailWidth = tileDetailRows and map.widthCells * 4,
|
||||
tileDetailHeight = tileDetailRows and map.heightCells * 4 }
|
||||
return MapOverview.build(map, markers)
|
||||
end
|
||||
|
||||
-- opts.arrive = "fly" | "teleport" picks the arrival FX; anything else
|
||||
|
||||
@@ -25,6 +25,8 @@
|
||||
local Logger = require("src.core.Logger")
|
||||
local Movement = require("src.script.gen2.Movement")
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
local HiddenItems = require("src.world.gen2.HiddenItems")
|
||||
local MapOverview = require("src.world.MapOverview")
|
||||
|
||||
local WorldAPI = {}
|
||||
WorldAPI.__index = WorldAPI
|
||||
@@ -50,6 +52,31 @@ function WorldAPI:current()
|
||||
facing = p and p.facing }
|
||||
end
|
||||
|
||||
-- The same read-only minimap contract as Gen 1, with Gold's object/event
|
||||
-- visibility rules supplying the semantic markers.
|
||||
function WorldAPI:mapOverview()
|
||||
local world = self:overworld()
|
||||
if not world or not world.map then return nil, NO_OVERWORLD end
|
||||
local map, def, markers = world.map, world.map.def or {}, {}
|
||||
for _, warp in ipairs(def.warps or {}) do
|
||||
markers[#markers + 1] = { kind = "warp", x = warp.x, y = warp.y }
|
||||
end
|
||||
local visible = {}
|
||||
for _, npc in ipairs(world.npcs or {}) do
|
||||
if npc.def then visible[npc.def] = true end
|
||||
end
|
||||
for _, obj in ipairs(def.objects or {}) do
|
||||
local item = obj.itemball and obj.itemball.item
|
||||
if item and item ~= "0" and item ~= 0 and visible[obj] then
|
||||
markers[#markers + 1] = { kind = "item", x = obj.x, y = obj.y }
|
||||
end
|
||||
end
|
||||
for _, item in ipairs(HiddenItems.unfound(def, world.events)) do
|
||||
markers[#markers + 1] = { kind = "hidden", x = item.x, y = item.y }
|
||||
end
|
||||
return MapOverview.build(map, markers)
|
||||
end
|
||||
|
||||
-- opts is accepted for signature parity with the Gen 1 arm; Gold's arrival FX
|
||||
-- come from the map setup method, so opts.arrive has nothing to select yet.
|
||||
function WorldAPI:warpTo(mapId, x, y, facing, opts)
|
||||
|
||||
@@ -191,12 +191,12 @@ return function(game)
|
||||
return cond()
|
||||
end
|
||||
|
||||
-- Jump the flash clock to just under FLASH_FRAMES (220) once the shot is
|
||||
-- Jump the flash clock to just under FLASH_FRAMES (368) once the shot is
|
||||
-- taken. EvolutionState:update only compares self.t against that constant,
|
||||
-- so this ends the animation early without touching the palette logic.
|
||||
local function skipToEnd()
|
||||
local st = evoTop()
|
||||
if st then st.t = 214 end
|
||||
if st then st.t = 362 end
|
||||
end
|
||||
|
||||
local function startEvo(species, into)
|
||||
@@ -212,7 +212,7 @@ return function(game)
|
||||
|
||||
-- ---- case 1: the reported case, SGB, mid-flash --------------------------
|
||||
local weedle = startEvo("WEEDLE", "KAKUNA")
|
||||
U.wait(24) -- into the flash, far short of FLASH_FRAMES = 220
|
||||
U.wait(24) -- into the flash, far short of FLASH_FRAMES = 368
|
||||
U.shot(game, DIR .. "/evo279_1_flash_sgb.png")
|
||||
local c1 = probe("flash/SGB", {
|
||||
monYellow1 = YELLOW[2], monYellow2 = YELLOW[3],
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
-- LINK_STATE_TRADING) skip that poll and cannot be cancelled.
|
||||
--
|
||||
-- Case 1 (level path, cancelable): open EvolutionState directly, wait past
|
||||
-- the 80-frame pre-animLoop delay (still well under FLASH_FRAMES=220),
|
||||
-- the 80-frame pre-animLoop delay (still well under FLASH_FRAMES=368),
|
||||
-- press B, and assert the mon stays CATERPIE with the "stopped evolving"
|
||||
-- text on screen.
|
||||
-- Case 1b: after cancel, checkParty with no level-ups must not re-offer;
|
||||
@@ -81,7 +81,7 @@ return function(game)
|
||||
Evolution.evolve(game, mon, "METAPOD", function() done1 = true end)
|
||||
|
||||
if not waitFor(evoTop, 300) then error("EvolutionState never opened (case1)") end
|
||||
U.wait(100) -- past the 80-frame pre-animLoop delay, still under 220
|
||||
U.wait(100) -- past the 80-frame pre-animLoop delay, still under 368
|
||||
U.log("case1 flash", "t=", top().t, "species=", mon.species)
|
||||
U.shot(game, DIR .. "/evo213_1_evolving.png")
|
||||
|
||||
@@ -133,8 +133,8 @@ return function(game)
|
||||
local done2 = false
|
||||
Evolution.evolve(game, mon2, "METAPOD", function() done2 = true end)
|
||||
if not waitFor(evoTop, 300) then error("EvolutionState never opened (case2)") end
|
||||
-- let the full flash run (FLASH_FRAMES=220) without pressing B
|
||||
waitFor(function() return not evoTop() end, 400)
|
||||
-- let the full flash run (FLASH_FRAMES=368) without pressing B
|
||||
waitFor(function() return not evoTop() end, 500)
|
||||
if not waitFor(function() return findText("evolved into") ~= nil end, 120) then
|
||||
error("Congratulations text not shown (case2)")
|
||||
end
|
||||
|
||||
@@ -89,8 +89,8 @@ return function(game)
|
||||
Evolution.evolve(game, mon, "GYARADOS", function() done1 = true end)
|
||||
|
||||
if not waitFor(evoTop, 300) then error("EvolutionState never opened (case1)") end
|
||||
-- let the full flash run (FLASH_FRAMES=220) with no input, then apply
|
||||
waitFor(function() return not evoTop() end, 400)
|
||||
-- let the full flash run (FLASH_FRAMES=368) with no input, then apply
|
||||
waitFor(function() return not evoTop() end, 500)
|
||||
if not waitFor(function() return findText("evolved into") ~= nil end, 120) then
|
||||
error("Congratulations text not shown (case1)")
|
||||
end
|
||||
@@ -124,7 +124,7 @@ return function(game)
|
||||
local done2 = false
|
||||
Evolution.evolve(game, mon2, "GYARADOS", function() done2 = true end)
|
||||
if not waitFor(evoTop, 300) then error("EvolutionState never opened (case2)") end
|
||||
waitFor(function() return not evoTop() end, 400)
|
||||
waitFor(function() return not evoTop() end, 500)
|
||||
if not waitFor(function() return findText("evolved into") ~= nil end, 120) then
|
||||
error("Congratulations text not shown (case2)")
|
||||
end
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
-- Driver: a trainer who walked up to the player must be back on her spawn
|
||||
-- cell the next time her map loads, connection crossings included (#1028).
|
||||
-- .loadNewMap calls LoadMapHeader for a seam crossing exactly as a warp does,
|
||||
-- and .loadSpriteData zeroes the sprite state data and re-seeds every
|
||||
-- SPRITESTATEDATA2_MAPY/MAPX from the map header's object data
|
||||
-- (pokered home/overworld.asm), so only the save-side defeat flag survives.
|
||||
--
|
||||
-- Route 3's Lass (data/maps/objects/Route3.asm object_event 23, 4,
|
||||
-- SPRITE_COOLTRAINER_F, STAY, LEFT, ..., OPP_LASS) has sight range 4, so
|
||||
-- standing above the third Bug Catcher at (19,4) is inside her line and she
|
||||
-- walks dist-1 = 3 cells west to (20,4) -- the reporter's exact setup.
|
||||
--
|
||||
-- POKEPORT_DRIVER=tests/drivers/trainer_reset_bug1028_test.lua \
|
||||
-- POKEPORT_IDENTITY=bug1028 POKEPORT_TOUCH=0 POKEPORT_VERSION=red love .
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
local Zoom = require("src.render.Zoom")
|
||||
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
|
||||
|
||||
local MAP = "ROUTE_3"
|
||||
local TARGET = 6
|
||||
local SPAWN = { x = 23, y = 4 }
|
||||
local STAND = { x = 19, y = 4 }
|
||||
|
||||
local pass = true
|
||||
local function check(label, ok)
|
||||
if not ok then pass = false end
|
||||
U.log(ok and "PASS" or "FAIL", label)
|
||||
return ok
|
||||
end
|
||||
|
||||
local function lass()
|
||||
local ow = game.overworld
|
||||
for _, n in ipairs(ow and ow.npcs or {}) do
|
||||
if n.def and n.def.index == TARGET then return n end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
local function at(n) return n and (n.cellX .. "," .. n.cellY) or "absent" end
|
||||
|
||||
-- hold a direction until cond() or the budget runs out, then let the
|
||||
-- half-finished step land
|
||||
local function holdUntil(btn, cond, budget)
|
||||
local first = true
|
||||
for _ = 1, budget or 900 do
|
||||
if cond() then break end
|
||||
if first then table.insert(game.input.pressQueue, btn); first = false end
|
||||
game.input.state[btn] = true
|
||||
coroutine.yield()
|
||||
end
|
||||
game.input.state[btn] = false
|
||||
for _ = 1, 40 do
|
||||
local ow = game.overworld
|
||||
if ow and not ow.player.moving then break end
|
||||
coroutine.yield()
|
||||
end
|
||||
U.wait(4)
|
||||
return cond()
|
||||
end
|
||||
|
||||
local function walkTo(btn, axis, want, budget)
|
||||
return holdUntil(btn, function()
|
||||
local p = game.overworld and game.overworld.player
|
||||
return p and p[axis] == want and not p.moving
|
||||
end, budget)
|
||||
end
|
||||
|
||||
local function onMap(id)
|
||||
return game.overworld and game.overworld.map and game.overworld.map.id == id
|
||||
end
|
||||
|
||||
-- mash A until the battle stack unwinds back to the overworld
|
||||
local function mashToOverworld(budget)
|
||||
for _ = 1, budget or 4000 do
|
||||
if game.stack:top() == game.overworld and not game.overworld.engaging then
|
||||
return true
|
||||
end
|
||||
U.tap(game, "a")
|
||||
U.wait(3)
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
-- absolute zoom: reset first so every shot frames the same amount of world
|
||||
local function survey(n)
|
||||
Zoom.reset()
|
||||
U.wait(2)
|
||||
for _ = 1, n do game:zoomStep(-1) end
|
||||
U.wait(30)
|
||||
end
|
||||
|
||||
-- back up to the sighting cell so the before/after shots share a camera
|
||||
local function returnToStand()
|
||||
walkTo("right", "cellX", 11)
|
||||
walkTo("up", "cellY", 4)
|
||||
walkTo("right", "cellX", STAND.x)
|
||||
end
|
||||
|
||||
game.save.party = {
|
||||
Pokemon.new(game.data, "CHARIZARD", 80),
|
||||
Pokemon.new(game.data, "SNORLAX", 80),
|
||||
}
|
||||
game.save.player.name = "MATT"
|
||||
|
||||
U.teleport(game, MAP, STAND.x, STAND.y, "right")
|
||||
U.wait(20)
|
||||
|
||||
-- only the target Lass may engage: the reporter has already cleared the
|
||||
-- Bug Catchers and Youngsters ahead of her
|
||||
local ow = game.overworld
|
||||
for _, n in ipairs(ow.npcs) do
|
||||
if n.def.trainerClass and n.def.index ~= TARGET then
|
||||
game.save.defeatedTrainers[n.id] = true
|
||||
end
|
||||
end
|
||||
|
||||
local l = lass()
|
||||
check("Lass starts on her spawn cell " .. SPAWN.x .. "," .. SPAWN.y,
|
||||
l ~= nil and l.cellX == SPAWN.x and l.cellY == SPAWN.y)
|
||||
U.log("spawn:", at(l))
|
||||
|
||||
-- she sights the player and walks up, then the battle runs
|
||||
for _ = 1, 600 do
|
||||
if ow.engaging then break end
|
||||
coroutine.yield()
|
||||
end
|
||||
check("the Lass sighted the player at " .. STAND.x .. "," .. STAND.y, ow.engaging == true)
|
||||
check("the battle ran to completion", mashToOverworld())
|
||||
U.wait(30)
|
||||
|
||||
l = lass()
|
||||
local movedX, movedY = l and l.cellX, l and l.cellY
|
||||
U.log("after the walk-up she stands at", at(l))
|
||||
check("she is off her spawn cell after walking up",
|
||||
l ~= nil and not (movedX == SPAWN.x and movedY == SPAWN.y))
|
||||
check("she is recorded as defeated", game.save.defeatedTrainers[l.id] == true)
|
||||
|
||||
survey(4)
|
||||
U.shot(game, DIR .. "/bug1028_1_walked_up.png")
|
||||
Zoom.reset()
|
||||
U.wait(5)
|
||||
|
||||
-- west along row 4, down the single gap at column 11, then west on row 9
|
||||
-- to the Pewter City seam
|
||||
walkTo("left", "cellX", 11)
|
||||
walkTo("down", "cellY", 9)
|
||||
check("reached the descent column", game.overworld.player.cellX == 11
|
||||
and game.overworld.player.cellY == 9)
|
||||
|
||||
-- crossing x=0 westward is the seam: crossConnection, not a warp
|
||||
holdUntil("left", function() return onMap("PEWTER_CITY") end, 1200)
|
||||
check("crossed the seam into Pewter City", onMap("PEWTER_CITY"))
|
||||
|
||||
survey(12)
|
||||
U.shot(game, DIR .. "/bug1028_2_pewter_survey.png")
|
||||
Zoom.reset()
|
||||
U.wait(5)
|
||||
|
||||
-- and back east into Route 3, the map load that must re-seed her
|
||||
holdUntil("right", function() return onMap(MAP) end, 1200)
|
||||
check("crossed back into Route 3", onMap(MAP))
|
||||
U.wait(30)
|
||||
|
||||
l = lass()
|
||||
U.log("after the return crossing she stands at", at(l))
|
||||
check("she is back on her spawn cell " .. SPAWN.x .. "," .. SPAWN.y,
|
||||
l ~= nil and l.cellX == SPAWN.x and l.cellY == SPAWN.y)
|
||||
check("she is still recorded as defeated",
|
||||
l ~= nil and game.save.defeatedTrainers[l.id] == true)
|
||||
|
||||
-- same cell, same zoom as shot 1: she should now be three cells further
|
||||
-- east, and being defeated she must not re-engage on the way back in
|
||||
returnToStand()
|
||||
check("walked back to the sighting cell",
|
||||
game.overworld.player.cellX == STAND.x
|
||||
and game.overworld.player.cellY == STAND.y)
|
||||
check("a defeated trainer does not re-engage", game.overworld.engaging ~= true)
|
||||
|
||||
survey(4)
|
||||
U.shot(game, DIR .. "/bug1028_3_reset.png")
|
||||
|
||||
Zoom.reset()
|
||||
U.log(pass and "RESULT PASS" or "RESULT FAIL")
|
||||
love.event.quit(pass and 0 or 1)
|
||||
while true do coroutine.yield() end
|
||||
end
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user