mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-17 11:11:10 +02:00
Compare commits
41 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 78e8a31ead | |||
| b6388013ec | |||
| ba7cd8fabf | |||
| 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
|
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:
|
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"') }}
|
runs-on: ${{ fromJSON(github.repository == 'bryanthaboi/gen1recomp' && '["self-hosted", "macOS"]' || '"macos-latest"') }}
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
@@ -269,6 +301,12 @@ jobs:
|
|||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
fetch-tags: true
|
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
|
- name: Import signing certificate into a temporary keychain
|
||||||
if: github.repository == 'bryanthaboi/gen1recomp'
|
if: github.repository == 'bryanthaboi/gen1recomp'
|
||||||
run: |
|
run: |
|
||||||
@@ -307,13 +345,21 @@ jobs:
|
|||||||
security find-identity -v -p codesigning "$KEYCHAIN_PATH"
|
security find-identity -v -p codesigning "$KEYCHAIN_PATH"
|
||||||
|
|
||||||
- name: Build macOS + Windows + Linux
|
- name: Build macOS + Windows + Linux
|
||||||
|
env:
|
||||||
|
GEN1TLS_DLL: ${{ github.workspace }}/dist/native/win-x64/gen1tls.dll
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
# Sign in-build (identity auto-detected from the temp keychain);
|
# Sign in-build (identity auto-detected from the temp keychain);
|
||||||
# notarize separately below so it uses secret credentials, not a
|
# notarize separately below so it uses secret credentials, not a
|
||||||
# login-keychain profile. "all" also builds the Linux AppImage,
|
# login-keychain profile. "all" also builds the Linux AppImage,
|
||||||
# which needs no signing/notarization.
|
# 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
|
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
|
- name: Build Android
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
+5
-1
@@ -3,8 +3,9 @@
|
|||||||
data/generated/
|
data/generated/
|
||||||
assets/generated/
|
assets/generated/
|
||||||
|
|
||||||
# LÖVE packages
|
# LÖVE packages & archives
|
||||||
*.love
|
*.love
|
||||||
|
*.zip
|
||||||
|
|
||||||
# Local saves (LÖVE writes to its save dir, but keep the repo clean anyway)
|
# Local saves (LÖVE writes to its save dir, but keep the repo clean anyway)
|
||||||
save/
|
save/
|
||||||
@@ -75,3 +76,6 @@ mobile/ios/bundle_id.local
|
|||||||
/dist/native/
|
/dist/native/
|
||||||
/dist/win/
|
/dist/win/
|
||||||
/.bazinga/
|
/.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
|
line, for every reach a static scan could not follow. Neither substitutes for a
|
||||||
real Gold boot.
|
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,
|
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
|
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
|
merge — only by tooling and the manager's detail pane — so an absent or
|
||||||
malformed card can never break a load.
|
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
|
distributed mod never carries ROM-derived bytes, not even in its preview
|
||||||
images.
|
images.
|
||||||
|
|
||||||
### 6. Tags
|
### 7. Tags
|
||||||
|
|
||||||
Lowercase kebab strings, open vocabulary. The showcase generator
|
Lowercase kebab strings, open vocabulary. The showcase generator
|
||||||
lowercases and de-dupes. A recommended starting set: `beginner`,
|
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
|
{ "set_flag", "EVENT_GOT_EEVEE" }, -- 7
|
||||||
{ "hide_object", "CELADON_MANSION_ROOF_HOUSE",
|
{ "hide_object", "CELADON_MANSION_ROOF_HOUSE",
|
||||||
"CELADONMANSION_ROOF_HOUSE_EEVEE_POKEBALL" }, -- 8
|
"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
|
{ "show_text", "_GotMonText", { RAM = "EEVEE" } }, -- 10
|
||||||
{ "jump", 13 }, -- 11
|
{ "jump", 13 }, -- 11
|
||||||
{ "show_text", "_BoxIsFullText" }, -- 12
|
{ "show_text", "_BoxIsFullText" }, -- 12
|
||||||
|
|||||||
@@ -10,10 +10,10 @@
|
|||||||
local function coinGiver(opts)
|
local function coinGiver(opts)
|
||||||
return function(game, ow, npc, done)
|
return function(game, ow, npc, done)
|
||||||
local TextBox = require("src.render.TextBox")
|
local TextBox = require("src.render.TextBox")
|
||||||
local Sound = require("src.core.Sound")
|
|
||||||
local t = game.data.text
|
local t = game.data.text
|
||||||
local function push(label, fallback, onDone)
|
local function push(label, fallback, onDone, popts)
|
||||||
game.stack:push(TextBox.new(game, t[label] or fallback, onDone or done))
|
game.stack:push(TextBox.new(game, t[label] or fallback, onDone or done,
|
||||||
|
popts))
|
||||||
end
|
end
|
||||||
if game.save.flags[opts.event] then
|
if game.save.flags[opts.event] then
|
||||||
push(opts.alreadyGotLabel, opts.alreadyGotFallback)
|
push(opts.alreadyGotLabel, opts.alreadyGotFallback)
|
||||||
@@ -30,9 +30,10 @@ local function coinGiver(opts)
|
|||||||
end
|
end
|
||||||
game.save.coins = math.min(9999, (game.save.coins or 0) + opts.amount)
|
game.save.coins = math.min(9999, (game.save.coins or 0) + opts.amount)
|
||||||
game.save.flags[opts.event] = true
|
game.save.flags[opts.event] = true
|
||||||
Sound.play(game.data, "Get_Item1")
|
-- the ReceivedNCoinsText strings carry sound_get_item_1
|
||||||
push(opts.receivedLabel,
|
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
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -22,8 +22,8 @@ return {
|
|||||||
local TextBox = require("src.render.TextBox")
|
local TextBox = require("src.render.TextBox")
|
||||||
local Commands = require("src.script.Commands")
|
local Commands = require("src.script.Commands")
|
||||||
local t = game.data.text
|
local t = game.data.text
|
||||||
local function say(label, cb)
|
local function say(label, cb, sopts)
|
||||||
game.stack:push(TextBox.new(game, t[label] or label, cb))
|
game.stack:push(TextBox.new(game, t[label] or label, cb, sopts))
|
||||||
end
|
end
|
||||||
|
|
||||||
if game.save.flags.EVENT_GOT_OLD_AMBER then
|
if game.save.flags.EVENT_GOT_OLD_AMBER then
|
||||||
@@ -39,8 +39,9 @@ return {
|
|||||||
game.save.flags.EVENT_GOT_OLD_AMBER = true
|
game.save.flags.EVENT_GOT_OLD_AMBER = true
|
||||||
Commands.hide_object({ save = game.save, overworld = ow, game = game },
|
Commands.hide_object({ save = game.save, overworld = ow, game = game },
|
||||||
"MUSEUM_1F", "MUSEUM1F_OLD_AMBER")
|
"MUSEUM_1F", "MUSEUM1F_OLD_AMBER")
|
||||||
require("src.core.Sound").play(game.data, "Get_Item1")
|
-- .ReceivedOldAmberText carries sound_get_item_1
|
||||||
say("_Museum1FScientist2ReceivedOldAmberText", done)
|
say("_Museum1FScientist2ReceivedOldAmberText", done,
|
||||||
|
TextBox.soundOpts(game, "Get_Item1"))
|
||||||
end)
|
end)
|
||||||
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
|
-- inside give_pokemon). Show the received text first so the
|
||||||
-- nickname prompt follows "you got X", matching Gen1.
|
-- nickname prompt follows "you got X", matching Gen1.
|
||||||
-- The received text carries sound_get_key_item (OaksLab.asm
|
-- The received text carries sound_get_key_item (OaksLab.asm
|
||||||
-- OaksLabReceivedMonText); the jingle plays as the box opens
|
-- OaksLabReceivedMonText), so the jingle fires once the box has
|
||||||
-- (same beat as the Yellow port's starter, #668).
|
-- typed out and holds it (same beat as the Yellow port's starter, #668).
|
||||||
{ "play_sound", "Get_Key_Item" }, -- 8
|
{ "text_sound", "Get_Key_Item" }, -- 8
|
||||||
{ "show_text", "_OaksLabReceivedMonText", { RAM = species } }, -- 9
|
{ "show_text", "_OaksLabReceivedMonText", { RAM = species } }, -- 9
|
||||||
{ "give_pokemon", species, 5 }, -- 10
|
{ "give_pokemon", species, 5 }, -- 10
|
||||||
{ "set_flag", "EVENT_GOT_STARTER" }, -- 11
|
{ "set_flag", "EVENT_GOT_STARTER" }, -- 11
|
||||||
@@ -54,7 +54,7 @@ local function starterBall(askText, species, choseFlag, ownBall,
|
|||||||
{ "face_object", 1, "up" }, -- 15
|
{ "face_object", 1, "up" }, -- 15
|
||||||
{ "show_text", "_OaksLabRivalIllTakeThisOneText" }, -- 16
|
{ "show_text", "_OaksLabRivalIllTakeThisOneText" }, -- 16
|
||||||
{ "hide_object", "OAKS_LAB", rivalBall }, -- 17
|
{ "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",
|
{ "show_text", "_OaksLabRivalReceivedMonText",
|
||||||
{ RAM = rivalBall == "OAKSLAB_CHARMANDER_POKE_BALL" and "CHARMANDER"
|
{ RAM = rivalBall == "OAKSLAB_CHARMANDER_POKE_BALL" and "CHARMANDER"
|
||||||
or rivalBall == "OAKSLAB_SQUIRTLE_POKE_BALL" and "SQUIRTLE"
|
or rivalBall == "OAKSLAB_SQUIRTLE_POKE_BALL" and "SQUIRTLE"
|
||||||
@@ -106,8 +106,8 @@ return {
|
|||||||
{ "check_item", "OAKS_PARCEL" },
|
{ "check_item", "OAKS_PARCEL" },
|
||||||
{ "jump_if_false", "raise_young" },
|
{ "jump_if_false", "raise_young" },
|
||||||
-- OaksLabOak1Text.got_parcel → RivalArrives + OakGivesPokedex
|
-- OaksLabOak1Text.got_parcel → RivalArrives + OakGivesPokedex
|
||||||
|
{ "text_sound", "Get_Key_Item" },
|
||||||
{ "show_text", "_OaksLabOak1DeliverParcelText" },
|
{ "show_text", "_OaksLabOak1DeliverParcelText" },
|
||||||
{ "play_sound", "Get_Key_Item" },
|
|
||||||
{ "show_text", "_OaksLabOak1ParcelThanksText" },
|
{ "show_text", "_OaksLabOak1ParcelThanksText" },
|
||||||
{ "take_item", "OAKS_PARCEL", 1 },
|
{ "take_item", "OAKS_PARCEL", 1 },
|
||||||
{ "stop_music" },
|
{ "stop_music" },
|
||||||
@@ -128,8 +128,8 @@ return {
|
|||||||
{ "face_object", 1, "up" },
|
{ "face_object", 1, "up" },
|
||||||
{ "face_object", 5, "down" },
|
{ "face_object", 5, "down" },
|
||||||
{ "show_text", "_OaksLabOakMyInventionPokedexText" },
|
{ "show_text", "_OaksLabOakMyInventionPokedexText" },
|
||||||
|
{ "text_sound", "Get_Key_Item" },
|
||||||
{ "show_text", "_OaksLabOakGotPokedexText" },
|
{ "show_text", "_OaksLabOakGotPokedexText" },
|
||||||
{ "play_sound", "Get_Key_Item" },
|
|
||||||
{ "hide_object", "OAKS_LAB", "OAKSLAB_POKEDEX1" },
|
{ "hide_object", "OAKS_LAB", "OAKSLAB_POKEDEX1" },
|
||||||
{ "hide_object", "OAKS_LAB", "OAKSLAB_POKEDEX2" },
|
{ "hide_object", "OAKS_LAB", "OAKSLAB_POKEDEX2" },
|
||||||
{ "face_object", 1, "up" },
|
{ "face_object", 1, "up" },
|
||||||
@@ -246,9 +246,17 @@ return {
|
|||||||
-- the table sprites; re-entering the lab applies the same HideObject
|
-- the table sprites; re-entering the lab applies the same HideObject
|
||||||
-- the gift script now does (OaksLab.asm OakGivesPokedex).
|
-- the gift script now does (OaksLab.asm OakGivesPokedex).
|
||||||
onEnter = function(game, ow)
|
onEnter = function(game, ow)
|
||||||
if not (game.save.flags and game.save.flags.EVENT_GOT_POKEDEX) then
|
local flags = game.save.flags or {}
|
||||||
return
|
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
|
end
|
||||||
|
if not flags.EVENT_GOT_POKEDEX then return end
|
||||||
local Commands = require("src.script.Commands")
|
local Commands = require("src.script.Commands")
|
||||||
local ctx = { save = game.save, game = game, overworld = ow }
|
local ctx = { save = game.save, game = game, overworld = ow }
|
||||||
Commands.hide_object(ctx, "OAKS_LAB", "OAKSLAB_POKEDEX1")
|
Commands.hide_object(ctx, "OAKS_LAB", "OAKSLAB_POKEDEX1")
|
||||||
@@ -286,6 +294,7 @@ return {
|
|||||||
-- fanfare for the taunt/challenge exchange, same as the Yellow port
|
-- fanfare for the taunt/challenge exchange, same as the Yellow port
|
||||||
-- (oaks_lab_yellow.lua); it was silently dropped here (#596).
|
-- (oaks_lab_yellow.lua); it was silently dropped here (#596).
|
||||||
local rows = {
|
local rows = {
|
||||||
|
{ "face_player_dir", "up" },
|
||||||
{ "stop_music" },
|
{ "stop_music" },
|
||||||
{ "play_music", "Music_MeetRival" },
|
{ "play_music", "Music_MeetRival" },
|
||||||
{ "show_text", "_OaksLabRivalIllTakeYouOnText" }, -- 1
|
{ "show_text", "_OaksLabRivalIllTakeYouOnText" }, -- 1
|
||||||
@@ -309,28 +318,19 @@ return {
|
|||||||
local base = #rows
|
local base = #rows
|
||||||
local party = flags.EVENT_CHOSE_BULBASAUR and 3
|
local party = flags.EVENT_CHOSE_BULBASAUR and 3
|
||||||
or flags.EVENT_CHOSE_SQUIRTLE and 2 or 1
|
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 })
|
table.insert(rows, { "start_battle", "trainer", "OPP_RIVAL1", party })
|
||||||
-- OaksLabRivalEndBattleScript: heal + flag on win or loss; no blackout
|
-- OaksLabRivalEndBattleScript: heal + flag on win or loss; no blackout
|
||||||
table.insert(rows, { "heal_party" })
|
table.insert(rows, { "heal_party" })
|
||||||
table.insert(rows, { "set_flag", "EVENT_BATTLED_RIVAL_IN_OAKS_LAB" })
|
table.insert(rows, { "set_flag", "EVENT_BATTLED_RIVAL_IN_OAKS_LAB" })
|
||||||
-- 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, { "jump_if_false", base + 6 })
|
||||||
table.insert(rows, { "show_text", "_OaksLabRivalIPickedTheWrongPokemonText" })
|
|
||||||
table.insert(rows, { "show_text", "_OaksLabRivalSmellYouLaterText" })
|
table.insert(rows, { "show_text", "_OaksLabRivalSmellYouLaterText" })
|
||||||
-- OaksLabRivalStartsExitScript: parting shot, rival exit fanfare, then
|
-- OaksLabRivalStartsExitScript: parting shot, rival exit fanfare, then
|
||||||
-- walk out past the player. The fanfare was dropped here (#683) -- the
|
-- walk out past the player. The fanfare was dropped here (#683) -- the
|
||||||
-- parcel scene above already plays Music_MeetRival on both arrival and
|
-- parcel scene above already plays Music_MeetRival on both arrival and
|
||||||
-- departure (lines 144-146), and this exit should match (#596).
|
-- departure (lines 144-146), and this exit should match (#596).
|
||||||
table.insert(rows, { "stop_music" })
|
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, { "move_npc_to", 1, 4, 11 })
|
||||||
table.insert(rows, { "hide_object", "OAKS_LAB", "OAKSLAB_RIVAL" })
|
table.insert(rows, { "hide_object", "OAKS_LAB", "OAKSLAB_RIVAL" })
|
||||||
table.insert(rows, { "play_music", "Music_OaksLab" })
|
table.insert(rows, { "play_music", "Music_OaksLab" })
|
||||||
|
|||||||
@@ -38,8 +38,8 @@ return {
|
|||||||
{ "jump_if_false", "raise_young" },
|
{ "jump_if_false", "raise_young" },
|
||||||
-- .DeliverParcelText: parcel handover, then the Pokédex scene
|
-- .DeliverParcelText: parcel handover, then the Pokédex scene
|
||||||
-- (OaksLabRivalArrivesAtOaksRequestScript -> OakGivesPokedexScript)
|
-- (OaksLabRivalArrivesAtOaksRequestScript -> OakGivesPokedexScript)
|
||||||
|
{ "text_sound", "Get_Key_Item" },
|
||||||
{ "show_text", "_OaksLabOak1DeliverParcelText" },
|
{ "show_text", "_OaksLabOak1DeliverParcelText" },
|
||||||
{ "play_sound", "Get_Key_Item" },
|
|
||||||
{ "show_text", "_OaksLabOak1ParcelThanksText" },
|
{ "show_text", "_OaksLabOak1ParcelThanksText" },
|
||||||
{ "take_item", "OAKS_PARCEL", 1 },
|
{ "take_item", "OAKS_PARCEL", 1 },
|
||||||
{ "stop_music" },
|
{ "stop_music" },
|
||||||
@@ -62,8 +62,8 @@ return {
|
|||||||
{ "face_object", RIVAL, "up" },
|
{ "face_object", RIVAL, "up" },
|
||||||
{ "face_object", OAK1, "down" },
|
{ "face_object", OAK1, "down" },
|
||||||
{ "show_text", "_OaksLabOakMyInventionPokedexText" },
|
{ "show_text", "_OaksLabOakMyInventionPokedexText" },
|
||||||
|
{ "text_sound", "Get_Key_Item" },
|
||||||
{ "show_text", "_OaksLabOakGotPokedexText" },
|
{ "show_text", "_OaksLabOakGotPokedexText" },
|
||||||
{ "play_sound", "Get_Key_Item" },
|
|
||||||
{ "hide_object", "OAKS_LAB", "OAKSLAB_POKEDEX1" },
|
{ "hide_object", "OAKS_LAB", "OAKSLAB_POKEDEX1" },
|
||||||
{ "hide_object", "OAKS_LAB", "OAKSLAB_POKEDEX2" },
|
{ "hide_object", "OAKS_LAB", "OAKSLAB_POKEDEX2" },
|
||||||
{ "face_object", RIVAL, "up" },
|
{ "face_object", RIVAL, "up" },
|
||||||
@@ -113,8 +113,8 @@ return {
|
|||||||
{ "jump_if_true", "come_see" },
|
{ "jump_if_true", "come_see" },
|
||||||
{ "set_flag", "EVENT_GOT_POKEBALLS_FROM_OAK" },
|
{ "set_flag", "EVENT_GOT_POKEBALLS_FROM_OAK" },
|
||||||
{ "give_item", "POKE_BALL", 5, false },
|
{ "give_item", "POKE_BALL", 5, false },
|
||||||
|
{ "text_sound", "Get_Key_Item" },
|
||||||
{ "show_text", "_OaksLabOak1ReceivedPokeballsText" },
|
{ "show_text", "_OaksLabOak1ReceivedPokeballsText" },
|
||||||
{ "play_sound", "Get_Key_Item" },
|
|
||||||
{ "show_text", "_OaksLabGivePokeballsExplanationText" },
|
{ "show_text", "_OaksLabGivePokeballsExplanationText" },
|
||||||
{ "jump", "end" },
|
{ "jump", "end" },
|
||||||
|
|
||||||
@@ -176,7 +176,7 @@ return {
|
|||||||
-- rival starter baseline (RIVAL_STARTER_JOLTEON) at snatch time
|
-- rival starter baseline (RIVAL_STARTER_JOLTEON) at snatch time
|
||||||
rows[#rows + 1] = { "set_field", "rivalStarter", 1 }
|
rows[#rows + 1] = { "set_field", "rivalStarter", 1 }
|
||||||
rows[#rows + 1] = { "show_text", "_OaksLabRivalTakesText1" }
|
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", "_OaksLabRivalTakesText2" }
|
||||||
rows[#rows + 1] = { "show_text", "_OaksLabRivalTakesText3" }
|
rows[#rows + 1] = { "show_text", "_OaksLabRivalTakesText3" }
|
||||||
rows[#rows + 1] = { "show_text", "_OaksLabRivalTakesText4" }
|
rows[#rows + 1] = { "show_text", "_OaksLabRivalTakesText4" }
|
||||||
@@ -196,7 +196,7 @@ return {
|
|||||||
rows[#rows + 1] = { "face_object", OAK1, "down" }
|
rows[#rows + 1] = { "face_object", OAK1, "down" }
|
||||||
-- OaksLabPlayerReceivedMonText clears wMonDataLocation, so AskName runs (#1013)
|
-- OaksLabPlayerReceivedMonText clears wMonDataLocation, so AskName runs (#1013)
|
||||||
rows[#rows + 1] = { "show_text", "_OaksLabOakGivesText" }
|
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] = { "show_text", "_OaksLabReceivedText", { RAM = "PIKACHU" } }
|
||||||
rows[#rows + 1] = { "give_pokemon", "PIKACHU", 5 }
|
rows[#rows + 1] = { "give_pokemon", "PIKACHU", 5 }
|
||||||
-- DisablePikachuOverworldSpriteDrawing keeps it in the ball (#1009)
|
-- DisablePikachuOverworldSpriteDrawing keeps it in the ball (#1009)
|
||||||
@@ -226,9 +226,15 @@ return {
|
|||||||
},
|
},
|
||||||
|
|
||||||
onEnter = function(game, ow)
|
onEnter = function(game, ow)
|
||||||
if not (game.save.flags and game.save.flags.EVENT_GOT_POKEDEX) then
|
local flags = game.save.flags or {}
|
||||||
return
|
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
|
end
|
||||||
|
if not flags.EVENT_GOT_POKEDEX then return end
|
||||||
local Commands = require("src.script.Commands")
|
local Commands = require("src.script.Commands")
|
||||||
local ctx = { save = game.save, game = game, overworld = ow }
|
local ctx = { save = game.save, game = game, overworld = ow }
|
||||||
Commands.hide_object(ctx, "OAKS_LAB", "OAKSLAB_POKEDEX1")
|
Commands.hide_object(ctx, "OAKS_LAB", "OAKSLAB_POKEDEX1")
|
||||||
@@ -296,13 +302,14 @@ return {
|
|||||||
table.insert(rows, { "wait", 20 })
|
table.insert(rows, { "wait", 20 })
|
||||||
table.insert(rows, { "show_text", "_OaksLabRivalSmellYouLaterText" })
|
table.insert(rows, { "show_text", "_OaksLabRivalSmellYouLaterText" })
|
||||||
table.insert(rows, { "stop_music" })
|
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, { "move_npc_to", RIVAL, 4, 11 })
|
||||||
table.insert(rows, { "hide_object", "OAKS_LAB", "OAKSLAB_RIVAL" })
|
table.insert(rows, { "hide_object", "OAKS_LAB", "OAKSLAB_RIVAL" })
|
||||||
table.insert(rows, { "play_music", "Music_OaksLab" })
|
table.insert(rows, { "play_music", "Music_OaksLab" })
|
||||||
-- OaksLabPikachuEscapesPokeballScript: the follower reaches the map (#1009)
|
-- OaksLabPikachuEscapesPokeballScript: the follower reaches the map (#1009)
|
||||||
table.insert(rows, { "face_player_dir", "up" })
|
table.insert(rows, { "face_player_dir", "up" })
|
||||||
table.insert(rows, { "set_field", "pikachuInBall", false })
|
table.insert(rows, { "set_field", "pikachuInBall", false })
|
||||||
|
table.insert(rows, { "spawn_pikachu_follower" })
|
||||||
table.insert(rows, { "play_cry", "PIKACHU" })
|
table.insert(rows, { "play_cry", "PIKACHU" })
|
||||||
table.insert(rows, { "show_text", "_OaksLabPikachuDislikesPokeballsText1" })
|
table.insert(rows, { "show_text", "_OaksLabPikachuDislikesPokeballsText1" })
|
||||||
table.insert(rows, { "show_text", "_OaksLabPikachuDislikesPokeballsText2" })
|
table.insert(rows, { "show_text", "_OaksLabPikachuDislikesPokeballsText2" })
|
||||||
|
|||||||
+21
-19
@@ -142,20 +142,23 @@ M.VIRIDIAN_CITY = {
|
|||||||
M.BLUES_HOUSE = {
|
M.BLUES_HOUSE = {
|
||||||
talk = {
|
talk = {
|
||||||
TEXT_BLUESHOUSE_DAISY_SITTING = {
|
TEXT_BLUESHOUSE_DAISY_SITTING = {
|
||||||
{ "face_player" }, -- 1
|
{ "face_player" },
|
||||||
{ "check_flag", "EVENT_GOT_TOWN_MAP" }, -- 2
|
{ "check_flag", "EVENT_GOT_TOWN_MAP" },
|
||||||
{ "jump_if_true", 10 }, -- 3
|
{ "jump_if_true", "got_map" },
|
||||||
{ "check_flag", "EVENT_GOT_STARTER" }, -- 4
|
{ "check_flag", "EVENT_GOT_POKEDEX" },
|
||||||
{ "jump_if_false", 12 }, -- 5
|
{ "jump_if_false", "too_early" },
|
||||||
{ "show_text", "_BluesHouseDaisyOfferMapText" }, -- 6
|
{ "show_text", "_BluesHouseDaisyOfferMapText" },
|
||||||
-- _GotMapText: "{PLAYER} got a\n{RAM:wStringBuffer}!" -- the
|
-- _GotMapText: "{PLAYER} got a\n{RAM:wStringBuffer}!" -- the
|
||||||
-- buffer supplies "TOWN MAP" (scripts/BluesHouse.asm GotMapText)
|
-- buffer supplies "TOWN MAP" (scripts/BluesHouse.asm GotMapText)
|
||||||
{ "give_item", "TOWN_MAP", 1, "_GotMapText" }, -- 7
|
{ "give_item", "TOWN_MAP", 1, "_GotMapText" },
|
||||||
{ "set_flag", "EVENT_GOT_TOWN_MAP" }, -- 8
|
{ "hide_object", "BLUES_HOUSE", "BLUESHOUSE_TOWN_MAP" },
|
||||||
{ "jump", 13 }, -- 9
|
{ "set_flag", "EVENT_GOT_TOWN_MAP" },
|
||||||
{ "show_text", "_BluesHouseDaisyUseMapText" }, -- 10
|
{ "jump", "end" },
|
||||||
{ "jump", 13 }, -- 11
|
{ "label", "got_map" },
|
||||||
{ "show_text", "_BluesHouseDaisyRivalAtLabText" }, -- 12
|
{ "show_text", "_BluesHouseDaisyUseMapText" },
|
||||||
|
{ "jump", "end" },
|
||||||
|
{ "label", "too_early" },
|
||||||
|
{ "show_text", "_BluesHouseDaisyRivalAtLabText" },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -294,6 +297,7 @@ M.BILLS_HOUSE = {
|
|||||||
|
|
||||||
M.ROUTE_25 = {
|
M.ROUTE_25 = {
|
||||||
onEnter = function(game, ow)
|
onEnter = function(game, ow)
|
||||||
|
game.save.pikachuMapScriptActive = nil
|
||||||
local flags = game.save.flags
|
local flags = game.save.flags
|
||||||
if flags.EVENT_LEFT_BILLS_HOUSE_AFTER_HELPING then return end
|
if flags.EVENT_LEFT_BILLS_HOUSE_AFTER_HELPING then return end
|
||||||
local Commands = require("src.script.Commands")
|
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
|
-- only read while EVENT_1ST_LOCK_OPENED is unset (the gym is only
|
||||||
-- reachable through this map, so a fresh visit always re-rolls).
|
-- reachable through this map, so a fresh visit always re-rolls).
|
||||||
onEnter = function(game, ow)
|
onEnter = function(game, ow)
|
||||||
|
game.save.pikachuMapScriptActive = nil
|
||||||
local puz = game.save.trashPuzzle or {}
|
local puz = game.save.trashPuzzle or {}
|
||||||
game.save.trashPuzzle = puz
|
game.save.trashPuzzle = puz
|
||||||
puz.first = love.math.random(0, 7) * 2
|
puz.first = love.math.random(0, 7) * 2
|
||||||
@@ -1058,6 +1063,7 @@ local championsRoomRivalScript = {
|
|||||||
-- OakCongratulatesPlayerScript: rival faces left, Oak faces down
|
-- OakCongratulatesPlayerScript: rival faces left, Oak faces down
|
||||||
{ "face_object", 1, "left" }, -- 17
|
{ "face_object", 1, "left" }, -- 17
|
||||||
{ "face_object", 2, "down" }, -- 18
|
{ "face_object", 2, "down" }, -- 18
|
||||||
|
{ "load_player_starter_name" },
|
||||||
{ "show_text", "_ChampionsRoomOakCongratulatesPlayerText" }, -- 19
|
{ "show_text", "_ChampionsRoomOakCongratulatesPlayerText" }, -- 19
|
||||||
-- OakDisappointedWithRivalScript: Oak turns to the rival (right)
|
-- OakDisappointedWithRivalScript: Oak turns to the rival (right)
|
||||||
{ "face_object", 2, "right" }, -- 20
|
{ "face_object", 2, "right" }, -- 20
|
||||||
@@ -1067,13 +1073,8 @@ local championsRoomRivalScript = {
|
|||||||
{ "show_text", "_ChampionsRoomOakComeWithMeText" }, -- 23
|
{ "show_text", "_ChampionsRoomOakComeWithMeText" }, -- 23
|
||||||
{ "move_npc", 2, "up", 2 }, -- 24 OakExitChampionsRoomMovement
|
{ "move_npc", 2, "up", 2 }, -- 24 OakExitChampionsRoomMovement
|
||||||
{ "hide_object", "CHAMPIONS_ROOM", "CHAMPIONSROOM_OAK" }, -- 25
|
{ "hide_object", "CHAMPIONS_ROOM", "CHAMPIONSROOM_OAK" }, -- 25
|
||||||
-- ChampionsRoomPlayerFollowsOakScript / WalkToHallOfFame_RLEMovement.
|
-- scripts/ChampionsRoom.asm WalkToHallOfFame_RLEMovement
|
||||||
-- The player walks out after Oak instead of the screen just fading on the
|
{ "move_player", "left", 1 },
|
||||||
-- 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
|
|
||||||
{ "move_player", "up", 3 }, -- 27
|
{ "move_player", "up", 3 }, -- 27
|
||||||
-- hand the induction off to the HALL_OF_FAME room (consumed by its
|
-- 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)
|
-- 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
|
{ "jump_if_false", "end" }, -- 6 loss: stay
|
||||||
{ "set_flag", "EVENT_BEAT_POKEMON_TOWER_RIVAL" }, -- 7
|
{ "set_flag", "EVENT_BEAT_POKEMON_TOWER_RIVAL" }, -- 7
|
||||||
{ "show_text", "_PokemonTower2FRivalDefeatedText" }, -- 8
|
{ "show_text", "_PokemonTower2FRivalDefeatedText" }, -- 8
|
||||||
|
{ "play_music", "Music_MeetRival", { start = "rival" } },
|
||||||
{ "walk_npc", 1, exitDirs }, -- 9
|
{ "walk_npc", 1, exitDirs }, -- 9
|
||||||
{ "hide_object", "POKEMON_TOWER_2F", "POKEMONTOWER2F_RIVAL" }, -- 10
|
{ "hide_object", "POKEMON_TOWER_2F", "POKEMONTOWER2F_RIVAL" }, -- 10
|
||||||
{ "jump", "end" }, -- 11
|
{ "jump", "end" }, -- 11
|
||||||
|
|||||||
@@ -413,6 +413,9 @@ M.ROUTE_8_GATE = saffronGate("TEXT_ROUTE8GATE_GUARD", { { 2, 3 }, { 2, 4 } }, tr
|
|||||||
-- -------------------------------------------------------------------
|
-- -------------------------------------------------------------------
|
||||||
|
|
||||||
M.POKEMON_FAN_CLUB = {
|
M.POKEMON_FAN_CLUB = {
|
||||||
|
onEnter = function(game, ow)
|
||||||
|
require("src.world.PikachuFollower").onFanClubEntered(game, ow)
|
||||||
|
end,
|
||||||
talk = {
|
talk = {
|
||||||
TEXT_POKEMONFANCLUB_CHAIRMAN = {
|
TEXT_POKEMONFANCLUB_CHAIRMAN = {
|
||||||
{ "face_player" }, -- 1
|
{ "face_player" }, -- 1
|
||||||
@@ -645,8 +648,10 @@ local function mtMoonFossil(itemId, otherName, gotFlag)
|
|||||||
end
|
end
|
||||||
local idef = game.data.items[itemId]
|
local idef = game.data.items[itemId]
|
||||||
game.stringBuffer = idef and idef.name or 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)
|
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,
|
game.stack:push(TextBox.new(game,
|
||||||
t._MtMoonB2FReceivedFossilText
|
t._MtMoonB2FReceivedFossilText
|
||||||
or ("{PLAYER} got the\n" .. game.stringBuffer .. "!"),
|
or ("{PLAYER} got the\n" .. game.stringBuffer .. "!"),
|
||||||
@@ -659,11 +664,11 @@ local function mtMoonFossil(itemId, otherName, gotFlag)
|
|||||||
ow.runner:run({
|
ow.runner:run({
|
||||||
{ "walk_npc", 1, dirs },
|
{ "walk_npc", 1, dirs },
|
||||||
{ "text_opts", { auto = true } },
|
{ "text_opts", { auto = true } },
|
||||||
|
{ "text_sound", "Get_Key_Item" },
|
||||||
{ "show_text", "_MtMoonB2FSuperNerdThenThisIsMineText" },
|
{ "show_text", "_MtMoonB2FSuperNerdThenThisIsMineText" },
|
||||||
{ "play_sound", "Get_Key_Item" },
|
|
||||||
{ "hide_object", "MT_MOON_B2F", otherName },
|
{ "hide_object", "MT_MOON_B2F", otherName },
|
||||||
}, { onDone = done })
|
}, { onDone = done })
|
||||||
end))
|
end, TextBox.soundOpts(game, "Get_Key_Item")))
|
||||||
end }))
|
end }))
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
+22
-21
@@ -8,9 +8,9 @@ local M = {}
|
|||||||
|
|
||||||
local function text(game) return game.data.text end
|
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")
|
local TextBox = require("src.render.TextBox")
|
||||||
game.stack:push(TextBox.new(game, s, done))
|
game.stack:push(TextBox.new(game, s, done, opts))
|
||||||
end
|
end
|
||||||
|
|
||||||
-- The question stays on screen under the YES/NO menu. The dojo prize
|
-- 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")
|
local Commands = require("src.script.Commands")
|
||||||
Commands.give_pokemon({ save = game.save, game = game, overworld = ow },
|
Commands.give_pokemon({ save = game.save, game = game, overworld = ow },
|
||||||
"MAGIKARP", 5)
|
"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)
|
||||||
end,
|
end,
|
||||||
},
|
},
|
||||||
@@ -263,7 +263,7 @@ M.SILPH_CO_7F = {
|
|||||||
{ "jump_if_false", "box_full" },
|
{ "jump_if_false", "box_full" },
|
||||||
-- flag ahead of the jingle, like the Celadon EEVEE (#426)
|
-- flag ahead of the jingle, like the Celadon EEVEE (#426)
|
||||||
{ "set_flag", "EVENT_GOT_LAPRAS" },
|
{ "set_flag", "EVENT_GOT_LAPRAS" },
|
||||||
{ "play_sound", "Get_Item1" },
|
{ "text_sound", "Get_Item1" },
|
||||||
{ "show_text", "_GotMonText", { RAM = "LAPRAS" } },
|
{ "show_text", "_GotMonText", { RAM = "LAPRAS" } },
|
||||||
{ "show_text", "_SilphCo7FSilphWorkerM1LaprasDescriptionText" },
|
{ "show_text", "_SilphCo7FSilphWorkerM1LaprasDescriptionText" },
|
||||||
{ "jump", "end" },
|
{ "jump", "end" },
|
||||||
@@ -307,12 +307,11 @@ M.COPYCATS_HOUSE_2F = {
|
|||||||
return
|
return
|
||||||
end
|
end
|
||||||
game.stringBuffer = game.data.items.TM_MIMIC.name
|
game.stringBuffer = game.data.items.TM_MIMIC.name
|
||||||
require("src.core.Sound").play(game.data, "Get_Item1")
|
|
||||||
Bag.remove(game.save, "POKE_DOLL", 1)
|
Bag.remove(game.save, "POKE_DOLL", 1)
|
||||||
game.save.flags.EVENT_GOT_TM31 = true
|
game.save.flags.EVENT_GOT_TM31 = true
|
||||||
push(game, t._CopycatsHouse2FCopycatReceivedTM31Text, function()
|
push(game, t._CopycatsHouse2FCopycatReceivedTM31Text, function()
|
||||||
push(game, t._CopycatsHouse2FCopycatTM31Explanation1Text, done)
|
push(game, t._CopycatsHouse2FCopycatTM31Explanation1Text, done)
|
||||||
end)
|
end, require("src.render.TextBox").soundOpts(game, "Get_Item1"))
|
||||||
end)
|
end)
|
||||||
end)
|
end)
|
||||||
end,
|
end,
|
||||||
@@ -478,7 +477,6 @@ M.CELADON_MART_ROOF = {
|
|||||||
return
|
return
|
||||||
end
|
end
|
||||||
game.save.flags[g.flag] = true
|
game.save.flags[g.flag] = true
|
||||||
require("src.core.Sound").play(game.data, "Get_Item1")
|
|
||||||
local subs = { player = game.save.player.name,
|
local subs = { player = game.save.player.name,
|
||||||
ram = game.data.items[g.tm].name }
|
ram = game.data.items[g.tm].name }
|
||||||
local explain = fill(t[g.explain] or "", subs)
|
local explain = fill(t[g.explain] or "", subs)
|
||||||
@@ -490,7 +488,7 @@ M.CELADON_MART_ROOF = {
|
|||||||
else
|
else
|
||||||
done()
|
done()
|
||||||
end
|
end
|
||||||
end)
|
end, require("src.render.TextBox").soundOpts(game, "Get_Item1"))
|
||||||
end)
|
end)
|
||||||
end,
|
end,
|
||||||
onCancel = done,
|
onCancel = done,
|
||||||
@@ -512,25 +510,28 @@ M.ROUTE_24 = {
|
|||||||
local flags = game.save.flags
|
local flags = game.save.flags
|
||||||
local function battleOrDone()
|
local function battleOrDone()
|
||||||
if ow:trainerDefeated(npc) then
|
if ow:trainerDefeated(npc) then
|
||||||
push(game, "I hate this!\nMy dreams of\nTEAM ROCKET...", done)
|
push(game, text(game)._Route24CooltrainerM1YouCouldBecomeATopLeaderText,
|
||||||
|
done)
|
||||||
else
|
else
|
||||||
ow:engageTrainer(npc, done)
|
ow:engageTrainer(npc, done)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
if not flags.EVENT_GOT_NUGGET then
|
if not flags.EVENT_GOT_NUGGET then
|
||||||
push(game, "Congratulations!\nYou beat our 5\ncontest trainers!\f"
|
local t = text(game)
|
||||||
.. "You just earned a\nfabulous prize!", function()
|
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
|
flags.EVENT_GOT_NUGGET = true
|
||||||
require("src.inventory.Bag").add(game.save, "NUGGET", 1)
|
game.stringBuffer = game.data.items.NUGGET.name
|
||||||
push(game, ("%s received\na NUGGET!"):format(game.save.player.name),
|
push(game, t._Route24CooltrainerM1ReceivedNuggetText, function()
|
||||||
function()
|
push(game, t._Route24CooltrainerM1JoinTeamRocketText,
|
||||||
ask(game, "By the way, would\nyou like to join\nTEAM ROCKET?",
|
battleOrDone)
|
||||||
function()
|
end, require("src.render.TextBox").soundOpts(game, "Get_Item1"))
|
||||||
push(game, "Arrgh! You are\nnot convinced?\fThen I'll show\n"
|
end, require("src.render.TextBox").soundOpts(game, "Get_Item1"))
|
||||||
.. "you my power!", battleOrDone)
|
|
||||||
end)
|
|
||||||
end)
|
|
||||||
end)
|
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
battleOrDone()
|
battleOrDone()
|
||||||
|
|||||||
+30
-17
@@ -4,9 +4,9 @@ local M = {}
|
|||||||
|
|
||||||
local function text(game) return game.data.text end
|
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")
|
local TextBox = require("src.render.TextBox")
|
||||||
game.stack:push(TextBox.new(game, s, done))
|
game.stack:push(TextBox.new(game, s, done, opts))
|
||||||
end
|
end
|
||||||
|
|
||||||
-- fill the extracted text placeholders ({RAM:...}, {PLAYER})
|
-- fill the extracted text placeholders ({RAM:...}, {PLAYER})
|
||||||
@@ -25,8 +25,8 @@ local function gift(opts)
|
|||||||
local t = text(game)
|
local t = text(game)
|
||||||
local itemName = game.data.items[opts.item].name
|
local itemName = game.data.items[opts.item].name
|
||||||
local subs = { ram = itemName, player = game.save.player.name }
|
local subs = { ram = itemName, player = game.save.player.name }
|
||||||
local function say(label, fallback, cb)
|
local function say(label, fallback, cb, sopts)
|
||||||
push(game, fill(t[label] or fallback, subs), cb)
|
push(game, fill(t[label] or fallback, subs), cb, sopts)
|
||||||
end
|
end
|
||||||
if game.save.flags[opts.flag] then
|
if game.save.flags[opts.flag] then
|
||||||
say(opts.already or opts.explain, "It's a useful\nitem, isn't it?", done)
|
say(opts.already or opts.explain, "It's a useful\nitem, isn't it?", done)
|
||||||
@@ -39,15 +39,16 @@ local function gift(opts)
|
|||||||
end
|
end
|
||||||
game.save.flags[opts.flag] = true
|
game.save.flags[opts.flag] = true
|
||||||
local idef = game.data.items[opts.item]
|
local idef = game.data.items[opts.item]
|
||||||
require("src.core.Sound").play(game.data,
|
-- the received texts carry sound_get_item_1 / sound_get_key_item, so
|
||||||
(idef and idef.keyItem) and "Get_Key_Item" or "Get_Item1")
|
-- the jingle only fires once that box has typed out
|
||||||
say(opts.received, "{PLAYER} received\n{RAM:}!", function()
|
say(opts.received, "{PLAYER} received\n{RAM:}!", function()
|
||||||
if opts.explain then
|
if opts.explain then
|
||||||
say(opts.explain, "", done)
|
say(opts.explain, "", done)
|
||||||
else
|
else
|
||||||
done()
|
done()
|
||||||
end
|
end
|
||||||
end)
|
end, require("src.render.TextBox").soundOpts(game,
|
||||||
|
(idef and idef.keyItem) and "Get_Key_Item" or "Get_Item1"))
|
||||||
end
|
end
|
||||||
if opts.pre then say(opts.pre, opts.preFallback or "", give) else give() end
|
if opts.pre then say(opts.pre, opts.preFallback or "", give) else give() end
|
||||||
end
|
end
|
||||||
@@ -508,12 +509,12 @@ M.PEWTER_CITY = {
|
|||||||
-- Rival ambush: show the hidden rival, walk him up to the player, run
|
-- 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
|
-- the battle rows, march him back and hide him. On a loss the walk is
|
||||||
-- skipped (the blackout rebuilds the map mid-script).
|
-- 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
|
if ow.runner:isRunning() then return false end
|
||||||
ow.player.facing = playerFacing
|
ow.player.facing = playerFacing
|
||||||
-- the rival encounter sting (MUSIC_MEET_RIVAL); the battle music
|
-- the rival encounter sting (MUSIC_MEET_RIVAL); the battle music
|
||||||
-- takes over and the map theme returns after the victory jingle
|
-- 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)
|
ow.runner:run(rows)
|
||||||
return true
|
return true
|
||||||
end
|
end
|
||||||
@@ -567,10 +568,12 @@ local function route22Scene(n, objIndex, objName, oppClass, baseParty, beatFlag,
|
|||||||
{ "face_object", objIndex, rivalFacing }, -- 3
|
{ "face_object", objIndex, rivalFacing }, -- 3
|
||||||
{ "show_text", "_Route22RivalBeforeBattleText" .. n }, -- 4
|
{ "show_text", "_Route22RivalBeforeBattleText" .. n }, -- 4
|
||||||
{ "rival_battle", oppClass, baseParty }, -- 5
|
{ "rival_battle", oppClass, baseParty }, -- 5
|
||||||
{ "jump_if_false", 11 }, -- 6
|
{ "jump_if_false", 12 }, -- 6
|
||||||
{ "set_flag", beatFlag }, -- 7
|
{ "set_flag", beatFlag }, -- 7
|
||||||
{ "show_text", "_Route22Rival" .. n .. "DefeatedText" }, -- 8
|
{ "show_text", "_Route22Rival" .. n .. "DefeatedText" }, -- 8
|
||||||
{ "show_text", "_Route22RivalAfterBattleText" .. n }, -- 9
|
{ "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
|
{ "walk_npc", objIndex, route22ExitDirs(n, py) }, -- 10
|
||||||
{ "hide_object", "ROUTE_22", objName }, -- 11
|
{ "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
|
if f.EVENT_BEAT_GIOVANNI and not f.EVENT_BEAT_ROUTE22_RIVAL_2ND_BATTLE then
|
||||||
return runAmbush(game, ow,
|
return runAmbush(game, ow,
|
||||||
route22Scene(2, 2, "ROUTE22_RIVAL2", "OPP_RIVAL2", 10,
|
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
|
end
|
||||||
return false
|
return false
|
||||||
end,
|
end,
|
||||||
@@ -618,10 +622,11 @@ local function ceruleanRivalScene(px, py)
|
|||||||
{ "face_object", 1, "down" }, -- 3
|
{ "face_object", 1, "down" }, -- 3
|
||||||
{ "show_text", "_CeruleanCityRivalPreBattleText" }, -- 4
|
{ "show_text", "_CeruleanCityRivalPreBattleText" }, -- 4
|
||||||
{ "rival_battle", "OPP_RIVAL1", 7 }, -- 5
|
{ "rival_battle", "OPP_RIVAL1", 7 }, -- 5
|
||||||
{ "jump_if_false", 11 }, -- 6
|
{ "jump_if_false", 12 }, -- 6
|
||||||
{ "set_flag", "EVENT_BEAT_CERULEAN_RIVAL" }, -- 7
|
{ "set_flag", "EVENT_BEAT_CERULEAN_RIVAL" }, -- 7
|
||||||
{ "show_text", "_CeruleanCityRivalDefeatedText" }, -- 8
|
{ "show_text", "_CeruleanCityRivalDefeatedText" }, -- 8
|
||||||
{ "show_text", "_CeruleanCityRivalIWentToBillsText" }, -- 9
|
{ "show_text", "_CeruleanCityRivalIWentToBillsText" }, -- 9
|
||||||
|
{ "play_music", "Music_MeetRival", { start = "rival" } },
|
||||||
{ "walk_npc", 1, ceruleanRivalExitDirs(px) }, -- 10
|
{ "walk_npc", 1, ceruleanRivalExitDirs(px) }, -- 10
|
||||||
{ "hide_object", "CERULEAN_CITY", "CERULEANCITY_RIVAL" }, -- 11
|
{ "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
|
-- 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
|
-- page has typed out (PrintText returning), and auto.tick then runs once
|
||||||
-- per frame while the gate it returns still reads as playing.
|
-- 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")
|
local Music = require("src.core.Music")
|
||||||
-- .findMatchingFacingDirectionLoop: the rotation picks up at the entry
|
-- .findMatchingFacingDirectionLoop: the rotation picks up at the entry
|
||||||
-- matching the sprite's current facing (showMapText has just turned it
|
-- 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
|
if npc then npc.facing = JIGGLYPUFF_SPIN[step] end
|
||||||
return
|
return
|
||||||
end
|
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,
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
@@ -789,7 +800,7 @@ M.PEWTER_POKECENTER = {
|
|||||||
local TextBox = require("src.render.TextBox")
|
local TextBox = require("src.render.TextBox")
|
||||||
game.stack:push(TextBox.new(game,
|
game.stack:push(TextBox.new(game,
|
||||||
text(game)._PewterPokecenterJigglypuffText or "JIGGLYPUFF: Puu\npupuu!",
|
text(game)._PewterPokecenterJigglypuffText or "JIGGLYPUFF: Puu\npupuu!",
|
||||||
done, { auto = jigglypuffDance(game, npc) }))
|
done, { auto = jigglypuffDance(game, npc, ow) }))
|
||||||
end,
|
end,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -864,10 +875,11 @@ M.SILPH_CO_7F = {
|
|||||||
{ "face_object", 9, "up" }, -- 4
|
{ "face_object", 9, "up" }, -- 4
|
||||||
{ "show_text", "_SilphCo7FRivalWaitedHereText" }, -- 5
|
{ "show_text", "_SilphCo7FRivalWaitedHereText" }, -- 5
|
||||||
{ "rival_battle", "OPP_RIVAL2", 7 }, -- 6
|
{ "rival_battle", "OPP_RIVAL2", 7 }, -- 6
|
||||||
{ "jump_if_false", 12 }, -- 7
|
{ "jump_if_false", 13 }, -- 7
|
||||||
{ "set_flag", "EVENT_BEAT_SILPH_CO_RIVAL" }, -- 8
|
{ "set_flag", "EVENT_BEAT_SILPH_CO_RIVAL" }, -- 8
|
||||||
{ "show_text", "_SilphCo7FRivalDefeatedText" }, -- 9
|
{ "show_text", "_SilphCo7FRivalDefeatedText" }, -- 9
|
||||||
{ "show_text", "_SilphCo7FRivalGoodLuckToYouText" }, -- 10
|
{ "show_text", "_SilphCo7FRivalGoodLuckToYouText" }, -- 10
|
||||||
|
{ "play_music", "Music_MeetRival", { start = "rival" } },
|
||||||
{ "move_npc_to", 9, 5, y + 1 }, -- 11
|
{ "move_npc_to", 9, 5, y + 1 }, -- 11
|
||||||
{ "hide_object", "SILPH_CO_7F", "SILPHCO7F_RIVAL" }, -- 12
|
{ "hide_object", "SILPH_CO_7F", "SILPHCO7F_RIVAL" }, -- 12
|
||||||
}, "down")
|
}, "down")
|
||||||
@@ -899,10 +911,11 @@ M.SS_ANNE_2F = {
|
|||||||
{ "face_object", 2, onLeft and "down" or "right" }, -- 3
|
{ "face_object", 2, onLeft and "down" or "right" }, -- 3
|
||||||
{ "show_text", "_SSAnne2FRivalText" }, -- 4
|
{ "show_text", "_SSAnne2FRivalText" }, -- 4
|
||||||
{ "rival_battle", "OPP_RIVAL2", 1 }, -- 5
|
{ "rival_battle", "OPP_RIVAL2", 1 }, -- 5
|
||||||
{ "jump_if_false", 11 }, -- 6
|
{ "jump_if_false", 12 }, -- 6
|
||||||
{ "set_flag", "EVENT_BEAT_SS_ANNE_RIVAL" }, -- 7
|
{ "set_flag", "EVENT_BEAT_SS_ANNE_RIVAL" }, -- 7
|
||||||
{ "show_text", "_SSAnne2FRivalDefeatedText" }, -- 8
|
{ "show_text", "_SSAnne2FRivalDefeatedText" }, -- 8
|
||||||
{ "show_text", "_SSAnne2FRivalCutMasterText" }, -- 9
|
{ "show_text", "_SSAnne2FRivalCutMasterText" }, -- 9
|
||||||
|
{ "play_music", "Music_MeetRival", { start = "rival" } },
|
||||||
{ "walk_npc", 2, ssAnne2FRivalExitDirs(onLeft) }, -- 10
|
{ "walk_npc", 2, ssAnne2FRivalExitDirs(onLeft) }, -- 10
|
||||||
{ "hide_object", "SS_ANNE_2F", "SSANNE2F_RIVAL" }, -- 11
|
{ "hide_object", "SS_ANNE_2F", "SSANNE2F_RIVAL" }, -- 11
|
||||||
}, onLeft and "up" or "left")
|
}, onLeft and "up" or "left")
|
||||||
|
|||||||
@@ -28,6 +28,14 @@
|
|||||||
-- is full; `gotFlag` (pokered's EVENT_GOT_TM*) is set only on a
|
-- 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
|
-- successful give, which is what makes the leader's talk script retry
|
||||||
-- later (gyms.lua).
|
-- 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 function range(prefix, first, last)
|
||||||
local t = {}
|
local t = {}
|
||||||
@@ -50,6 +58,8 @@ return {
|
|||||||
{ "PEWTER_CITY", "PEWTERCITY_YOUNGSTER" },
|
{ "PEWTER_CITY", "PEWTERCITY_YOUNGSTER" },
|
||||||
{ "ROUTE_22", "ROUTE22_RIVAL1" },
|
{ "ROUTE_22", "ROUTE22_RIVAL1" },
|
||||||
},
|
},
|
||||||
|
badgeSound = "Get_Item1", -- sound_level_up
|
||||||
|
tmSound = "Get_Item1",
|
||||||
dialogue = {
|
dialogue = {
|
||||||
"_PewterGymBrockReceivedBoulderBadgeText",
|
"_PewterGymBrockReceivedBoulderBadgeText",
|
||||||
"_PewterGymBrockBoulderBadgeInfoText",
|
"_PewterGymBrockBoulderBadgeInfoText",
|
||||||
@@ -64,6 +74,8 @@ return {
|
|||||||
gotFlag = "EVENT_GOT_TM11",
|
gotFlag = "EVENT_GOT_TM11",
|
||||||
noRoom = "_CeruleanGymMistyTM11NoRoomText",
|
noRoom = "_CeruleanGymMistyTM11NoRoomText",
|
||||||
deactivate = range("EVENT_BEAT_CERULEAN_GYM_TRAINER_", 0, 1),
|
deactivate = range("EVENT_BEAT_CERULEAN_GYM_TRAINER_", 0, 1),
|
||||||
|
badgeSound = "Get_Key_Item",
|
||||||
|
tmSound = "Get_Item1",
|
||||||
dialogue = {
|
dialogue = {
|
||||||
"_CeruleanGymMistyReceivedCascadeBadgeText",
|
"_CeruleanGymMistyReceivedCascadeBadgeText",
|
||||||
},
|
},
|
||||||
@@ -76,6 +88,7 @@ return {
|
|||||||
gotFlag = "EVENT_GOT_TM24",
|
gotFlag = "EVENT_GOT_TM24",
|
||||||
noRoom = "_VermilionGymLTSurgeTM24NoRoomText",
|
noRoom = "_VermilionGymLTSurgeTM24NoRoomText",
|
||||||
deactivate = range("EVENT_BEAT_VERMILION_GYM_TRAINER_", 0, 2),
|
deactivate = range("EVENT_BEAT_VERMILION_GYM_TRAINER_", 0, 2),
|
||||||
|
tmSound = "Get_Key_Item",
|
||||||
dialogue = {
|
dialogue = {
|
||||||
"_VermilionGymLTSurgeReceivedThunderBadgeText",
|
"_VermilionGymLTSurgeReceivedThunderBadgeText",
|
||||||
},
|
},
|
||||||
@@ -89,6 +102,7 @@ return {
|
|||||||
gotFlag = "EVENT_GOT_TM21",
|
gotFlag = "EVENT_GOT_TM21",
|
||||||
noRoom = "_CeladonGymTM21NoRoomText",
|
noRoom = "_CeladonGymTM21NoRoomText",
|
||||||
deactivate = range("EVENT_BEAT_CELADON_GYM_TRAINER_", 0, 6),
|
deactivate = range("EVENT_BEAT_CELADON_GYM_TRAINER_", 0, 6),
|
||||||
|
tmSound = "Get_Item1",
|
||||||
dialogue = {
|
dialogue = {
|
||||||
"_CeladonGymErikaReceivedRainbowBadgeText",
|
"_CeladonGymErikaReceivedRainbowBadgeText",
|
||||||
},
|
},
|
||||||
@@ -102,6 +116,7 @@ return {
|
|||||||
gotFlag = "EVENT_GOT_TM06",
|
gotFlag = "EVENT_GOT_TM06",
|
||||||
noRoom = "_FuchsiaGymKogaTM06NoRoomText",
|
noRoom = "_FuchsiaGymKogaTM06NoRoomText",
|
||||||
deactivate = range("EVENT_BEAT_FUCHSIA_GYM_TRAINER_", 0, 5),
|
deactivate = range("EVENT_BEAT_FUCHSIA_GYM_TRAINER_", 0, 5),
|
||||||
|
tmSound = "Get_Key_Item",
|
||||||
dialogue = {
|
dialogue = {
|
||||||
"_FuchsiaGymKogaReceivedSoulBadgeText",
|
"_FuchsiaGymKogaReceivedSoulBadgeText",
|
||||||
},
|
},
|
||||||
@@ -115,6 +130,8 @@ return {
|
|||||||
gotFlag = "EVENT_GOT_TM46",
|
gotFlag = "EVENT_GOT_TM46",
|
||||||
noRoom = "_SaffronGymSabrinaTM46NoRoomText",
|
noRoom = "_SaffronGymSabrinaTM46NoRoomText",
|
||||||
deactivate = range("EVENT_BEAT_SAFFRON_GYM_TRAINER_", 0, 6),
|
deactivate = range("EVENT_BEAT_SAFFRON_GYM_TRAINER_", 0, 6),
|
||||||
|
badgeSound = "Get_Key_Item",
|
||||||
|
tmSound = "Get_Item1",
|
||||||
dialogue = {
|
dialogue = {
|
||||||
"_SaffronGymSabrinaReceivedMarshBadgeText",
|
"_SaffronGymSabrinaReceivedMarshBadgeText",
|
||||||
},
|
},
|
||||||
@@ -128,6 +145,8 @@ return {
|
|||||||
gotFlag = "EVENT_GOT_TM38",
|
gotFlag = "EVENT_GOT_TM38",
|
||||||
noRoom = "_CinnabarGymBlaineTM38NoRoomText",
|
noRoom = "_CinnabarGymBlaineTM38NoRoomText",
|
||||||
deactivate = range("EVENT_BEAT_CINNABAR_GYM_TRAINER_", 0, 6),
|
deactivate = range("EVENT_BEAT_CINNABAR_GYM_TRAINER_", 0, 6),
|
||||||
|
badgeSound = "Get_Key_Item",
|
||||||
|
tmSound = "Get_Item1",
|
||||||
dialogue = {
|
dialogue = {
|
||||||
"_CinnabarGymBlaineReceivedVolcanoBadgeText",
|
"_CinnabarGymBlaineReceivedVolcanoBadgeText",
|
||||||
},
|
},
|
||||||
@@ -141,6 +160,8 @@ return {
|
|||||||
gotFlag = "EVENT_GOT_TM27",
|
gotFlag = "EVENT_GOT_TM27",
|
||||||
noRoom = "_ViridianGymGiovanniTM27NoRoomText",
|
noRoom = "_ViridianGymGiovanniTM27NoRoomText",
|
||||||
deactivate = range("EVENT_BEAT_VIRIDIAN_GYM_TRAINER_", 0, 7),
|
deactivate = range("EVENT_BEAT_VIRIDIAN_GYM_TRAINER_", 0, 7),
|
||||||
|
badgeSound = "Get_Item1", -- sound_level_up
|
||||||
|
tmSound = "Get_Item1",
|
||||||
dialogue = {
|
dialogue = {
|
||||||
"_ViridianGymGiovanniReceivedEarthBadgeText",
|
"_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:
|
duplicate) and returns one row per mod:
|
||||||
`{id, name, version, badge, description, enabled, status, statusDetail}`.
|
`{id, name, version, badge, description, enabled, status, statusDetail}`.
|
||||||
`badge` is the manifest's `category`, falling back to `profile`, then
|
`badge` is the manifest's `category`, falling back to `profile`, then
|
||||||
`"MOD"`, uppercased. `enabled` reads `options.mods[id]` (missing means
|
`"MOD"`, uppercased. `enabledByVersion` contains an answer for each game;
|
||||||
enabled, matching the loader's own default).
|
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
|
- `status` is `"ok"`, `"warn"`, or `"conflict"`, computed by the pure
|
||||||
`LauncherMods.deriveList`/`statusFor` against `ManagerState.resolveToggle`
|
`LauncherMods.deriveList`/`statusFor` against `ManagerState.resolveToggle`
|
||||||
and the validated manifests: `conflict` when enabling this mod collides
|
and the validated manifests: `conflict` when enabling this mod collides
|
||||||
with another enabled one; `warn` for an out-of-range `game_version` or an
|
with another enabled one; `warn` for an out-of-range `game_version` or an
|
||||||
absent/disabled/wrong-version hard dependency; `ok` otherwise. Having no
|
absent/disabled/wrong-version hard dependency; `ok` otherwise. Having no
|
||||||
`love.*` calls, this half is table-driven by the test suite on its own.
|
`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
|
- `LauncherMods.setEnabled(id, bool, version)` persists the selected game's
|
||||||
boolean, the exact shape `Loader:_saveState` writes, so the running game
|
answer, so the running game and the in-game `ManagerState` see the change on
|
||||||
and the in-game `ManagerState` see the change on next boot. The mods panel
|
next boot. The MODS panel renders a coloured checkbox for Red, Blue, Yellow,
|
||||||
calls this on every toggle and re-derives the list right away
|
and Gold on every row and re-derives the list right away
|
||||||
(`RomImporter:_refreshMods`) so a status change (e.g. a new conflict)
|
(`RomImporter:_refreshMods`) so a status change (e.g. a new conflict)
|
||||||
shows without waiting for a reload.
|
shows without waiting for a reload.
|
||||||
- `LauncherMods.installZip(path)` mounts the archive with
|
- `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)`,
|
dependency does not run on the selected game reads `Needs <id> (not for Gold)`,
|
||||||
matching the loader's contagious skip.
|
matching the loader's contagious skip.
|
||||||
|
|
||||||
A separate overlay, `options.modsByVersion[version][id]`, is where a per-game
|
A separate overlay, `options.modsByVersion[version][id]`, holds each game's
|
||||||
enable flag will live. It is a preview: `SaveData.PER_VERSION_MODS` is `false`,
|
enable flag. The launcher shows a coloured Red / Blue / Yellow / Gold checkbox
|
||||||
so `SaveData.modScope` answers nil for every caller and the launcher panel, the
|
for every installed mod, and the loader and in-game manager read the same
|
||||||
in-game manager and the loader all read *and* write the one shared
|
game-specific answer on the next boot. On the first launch after this feature,
|
||||||
`options.mods` flag. Nothing consults the overlay for enablement until that
|
the existing shared state is copied to every game, so a mod that was enabled
|
||||||
flips, which is deliberate: the overlay is plantable from an imported
|
remains enabled everywhere; after that, changing one checkbox affects only
|
||||||
`.g1rmodlist`, and a reader scoped differently from the writers would show a
|
that game. New mods still default to enabled on every game (experimental mods
|
||||||
mod set no boot would honour.
|
retain their explicit opt-in default).
|
||||||
|
|
||||||
That is deliberate. Gold reimplements the battle engine, the overworld, the
|
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
|
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.
|
generations if you want both.
|
||||||
|
|
||||||
Two riders. **A hard dependency that does not run here takes the dependent down
|
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
|
with it** (unless scoped to specific games, e.g.
|
||||||
wording (`depends on X, which does not run here (For Blue, not Red)`), so the
|
`dependencies: [{ id = "x", games = ["gen2"] }]`), as a skip rather than a
|
||||||
whole chain has to cover the same games. And **the claim is yours, not the last
|
failure and carrying the dependency's own wording (`depends on X, which does not
|
||||||
word**: it is the manager's `TRY HERE ANYWAY` row that lets a player run a mod
|
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
|
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
|
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
|
`{ [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
|
(`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
|
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.
|
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
|
`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
|
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
|
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`,
|
`pokemon.level_up`, `pokemon.move_learned`; hooks `battle.damage`,
|
||||||
`battle.crit`, `battle.accuracy`, `battle.turn_order`,
|
`battle.crit`, `battle.accuracy`, `battle.turn_order`,
|
||||||
`battle.enemy_action`, `battle.run`, `battle.exp_award`, `exp.gain`,
|
`battle.enemy_action`, `battle.run`, `battle.exp_award`, `exp.gain`,
|
||||||
`catch.rate`, `trainer.party`, `battle.overlay`, `battle.low_health_alarm`
|
`catch.rate`, `trainer.party`, `battle.overlay`, `battle.low_health_alarm`,
|
||||||
and `battle.catch_exp`. One payload difference: Gen 1's vanilla
|
`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
|
`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
|
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
|
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
|
each row's decision in `evolution.check`. The hook passes `data` where Gen 1
|
||||||
passes `game`; positions 2-4 (mon, row, trigger) match.
|
passes `game`; positions 2-4 (mon, row, trigger) match.
|
||||||
- *The frame (`src/core/Game2.lua`):* hooks `input.step`, `input.pointer`,
|
- *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
|
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
|
-- 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
|
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
|
`render.hud`'s `gameX` / `gameY` really is where Gold's dialogue boxes and
|
||||||
menus land, because `Chrome.fitScale` / `fitOrigin` and `World:fitScale`
|
menus land, because `Chrome.fitScale` / `fitOrigin` and `World:fitScale`
|
||||||
compute the same number. `render.zones` is handed `nil` in GBC mode (Gold
|
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
|
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)
|
## Mods and Gold (Gen 2)
|
||||||
|
|
||||||
The mod API is one API across both generations, but Gold runs its own battle
|
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.
|
`"3"` (darkest); their matching width and height fields describe the grid.
|
||||||
`markers` contains active `{ kind, x, y }` points in map-cell coordinates for
|
`markers` contains active `{ kind, x, y }` points in map-cell coordinates for
|
||||||
`warp`, visible `item`, and untaken `hidden` locations. All fields are
|
`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
|
## 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,
|
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.
|
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
|
`screen.render_visible` receives `(next, state)` while the main screen is being
|
||||||
composed. Return `false` to omit that state from drawing, opacity selection and
|
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
|
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
|
`battle.bottom_ui_visible` and `battle.status_hud_visible` independently
|
||||||
control the battle text/menu layer and the HP/status panels. Both receive
|
control the battle text/menu layer and the HP/status panels. Both receive
|
||||||
`(next, state)` and default to `true`, so vanilla rendering is unchanged.
|
`(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
|
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
|
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
|
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
|
The live `game` supplies only the current option context. Formatting never
|
||||||
mutates the save, options, or timestamp, and invalid timestamps return
|
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**
|
* **Soft reset button combination**
|
||||||
* **Keyboard and controller rebinding**
|
* **Keyboard and controller rebinding**
|
||||||
* **Mod profiles** with separate mod settings and save slots
|
* **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
|
* **Improved launcher and save editor UI**, including background downloads and update checks
|
||||||
* **Direct-launch options** for shortcuts, Steam entries, and handheld frontends
|
* **Direct-launch options** for shortcuts, Steam entries, and handheld frontends
|
||||||
* **Custom boot branding**
|
* **Custom boot branding**
|
||||||
|
|
||||||
## Pokémon Gold (Gen 2)
|
## 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**
|
* **COLOR, zoom, tilt, GBC FX, and quick save/load**
|
||||||
* **UI that stays fixed while the overworld zooms**
|
* **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
|
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`.
|
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
|
### One limit worth knowing
|
||||||
|
|
||||||
**Per-game enable flags are still a preview.** The overlay
|
**Enablement is per game.** The overlay
|
||||||
`options.modsByVersion[version][id]` exists and every surface goes through
|
`options.modsByVersion[version][id]` is read and written through
|
||||||
`SaveData.modEnabled` / `SaveData.setModEnabled`, but
|
`SaveData.modEnabled` / `SaveData.setModEnabled` by the launcher, in-game
|
||||||
`SaveData.PER_VERSION_MODS` is `false`
|
manager, and loader. Existing shared settings are copied to every game the
|
||||||
(`src/core/SaveData.lua:489`). While it is false, `SaveData.modScope` answers
|
first time this version sees the installed mods; from then on, each coloured
|
||||||
nil for every caller, so the launcher panel, the in-game manager *and* the
|
game checkbox changes only that game's next boot. Nothing about this affects a
|
||||||
loader all read and write the one shared `options.mods` flag and the overlay is
|
mod author; it affects what a player can express.
|
||||||
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.
|
|
||||||
|
|
||||||
Targeting is a different question from enablement and *is* enforced per game,
|
Targeting is a different question from enablement and *is* enforced per game,
|
||||||
as above. The two do not share a switch.
|
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,34 @@
|
|||||||
"tintColor": "3b5ca8",
|
"tintColor": "3b5ca8",
|
||||||
"category": "games",
|
"category": "games",
|
||||||
"versions": [
|
"versions": [
|
||||||
|
{
|
||||||
|
"version": "0.1.84",
|
||||||
|
"date": "2026-08-14",
|
||||||
|
"size": 11306462,
|
||||||
|
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.84/gen1recomp++-0.1.84-ios.ipa",
|
||||||
|
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #919 Bills House game save moves strangely after reload\n- #982 Trading QOL aspect missing from OG.\n- #1003 Land Pokemon Encounters while Surfing in Cerulean Cave\n- #1012 Multiple issues with text speed, animations, and audio\n- #1022 Cannot interact with small trees\n- #1028 Trainers not resetting on route change.\n- #1033 Some issues with Link battles.\n- #1243 Can't sell TMs at Poké Marts\n\n## Contributors\n\n- @1Jamie\n- @AverageConsumer\n- @bryanthaboi\n- @TheRealSolidusSnake\n- Myles Resnick\n- ShaneMcGovernIE"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"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",
|
"version": "0.1.80",
|
||||||
"date": "2026-08-12",
|
"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
|
fused executable. Mods load it through LuaJIT FFI; no .NET runtime is
|
||||||
required on the player's machine.
|
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
|
## Android
|
||||||
|
|
||||||
On Android, the matching API is exposed as `love.system.tlsOpen` /
|
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"/*.dll "$out_dir"/
|
||||||
cp "$love_dir"/license.txt "$out_dir"/ 2>/dev/null || true
|
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
|
# 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
|
# 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,
|
# 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" \
|
grep -q '^FUSE_PATH="\$APPDIR/game.love"$' "$appdir/AppRun" \
|
||||||
|| fail "failed to enable FUSE_PATH in AppRun (upstream AppRun changed?)"
|
|| 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
|
# Match the upstream image's compression (gzip, 128K blocks) so the
|
||||||
# bundled runtime can read it.
|
# bundled runtime can read it.
|
||||||
local sfs_out="$WORK/game.squashfs"
|
local sfs_out="$WORK/game.squashfs"
|
||||||
|
|||||||
@@ -93,7 +93,8 @@ build_autotools mpg123 "$MPG123_VERSION" "$MPG123_TARBALL" libmpg123.so.0 \
|
|||||||
# The symbol that was missing when this was bullseye's copy. Assert it, so a
|
# The symbol that was missing when this was bullseye's copy. Assert it, so a
|
||||||
# version bump that quietly regresses below the host's expectations fails the
|
# version bump that quietly regresses below the host's expectations fails the
|
||||||
# build instead of silently killing audio again.
|
# build instead of silently killing audio again.
|
||||||
objdump -T "$PREFIX/lib/libmpg123.so.0" | grep -q 'mpg123_info2' \
|
mpg123_syms="$(objdump -T "$PREFIX/lib/libmpg123.so.0")"
|
||||||
|
grep -q 'mpg123_info2' <<<"$mpg123_syms" \
|
||||||
|| fail "bundled libmpg123 lacks mpg123_info2; the host's libsndfile will fail to relocate"
|
|| fail "bundled libmpg123 lacks mpg123_info2; the host's libsndfile will fail to relocate"
|
||||||
|
|
||||||
# ------------------------------------------------------------ compile SDL2
|
# ------------------------------------------------------------ compile SDL2
|
||||||
@@ -128,8 +129,9 @@ fi
|
|||||||
# the host having that exact stack -- which is the bug this replaced.
|
# the host having that exact stack -- which is the bug this replaced.
|
||||||
sdl_lib="$PREFIX/lib/libSDL2-2.0.so.0"
|
sdl_lib="$PREFIX/lib/libSDL2-2.0.so.0"
|
||||||
[ -f "$sdl_lib" ] || fail "SDL2 build produced no libSDL2-2.0.so.0"
|
[ -f "$sdl_lib" ] || fail "SDL2 build produced no libSDL2-2.0.so.0"
|
||||||
|
sdl_needed="$(objdump -p "$sdl_lib")"
|
||||||
for forbidden in libpulse libasound libX11 libwayland libdrm libgbm libsndio; do
|
for forbidden in libpulse libasound libX11 libwayland libdrm libgbm libsndio; do
|
||||||
if objdump -p "$sdl_lib" | grep -q "NEEDED.*$forbidden"; then
|
if grep -q "NEEDED.*$forbidden" <<<"$sdl_needed"; then
|
||||||
fail "SDL2 hard-links $forbidden; it must dlopen its backends (--enable-*-shared)"
|
fail "SDL2 hard-links $forbidden; it must dlopen its backends (--enable-*-shared)"
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
@@ -164,8 +166,9 @@ fi
|
|||||||
|
|
||||||
openal_lib="$PREFIX/lib/libopenal.so.1"
|
openal_lib="$PREFIX/lib/libopenal.so.1"
|
||||||
[ -f "$openal_lib" ] || fail "openal-soft build produced no libopenal.so.1"
|
[ -f "$openal_lib" ] || fail "openal-soft build produced no libopenal.so.1"
|
||||||
|
openal_needed="$(objdump -p "$openal_lib")"
|
||||||
for forbidden in libsndio libasound libpulse libjack; do
|
for forbidden in libsndio libasound libpulse libjack; do
|
||||||
if objdump -p "$openal_lib" | grep -q "NEEDED.*$forbidden"; then
|
if grep -q "NEEDED.*$forbidden" <<<"$openal_needed"; then
|
||||||
fail "openal hard-links $forbidden; backends must stay behind dlopen"
|
fail "openal hard-links $forbidden; backends must stay behind dlopen"
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
@@ -196,7 +199,8 @@ fi
|
|||||||
|
|
||||||
theora_lib="$PREFIX/lib/libtheoradec.so.1"
|
theora_lib="$PREFIX/lib/libtheoradec.so.1"
|
||||||
[ -f "$theora_lib" ] || fail "libtheora build produced no libtheoradec.so.1"
|
[ -f "$theora_lib" ] || fail "libtheora build produced no libtheoradec.so.1"
|
||||||
if objdump -p "$theora_lib" | grep -q "NEEDED.*libcairo"; then
|
theora_needed="$(objdump -p "$theora_lib")"
|
||||||
|
if grep -q "NEEDED.*libcairo" <<<"$theora_needed"; then
|
||||||
fail "libtheoradec still links libcairo (--disable-examples stopped working)"
|
fail "libtheoradec still links libcairo (--disable-examples stopped working)"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -231,16 +235,18 @@ love_bin="$PREFIX/bin/love"
|
|||||||
love_lib="$PREFIX/lib/liblove-$LOVE_VERSION.so"
|
love_lib="$PREFIX/lib/liblove-$LOVE_VERSION.so"
|
||||||
[ -x "$love_bin" ] || fail "LÖVE build produced no bin/love"
|
[ -x "$love_bin" ] || fail "LÖVE build produced no bin/love"
|
||||||
[ -f "$love_lib" ] || fail "LÖVE build produced no lib/liblove-$LOVE_VERSION.so"
|
[ -f "$love_lib" ] || fail "LÖVE build produced no lib/liblove-$LOVE_VERSION.so"
|
||||||
file "$love_bin" | grep -q 'ARM aarch64' \
|
love_file="$(file "$love_bin")"
|
||||||
|
grep -q 'ARM aarch64' <<<"$love_file" \
|
||||||
|| fail "built love is not an aarch64 ELF (got: $(file -b "$love_bin"))"
|
|| fail "built love is not an aarch64 ELF (got: $(file -b "$love_bin"))"
|
||||||
|
|
||||||
# A configure run that lost an optional dependency still exits 0 and still
|
# A configure run that lost an optional dependency still exits 0 and still
|
||||||
# builds -- the loss only shows up as a missing love module at runtime, i.e.
|
# builds -- the loss only shows up as a missing love module at runtime, i.e.
|
||||||
# in a shipped artifact. Assert the decoder/font/video libs really linked.
|
# in a shipped artifact. Assert the decoder/font/video libs really linked.
|
||||||
|
love_needed="$(objdump -p "$love_lib")"
|
||||||
for soname in libSDL2-2.0.so.0 libopenal.so.1 libfreetype.so.6 \
|
for soname in libSDL2-2.0.so.0 libopenal.so.1 libfreetype.so.6 \
|
||||||
libmodplug.so.1 libmpg123.so.0 libvorbisfile.so.3 \
|
libmodplug.so.1 libmpg123.so.0 libvorbisfile.so.3 \
|
||||||
libtheoradec.so.1 libluajit-5.1.so.2; do
|
libtheoradec.so.1 libluajit-5.1.so.2; do
|
||||||
objdump -p "$love_lib" | grep -q "NEEDED.*$soname" \
|
grep -q "NEEDED.*$soname" <<<"$love_needed" \
|
||||||
|| fail "liblove is not linked against $soname (a -dev package went missing)"
|
|| fail "liblove is not linked against $soname (a -dev package went missing)"
|
||||||
done
|
done
|
||||||
|
|
||||||
@@ -405,6 +411,12 @@ if [ -z "\$LUA_CPATH" ]; then
|
|||||||
fi
|
fi
|
||||||
export LUA_CPATH="\$APPDIR/lib/lua/5.1/?.so;\$LUA_CPATH"
|
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" "\$@"
|
exec "\$APPDIR/bin/love" --fused "\$APPDIR/game.love" "\$@"
|
||||||
EOF
|
EOF
|
||||||
chmod +x "$APPDIR/AppRun"
|
chmod +x "$APPDIR/AppRun"
|
||||||
|
|||||||
@@ -35,4 +35,10 @@ find_love() {
|
|||||||
LOVE_BIN="$(find_love)" \
|
LOVE_BIN="$(find_love)" \
|
||||||
|| fail "LÖVE not found, run scripts/setup.sh (or install from https://love2d.org)"
|
|| 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" "$@"
|
exec "$LOVE_BIN" "$ROOT" "$@"
|
||||||
|
|||||||
+153
-82
@@ -501,6 +501,9 @@ local function makeBattler(data, mon, isPlayer, save)
|
|||||||
badgeBoosts = badgeBoosts,
|
badgeBoosts = badgeBoosts,
|
||||||
statuses = data.statuses,
|
statuses = data.statuses,
|
||||||
shownHP = mon.hp, -- the HP the bar displays (UpdateHPBar drain)
|
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
|
-- HUD status label (DrawHUDsAndHPBars); mon.status can land mid-move
|
||||||
-- while the tilemap still shows the prior condition until the next
|
-- while the tilemap still shows the prior condition until the next
|
||||||
-- post-action HUD refresh (core.asm after Execute*Move)
|
-- post-action HUD refresh (core.asm after Execute*Move)
|
||||||
@@ -563,10 +566,6 @@ local function markOwned(game, species)
|
|||||||
local dex = game.save.pokedex
|
local dex = game.save.pokedex
|
||||||
if dex then
|
if dex then
|
||||||
dex.seen[species] = true
|
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
|
dex.owned[species] = true
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -613,6 +612,7 @@ local function newBattle(game)
|
|||||||
self.phase = "intro"
|
self.phase = "intro"
|
||||||
self.menuIndex = 1
|
self.menuIndex = 1
|
||||||
self.moveIndex = 1
|
self.moveIndex = 1
|
||||||
|
self.playerMoveListIndex = 1
|
||||||
self.frame = 0
|
self.frame = 0
|
||||||
return self
|
return self
|
||||||
end
|
end
|
||||||
@@ -929,6 +929,22 @@ function BattleState:sayNext(text)
|
|||||||
table.insert(self.queue, self.nextInsert, { text = text })
|
table.insert(self.queue, self.nextInsert, { text = text })
|
||||||
end
|
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)
|
-- sayNext for a page that ends in `text_end` (see sayAuto) (#765)
|
||||||
function BattleState:sayNextAuto(text, delay)
|
function BattleState:sayNextAuto(text, delay)
|
||||||
self.nextInsert = (self.nextInsert or 0) + 1
|
self.nextInsert = (self.nextInsert or 0) + 1
|
||||||
@@ -1016,22 +1032,39 @@ function BattleState:stepHPDrain()
|
|||||||
and b.shownHP >= b.drainFloor then
|
and b.shownHP >= b.drainFloor then
|
||||||
goal = b.drainFloor
|
goal = b.drainFloor
|
||||||
end
|
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
|
if (b.drainHold or 0) > 0 then
|
||||||
b.drainHold = b.drainHold - 1
|
b.drainHold = b.drainHold - 1
|
||||||
busy = true
|
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
|
elseif b.shownHP ~= goal then
|
||||||
local maxHP = math.max(1, b.mon.stats.hp)
|
local spent = 0
|
||||||
local playerSide = (b == self.player)
|
|
||||||
local cost = 0
|
|
||||||
-- consume whole HP steps until this frame's budget is spent; on the
|
-- consume whole HP steps until this frame's budget is spent; on the
|
||||||
-- enemy HUD several free steps can land in the same frame
|
-- enemy HUD several free steps can land in the same frame
|
||||||
while b.shownHP ~= goal and cost < 1 do
|
repeat
|
||||||
local nextHP = b.shownHP + ((b.shownHP > goal) and -1 or 1)
|
b.shownHP = b.shownHP + ((b.shownHP > goal) and -1 or 1)
|
||||||
cost = cost + Timing.hpDrainStepFrames(b.shownHP, nextHP,
|
spent = spent + (playerSide and Timing.HP_BAR_HP_STEP or 0)
|
||||||
maxHP, playerSide)
|
targetPx = Timing.hpBarPixels(b.shownHP, maxHP)
|
||||||
b.shownHP = nextHP
|
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
|
end
|
||||||
b.drainHold = math.max(0, cost - 1)
|
|
||||||
b.draining = true
|
b.draining = true
|
||||||
busy = true
|
busy = true
|
||||||
elseif b.draining then
|
elseif b.draining then
|
||||||
@@ -1255,13 +1288,13 @@ function BattleState:updateQueue()
|
|||||||
-- no subanimation player: keep the single-sound fallback (with
|
-- no subanimation player: keep the single-sound fallback (with
|
||||||
-- the move's pitch/tempo modifiers; GROWL/ROAR play the
|
-- the move's pitch/tempo modifiers; GROWL/ROAR play the
|
||||||
-- attacker's cry -- GetMoveSound/IsCryMove)
|
-- 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
|
local attacker = item.attackerIsPlayer and self.player or self.enemy
|
||||||
if attacker then
|
if attacker then
|
||||||
require("src.core.Sound").playMoveCry(self.data, attacker.mon.species,
|
require("src.core.Sound").playMoveCry(self.data, attacker.mon.species,
|
||||||
anim and anim.tempo)
|
anim and anim.tempo)
|
||||||
end
|
end
|
||||||
elseif anim and anim.sound then
|
elseif self:animationsOn() and anim and anim.sound then
|
||||||
local Sound = require("src.core.Sound")
|
local Sound = require("src.core.Sound")
|
||||||
if Sound.playMove then
|
if Sound.playMove then
|
||||||
Sound.playMove(self.data, anim)
|
Sound.playMove(self.data, anim)
|
||||||
@@ -1361,6 +1394,11 @@ function BattleState:updateQueue()
|
|||||||
self.current = nil
|
self.current = nil
|
||||||
end
|
end
|
||||||
elseif not (item and item.choice) then
|
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
|
-- The page is typed out and waiting on the player: PromptText
|
||||||
-- (home/text.asm:209-217) writes '▼' at (18,16) and ManualTextScroll
|
-- (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
|
-- blinks it until A/B, so the arrow belongs on a finished page and not
|
||||||
@@ -1420,7 +1458,7 @@ end
|
|||||||
function BattleState:playEntranceCry(battler)
|
function BattleState:playEntranceCry(battler)
|
||||||
local mon = battler and battler.mon
|
local mon = battler and battler.mon
|
||||||
if not mon then return end
|
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)
|
mon.status == "SLP" and 37 or 11)
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -1544,7 +1582,23 @@ function BattleState:enter()
|
|||||||
-- default stays opaque for every other battle and for older saves.
|
-- default stays opaque for every other battle and for older saves.
|
||||||
self.isOpaque = self:bgMode() ~= "world"
|
self.isOpaque = self:bgMode() ~= "world"
|
||||||
self.introSlide = Timing.BATTLE_SLIDE_IN_FRAMES
|
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
|
-- DrawAllPokeballs (common_text.asm:27) puts the party ball rows AND the
|
||||||
-- HUD corner/underline tiles under them (PlacePlayerHUDTiles /
|
-- HUD corner/underline tiles under them (PlacePlayerHUDTiles /
|
||||||
-- PlaceEnemyHUDTiles, draw_hud_pokeball_gfx.asm:119-165) on screen with
|
-- 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
|
-- a different point in each battle kind, so queue it per branch
|
||||||
local function queueEnemyCry()
|
local function queueEnemyCry()
|
||||||
self:act(function()
|
self:act(function()
|
||||||
self:playEntranceCry(self.enemy)
|
self:waitSfxNext(self:playEntranceCry(self.enemy))
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
-- PrintBeginningBattleText (engine/battle/common_text.asm:10-19): a wild
|
-- PrintBeginningBattleText (engine/battle/common_text.asm:10-19): a wild
|
||||||
-- battle calls PlayCry BEFORE PrintText WildMonAppearedText, so the cry
|
-- battle calls PlayCry BEFORE PrintText WildMonAppearedText, and PlayCry
|
||||||
-- sounds with the "Wild X appeared!" box instead of waiting on the A
|
-- ends in WaitForSoundToFinish, so the cry runs to its end and only then
|
||||||
-- press that clears its `prompt` (#303). The Silph-Scope-less tower
|
-- 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
|
-- ghost gets no cry at all (common_text.asm:43-48), and neither does the
|
||||||
-- unveiled MAROWAK: .isMarowak never reaches PlayCry (#492).
|
-- unveiled MAROWAK: .isMarowak never reaches PlayCry (#492).
|
||||||
if self.kind ~= "trainer" and self.kind ~= "link"
|
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
|
-- The sfx is extracted as "Trainer_Appeared" (tools/rom_manifest.json
|
||||||
-- sfxHeaders, bank 8 / $42bb -- the same header pokered names
|
-- sfxHeaders, bank 8 / $42bb -- the same header pokered names
|
||||||
-- SFX_Silph_Scope); nothing had ever played it.
|
-- 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:act(function()
|
||||||
self.introSfx = require("src.core.Sound").play(self.data,
|
self.introSfx = require("src.core.Sound").play(self.data,
|
||||||
"Trainer_Appeared")
|
"Trainer_Appeared")
|
||||||
@@ -1618,13 +1675,17 @@ function BattleState:enter()
|
|||||||
-- battle -- not on a switch, and not when the beaten trainer's pic
|
-- battle -- not on a switch, and not when the beaten trainer's pic
|
||||||
-- scrolls back in (#317, #282)
|
-- scrolls back in (#317, #282)
|
||||||
self:act(function() self.introBalls = nil end)
|
self:act(function() self.introBalls = nil end)
|
||||||
if self.kind == "trainer" then
|
if self.kind == "trainer" or self.kind == "link" then
|
||||||
-- EnemySendOutFirstMon (core.asm:1308-1310): SlideTrainerPicOffScreen
|
local foeName = self.trainer and self.trainer.name
|
||||||
-- walks the foe's pic off the RIGHT edge (hlcoord 18,0, a = 8 tiles,
|
or self.opponentName or Strings("FOE")
|
||||||
-- one tile every 2 frames) BEFORE TrainerSentOutText -- the pic does
|
if self.showEnemyTrainer then
|
||||||
-- not blink out under the text (#317)
|
-- EnemySendOutFirstMon (core.asm:1308-1310): SlideTrainerPicOffScreen
|
||||||
self:act(function() self:slidePic("foe", 0, 64, 4) end)
|
-- walks the foe's pic off the RIGHT edge (hlcoord 18,0, a = 8 tiles,
|
||||||
table.insert(self.queue, { wait = 16 })
|
-- 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:act(function()
|
||||||
self.showEnemyTrainer = false
|
self.showEnemyTrainer = false
|
||||||
-- the slot is EMPTY from here until AnimateSendingOutMon runs below:
|
-- the slot is EMPTY from here until AnimateSendingOutMon runs below:
|
||||||
@@ -1637,7 +1698,7 @@ function BattleState:enter()
|
|||||||
self.enemySendingOut = true
|
self.enemySendingOut = true
|
||||||
self:slidePic("foe")
|
self:slidePic("foe")
|
||||||
end)
|
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()
|
self:act(function()
|
||||||
-- EnemySendOutFirstMon (core.asm:1421-1434): after the text the
|
-- EnemySendOutFirstMon (core.asm:1421-1434): after the text the
|
||||||
-- pic grows out of the ball (AnimateSendingOutMon), then the cry
|
-- pic grows out of the ball (AnimateSendingOutMon), then the cry
|
||||||
@@ -1645,18 +1706,6 @@ function BattleState:enter()
|
|||||||
self:startGrowIn(self.enemy)
|
self:startGrowIn(self.enemy)
|
||||||
end)
|
end)
|
||||||
queueEnemyCry()
|
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
|
end
|
||||||
-- StartBattle .foundFirstAliveEnemyMon (core.asm:152-156): the `call nz`
|
-- StartBattle .foundFirstAliveEnemyMon (core.asm:152-156): the `call nz`
|
||||||
-- gates only EnemySendOutFirstMon -- the `ld c, 40 / call DelayFrames`
|
-- 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
|
-- SendOutMon (core.asm:1757-1762): after the poof the mon grows
|
||||||
-- out of the ball (AnimateSendingOutMon at hlcoord 4,11)
|
-- out of the ball (AnimateSendingOutMon at hlcoord 4,11)
|
||||||
self:startGrowIn(self.player)
|
self:startGrowIn(self.player)
|
||||||
self:playEntranceCry(self.player)
|
self:waitSfxNext(self:playEntranceCry(self.player))
|
||||||
end)
|
end)
|
||||||
self:markParticipant()
|
self:markParticipant()
|
||||||
end
|
end
|
||||||
@@ -1746,6 +1795,7 @@ end
|
|||||||
local function sendOutMonCursors(self)
|
local function sendOutMonCursors(self)
|
||||||
self.menuIndex = 1
|
self.menuIndex = 1
|
||||||
self.moveIndex = 1
|
self.moveIndex = 1
|
||||||
|
self.playerMoveListIndex = 1
|
||||||
end
|
end
|
||||||
|
|
||||||
-- core.asm:297-300: both sides' FLINCHED bits are cleared as a turn's move
|
-- 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
|
if self.phase == "menu" then
|
||||||
for _, b in ipairs({ self.player, self.enemy }) do
|
for _, b in ipairs({ self.player, self.enemy }) do
|
||||||
if b then
|
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.drainFloor = nil
|
||||||
b.shownStatus = b.mon.status
|
b.shownStatus = b.mon.status
|
||||||
end
|
end
|
||||||
@@ -2044,8 +2097,10 @@ function BattleState:update(dt)
|
|||||||
if self.moveSwapIndex then
|
if self.moveSwapIndex then
|
||||||
self:swapMoves(self.moveSwapIndex, self.moveIndex)
|
self:swapMoves(self.moveSwapIndex, self.moveIndex)
|
||||||
self.moveSwapIndex = nil
|
self.moveSwapIndex = nil
|
||||||
|
self.moveIndex = math.min(self.playerMoveListIndex or 1, #moves)
|
||||||
else
|
else
|
||||||
self.moveSwapIndex = self.moveIndex
|
self.moveSwapIndex = self.moveIndex
|
||||||
|
self.moveIndex = math.min(self.playerMoveListIndex or 1, #moves)
|
||||||
end
|
end
|
||||||
elseif input:wasPressed("b") then
|
elseif input:wasPressed("b") then
|
||||||
require("src.core.Sound").play(self.data, "Press_AB")
|
require("src.core.Sound").play(self.data, "Press_AB")
|
||||||
@@ -2068,6 +2123,7 @@ function BattleState:update(dt)
|
|||||||
self.phase = "messages"
|
self.phase = "messages"
|
||||||
self.afterQueue = "menu"
|
self.afterQueue = "menu"
|
||||||
else
|
else
|
||||||
|
self.playerMoveListIndex = self.moveIndex
|
||||||
self:resolveTurn(mv)
|
self:resolveTurn(mv)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -2279,10 +2335,10 @@ function BattleState:oldManThrow()
|
|||||||
return
|
return
|
||||||
end
|
end
|
||||||
self:ballChain("TOSS_ANIM", true, 3, "POKE_BALL")
|
self:ballChain("TOSS_ANIM", true, 3, "POKE_BALL")
|
||||||
self:actNext(function()
|
-- ItemUseBallText05: text_far, sound_caught_mon, text_promptbutton --
|
||||||
require("src.core.Sound").play(self.data, "Caught_Mon")
|
-- the fanfare follows the caught text and holds the prompt
|
||||||
end)
|
self:sayNextWaitSfx(Strings("All right!\n%s was\ncaught!", self.enemy.name),
|
||||||
self:sayNext(Strings("All right!\n%s was\ncaught!", self.enemy.name))
|
function() return require("src.core.Sound").play(self.data, "Caught_Mon") end)
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -2423,11 +2479,9 @@ function BattleState:resolveTurn(playerAction)
|
|||||||
end
|
end
|
||||||
local order
|
local order
|
||||||
if pFirst then
|
if pFirst then
|
||||||
order = { { self.player, self.enemy, playerAction },
|
order = { { true, playerAction }, { false, enemyAction } }
|
||||||
{ self.enemy, self.player, enemyAction } }
|
|
||||||
else
|
else
|
||||||
order = { { self.enemy, self.player, enemyAction },
|
order = { { false, enemyAction }, { true, playerAction } }
|
||||||
{ self.player, self.enemy, playerAction } }
|
|
||||||
end
|
end
|
||||||
|
|
||||||
self.phase = "messages"
|
self.phase = "messages"
|
||||||
@@ -2435,7 +2489,9 @@ function BattleState:resolveTurn(playerAction)
|
|||||||
|
|
||||||
for _, entry in ipairs(order) do
|
for _, entry in ipairs(order) do
|
||||||
self:act(function()
|
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)
|
||||||
end
|
end
|
||||||
self:act(function() self:endOfTurn() end)
|
self:act(function() self:endOfTurn() end)
|
||||||
@@ -2466,7 +2522,7 @@ function BattleState:resolveSwitch(newMon)
|
|||||||
self.sendingOut = false
|
self.sendingOut = false
|
||||||
-- SendOutMon (core.asm:1757-1762): poof, then the grow-in
|
-- SendOutMon (core.asm:1757-1762): poof, then the grow-in
|
||||||
self:startGrowIn(self.player)
|
self:startGrowIn(self.player)
|
||||||
self:playEntranceCry(self.player)
|
self:waitSfxNext(self:playEntranceCry(self.player))
|
||||||
end)
|
end)
|
||||||
end)
|
end)
|
||||||
self:act(function()
|
self:act(function()
|
||||||
@@ -2945,7 +3001,7 @@ function BattleState:applyHitFx(hit)
|
|||||||
Sound.play(self.data, hit.sfx)
|
Sound.play(self.data, hit.sfx)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
if not t or not self:animationsOn() then return end
|
if not t then return end
|
||||||
if t == 1 then
|
if t == 1 then
|
||||||
-- PredefShakeScreenVertically b=8: the window drops by b for 3 frames
|
-- PredefShakeScreenVertically b=8: the window drops by b for 3 frames
|
||||||
-- then home for 3, b counting down
|
-- then home for 3, b counting down
|
||||||
@@ -3778,7 +3834,7 @@ function BattleState:onFaint(battler)
|
|||||||
if battler.isPlayer then
|
if battler.isPlayer then
|
||||||
-- RemoveFaintedPlayerMon (core.asm:1040-1042): the player mon's
|
-- RemoveFaintedPlayerMon (core.asm:1040-1042): the player mon's
|
||||||
-- faint plays its ordinary species cry -- no Faint_Fall
|
-- 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
|
elseif self.kind ~= "wild" then
|
||||||
-- FaintEnemyPokemon (core.asm:732-771): the enemy faint plays no
|
-- FaintEnemyPokemon (core.asm:732-771): the enemy faint plays no
|
||||||
-- species cry; trainer battles get SFX_FAINT_FALL, then SFX_FAINT_THUD
|
-- species cry; trainer battles get SFX_FAINT_FALL, then SFX_FAINT_THUD
|
||||||
@@ -3793,6 +3849,11 @@ function BattleState:onFaint(battler)
|
|||||||
end)
|
end)
|
||||||
self.nextInsert = (self.nextInsert or 0) + 1
|
self.nextInsert = (self.nextInsert or 0) + 1
|
||||||
table.insert(self.queue, self.nextInsert, { wait = Timing.FAINT_SLIDE })
|
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
|
if not battler.isPlayer and self.kind ~= "wild" then
|
||||||
-- FaintEnemyPokemon's SFX_FAINT_THUD lands as the slide does (after
|
-- FaintEnemyPokemon's SFX_FAINT_THUD lands as the slide does (after
|
||||||
-- Faint_Fall, before EnemyMonFaintedText)
|
-- Faint_Fall, before EnemyMonFaintedText)
|
||||||
@@ -3839,9 +3900,12 @@ function BattleState:awardExp()
|
|||||||
participants, alive = 1, { self.player.mon }
|
participants, alive = 1, { self.player.mon }
|
||||||
end
|
end
|
||||||
local function applyShare(mon, split, announce)
|
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,
|
local levels, gained = Experience.apply(self.data, mon, self.enemy.def,
|
||||||
self.enemy.mon.level, self.kind == "trainer",
|
self.enemy.mon.level, self.kind == "trainer",
|
||||||
split, mon.traded)
|
split, traded)
|
||||||
-- Track level-ups for EvolveAfterBattle (OverworldState:afterBattle ->
|
-- Track level-ups for EvolveAfterBattle (OverworldState:afterBattle ->
|
||||||
-- Evolution.checkParty). B-cancel leaves the mon at/above threshold;
|
-- Evolution.checkParty). B-cancel leaves the mon at/above threshold;
|
||||||
-- without this gate it re-triggers after every later fight (#213).
|
-- 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!")
|
local text = Strings.source("%s gained\n%d EXP. Points!")
|
||||||
if announce == "expAll" then
|
if announce == "expAll" then
|
||||||
text = Strings.source("%s gained\nwith EXP.ALL,\v%d EXP. Points!")
|
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!")
|
text = Strings.source("%s gained\na boosted\v%d EXP. Points!")
|
||||||
end
|
end
|
||||||
self:sayNext(Strings(text, name, gained))
|
self:sayNext(Strings(text, name, gained))
|
||||||
@@ -3877,9 +3941,11 @@ function BattleState:awardExp()
|
|||||||
-- experience.asm:248 fires per grew-level text
|
-- experience.asm:248 fires per grew-level text
|
||||||
require("src.world.PikachuFollower")
|
require("src.world.PikachuFollower")
|
||||||
.modifyHappiness(game.save, "LEVELUP", mon)
|
.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()
|
self:uiNext(function()
|
||||||
require("src.core.Sound").play(game.data, "Level_Up")
|
|
||||||
return StatBox.new(game, mon)
|
return StatBox.new(game, mon)
|
||||||
end)
|
end)
|
||||||
-- After PrintStatsBox, experience.asm reloads the active battler's
|
-- After PrintStatsBox, experience.asm reloads the active battler's
|
||||||
@@ -4024,7 +4090,7 @@ function BattleState:enemyMonFainted()
|
|||||||
self.enemySendingOut = false
|
self.enemySendingOut = false
|
||||||
self:startGrowIn(self.enemy)
|
self:startGrowIn(self.enemy)
|
||||||
self:actNext(function()
|
self:actNext(function()
|
||||||
self:playEntranceCry(self.enemy)
|
self:waitSfxNext(self:playEntranceCry(self.enemy))
|
||||||
end)
|
end)
|
||||||
end)
|
end)
|
||||||
end)
|
end)
|
||||||
@@ -4063,7 +4129,7 @@ function BattleState:enemyMonFainted()
|
|||||||
self:actNext(function()
|
self:actNext(function()
|
||||||
self.sendingOut = false
|
self.sendingOut = false
|
||||||
self:startGrowIn(self.player)
|
self:startGrowIn(self.player)
|
||||||
self:playEntranceCry(self.player)
|
self:waitSfxNext(self:playEntranceCry(self.player))
|
||||||
end)
|
end)
|
||||||
end)
|
end)
|
||||||
return
|
return
|
||||||
@@ -4129,15 +4195,17 @@ function BattleState:learnMove(mon, moveId)
|
|||||||
if #mon.moves < 4 then
|
if #mon.moves < 4 then
|
||||||
table.insert(mon.moves, { id = moveId, pp = mdef.pp })
|
table.insert(mon.moves, { id = moveId, pp = mdef.pp })
|
||||||
Runtime.emit("pokemon.move_learned", { mon = mon, moveId = moveId })
|
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,
|
self:sayNextWaitSfx(self:romText("_MimicLearnedMoveText", "%s learned\n%s!", mon.nickname or self.data.pokemon[mon.species].name,
|
||||||
mdef.name))
|
mdef.name), function()
|
||||||
|
return require("src.core.Sound").play(self.data, "Level_Up")
|
||||||
|
end)
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
-- the "trying to learn" preamble lives inside MoveLearnMenu:enter;
|
-- the "trying to learn" preamble lives inside MoveLearnMenu:enter;
|
||||||
-- ordered insert so multi-level gains keep each level's checks
|
-- ordered insert so multi-level gains keep each level's checks
|
||||||
-- between its own stat box and the next "grew to level" text
|
-- between its own stat box and the next "grew to level" text
|
||||||
self:uiNext(function()
|
self:uiNext(function()
|
||||||
return self:buildScreen("MoveLearnMenu", mon, moveId)
|
return self:buildScreen("MoveLearnMenu", mon, moveId, nil, "Level_Up")
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -4257,7 +4325,7 @@ function BattleState:openReplacementMenu()
|
|||||||
self.sendingOut = false
|
self.sendingOut = false
|
||||||
-- SendOutMon (core.asm:1757-1762): poof, then the grow-in
|
-- SendOutMon (core.asm:1757-1762): poof, then the grow-in
|
||||||
self:startGrowIn(self.player)
|
self:startGrowIn(self.player)
|
||||||
self:playEntranceCry(self.player)
|
self:waitSfxNext(self:playEntranceCry(self.player))
|
||||||
end)
|
end)
|
||||||
end,
|
end,
|
||||||
})
|
})
|
||||||
@@ -4301,11 +4369,10 @@ function BattleState:safariAction(choice)
|
|||||||
-- above DoBallTossSpecialEffects's <= ULTRA_BALL check)
|
-- above DoBallTossSpecialEffects's <= ULTRA_BALL check)
|
||||||
self:ballChain(self:tossAnimFor("SAFARI_BALL"), caught, shakes, "SAFARI_BALL")
|
self:ballChain(self:tossAnimFor("SAFARI_BALL"), caught, shakes, "SAFARI_BALL")
|
||||||
if caught then
|
if caught then
|
||||||
-- ItemUseBallText05's sound_caught_mon: fanfare with the text
|
-- ItemUseBallText05: text_far, sound_caught_mon, text_promptbutton --
|
||||||
self:actNext(function()
|
-- the fanfare follows the caught text and holds the prompt
|
||||||
require("src.core.Sound").play(self.data, "Caught_Mon")
|
self:sayNextWaitSfx(Strings("All right!\n%s was\ncaught!", self.enemy.name),
|
||||||
end)
|
function() return require("src.core.Sound").play(self.data, "Caught_Mon") end)
|
||||||
self:sayNext(Strings("All right!\n%s was\ncaught!", self.enemy.name))
|
|
||||||
-- same ItemUseBall .captured flow as a regular ball
|
-- same ItemUseBall .captured flow as a regular ball
|
||||||
self:act(function() self:storeCaughtMon() end)
|
self:act(function() self:storeCaughtMon() end)
|
||||||
else
|
else
|
||||||
@@ -4533,8 +4600,12 @@ function BattleState:storeCaughtMon()
|
|||||||
markOwned(game, species)
|
markOwned(game, species)
|
||||||
stampOT(game.save, self.enemy.mon)
|
stampOT(game.save, self.enemy.mon)
|
||||||
if isNew then
|
if isNew then
|
||||||
-- _ItemUseBallText06 + ShowPokedexData
|
-- _ItemUseBallText06 + ShowPokedexData: text_far, sound_dex_page_added,
|
||||||
self:sayNext(Strings("New POKéDEX data\nwill be added for\n%s!", self.enemy.name))
|
-- 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()
|
self:uiNext(function()
|
||||||
return self:buildScreen("DexEntryMenu", species)
|
return self:buildScreen("DexEntryMenu", species)
|
||||||
end)
|
end)
|
||||||
@@ -4684,12 +4755,10 @@ function BattleState:throwBall(ball)
|
|||||||
self:ballChain(self:tossAnimFor(ball), caught, shakes, ball)
|
self:ballChain(self:tossAnimFor(ball), caught, shakes, ball)
|
||||||
if caught then
|
if caught then
|
||||||
-- ItemUseBallText05 carries sound_caught_mon (item_effects.asm:
|
-- ItemUseBallText05 carries sound_caught_mon (item_effects.asm:
|
||||||
-- 608-614): the fanfare sounds with the caught message, before
|
-- 608-614): text_far, sound_caught_mon, text_promptbutton -- the
|
||||||
-- the prompt, not after the text is dismissed
|
-- fanfare follows the caught message and holds the prompt
|
||||||
self:actNext(function()
|
self:sayNextWaitSfx(Strings("All right!\n%s was\ncaught!", self.enemy.name),
|
||||||
require("src.core.Sound").play(self.data, "Caught_Mon")
|
function() return require("src.core.Sound").play(self.data, "Caught_Mon") end)
|
||||||
end)
|
|
||||||
self:sayNext(Strings("All right!\n%s was\ncaught!", self.enemy.name))
|
|
||||||
self:act(function() self:storeCaughtMon() end)
|
self:act(function() self:storeCaughtMon() end)
|
||||||
else
|
else
|
||||||
self:sayNext(self:ballMissMessage(shakes))
|
self:sayNext(self:ballMissMessage(shakes))
|
||||||
@@ -5176,7 +5245,8 @@ function BattleState:sgbBattlePals()
|
|||||||
local function bar(b)
|
local function bar(b)
|
||||||
if not b then return pals.GREENBAR end
|
if not b then return pals.GREENBAR end
|
||||||
local hp = b.shownHP or b.mon.hp
|
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
|
end
|
||||||
local function mon(b, placeholder)
|
local function mon(b, placeholder)
|
||||||
if placeholder or not b then return pals.MEWMON or pals.GREENBAR end
|
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)
|
hudTile(0x73, 8, 16)
|
||||||
drawHPBar(barData, 2, 2,
|
drawHPBar(barData, 2, 2,
|
||||||
{ hp = shownHP(self.enemy), stats = self.enemy.mon.stats },
|
{ hp = shownHP(self.enemy), stats = self.enemy.mon.stats },
|
||||||
nil, grayFill)
|
nil, grayFill, nil, self.enemy.shownPx)
|
||||||
hudTile(0x74, 8, 24)
|
hudTile(0x74, 8, 24)
|
||||||
for i = 2, 9 do hudTile(0x76, i * 8, 24) end
|
for i = 2, 9 do hudTile(0x76, i * 8, 24) end
|
||||||
hudTile(0x78, 80, 24)
|
hudTile(0x78, 80, 24)
|
||||||
@@ -5655,7 +5725,7 @@ function BattleState:drawHUDs(slide)
|
|||||||
end
|
end
|
||||||
drawHPBar(barData, 10, 9,
|
drawHPBar(barData, 10, 9,
|
||||||
{ hp = shownHP(self.player), stats = self.player.mon.stats },
|
{ 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)
|
Font.draw(("%3d/%3d"):format(shownHP(self.player), self.player.mon.stats.hp), 88, 80)
|
||||||
hudTile(0x73, 144, 80)
|
hudTile(0x73, 144, 80)
|
||||||
hudTile(0x77, 144, 88)
|
hudTile(0x77, 144, 88)
|
||||||
@@ -5791,6 +5861,7 @@ function BattleState:drawTextArea()
|
|||||||
Font.draw(self.data.moves[m.id].name, 16, (7 + i) * 8)
|
Font.draw(self.data.moves[m.id].name, 16, (7 + i) * 8)
|
||||||
end
|
end
|
||||||
Font.drawCode(0xED, 8, (7 + self.mimicIndex) * 8)
|
Font.drawCode(0xED, 8, (7 + self.mimicIndex) * 8)
|
||||||
|
Font.draw(Strings("WHICH TECHNIQUE?"), 8, 112)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -310,8 +310,7 @@ function EffectRegistry.runDamaging(battle, ctx, record)
|
|||||||
if record and record.afterDamage then
|
if record and record.afterDamage then
|
||||||
record.afterDamage(ctx, totalDealt)
|
record.afterDamage(ctx, totalDealt)
|
||||||
elseif moveInst.struggle then
|
elseif moveInst.struggle then
|
||||||
-- struggle recoils even when its effect id resolves to no record
|
local recoil = math.max(1, math.floor(totalDealt / 2))
|
||||||
local recoil = math.max(1, math.floor(dmg / 2))
|
|
||||||
battle:sayNext(romText(battle.data, "_HitWithRecoilText", "%s's\nhit with recoil!", displayName(user)))
|
battle:sayNext(romText(battle.data, "_HitWithRecoilText", "%s's\nhit with recoil!", displayName(user)))
|
||||||
battle:applyDamage(user, recoil)
|
battle:applyDamage(user, recoil)
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -348,8 +348,9 @@ MoveEffects.primary = {
|
|||||||
target.disabledTurns = battle.rng(1, 8)
|
target.disabledTurns = battle.rng(1, 8)
|
||||||
local id = target.curMoves[slot].id
|
local id = target.curMoves[slot].id
|
||||||
-- _MoveWasDisabledText: "X's / MOVE was / disabled!"
|
-- _MoveWasDisabledText: "X's / MOVE was / disabled!"
|
||||||
return { romText(battle.data, "_MoveWasDisabledText", "%s's\n%s was\ndisabled!", displayName(target),
|
return { romText(battle.data, "_MoveWasDisabledText", "%s's\n%s was\ndisabled!",
|
||||||
battle.data.moves[id].name) }
|
{ TARGET = displayName(target),
|
||||||
|
["RAM:wNameBuffer"] = battle.data.moves[id].name }) }
|
||||||
end,
|
end,
|
||||||
|
|
||||||
SPLASH_EFFECT = function(battle)
|
SPLASH_EFFECT = function(battle)
|
||||||
@@ -441,11 +442,10 @@ local function hitsFrom(dist, ctx)
|
|||||||
return dist[r + 1]
|
return dist[r + 1]
|
||||||
end
|
end
|
||||||
|
|
||||||
-- drain_hp.asm halves the RAW wDamage IN PLACE (minimum 1) and heals
|
-- engine/battle/core.asm ApplyDamageToEnemyPokemon
|
||||||
-- that amount, so Counter would see the halved value
|
|
||||||
local function drainHalf(label, text)
|
local function drainHalf(label, text)
|
||||||
return function(ctx)
|
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
|
ctx.battle.lastDamage = heal
|
||||||
local mon = ctx.user.mon
|
local mon = ctx.user.mon
|
||||||
mon.hp = math.min(mon.stats.hp, mon.hp + heal)
|
mon.hp = math.min(mon.stats.hp, mon.hp + heal)
|
||||||
@@ -529,9 +529,8 @@ MoveEffects.full = {
|
|||||||
|
|
||||||
RECOIL_EFFECT = {
|
RECOIL_EFFECT = {
|
||||||
afterDamage = function(ctx)
|
afterDamage = function(ctx)
|
||||||
-- recoil.asm reads the RAW computed wDamage (not the HP actually
|
-- engine/battle/move_effects/recoil.asm
|
||||||
-- removed): overkill and substitute hits recoil at full strength
|
local recoil = math.max(1, math.floor(ctx.totalDealt
|
||||||
local recoil = math.max(1, math.floor(ctx.rawDamage
|
|
||||||
/ (ctx.moveInst.struggle and 2 or 4)))
|
/ (ctx.moveInst.struggle and 2 or 4)))
|
||||||
ctx.say(romText(ctx.battle.data, "_HitWithRecoilText", "%s's\nhit with recoil!", displayName(ctx.user)))
|
ctx.say(romText(ctx.battle.data, "_HitWithRecoilText", "%s's\nhit with recoil!", displayName(ctx.user)))
|
||||||
ctx.battle:applyDamage(ctx.user, recoil)
|
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
|
local shown = moves and moves[selectedMoveId] and moves[selectedMoveId].name
|
||||||
or tostring(selectedMoveId)
|
or tostring(selectedMoveId)
|
||||||
table.insert(msgs, romText(battle and battle.data, "_MoveIsDisabledText",
|
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
|
return false, msgs
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -108,7 +108,7 @@ local function drawStatusPanel(battle, battler, x, y, player)
|
|||||||
HudTiles.drawHPBar(battle.data, tx + 1, ty + 2, {
|
HudTiles.drawHPBar(battle.data, tx + 1, ty + 2, {
|
||||||
hp = shownHP(battler),
|
hp = shownHP(battler),
|
||||||
stats = battler.mon.stats,
|
stats = battler.mon.stats,
|
||||||
}, nil, monoMode(), tw - 5)
|
}, nil, monoMode(), tw - 5, battler.shownPx)
|
||||||
|
|
||||||
if player then
|
if player then
|
||||||
Font.draw(("%3d/%3d"):format(shownHP(battler), battler.mon.stats.hp),
|
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
|
-- * Gen 1's `user` / `target` / `battler` are battler wrappers around a mon
|
||||||
-- ({ mon = , name = , isPlayer = }); Gen 2's engine works on the party mon
|
-- ({ mon = , name = , isPlayer = }); Gen 2's engine works on the party mon
|
||||||
-- table directly, so that is what these payloads carry.
|
-- 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
|
-- * Gen 1's `rng` is love.math.random (rng(n) → 1..n, rng(lo,hi) → lo..hi).
|
||||||
-- the cart's BattleRandom convention (0..n-1). Both keys are present.
|
-- 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")
|
local Runtime = require("src.mods.Runtime")
|
||||||
-- The two battle lines that carry the cart's own `line` break: a marker-bearing
|
-- 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
|
-- 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
|
return math.random(n) - 1
|
||||||
end
|
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
|
-- data/trainers/leaders.asm. The two lists are ONE array in the ROM: only
|
||||||
-- KantoGymLeaders carries the -1 terminator, and GymLeaders falls through into
|
-- KantoGymLeaders carries the -1 terminator, and GymLeaders falls through into
|
||||||
-- it, so IsGymLeader matches all twenty-two classes while IsKantoGymLeader
|
-- 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`
|
-- this wild battle is BATTLETYPE_ROAMING; the caller built `wild`
|
||||||
-- through Roamers.beginBattle and reads Battle.roaming back to
|
-- through Roamers.beginBattle and reads Battle.roaming back to
|
||||||
-- bank the beast's HP afterwards
|
-- 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)
|
function Battle.new(opts)
|
||||||
opts = opts or {}
|
opts = opts or {}
|
||||||
local self = setmetatable({}, Battle)
|
local self = setmetatable({}, Battle)
|
||||||
self.data = opts.data or {}
|
self.data = opts.data or {}
|
||||||
self.random = opts.random or function(n) return rand(nil, n) end
|
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.party = opts.party or {}
|
||||||
self.trainer = opts.trainer
|
self.trainer = opts.trainer
|
||||||
self.save = opts.save
|
self.save = opts.save
|
||||||
@@ -403,11 +421,8 @@ function Battle:endBattle(outcome)
|
|||||||
Runtime.emit("battle.ended", { battle = self, result = outcome })
|
Runtime.emit("battle.ended", { battle = self, result = outcome })
|
||||||
end
|
end
|
||||||
|
|
||||||
-- A never-nil 0..n-1 roller for the hook contexts. `random` is optional on the
|
-- Never-nil BattleRandom (0..n-1) for call sites that want the cart byte.
|
||||||
-- constructor (a headless test injects one, the game leaves it to love.math),
|
-- `battle.rng` is the Gen 1 / love.math view of the same stream.
|
||||||
-- 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).
|
|
||||||
function Battle:roller()
|
function Battle:roller()
|
||||||
if not self.rollerFn then
|
if not self.rollerFn then
|
||||||
self.rollerFn = function(n) return rand(self.random, n) end
|
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
|
-- 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
|
-- MAGNITUDE at power 1 precisely because this overwrites it. Returns the
|
||||||
-- power and the magnitude number the text prints.
|
-- 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)
|
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
|
for _, row in ipairs(Effects.MAGNITUDE_POWER) do
|
||||||
if row[1] >= roll then return row[2], row[3] end
|
if row[1] >= roll then return row[2], row[3] end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -334,6 +334,12 @@ function BattleCheckpoint.restore(game, checkpoint, copy)
|
|||||||
battle.player.mon.hp, battle.player.mon.status
|
battle.player.mon.hp, battle.player.mon.status
|
||||||
battle.enemy.shownHP, battle.enemy.shownStatus =
|
battle.enemy.shownHP, battle.enemy.shownStatus =
|
||||||
battle.enemy.mon.hp, battle.enemy.mon.status
|
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
|
local ow = game.overworld
|
||||||
if not ow or type(ow.restoreBattleContinuation) ~= "function"
|
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.tempo = header.tempo
|
||||||
engine.tempoLocked = true
|
engine.tempoLocked = true
|
||||||
end
|
end
|
||||||
for _, spec in ipairs(chip and chip.channels
|
local channels = chip and chip.channels or headerChannels(banks, header)
|
||||||
or headerChannels(banks, header)) do
|
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 frameTicks = options.frameTicks
|
||||||
local hardware = (spec.number - 1) % 4 + 1
|
local hardware = (spec.number - 1) % 4 + 1
|
||||||
if hardware == 4 then
|
if hardware == 4 then
|
||||||
|
|||||||
+44
-15
@@ -73,7 +73,7 @@ end
|
|||||||
--
|
--
|
||||||
-- Gold composites its own frame (Game2:draw / drawScene) and pumps its own pad
|
-- 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
|
-- (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
|
-- 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
|
-- 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
|
-- 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)
|
-- input.pointer uncaptured pointer events (src/core/Game.lua:887)
|
||||||
-- render.zones the palette pass, pre-blit (src/core/Game.lua:505)
|
-- render.zones the palette pass, pre-blit (src/core/Game.lua:505)
|
||||||
-- render.compose the whole-window composite (Renderer.lua:759)
|
-- 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.letterbox the void around the 160x144 blit (Renderer.lua:840)
|
||||||
-- render.hud screen-space UI over the frame (src/core/Game.lua:521)
|
-- 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)
|
self:say(result.text)
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
self:consumeItem(itemId)
|
if action == "stone" then
|
||||||
if action == "candy" 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)
|
self:say(result.text, function() self:afterRareCandy(mon, result) end)
|
||||||
else
|
else
|
||||||
|
self:consumeItem(itemId)
|
||||||
self:say(result.text)
|
self:say(result.text)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -1366,12 +1383,14 @@ function Game2:draw()
|
|||||||
local zoned = type(zones) == "table" and zones[1] ~= nil
|
local zoned = type(zones) == "table" and zones[1] ~= nil
|
||||||
|
|
||||||
-- A present canvas is paid for only when something reads it: the zone pass,
|
-- 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
|
-- GBC FX, a mod post-process, render.compose, or an enabled render.output
|
||||||
-- handed the finished frame. With none of them the frame draws straight to
|
-- subscriber. With none of them the frame draws straight to the screen
|
||||||
-- the screen exactly as it always did.
|
-- exactly as it always did.
|
||||||
local composing = ModRuntime.wantsHook("render.compose")
|
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
|
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)
|
scene = self:presentCanvas(1, w, h)
|
||||||
end
|
end
|
||||||
if not scene then
|
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
|
-- untinted one. On its own the tint rides the final blit and no second
|
||||||
-- canvas is paid for.
|
-- canvas is paid for.
|
||||||
local source = scene
|
local source = scene
|
||||||
local reread = fx or Pipelines.wantsPresent()
|
local reread = fx or Pipelines.wantsPresent() or hasOutputHook
|
||||||
if zoned and reread then
|
if zoned and reread then
|
||||||
local tinted = self:presentCanvas(2, w, h)
|
local tinted = self:presentCanvas(2, w, h)
|
||||||
if tinted then
|
if tinted then
|
||||||
@@ -1423,15 +1442,25 @@ function Game2:draw()
|
|||||||
-- Post-process pipelines run over the finished composite and before GBC
|
-- Post-process pipelines run over the finished composite and before GBC
|
||||||
-- FX. Each hands back a canvas; with none registered this returns `source`
|
-- FX. Each hands back a canvas; with none registered this returns `source`
|
||||||
-- unchanged and the frame is byte-identical (Renderer.lua:1058).
|
-- 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,
|
source = Pipelines.present(source, { width = w, height = h, scale = scale,
|
||||||
dpi = dpi, dpiX = dpi, dpiY = dpi }) or source
|
dpi = dpi, dpiX = dpi, dpiY = dpi }) or source
|
||||||
if fx then
|
local outputHandled = hasOutputHook
|
||||||
GBCFX.present(source, self:pixelScale(w, h))
|
and ModRuntime.call("render.output", function() return false end, {
|
||||||
else
|
canvas = source, width = w, height = h,
|
||||||
G.setColor(1, 1, 1, 1)
|
gameX = ox, gameY = oy,
|
||||||
G.draw(source, 0, 0)
|
gameWidth = 160 * scale, gameHeight = 144 * scale,
|
||||||
G.setShader()
|
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
|
||||||
end
|
end
|
||||||
G.pop()
|
G.pop()
|
||||||
|
|||||||
+17
-2
@@ -64,6 +64,7 @@ state = {
|
|||||||
fanfareResume = false, -- start/resume state.source when the fanfare ends
|
fanfareResume = false, -- start/resume state.source when the fanfare ends
|
||||||
fade = nil, -- active volume-ramp fade-out (see Music.fadeOut)
|
fade = nil, -- active volume-ramp fade-out (see Music.fadeOut)
|
||||||
tempo = nil, -- alternate-tempo override in force for `current`
|
tempo = nil, -- alternate-tempo override in force for `current`
|
||||||
|
start = nil,
|
||||||
failed = {}, -- labels whose def could not be started; logged once
|
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)
|
song = selectSong(song, ctx)
|
||||||
|
|
||||||
local tempo = ctx and ctx.tempo or nil
|
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
|
-- a hook may silence the cue outright, or swap in a label the dedupe
|
||||||
-- below has to compare against
|
-- 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)
|
local def = songDef(data, song)
|
||||||
if not def or state.failed[song] then return end
|
if not def or state.failed[song] then return end
|
||||||
|
|
||||||
@@ -240,6 +243,17 @@ function Music.play(data, song, loop, ctx)
|
|||||||
slowed.tempo = tempo
|
slowed.tempo = tempo
|
||||||
def = slowed
|
def = slowed
|
||||||
end
|
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 wantLoop = loop ~= false
|
||||||
local src, loopSrc, isChip, err = startSong(data, def, wantLoop)
|
local src, loopSrc, isChip, err = startSong(data, def, wantLoop)
|
||||||
if not src then
|
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.source, state.loopSource, state.chip = src, loopSrc, isChip
|
||||||
state.current = song
|
state.current = song
|
||||||
state.tempo = tempo
|
state.tempo = tempo
|
||||||
|
state.start = start
|
||||||
if Runtime.wants("music.started") then
|
if Runtime.wants("music.started") then
|
||||||
Runtime.emit("music.started", {
|
Runtime.emit("music.started", {
|
||||||
song = song, previous = previous, chip = isChip,
|
song = song, previous = previous, chip = isChip,
|
||||||
@@ -290,7 +305,7 @@ function Music.stop()
|
|||||||
stopSource(state.loopSource)
|
stopSource(state.loopSource)
|
||||||
require("src.core.ChipAudio").stopMusic()
|
require("src.core.ChipAudio").stopMusic()
|
||||||
state.current, state.source, state.loopSource, state.fade = nil, nil, nil, nil
|
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.chip = false
|
||||||
state.pendingRestore = nil
|
state.pendingRestore = nil
|
||||||
if previous and Runtime.wants("music.stopped") then
|
if previous and Runtime.wants("music.stopped") then
|
||||||
|
|||||||
@@ -33,6 +33,14 @@ return function(data, label, fallback, ...)
|
|||||||
local args = { ... }
|
local args = { ... }
|
||||||
if #args == 0 then return text end
|
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
|
local slots, named = 0, 0
|
||||||
for token in text:gmatch("%b{}") do
|
for token in text:gmatch("%b{}") do
|
||||||
slots = slots + 1
|
slots = slots + 1
|
||||||
|
|||||||
+63
-8
@@ -304,6 +304,10 @@ function SaveData.defaultOptions()
|
|||||||
-- options.lua written before this key keeps its exact meaning. Read and
|
-- options.lua written before this key keeps its exact meaning. Read and
|
||||||
-- written through SaveData.modEnabled / SaveData.setModEnabled.
|
-- written through SaveData.modEnabled / SaveData.setModEnabled.
|
||||||
modsByVersion = {},
|
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
|
-- Named setups the player can switch between (#593; src/mods/ModProfile.lua
|
||||||
-- owns the shape, src/mods/ManagerState.lua the UI): each row is
|
-- owns the shape, src/mods/ManagerState.lua the UI): each row is
|
||||||
-- { name, enabled = {id=bool}, options = {id={k=v}}, slots = {version=slotId} }.
|
-- { 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
|
-- 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.
|
-- 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
|
-- Per-game answers are live. Every reader and writer goes through modScope,
|
||||||
-- flags once, before any entry chunk (src/mods/Loader.lua _loadState), so this
|
-- so a choice made in the launcher is the choice the next boot loads.
|
||||||
-- flips on with that read and not before: until then every writer keeps to the
|
SaveData.PER_VERSION_MODS = true
|
||||||
-- shared flag and no surface promises what the boot does not do.
|
|
||||||
SaveData.PER_VERSION_MODS = false
|
|
||||||
|
|
||||||
-- The version a write should be scoped to: the game asked for once per-game
|
-- The version a write is scoped to: per-game controls name one game; a nil
|
||||||
-- flags are live, nil (the shared flag) while they are only a preview.
|
-- caller still addresses the legacy shared fallback.
|
||||||
function SaveData.modScope(version)
|
function SaveData.modScope(version)
|
||||||
if SaveData.PER_VERSION_MODS then return version end
|
if SaveData.PER_VERSION_MODS then return version end
|
||||||
return nil
|
return nil
|
||||||
end
|
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
|
-- true/false as chosen for `version`, else the shared flag, else nil -- the
|
||||||
-- caller owns the default (the loader enables, the launcher keeps
|
-- caller owns the default (the loader enables, the launcher keeps
|
||||||
-- experimental mods off until asked).
|
-- experimental mods off until asked).
|
||||||
@@ -1834,6 +1887,8 @@ function SaveData.newGame(boot)
|
|||||||
boot = type(boot) == "table" and boot or {}
|
boot = type(boot) == "table" and boot or {}
|
||||||
local map = boot.startMap or "REDS_HOUSE_2F"
|
local map = boot.startMap or "REDS_HOUSE_2F"
|
||||||
local x, y = boot.startX or 3, boot.startY or 6
|
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 heal = SaveData.defaultHeal(boot)
|
||||||
local save = {
|
local save = {
|
||||||
meta = { format = Version.saveFormat, mods = {} },
|
meta = { format = Version.saveFormat, mods = {} },
|
||||||
@@ -1844,7 +1899,7 @@ function SaveData.newGame(boot)
|
|||||||
map = map,
|
map = map,
|
||||||
x = x,
|
x = x,
|
||||||
y = y,
|
y = y,
|
||||||
facing = boot.startFacing or "down",
|
facing = facing,
|
||||||
name = boot.playerName or "RED",
|
name = boot.playerName or "RED",
|
||||||
rival = boot.rivalName or "BLUE",
|
rival = boot.rivalName or "BLUE",
|
||||||
-- 16-bit trainer ID rolled at new game (wPlayerID, filled from
|
-- 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)
|
return rareCandy(ctx.mon, ctx.data)
|
||||||
end)
|
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
|
for itemId in pairs(ItemEffects.REVIVE) do
|
||||||
record(itemId, "revive", function(ctx) return revive(ctx.item, ctx.mon) end)
|
record(itemId, "revive", function(ctx) return revive(ctx.item, ctx.mon) end)
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -116,6 +116,8 @@ local physfsMountFn = nil
|
|||||||
local function resolveMount()
|
local function resolveMount()
|
||||||
if physfsMountFn ~= nil then return physfsMountFn end
|
if physfsMountFn ~= nil then return physfsMountFn end
|
||||||
physfsMountFn = false
|
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")
|
local ok, ffi = pcall(require, "ffi")
|
||||||
if not ok then return physfsMountFn end
|
if not ok then return physfsMountFn end
|
||||||
pcall(ffi.cdef,
|
pcall(ffi.cdef,
|
||||||
@@ -159,6 +161,8 @@ local physfsUnmountFn = nil
|
|||||||
local function resolveUnmount()
|
local function resolveUnmount()
|
||||||
if physfsUnmountFn ~= nil then return physfsUnmountFn end
|
if physfsUnmountFn ~= nil then return physfsUnmountFn end
|
||||||
physfsUnmountFn = false
|
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")
|
local ok, ffi = pcall(require, "ffi")
|
||||||
if not ok then return physfsUnmountFn end
|
if not ok then return physfsUnmountFn end
|
||||||
pcall(ffi.cdef, "int PHYSFS_unmount(const char *oldDir);")
|
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 = {},
|
saveNotice = {},
|
||||||
-- MODS panel state (pass 3): mods is the cached LauncherMods.list() array
|
-- MODS panel state (pass 3): mods is the cached LauncherMods.list() array
|
||||||
-- (refreshed lazily on first draw and after any toggle/install/delete);
|
-- (refreshed lazily on first draw and after any toggle/install/delete);
|
||||||
-- modScroll is the list scroll offset (px, clamped in draw); modNotice is
|
-- modScroll is the current paged list's inner scroll offset (px, clamped
|
||||||
-- the last install/delete result { ok, text } shown as a line above the list.
|
-- in draw); modNotice is the last install/delete result { ok, text }.
|
||||||
mods = nil, modScroll = 0, modNotice = nil,
|
mods = nil, modScroll = 0, modNotice = nil,
|
||||||
-- Which game the MODS panel is answering for (a GameVersion id, nil =
|
-- Which game the MODS panel is answering for (a GameVersion id, nil =
|
||||||
-- every game). Rows resolve their enable-state and their "runs here"
|
-- every game). Rows resolve their enable-state and their "runs here"
|
||||||
@@ -1649,7 +1649,7 @@ end
|
|||||||
function RomImporter:_installMod(source)
|
function RomImporter:_installMod(source)
|
||||||
if self.workState == "working" then return end
|
if self.workState == "working" then return end
|
||||||
self.tab = "mods"
|
self.tab = "mods"
|
||||||
local ok, installed, res = pcall(function()
|
local ok, installed, res, manifest = pcall(function()
|
||||||
local LauncherMods = require("src.mods.LauncherMods")
|
local LauncherMods = require("src.mods.LauncherMods")
|
||||||
return LauncherMods.installZip(source)
|
return LauncherMods.installZip(source)
|
||||||
end)
|
end)
|
||||||
@@ -1661,6 +1661,17 @@ function RomImporter:_installMod(source)
|
|||||||
if installed then
|
if installed then
|
||||||
pcall(self._refreshMods, self)
|
pcall(self._refreshMods, self)
|
||||||
self.modNotice = { ok = true, text = "Installed " .. tostring(res) }
|
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
|
else
|
||||||
self.modNotice = { ok = false, text = tostring(res) }
|
self.modNotice = { ok = false, text = tostring(res) }
|
||||||
end
|
end
|
||||||
@@ -2048,6 +2059,15 @@ end
|
|||||||
|
|
||||||
function RomImporter:update(dt)
|
function RomImporter:update(dt)
|
||||||
self.pulse = self.pulse + 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:_updatePadCursor(dt)
|
||||||
self:_stepBaseRomScan()
|
self:_stepBaseRomScan()
|
||||||
-- Pump the FlexLove view (input polling + the queued click actions). The
|
-- Pump the FlexLove view (input polling + the queued click actions). The
|
||||||
@@ -2436,9 +2456,15 @@ function RomImporter:joystickhat(joystick, hat, direction)
|
|||||||
end
|
end
|
||||||
|
|
||||||
-- Player pressed Play on a game whose ROM is imported: hand off to boot.
|
-- 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 self.workState == "working" then return end
|
||||||
if not self.ready[version] 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
|
self._handedOff = true
|
||||||
-- #835: remember the game being launched so the next launcher start opens on
|
-- #835: remember the game being launched so the next launcher start opens on
|
||||||
-- its column (_applyLastVersionTab). It rides options.lua rather than a file
|
-- its column (_applyLastVersionTab). It rides options.lua rather than a file
|
||||||
@@ -2701,6 +2727,43 @@ function RomImporter:fileUrl(path)
|
|||||||
end
|
end
|
||||||
|
|
||||||
function RomImporter:keypressed(key)
|
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 self._settingsText then
|
||||||
if key == "backspace" then
|
if key == "backspace" then
|
||||||
self._settingsText.text = utf8Back(self._settingsText.text)
|
self._settingsText.text = utf8Back(self._settingsText.text)
|
||||||
@@ -2870,6 +2933,14 @@ function RomImporter:_commitRename()
|
|||||||
end
|
end
|
||||||
|
|
||||||
function RomImporter:textinput(text)
|
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
|
if self._settingsText then
|
||||||
local st = self._settingsText
|
local st = self._settingsText
|
||||||
st.text = utf8Cap(st.text .. text, st.maxLen or MAX_SLOT_LABEL)
|
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
|
return self.modUpdateInfo and self.modUpdateInfo[id] or nil
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Flip a mod's enabled flag (persisted via LauncherMods.setEnabled) and relist
|
-- Flip one game's mod flag (persisted via LauncherMods.setEnabled) and relist
|
||||||
-- so the toggle, count, and every status chip reflect the new resolution.
|
-- so that game's checkbox and status chips reflect the new resolution.
|
||||||
-- Enabling an experimental mod arms a confirm first.
|
-- Enabling an experimental mod arms a confirmation for that same game.
|
||||||
function RomImporter:_toggleMod(id, confirmed)
|
function RomImporter:_toggleMod(id, confirmed, version)
|
||||||
local LauncherMods = require("src.mods.LauncherMods")
|
local LauncherMods = require("src.mods.LauncherMods")
|
||||||
local cur, experimental = false, false
|
local cur, experimental = false, false
|
||||||
for _, m in ipairs(self.mods or {}) do
|
for _, m in ipairs(self.mods or {}) do
|
||||||
if m.id == id then
|
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
|
experimental = m.experimental == true
|
||||||
break
|
break
|
||||||
end
|
end
|
||||||
@@ -3080,7 +3155,7 @@ function RomImporter:_toggleMod(id, confirmed)
|
|||||||
local want = not cur
|
local want = not cur
|
||||||
if want and experimental and not confirmed then
|
if want and experimental and not confirmed then
|
||||||
self._modConfirm = {
|
self._modConfirm = {
|
||||||
kind = "experimental", id = id,
|
kind = "experimental", id = id, version = version,
|
||||||
title = "Experimental mod",
|
title = "Experimental mod",
|
||||||
yesLabel = "Enable",
|
yesLabel = "Enable",
|
||||||
lines = {
|
lines = {
|
||||||
@@ -3092,7 +3167,7 @@ function RomImporter:_toggleMod(id, confirmed)
|
|||||||
return
|
return
|
||||||
end
|
end
|
||||||
self._modConfirm = nil
|
self._modConfirm = nil
|
||||||
LauncherMods.setEnabled(id, want, self.modScope)
|
LauncherMods.setEnabled(id, want, version or self.modScope)
|
||||||
self:_refreshMods()
|
self:_refreshMods()
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -3352,6 +3427,76 @@ function RomImporter:_pumpModInstall()
|
|||||||
else
|
else
|
||||||
self.modNotice = { ok = true, text = text }
|
self.modNotice = { ok = true, text = text }
|
||||||
end
|
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
|
end
|
||||||
|
|
||||||
function RomImporter:_confirmModUpdate(modId, release)
|
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.
|
-- (ItemUsePokeFlute, engine/items/item_effects.asm); never consumed.
|
||||||
if itemId == "POKE_FLUTE" then
|
if itemId == "POKE_FLUTE" then
|
||||||
if not battle 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
|
-- standing next to a not-yet-beaten Snorlax: this is the ONLY way
|
||||||
-- Snorlax wakes -- using the flute from the item-use menu, never
|
-- Snorlax wakes -- using the flute from the item-use menu, never
|
||||||
-- just talking to it with the flute in the bag (see
|
-- just talking to it with the flute in the bag (see
|
||||||
|
|||||||
+120
-11
@@ -9,15 +9,16 @@
|
|||||||
-- cable pull.
|
-- cable pull.
|
||||||
--
|
--
|
||||||
-- Cable rules: no experience, no money, no items; either side may RUN
|
-- 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
|
-- (a draw); a fainted mon is replaced from the party menu and the chosen
|
||||||
-- member (the original prompts; documented divergence). Badge stat
|
-- slot rides the wire, the way ChooseNextMon hands it to
|
||||||
-- boosts don't apply on either side (divergence: Gen 1 famously kept
|
-- LinkBattleExchangeData. Badge stat boosts don't apply on either side
|
||||||
-- them in link battles).
|
-- (divergence: Gen 1 famously kept them in link battles).
|
||||||
|
|
||||||
local Fingerprint = require("src.link.Fingerprint")
|
local Fingerprint = require("src.link.Fingerprint")
|
||||||
local Font = require("src.render.Font")
|
local Font = require("src.render.Font")
|
||||||
local Handshake = require("src.link.Handshake")
|
local Handshake = require("src.link.Handshake")
|
||||||
local Logger = require("src.core.Logger")
|
local Logger = require("src.core.Logger")
|
||||||
|
local Party = require("src.pokemon.Party")
|
||||||
local Protocol = require("src.link.Protocol")
|
local Protocol = require("src.link.Protocol")
|
||||||
local Runtime = require("src.mods.Runtime")
|
local Runtime = require("src.mods.Runtime")
|
||||||
local TurnOrder = require("src.battle.TurnOrder")
|
local TurnOrder = require("src.battle.TurnOrder")
|
||||||
@@ -257,7 +258,9 @@ function LinkBattle.new(game, net, opts)
|
|||||||
self.enemyParty = theirParty
|
self.enemyParty = theirParty
|
||||||
self.playerParty = myParty -- intro ball row uses the clamped copies
|
self.playerParty = myParty -- intro ball row uses the clamped copies
|
||||||
self.opponentName = theirName
|
self.opponentName = theirName
|
||||||
self.introText = 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.remoteHashes = {}
|
||||||
self.localHashes = {}
|
self.localHashes = {}
|
||||||
self.remoteParts = {}
|
self.remoteParts = {}
|
||||||
@@ -327,6 +330,68 @@ function LinkBattle.new(game, net, opts)
|
|||||||
end)
|
end)
|
||||||
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
|
-- decode a remote action message against the enemy battler
|
||||||
local function decodeTheirAction(s, msg)
|
local function decodeTheirAction(s, msg)
|
||||||
return decodeWireAction(s, msg, s.enemy)
|
return decodeWireAction(s, msg, s.enemy)
|
||||||
@@ -540,12 +605,17 @@ function LinkBattle.new(game, net, opts)
|
|||||||
submit(s, { type = "action", kind = "run" }, nil)
|
submit(s, { type = "action", kind = "run" }, nil)
|
||||||
end
|
end
|
||||||
|
|
||||||
-- fainted mons auto-replace with the next healthy teammate, in party
|
-- linkChoiceQueued: awaitReplacement already pre-empted itself with this
|
||||||
-- order, identically on both machines
|
-- 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)
|
self.playerMonFainted = function(s)
|
||||||
|
if s.linkChoiceQueued then
|
||||||
|
s.linkChoiceQueued = nil
|
||||||
|
return
|
||||||
|
end
|
||||||
for _, mon in ipairs(myParty) do
|
for _, mon in ipairs(myParty) do
|
||||||
if mon.hp > 0 then
|
if mon.hp > 0 then
|
||||||
s:act(function() sendOutPlayer(s, mon) end)
|
if not s.result then chooseReplacement(s) end
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -558,7 +628,7 @@ function LinkBattle.new(game, net, opts)
|
|||||||
self.enemyMonFainted = function(s)
|
self.enemyMonFainted = function(s)
|
||||||
for _, mon in ipairs(theirParty) do
|
for _, mon in ipairs(theirParty) do
|
||||||
if mon.hp > 0 then
|
if mon.hp > 0 then
|
||||||
s:act(function() sendOutEnemy(s, mon) end)
|
if not s.result then awaitReplacement(s) end
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -579,6 +649,9 @@ function LinkBattle.new(game, net, opts)
|
|||||||
s.remoteHashes[msg.turn or 0] = msg.value
|
s.remoteHashes[msg.turn or 0] = msg.value
|
||||||
s.remoteParts[msg.turn or 0] = msg.parts
|
s.remoteParts[msg.turn or 0] = msg.parts
|
||||||
checkHashes(s)
|
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
|
elseif msg.type == "bye" then
|
||||||
-- only a draw if our own simulation hasn't already decided
|
-- only a draw if our own simulation hasn't already decided
|
||||||
-- (the winner's bye can arrive while we're still animating)
|
-- (the winner's bye can arrive while we're still animating)
|
||||||
@@ -770,6 +843,34 @@ function LinkBattle.newSpectator(game, net, opts)
|
|||||||
end)
|
end)
|
||||||
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)
|
local function resolveSpecTurn(s, hostMsg, guestMsg)
|
||||||
if hostMsg.kind == "run" or guestMsg.kind == "run" then
|
if hostMsg.kind == "run" or guestMsg.kind == "run" then
|
||||||
endSpectate(s, "The match ended.")
|
endSpectate(s, "The match ended.")
|
||||||
@@ -840,7 +941,7 @@ function LinkBattle.newSpectator(game, net, opts)
|
|||||||
self.playerMonFainted = function(s)
|
self.playerMonFainted = function(s)
|
||||||
for _, mon in ipairs(hostParty) do
|
for _, mon in ipairs(hostParty) do
|
||||||
if mon.hp > 0 then
|
if mon.hp > 0 then
|
||||||
s:act(function() sendOutHost(s, mon) end)
|
if not s.result then awaitHostReplacement(s) end
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -852,7 +953,7 @@ function LinkBattle.newSpectator(game, net, opts)
|
|||||||
self.enemyMonFainted = function(s)
|
self.enemyMonFainted = function(s)
|
||||||
for _, mon in ipairs(guestParty) do
|
for _, mon in ipairs(guestParty) do
|
||||||
if mon.hp > 0 then
|
if mon.hp > 0 then
|
||||||
s:act(function() sendOutGuest(s, mon) end)
|
if not s.result then awaitGuestReplacement(s) end
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -862,6 +963,7 @@ function LinkBattle.newSpectator(game, net, opts)
|
|||||||
end
|
end
|
||||||
|
|
||||||
self.hostMsg, self.guestMsg = nil, nil
|
self.hostMsg, self.guestMsg = nil, nil
|
||||||
|
self.hostReplace, self.guestReplace = {}, {}
|
||||||
local baseUpdate = self.update
|
local baseUpdate = self.update
|
||||||
self.update = function(s, dt)
|
self.update = function(s, dt)
|
||||||
net:update()
|
net:update()
|
||||||
@@ -875,6 +977,13 @@ function LinkBattle.newSpectator(game, net, opts)
|
|||||||
s.hostMsg, s.guestMsg = nil, nil
|
s.hostMsg, s.guestMsg = nil, nil
|
||||||
resolveSpecTurn(s, h, g)
|
resolveSpecTurn(s, h, g)
|
||||||
end
|
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
|
elseif inner.type == "bye" or inner.type == "forfeit" then
|
||||||
if not s.result then endSpectate(s, "The match ended.") end
|
if not s.result then endSpectate(s, "The match ended.") end
|
||||||
end
|
end
|
||||||
|
|||||||
+87
-17
@@ -19,6 +19,7 @@ LinkState.__index = LinkState
|
|||||||
LinkState.isOpaque = true
|
LinkState.isOpaque = true
|
||||||
|
|
||||||
local CURSOR = 0xED
|
local CURSOR = 0xED
|
||||||
|
local CURSOR_HOLLOW = 0xEC
|
||||||
local ANY = "ANY" -- sentinel: a leading nil array entry breaks ipairs under
|
local ANY = "ANY" -- sentinel: a leading nil array entry breaks ipairs under
|
||||||
-- LuaJIT even though # still reports the full size, so
|
-- LuaJIT even though # still reports the full size, so
|
||||||
-- the level picker cycles this string instead of nil,
|
-- the level picker cycles this string instead of nil,
|
||||||
@@ -114,6 +115,7 @@ end
|
|||||||
function LinkState:exitWith(message, reason)
|
function LinkState:exitWith(message, reason)
|
||||||
DiscordPresence.setJoinCode(nil)
|
DiscordPresence.setJoinCode(nil)
|
||||||
self.game.linkSession = nil -- back to the player's own GAME SPEED
|
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") })
|
Runtime.emit("link.ended", { reason = reason or (message and "error" or "bye") })
|
||||||
if self.net then self.net:close() end
|
if self.net then self.net:close() end
|
||||||
self.game.stack:pop()
|
self.game.stack:pop()
|
||||||
@@ -519,6 +521,9 @@ function LinkState:startMode(mode, isHost)
|
|||||||
})
|
})
|
||||||
self.net:send(self.trade:opening())
|
self.net:send(self.trade:opening())
|
||||||
self.index = 1
|
self.index = 1
|
||||||
|
self.theirIndex = 1
|
||||||
|
self.side = "mine"
|
||||||
|
self.pickChoice = nil
|
||||||
else
|
else
|
||||||
self.stage = "battleWait"
|
self.stage = "battleWait"
|
||||||
-- the host deals the shared RNG seed for the lockstep simulation
|
-- the host deals the shared RNG seed for the lockstep simulation
|
||||||
@@ -536,7 +541,14 @@ end
|
|||||||
-- trade flow
|
-- 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)
|
function LinkState:updateTrade(input)
|
||||||
|
if self.game.linkNet == self.net then self.game.linkNet = nil end
|
||||||
for _, msg in ipairs(self.net:poll()) do
|
for _, msg in ipairs(self.net:poll()) do
|
||||||
local reply = self.trade:handle(msg)
|
local reply = self.trade:handle(msg)
|
||||||
if reply then self.net:send(reply) end
|
if reply then self.net:send(reply) end
|
||||||
@@ -591,10 +603,51 @@ function LinkState:updateTrade(input)
|
|||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
if t.stage == "picking" and input:wasPressed("up") then
|
-- pokered engine/link/cable_club.asm TradeCenter_SelectMon: A on one of
|
||||||
self.index = math.max(1, self.index - 1)
|
-- 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
|
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
|
elseif self.confirmed == nil and input:wasPressed("b") then
|
||||||
-- once confirm=true has been sent to the peer, backing out here
|
-- once confirm=true has been sent to the peer, backing out here
|
||||||
-- would desync the two sides (the peer may already be committing
|
-- 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.net:send({ type = "bye" })
|
||||||
self:exitWith(Strings("The trade was\ncancelled."))
|
self:exitWith(Strings("The trade was\ncancelled."))
|
||||||
elseif t.stage == "picking" and input:wasPressed("a") then
|
elseif t.stage == "picking" and input:wasPressed("a") then
|
||||||
if t:canPick(self.index) then
|
if self.side == "theirs" then
|
||||||
self.net:send(t:pick(self.index))
|
self:openStats((t.theirParty or {})[self.theirIndex])
|
||||||
|
else
|
||||||
|
self.pickChoice = 1
|
||||||
end
|
end
|
||||||
elseif t.stage == "confirming" and self.confirmed == nil then
|
elseif t.stage == "confirming" and self.confirmed == nil then
|
||||||
if input:wasPressed("a") then
|
if input:wasPressed("a") then
|
||||||
@@ -738,25 +793,40 @@ function LinkState:draw()
|
|||||||
local label = (mon.nickname or def.name):sub(1, 8)
|
local label = (mon.nickname or def.name):sub(1, 8)
|
||||||
if not t:canPick(i) then label = label .. "X" end
|
if not t:canPick(i) then label = label .. "X" end
|
||||||
Font.draw(label, 16, 20 + i * 12)
|
Font.draw(label, 16, 20 + i * 12)
|
||||||
if i == self.index then Font.drawCode(CURSOR, 8, 20 + i * 12) end
|
if i == self.index and self.side ~= "theirs" then
|
||||||
|
Font.drawCode(CURSOR, 8, 20 + i * 12)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
Font.draw(Strings("THEIRS"), 84, 20)
|
Font.draw(Strings("THEIRS"), 84, 20)
|
||||||
for i, mon in ipairs(t.theirParty or {}) do
|
for i, mon in ipairs(t.theirParty or {}) do
|
||||||
local def = self.game.data.pokemon[mon.species]
|
local def = self.game.data.pokemon[mon.species]
|
||||||
Font.draw((mon.nickname or def.name):sub(1, 8), 92, 20 + i * 12)
|
Font.draw((mon.nickname or def.name):sub(1, 8), 92, 20 + i * 12)
|
||||||
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
|
end
|
||||||
local hint
|
if self.pickChoice then
|
||||||
if t.stage == "waitRecords" then hint = "Comparing games..."
|
Font.draw(Strings("STATS"), 16, 128)
|
||||||
elseif t.stage == "waitParty" then hint = "Exchanging data..."
|
Font.draw(Strings("TRADE"), 96, 128)
|
||||||
elseif t.stage == "picking" then
|
Font.drawCode(CURSOR, self.pickChoice == 1 and 8 or 88, 128)
|
||||||
hint = t:canPick(self.index) and "Pick one to trade"
|
else
|
||||||
or Strings("X: not on theirs")
|
local hint
|
||||||
elseif t.stage == "waitPick" then hint = "Waiting for them..."
|
if t.stage == "waitRecords" then hint = "Comparing games..."
|
||||||
elseif t.stage == "confirming" then
|
elseif t.stage == "waitParty" then hint = "Exchanging data..."
|
||||||
hint = self.confirmed and "Waiting..." or Strings("A: trade B: cancel")
|
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
|
end
|
||||||
Font.draw(hint or "", 8, 132)
|
|
||||||
|
|
||||||
elseif self.stage == "battleWait" or self.stage == "battleRunning" then
|
elseif self.stage == "battleWait" or self.stage == "battleRunning" then
|
||||||
drawTitle("LINK BATTLE")
|
drawTitle("LINK BATTLE")
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
|
|
||||||
local Logger = require("src.core.Logger")
|
local Logger = require("src.core.Logger")
|
||||||
local Runtime = require("src.mods.Runtime")
|
local Runtime = require("src.mods.Runtime")
|
||||||
|
local SafePath = require("src.mods.SafePath")
|
||||||
|
|
||||||
local unpack = table.unpack or unpack
|
local unpack = table.unpack or unpack
|
||||||
local loadstring = loadstring or load
|
local loadstring = loadstring or load
|
||||||
@@ -31,25 +32,8 @@ AssetTransform.SOURCE_ROOT = SOURCE_ROOT
|
|||||||
AssetTransform.DERIVED_ROOT = DERIVED_ROOT
|
AssetTransform.DERIVED_ROOT = DERIVED_ROOT
|
||||||
|
|
||||||
-- ------- path sandbox
|
-- ------- path sandbox
|
||||||
|
-- shared with mod:read and the manifest's own paths (src/mods/SafePath.lua)
|
||||||
-- a relative path that cannot climb out of the root it is joined to
|
local requireRelative = SafePath.require
|
||||||
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
|
|
||||||
|
|
||||||
-- ------- the restricted context
|
-- ------- the restricted context
|
||||||
|
|
||||||
|
|||||||
@@ -736,7 +736,7 @@ COVERAGE["src.pokemon.Boxes"] = {
|
|||||||
|
|
||||||
COVERAGE["src.world.WorldAPI"] = {
|
COVERAGE["src.world.WorldAPI"] = {
|
||||||
kind = "alias", target = "src.world.gen2.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",
|
.. "spawnNpc removeNpc npc queueScript invalidateMap",
|
||||||
warned = "setFlag getFlag",
|
warned = "setFlag getFlag",
|
||||||
absent = "",
|
absent = "",
|
||||||
|
|||||||
+378
-30
@@ -36,10 +36,13 @@ local ModTargets = require("src.mods.ModTargets")
|
|||||||
local Semver = require("src.mods.Semver")
|
local Semver = require("src.mods.Semver")
|
||||||
local Version = require("src.core.Version")
|
local Version = require("src.core.Version")
|
||||||
local SaveData = require("src.core.SaveData")
|
local SaveData = require("src.core.SaveData")
|
||||||
|
local GameVersion = require("src.core.GameVersion")
|
||||||
local CacheFs = require("src.import.CacheFs")
|
local CacheFs = require("src.import.CacheFs")
|
||||||
|
|
||||||
local LauncherMods = {}
|
local LauncherMods = {}
|
||||||
|
|
||||||
|
local discover -- forward declaration for helper functions above line 343
|
||||||
|
|
||||||
-- ------- pure status derivation
|
-- ------- pure status derivation
|
||||||
|
|
||||||
-- A hard-dependency / conflict / version verdict for one manifest. mods is
|
-- 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
|
-- resolveToggle would cascade-enable a merely-disabled dep rather than flag
|
||||||
-- it, so the disabled case is judged straight off the manifest here.
|
-- it, so the disabled case is judged straight off the manifest here.
|
||||||
for _, spec in ipairs(m.dependencySpecs or {}) do
|
for _, spec in ipairs(m.dependencySpecs or {}) do
|
||||||
local dep = mods[spec.id]
|
if ModTargets.specApplies(spec, version) then
|
||||||
if not dep then
|
local dep = mods[spec.id]
|
||||||
return "warn", "Needs " .. spec.id .. " (not installed)"
|
if not dep then
|
||||||
elseif not enabledSet[spec.id] then
|
return "warn", "Needs " .. spec.id .. " (not installed)"
|
||||||
return "warn", "Needs " .. spec.id .. " (disabled)"
|
elseif not enabledSet[spec.id] then
|
||||||
-- installed and on, but not for THIS game: the loader skips the
|
return "warn", "Needs " .. spec.id .. " (disabled)"
|
||||||
-- dependency and the skip is contagious (Loader:_enforceDependencies),
|
-- installed and on, but not for THIS game: the loader skips the
|
||||||
-- so a mod that runs everywhere still does not run here
|
-- dependency and the skip is contagious (Loader:_enforceDependencies),
|
||||||
elseif version
|
-- so a mod that runs everywhere still does not run here
|
||||||
and not ModTargets.runsHere(dep, version, nil, forcedFor(spec.id)) then
|
elseif version
|
||||||
return "warn", "Needs " .. spec.id .. " (not for "
|
and not ModTargets.runsHere(dep, version, nil, forcedFor(spec.id)) then
|
||||||
.. ModTargets.gameLabel(version) .. ")"
|
return "warn", "Needs " .. spec.id .. " (not for "
|
||||||
elseif spec.range
|
.. ModTargets.gameLabel(version) .. ")"
|
||||||
and not Semver.satisfies(dep.version, spec.range) then
|
elseif spec.range
|
||||||
return "warn", "Needs " .. spec.id .. " " .. spec.range
|
and not Semver.satisfies(dep.version, spec.range) then
|
||||||
|
return "warn", "Needs " .. spec.id .. " " .. spec.range
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
return "ok", "Ready"
|
return "ok", "Ready"
|
||||||
end
|
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.
|
-- deriveList(manifests, options [, version]) -> the panel row list, pure.
|
||||||
-- manifests is an array of validated manifests (Manifest.validate output);
|
-- manifests is an array of validated manifests (Manifest.validate output);
|
||||||
-- options is the options table (options.mods, options.modsByVersion and
|
-- options is the options table (options.mods, options.modsByVersion and
|
||||||
-- options.modsGen2 are read). `version` is the game the panel is showing:
|
-- 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.
|
-- 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.
|
-- Rows come back sorted by id so the panel order is stable.
|
||||||
function LauncherMods.deriveList(manifests, options, version)
|
function LauncherMods.deriveList(manifests, options, version)
|
||||||
local ordered = {}
|
local ordered = {}
|
||||||
@@ -126,8 +294,7 @@ function LauncherMods.deriveList(manifests, options, version)
|
|||||||
-- this game's choice, then the shared flag, then the default: enabled,
|
-- this game's choice, then the shared flag, then the default: enabled,
|
||||||
-- matching the loader -- except experimental mods, which stay off until
|
-- matching the loader -- except experimental mods, which stay off until
|
||||||
-- the player opts in. Scoped through modScope, so this reads exactly what
|
-- the player opts in. Scoped through modScope, so this reads exactly what
|
||||||
-- setEnabled writes and the loader loads: while per-game flags are a
|
-- setEnabled writes and the loader loads for the selected game.
|
||||||
-- preview the shared flag is the whole answer on every surface.
|
|
||||||
local decided = SaveData.modEnabled(options, m.id, SaveData.modScope(version))
|
local decided = SaveData.modEnabled(options, m.id, SaveData.modScope(version))
|
||||||
if decided == nil then decided = not m.experimental end
|
if decided == nil then decided = not m.experimental end
|
||||||
if decided then enabledSet[m.id] = true end
|
if decided then enabledSet[m.id] = true end
|
||||||
@@ -152,10 +319,20 @@ function LauncherMods.deriveList(manifests, options, version)
|
|||||||
badge = badge,
|
badge = badge,
|
||||||
description = m.description or "",
|
description = m.description or "",
|
||||||
enabled = enabled,
|
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,
|
status = status,
|
||||||
statusDetail = detail,
|
statusDetail = detail,
|
||||||
github = m.github,
|
github = m.github,
|
||||||
experimental = m.experimental == true,
|
experimental = m.experimental == true,
|
||||||
|
dependencySpecs = m.dependencySpecs,
|
||||||
|
manifest = m,
|
||||||
-- what game this mod is for, and whether it will run on the one the
|
-- what game this mod is for, and whether it will run on the one the
|
||||||
-- panel is showing (src/mods/ModTargets.lua)
|
-- panel is showing (src/mods/ModTargets.lua)
|
||||||
targets = ModTargets.chip(m),
|
targets = ModTargets.chip(m),
|
||||||
@@ -243,7 +420,7 @@ end
|
|||||||
-- Scan "mods/" one level deep for valid manifests (mirrors Loader:_discover,
|
-- 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
|
-- but validates only -- no entry chunk is ever loaded). First id wins on a
|
||||||
-- duplicate. Returns an array of validated manifests.
|
-- duplicate. Returns an array of validated manifests.
|
||||||
local function discover()
|
discover = function()
|
||||||
local fs = love and love.filesystem
|
local fs = love and love.filesystem
|
||||||
local out = {}
|
local out = {}
|
||||||
if not (fs and fs.getInfo and fs.getDirectoryItems) then return out end
|
if not (fs and fs.getInfo and fs.getDirectoryItems) then return out end
|
||||||
@@ -281,7 +458,14 @@ end
|
|||||||
function LauncherMods.list(version)
|
function LauncherMods.list(version)
|
||||||
local ok, result = pcall(function()
|
local ok, result = pcall(function()
|
||||||
local options = SaveData.loadOptions()
|
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)
|
end)
|
||||||
if not ok then
|
if not ok then
|
||||||
-- a single bad options/mod file must not blank the launcher
|
-- a single bad options/mod file must not blank the launcher
|
||||||
@@ -364,14 +548,13 @@ function LauncherMods.translationStrings()
|
|||||||
return merged
|
return merged
|
||||||
end
|
end
|
||||||
|
|
||||||
-- setEnabled(id, enabled [, version]): persist options.mods[id] in the exact
|
-- setEnabled(id, enabled [, version]): with a game, persist just that game's
|
||||||
-- shape Loader:_saveState writes (a plain boolean), so the running game and
|
-- answer. The loader and the in-game manager use the same scope on next boot.
|
||||||
-- the in-game ManagerState pick it up unchanged. With `version` the choice
|
|
||||||
-- lands in that game's overlay instead and no other game moves.
|
|
||||||
function LauncherMods.setEnabled(id, enabled, version)
|
function LauncherMods.setEnabled(id, enabled, version)
|
||||||
local options = SaveData.loadOptions()
|
local options = SaveData.loadOptions()
|
||||||
SaveData.setModEnabled(options, id, enabled, SaveData.modScope(version))
|
SaveData.setModEnabled(options, id, enabled, SaveData.modScope(version))
|
||||||
SaveData.saveOptions(options)
|
SaveData.saveOptions(options)
|
||||||
|
LauncherMods.syncActiveProfile(options)
|
||||||
return true
|
return true
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -384,9 +567,18 @@ function LauncherMods.setAllEnabled(ids, enabled, version)
|
|||||||
local options = SaveData.loadOptions()
|
local options = SaveData.loadOptions()
|
||||||
local scope = SaveData.modScope(version)
|
local scope = SaveData.modScope(version)
|
||||||
for _, id in ipairs(ids or {}) do
|
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
|
end
|
||||||
SaveData.saveOptions(options)
|
SaveData.saveOptions(options)
|
||||||
|
LauncherMods.syncActiveProfile(options)
|
||||||
return true
|
return true
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -652,9 +844,9 @@ function LauncherMods.adoptStrays() return scanStrays(true) end
|
|||||||
-- opts.replace = true uninstalls an existing same-id mod first (updates /
|
-- opts.replace = true uninstalls an existing same-id mod first (updates /
|
||||||
-- rollbacks). opts.expectId, when set, refuses a zip whose manifest id differs.
|
-- rollbacks). opts.expectId, when set, refuses a zip whose manifest id differs.
|
||||||
function LauncherMods.installZip(source, opts)
|
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
|
if not ok then return nil, "import failed: " .. tostring(result) end
|
||||||
return result, err
|
return result, res2, res3
|
||||||
end
|
end
|
||||||
|
|
||||||
function LauncherMods._installZipInner(source, opts)
|
function LauncherMods._installZipInner(source, opts)
|
||||||
@@ -764,7 +956,7 @@ function LauncherMods._installZipInner(source, opts)
|
|||||||
return nil, copyErr or "could not copy the mod files"
|
return nil, copyErr or "could not copy the mod files"
|
||||||
end
|
end
|
||||||
cleanup()
|
cleanup()
|
||||||
return true, manifest.id
|
return true, manifest.id, manifest
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Install (or replace) a mod from a GitHub release zip URL.
|
-- Install (or replace) a mod from a GitHub release zip URL.
|
||||||
@@ -843,7 +1035,7 @@ end
|
|||||||
|
|
||||||
-- uninstall(id) -> true | nil, errString
|
-- uninstall(id) -> true | nil, errString
|
||||||
-- Removes mods/<id>/ from wherever it was installed (the portable game folder
|
-- 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.
|
-- so the loader and in-game manager no longer see it. Rejects missing ids.
|
||||||
-- Does not touch other mods' enable state.
|
-- Does not touch other mods' enable state.
|
||||||
function LauncherMods.uninstall(id)
|
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
|
-- Drop the enable flag so a reinstall of the same id starts from the
|
||||||
-- loader's default (enabled) rather than a stale false.
|
-- loader's default (enabled) rather than a stale false.
|
||||||
local options = SaveData.loadOptions()
|
local options = SaveData.loadOptions()
|
||||||
|
local changed = false
|
||||||
if options.mods and options.mods[id] ~= nil then
|
if options.mods and options.mods[id] ~= nil then
|
||||||
options.mods[id] = nil
|
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)
|
SaveData.saveOptions(options)
|
||||||
end
|
end
|
||||||
return true
|
return true
|
||||||
|
|||||||
+171
-66
@@ -12,12 +12,15 @@ local Manifest = require("src.mods.Manifest")
|
|||||||
local Merge = require("src.mods.Merge")
|
local Merge = require("src.mods.Merge")
|
||||||
local ModTargets = require("src.mods.ModTargets")
|
local ModTargets = require("src.mods.ModTargets")
|
||||||
local Registry = require("src.mods.Registry")
|
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 Schemas = require("src.mods.Schemas")
|
||||||
local Semver = require("src.mods.Semver")
|
local Semver = require("src.mods.Semver")
|
||||||
local Events = require("src.mods.Events")
|
local Events = require("src.mods.Events")
|
||||||
local Gen2Compat = require("src.mods.Gen2Compat")
|
local Gen2Compat = require("src.mods.Gen2Compat")
|
||||||
local Hooks = require("src.mods.Hooks")
|
local Hooks = require("src.mods.Hooks")
|
||||||
local Runtime = require("src.mods.Runtime")
|
local Runtime = require("src.mods.Runtime")
|
||||||
|
local Steps = require("src.mods.Steps")
|
||||||
|
|
||||||
local Loader = {}
|
local Loader = {}
|
||||||
Loader.__index = Loader
|
Loader.__index = Loader
|
||||||
@@ -72,10 +75,12 @@ local function orderedIds(mods, filter)
|
|||||||
return ids
|
return ids
|
||||||
end
|
end
|
||||||
|
|
||||||
-- ------- dev-mode permissions tripwire
|
-- ------- the require gate
|
||||||
-- Attribution only: the shim delegates unconditionally and blocks nothing.
|
-- Two jobs in one interposition. The engine_internals/network scan is
|
||||||
-- Installed once per process and only when the loader runs in dev mode, so a
|
-- attribution only and stays dev-mode: it warns and delegates. The
|
||||||
-- player build has zero interposition.
|
-- 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 }
|
local devShim = { installed = false, permissions = {}, warned = {}, depth = 0 }
|
||||||
|
|
||||||
@@ -135,7 +140,8 @@ end
|
|||||||
|
|
||||||
local function scanRequire(name)
|
local function scanRequire(name)
|
||||||
local modId = Runtime.currentMod
|
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 granted = devShim.permissions[modId] or {}
|
||||||
local function warnOnce(permission)
|
local function warnOnce(permission)
|
||||||
local key = modId .. "|" .. permission .. "|" .. name
|
local key = modId .. "|" .. permission .. "|" .. name
|
||||||
@@ -188,6 +194,7 @@ function Loader:_installDevShim()
|
|||||||
for id, mod in pairs(self.mods) do
|
for id, mod in pairs(self.mods) do
|
||||||
devShim.permissions[id] = mod.manifest.permissionSet
|
devShim.permissions[id] = mod.manifest.permissionSet
|
||||||
end
|
end
|
||||||
|
devShim.dev = self.dev
|
||||||
if devShim.installed then return end
|
if devShim.installed then return end
|
||||||
devShim.installed = true
|
devShim.installed = true
|
||||||
local delegate = require
|
local delegate = require
|
||||||
@@ -195,12 +202,22 @@ function Loader:_installDevShim()
|
|||||||
-- only the mod's own call is the mod's doing; whatever that module
|
-- only the mod's own call is the mod's doing; whatever that module
|
||||||
-- requires in turn is the engine wiring itself up
|
-- requires in turn is the engine wiring itself up
|
||||||
if devShim.depth == 0 then
|
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.
|
-- 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
|
-- Engine code keeps the real module: src/render/PaletteFX.lua:776
|
||||||
-- requires src.core.Game on both generations and means it.
|
-- requires src.core.Game on both generations and means it.
|
||||||
if devShim.generation ~= 1 and Gen2Compat.serves(name)
|
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)
|
local adapter = Gen2Compat.resolve(name, Runtime.currentMod)
|
||||||
if adapter then
|
if adapter then
|
||||||
local key = "adapter|" .. name
|
local key = "adapter|" .. name
|
||||||
@@ -235,7 +252,7 @@ function Loader.new(opts)
|
|||||||
events = Events.new(), hooks = Hooks.new(), content = {}, assets = {},
|
events = Events.new(), hooks = Hooks.new(), content = {}, assets = {},
|
||||||
exports = {}, migrations = {}, order = {},
|
exports = {}, migrations = {}, order = {},
|
||||||
modSave = {}, modOptions = {}, optionSchemas = {}, imageCache = {},
|
modSave = {}, modOptions = {}, optionSchemas = {}, imageCache = {},
|
||||||
modInput = {},
|
modInput = {}, modEnv = {}, stepsQueues = {},
|
||||||
fs = (opts and opts.fs) or (love and love.filesystem),
|
fs = (opts and opts.fs) or (love and love.filesystem),
|
||||||
dev = dev,
|
dev = dev,
|
||||||
-- Which generation this boot is (1 or 2). Fixed at construction: the
|
-- Which generation this boot is (1 or 2). Fixed at construction: the
|
||||||
@@ -270,8 +287,7 @@ function Loader:_targetVersion()
|
|||||||
return version
|
return version
|
||||||
end
|
end
|
||||||
|
|
||||||
-- The version an enable flag is read and written under: this game once
|
-- The version an enable flag is read and written under: this running game.
|
||||||
-- per-game flags are live, nil (the shared flag) while they are a preview.
|
|
||||||
-- Reads and writes go through the same answer so the two can never drift.
|
-- Reads and writes go through the same answer so the two can never drift.
|
||||||
function Loader:_enableScope()
|
function Loader:_enableScope()
|
||||||
return SaveData.modScope(self:_targetVersion())
|
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
|
-- demand; using it here means older mods do not need to migrate to
|
||||||
-- mod.options:define just to appear in a launcher settings screen.
|
-- mod.options:define just to appear in a launcher settings screen.
|
||||||
if schema == nil and mod.manifest.options_schema and self.fs.load then
|
if schema == nil and mod.manifest.options_schema and self.fs.load then
|
||||||
local chunk = self.fs.load(mod.path .. "/" .. mod.manifest.options_schema)
|
local ok, rows = pcall(function()
|
||||||
if chunk then
|
local path = SafePath.join(mod.path, mod.manifest.options_schema,
|
||||||
local ok, rows = pcall(chunk)
|
"options_schema")
|
||||||
if ok and type(rows) == "table" then schema = rows end
|
local chunk = Sandbox.loadFile(self.fs, path, self:_modEnv(mod))
|
||||||
end
|
return chunk and chunk()
|
||||||
|
end)
|
||||||
|
if ok and type(rows) == "table" then schema = rows end
|
||||||
end
|
end
|
||||||
if schema ~= nil then
|
if schema ~= nil then
|
||||||
mods[id] = schema
|
mods[id] = schema
|
||||||
@@ -562,42 +580,46 @@ end
|
|||||||
-- hard dependencies must exist, be enabled, have survived, and satisfy their
|
-- hard dependencies must exist, be enabled, have survived, and satisfy their
|
||||||
-- range; run to a fixpoint so failures propagate to dependents transitively
|
-- range; run to a fixpoint so failures propagate to dependents transitively
|
||||||
function Loader:_enforceDependencies()
|
function Loader:_enforceDependencies()
|
||||||
|
local targetVersion = self:_targetVersion()
|
||||||
|
local generation = self.generation
|
||||||
local changed = true
|
local changed = true
|
||||||
while changed do
|
while changed do
|
||||||
changed = false
|
changed = false
|
||||||
for _, id in ipairs(orderedIds(self.mods, isActive)) do
|
for _, id in ipairs(orderedIds(self.mods, isActive)) do
|
||||||
local mod = self.mods[id]
|
local mod = self.mods[id]
|
||||||
for _, spec in ipairs(mod.manifest.dependencySpecs) do
|
for _, spec in ipairs(mod.manifest.dependencySpecs) do
|
||||||
local dep = self.mods[spec.id]
|
if ModTargets.specApplies(spec, targetVersion, generation) then
|
||||||
local reason, skip
|
local dep = self.mods[spec.id]
|
||||||
if not dep then
|
local reason, skip
|
||||||
reason = "missing dependency: " .. spec.id
|
if not dep then
|
||||||
elseif not dep.enabled then
|
reason = "missing dependency: " .. spec.id
|
||||||
reason = ("dependency %s is disabled"):format(spec.id)
|
elseif not dep.enabled then
|
||||||
elseif dep.state == "wrong_generation" then
|
reason = ("dependency %s is disabled"):format(spec.id)
|
||||||
-- the gate's skip is contagious as a SKIP, not as a failure: the
|
elseif dep.state == "wrong_generation" then
|
||||||
-- dependency has no bug to report and neither does this mod, so
|
-- the gate's skip is contagious as a SKIP, not as a failure: the
|
||||||
-- nothing here lands on the boot error list
|
-- dependency has no bug to report and neither does this mod, so
|
||||||
skip = true
|
-- nothing here lands on the boot error list
|
||||||
-- carry the dependency's own reason: it names the game or the
|
skip = true
|
||||||
-- missing gen2compat, and a guess here would name the wrong one
|
-- carry the dependency's own reason: it names the game or the
|
||||||
reason = ("depends on %s, which does not run here (%s)")
|
-- missing gen2compat, and a guess here would name the wrong one
|
||||||
:format(spec.id, dep.skipReason or "not made for this game")
|
reason = ("depends on %s, which does not run here (%s)")
|
||||||
elseif dep.failed then
|
:format(spec.id, dep.skipReason or "not made for this game")
|
||||||
reason = ("dependency %s failed to load"):format(spec.id)
|
elseif dep.failed then
|
||||||
elseif spec.range
|
reason = ("dependency %s failed to load"):format(spec.id)
|
||||||
and not Semver.satisfies(dep.manifest.version, spec.range) then
|
elseif spec.range
|
||||||
reason = ("needs %s@%s, found %s")
|
and not Semver.satisfies(dep.manifest.version, spec.range) then
|
||||||
:format(spec.id, spec.range, dep.manifest.version)
|
reason = ("needs %s@%s, found %s")
|
||||||
end
|
:format(spec.id, spec.range, dep.manifest.version)
|
||||||
if reason then
|
end
|
||||||
if skip then
|
if reason then
|
||||||
self:_skip(mod, "wrong_generation", reason)
|
if skip then
|
||||||
else
|
self:_skip(mod, "wrong_generation", reason)
|
||||||
self:_fail(mod, "blocked_dependency", reason)
|
else
|
||||||
|
self:_fail(mod, "blocked_dependency", reason)
|
||||||
|
end
|
||||||
|
changed = true
|
||||||
|
break
|
||||||
end
|
end
|
||||||
changed = true
|
|
||||||
break
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -607,6 +629,8 @@ end
|
|||||||
-- Tarjan SCC over the hard-dependency graph: only a cycle's own members
|
-- Tarjan SCC over the hard-dependency graph: only a cycle's own members
|
||||||
-- fail, so an unrelated mod beside a cycle still loads
|
-- fail, so an unrelated mod beside a cycle still loads
|
||||||
function Loader:_failCycles()
|
function Loader:_failCycles()
|
||||||
|
local targetVersion = self:_targetVersion()
|
||||||
|
local generation = self.generation
|
||||||
local mods = self.mods
|
local mods = self.mods
|
||||||
local counter, stack, onStack, index, low = 0, {}, {}, {}, {}
|
local counter, stack, onStack, index, low = 0, {}, {}, {}, {}
|
||||||
local cycles = {}
|
local cycles = {}
|
||||||
@@ -617,14 +641,16 @@ function Loader:_failCycles()
|
|||||||
onStack[id] = true
|
onStack[id] = true
|
||||||
local selfEdge = false
|
local selfEdge = false
|
||||||
for _, spec in ipairs(mods[id].manifest.dependencySpecs) do
|
for _, spec in ipairs(mods[id].manifest.dependencySpecs) do
|
||||||
local dep = mods[spec.id]
|
if ModTargets.specApplies(spec, targetVersion, generation) then
|
||||||
if spec.id == id then selfEdge = true end
|
local dep = mods[spec.id]
|
||||||
if dep and isActive(dep) and spec.id ~= id then
|
if spec.id == id then selfEdge = true end
|
||||||
if not index[spec.id] then
|
if dep and isActive(dep) and spec.id ~= id then
|
||||||
connect(spec.id)
|
if not index[spec.id] then
|
||||||
if low[spec.id] < low[id] then low[id] = low[spec.id] end
|
connect(spec.id)
|
||||||
elseif onStack[spec.id] and index[spec.id] < low[id] then
|
if low[spec.id] < low[id] then low[id] = low[spec.id] end
|
||||||
low[id] = index[spec.id]
|
elseif onStack[spec.id] and index[spec.id] < low[id] then
|
||||||
|
low[id] = index[spec.id]
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -675,6 +701,8 @@ end
|
|||||||
-- Kahn over the surviving graph with the ready set kept in (priority, id)
|
-- 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
|
-- order, so dependencies come first and the rest matches the v1 contract
|
||||||
function Loader:_order()
|
function Loader:_order()
|
||||||
|
local targetVersion = self:_targetVersion()
|
||||||
|
local generation = self.generation
|
||||||
local pending, indegree, dependents = {}, {}, {}
|
local pending, indegree, dependents = {}, {}, {}
|
||||||
for _, id in ipairs(orderedIds(self.mods, isActive)) do
|
for _, id in ipairs(orderedIds(self.mods, isActive)) do
|
||||||
pending[id], indegree[id] = true, 0
|
pending[id], indegree[id] = true, 0
|
||||||
@@ -687,9 +715,17 @@ function Loader:_order()
|
|||||||
dependents[depId][#dependents[depId] + 1] = id
|
dependents[depId][#dependents[depId] + 1] = id
|
||||||
indegree[id] = indegree[id] + 1
|
indegree[id] = indegree[id] + 1
|
||||||
end
|
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
|
-- 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
|
end
|
||||||
local ordered = {}
|
local ordered = {}
|
||||||
local function nextId()
|
local function nextId()
|
||||||
@@ -981,6 +1017,40 @@ function Loader:_api(mod)
|
|||||||
return DateTime.dateTime(game, timestamp)
|
return DateTime.dateTime(game, timestamp)
|
||||||
end,
|
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 /
|
-- namespaced per mod; M11 backs these with save.modData /
|
||||||
-- options.modOptions, the shape mods compile against is already final
|
-- options.modOptions, the shape mods compile against is already final
|
||||||
save = {
|
save = {
|
||||||
@@ -1091,9 +1161,11 @@ function Loader:_api(mod)
|
|||||||
-- assets keeps the v1 alias to the content accessors and adds the file
|
-- 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
|
-- helpers on top, so mod.assets.pokemon and mod.assets:image both resolve
|
||||||
api.assets = setmetatable({
|
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)
|
image = function(_, relative)
|
||||||
local full = mod.path .. "/" .. relative
|
local full = SafePath.join(mod.path, relative, "mod.assets:image")
|
||||||
local cached = loader.imageCache[full]
|
local cached = loader.imageCache[full]
|
||||||
if cached then return cached end
|
if cached then return cached end
|
||||||
assert(love and love.graphics,
|
assert(love and love.graphics,
|
||||||
@@ -1103,9 +1175,10 @@ function Loader:_api(mod)
|
|||||||
return image
|
return image
|
||||||
end,
|
end,
|
||||||
}, { __index = api.content })
|
}, { __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)
|
function api:read(relative)
|
||||||
local path = self.path .. "/" .. relative
|
return loader.fs.read(SafePath.join(self.path, relative, "mod:read"))
|
||||||
return loader.fs.read(path)
|
|
||||||
end
|
end
|
||||||
-- mod.world materializes on first touch, like the image helper above: a
|
-- 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
|
-- 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")
|
return engineRequire("src.core.Game")
|
||||||
end
|
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)
|
function Loader:_loadMod(mod)
|
||||||
local path = mod.path .. "/" .. mod.manifest.entry
|
local path = SafePath.join(mod.path, mod.manifest.entry, "manifest entry")
|
||||||
local chunk, err = self.fs.load(path)
|
local chunk, err = Sandbox.loadFile(self.fs, path, self:_modEnv(mod))
|
||||||
if not chunk then error(err or ("unable to load " .. path)) end
|
if not chunk then error(err or ("unable to load " .. path)) end
|
||||||
local api = self:_api(mod)
|
local api = self:_api(mod)
|
||||||
local result = chunk(api)
|
local result = chunk(api)
|
||||||
@@ -1183,6 +1268,7 @@ function Loader:_rollback(modId)
|
|||||||
self.optionSchemas[modId] = nil
|
self.optionSchemas[modId] = nil
|
||||||
self.migrations[modId] = nil
|
self.migrations[modId] = nil
|
||||||
self.modSave[modId] = nil
|
self.modSave[modId] = nil
|
||||||
|
self.stepsQueues[modId] = nil
|
||||||
end
|
end
|
||||||
|
|
||||||
-- a mod that explicitly swears it stays link-compatible while writing into a
|
-- 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)
|
require("src.mods.Builtins").install(self.content, data, self.generation)
|
||||||
self:_loadState()
|
self:_loadState()
|
||||||
self:_discover()
|
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
|
-- Experimental mods stay off until the player opts in: a missing
|
||||||
-- options.mods entry normally means enabled, but experimental flips that.
|
-- options.mods entry normally means enabled, but experimental flips that.
|
||||||
do
|
do
|
||||||
local options = SaveData.loadOptions(self.fs)
|
local options = SaveData.loadOptions(self.fs)
|
||||||
local modsOpt = options.mods or {}
|
local scope = self:_enableScope()
|
||||||
for id, mod in pairs(self.mods) do
|
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
|
and mod.manifest.experimental then
|
||||||
self.disabled[id] = true
|
self.disabled[id] = true
|
||||||
end
|
end
|
||||||
@@ -1349,10 +1452,12 @@ function Loader:load(data)
|
|||||||
-- every touch: a mod captures the facade at file scope, before Game2 has a
|
-- every touch: a mod captures the facade at file scope, before Game2 has a
|
||||||
-- save or a world (src/mods/Gen2Compat.lua).
|
-- save or a world (src/mods/Gen2Compat.lua).
|
||||||
Gen2Compat.bind(function() return self:_game() end)
|
Gen2Compat.bind(function() return self:_game() end)
|
||||||
-- Dev mode wants the permissions tripwire; a Gold boot with mods on it wants
|
-- Any boot with mods on it needs the gate, because require("io") is how a
|
||||||
-- the Gen 1-only require report, which is the difference between "the mod
|
-- mod would walk out of Sandbox.envFor. Dev mode adds the permissions
|
||||||
-- does nothing" and knowing why. A Gold boot with no mods pays nothing.
|
-- tripwire on top, and a Gold boot the Gen 1-only require report -- the
|
||||||
if self.dev or (self.generation ~= 1 and next(self.mods) ~= nil) then
|
-- 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()
|
self:_installDevShim()
|
||||||
end
|
end
|
||||||
for _, mod in ipairs(ordered) do
|
for _, mod in ipairs(ordered) do
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ local Font = require("src.render.Font")
|
|||||||
local GameVersion = require("src.core.GameVersion")
|
local GameVersion = require("src.core.GameVersion")
|
||||||
local ModTargets = require("src.mods.ModTargets")
|
local ModTargets = require("src.mods.ModTargets")
|
||||||
local Runtime = require("src.mods.Runtime")
|
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 SaveData = require("src.core.SaveData")
|
||||||
local Semver = require("src.mods.Semver")
|
local Semver = require("src.mods.Semver")
|
||||||
local Version = require("src.core.Version")
|
local Version = require("src.core.Version")
|
||||||
@@ -735,8 +737,7 @@ function ManagerState:toggleGen2Force(m)
|
|||||||
}, apply)
|
}, apply)
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Where the loader persisted an enable flag: this game's slot once it keeps
|
-- Where the loader persists an enable flag: this running game's slot.
|
||||||
-- them per game, the shared flag until then (SaveData.modScope).
|
|
||||||
function ManagerState:enableScope()
|
function ManagerState:enableScope()
|
||||||
return SaveData.modScope((self:targetGame()))
|
return SaveData.modScope((self:targetGame()))
|
||||||
end
|
end
|
||||||
@@ -936,13 +937,18 @@ function ManagerState:schemaFor(m)
|
|||||||
local schema = loader.optionSchemas and loader.optionSchemas[m.id]
|
local schema = loader.optionSchemas and loader.optionSchemas[m.id]
|
||||||
if schema == nil and m.options_schema and m.path
|
if schema == nil and m.options_schema and m.path
|
||||||
and loader.fs and loader.fs.load then
|
and loader.fs and loader.fs.load then
|
||||||
local chunk = loader.fs.load(m.path .. "/" .. m.options_schema)
|
-- mod-authored code, so it runs in the same sandbox the entry chunk does
|
||||||
if chunk then
|
local ok, rows = pcall(function()
|
||||||
local ok, rows = pcall(chunk)
|
local path = SafePath.join(m.path, m.options_schema, "options_schema")
|
||||||
if ok and type(rows) == "table" then
|
local mod = loader.mods and loader.mods[m.id]
|
||||||
schema = rows
|
local env = mod and loader._modEnv and loader:_modEnv(mod)
|
||||||
if loader.optionSchemas then loader.optionSchemas[m.id] = schema end
|
or Sandbox.envFor()
|
||||||
end
|
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
|
||||||
end
|
end
|
||||||
return schema
|
return schema
|
||||||
|
|||||||
+80
-26
@@ -3,13 +3,15 @@
|
|||||||
-- that need to stat a file, this owns shape, vocabulary and range grammar.
|
-- that need to stat a file, this owns shape, vocabulary and range grammar.
|
||||||
local Logger = require("src.core.Logger")
|
local Logger = require("src.core.Logger")
|
||||||
local ModTargets = require("src.mods.ModTargets")
|
local ModTargets = require("src.mods.ModTargets")
|
||||||
|
local SafePath = require("src.mods.SafePath")
|
||||||
local Semver = require("src.mods.Semver")
|
local Semver = require("src.mods.Semver")
|
||||||
local Version = require("src.core.Version")
|
local Version = require("src.core.Version")
|
||||||
|
|
||||||
local Manifest = {}
|
local Manifest = {}
|
||||||
|
|
||||||
Manifest.PROFILES = { content = true, overhaul = true, total_conversion = true }
|
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
|
-- link-relevant registries; a mod that writes into one of these while
|
||||||
-- declaring affects_link = false gets an attributed warning from the loader
|
-- 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)
|
Logger.warn("[%s] %s", tostring(id), message)
|
||||||
end
|
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.
|
-- Optional GitHub repo for launcher auto-update / other-versions.
|
||||||
-- Accepts "owner/repo" or a github.com URL; empty/absent means no updates.
|
-- Accepts "owner/repo" or a github.com URL; empty/absent means no updates.
|
||||||
function Manifest.parseGithub(value)
|
function Manifest.parseGithub(value)
|
||||||
@@ -73,6 +55,69 @@ function Manifest.parseGithub(value)
|
|||||||
return owner .. "/" .. repo
|
return owner .. "/" .. repo
|
||||||
end
|
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
|
-- conflicts + incompatible (alias) merged, first-wins on duplicate ids
|
||||||
local function mergeConflictLists(conflicts, incompatible)
|
local function mergeConflictLists(conflicts, incompatible)
|
||||||
local seen, out = {}, {}
|
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.name) == "string" and raw.name ~= "", "manifest name is required")
|
||||||
assert(type(raw.version) == "string" and raw.version ~= "", "manifest version 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")
|
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
|
-- 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")
|
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
|
local affectsLink = profile ~= "content" and not language
|
||||||
if type(raw.affects_link) == "boolean" then affectsLink = raw.affects_link end
|
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
|
if value == nil then return nil end
|
||||||
assert(type(value) == "string" and value ~= "", field .. " must be a file path")
|
assert(type(value) == "string" and value ~= "", field .. " must be a file path")
|
||||||
return value
|
return value
|
||||||
end
|
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)
|
local conflicts = mergeConflictLists(raw.conflicts, raw.incompatible)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id = raw.id,
|
id = raw.id,
|
||||||
name = raw.name,
|
name = raw.name,
|
||||||
version = raw.version,
|
version = raw.version,
|
||||||
entry = raw.entry,
|
entry = entry,
|
||||||
api = api,
|
api = api,
|
||||||
priority = tonumber(raw.priority) or 0,
|
priority = tonumber(raw.priority) or 0,
|
||||||
dependencies = array(raw.dependencies),
|
dependencies = array(raw.dependencies),
|
||||||
optional_dependencies = array(raw.optional_dependencies),
|
optional_dependencies = array(raw.optional_dependencies),
|
||||||
conflicts = conflicts,
|
conflicts = conflicts,
|
||||||
incompatible = array(raw.incompatible),
|
incompatible = array(raw.incompatible),
|
||||||
dependencySpecs = parseSpecs(array(raw.dependencies), "dependencies"),
|
dependencySpecs = parseSpecs(array(raw.dependencies), "dependencies", raw.dependency_sources),
|
||||||
optionalSpecs = parseSpecs(array(raw.optional_dependencies), "optional_dependencies"),
|
optionalSpecs = parseSpecs(array(raw.optional_dependencies), "optional_dependencies", raw.dependency_sources),
|
||||||
conflictSpecs = parseSpecs(conflicts, "conflicts"),
|
conflictSpecs = parseSpecs(conflicts, "conflicts"),
|
||||||
category = raw.category or "OTHER",
|
category = raw.category or "OTHER",
|
||||||
game_version = raw.game_version,
|
game_version = raw.game_version,
|
||||||
@@ -265,7 +318,8 @@ function Manifest.validate(raw, path)
|
|||||||
permissionSet = permissionSet,
|
permissionSet = permissionSet,
|
||||||
options_schema = optionalFile(raw.options_schema, "options_schema"),
|
options_schema = optionalFile(raw.options_schema, "options_schema"),
|
||||||
assets_transforms = optionalFile(raw.assets_transforms, "assets_transforms"),
|
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,
|
path = path,
|
||||||
raw = raw,
|
raw = raw,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -334,9 +334,15 @@ function ModIndex.compatIssues(entry, ctx)
|
|||||||
local function eachSpec(spec, fn)
|
local function eachSpec(spec, fn)
|
||||||
if type(spec) ~= "table" then return end
|
if type(spec) ~= "table" then return end
|
||||||
for k, v in pairs(spec) do
|
for k, v in pairs(spec) do
|
||||||
if type(k) == "number" and type(v) == "string" then
|
if type(k) == "number" then
|
||||||
local id, range = v:match("^([^@]+)@(.+)$")
|
if type(v) == "table" and type(v.id) == "string" then
|
||||||
fn(id or v, range)
|
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
|
elseif type(k) == "string" then
|
||||||
fn(k, type(v) == "string" and v or nil)
|
fn(k, type(v) == "string" and v or nil)
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -168,4 +168,39 @@ function ModTargets.detail(manifest, version)
|
|||||||
ModTargets.gameLabel(version))
|
ModTargets.gameLabel(version))
|
||||||
end
|
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
|
return ModTargets
|
||||||
|
|||||||
@@ -28,6 +28,11 @@ Runtime.errors = nil
|
|||||||
-- permissions tripwire knows there is nobody to attribute to
|
-- permissions tripwire knows there is nobody to attribute to
|
||||||
Runtime.currentMod = nil
|
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)
|
function Runtime.install(events, hooks, errors)
|
||||||
Runtime.events, Runtime.hooks = events, hooks
|
Runtime.events, Runtime.hooks = events, hooks
|
||||||
Runtime.errors = errors
|
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
|
if #mon.moves < 4 then
|
||||||
table.insert(mon.moves, { id = moveId, pp = mdef.pp })
|
table.insert(mon.moves, { id = moveId, pp = mdef.pp })
|
||||||
Runtime.emit("pokemon.move_learned", { mon = mon, moveId = moveId })
|
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,
|
game.stack:push(TextBox.new(game,
|
||||||
romText(game.data, "_LearnedMove1Text",
|
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
|
else
|
||||||
-- LearnMoveFromLevelUp with a full moveset: the forget UI
|
-- LearnMoveFromLevelUp with a full moveset: the forget UI
|
||||||
Screens.push(game, "MoveLearnMenu", mon, moveId, nextStep)
|
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
|
-- 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 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.
|
-- 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
|
local x, y = tx * 8, ty * 8
|
||||||
segments = math.max(1, math.floor(segments or 6))
|
segments = math.max(1, math.floor(segments or 6))
|
||||||
HudTiles.tile(0x71, x, y)
|
HudTiles.tile(0x71, x, y)
|
||||||
HudTiles.tile(0x62, x + 8, y)
|
HudTiles.tile(0x62, x + 8, y)
|
||||||
local px = 0
|
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))
|
px = math.max(1, math.floor(mon.hp * segments * 8 / mon.stats.hp))
|
||||||
end
|
end
|
||||||
local tint
|
local tint
|
||||||
|
|||||||
@@ -691,10 +691,12 @@ function PaletteFX.spriteObp(spriteDef, seed)
|
|||||||
return PaletteFX.darkObp(w.spritePalettes[group], group)
|
return PaletteFX.darkObp(w.spritePalettes[group], group)
|
||||||
end
|
end
|
||||||
|
|
||||||
-- GetHealthBarColor (home/palettes.asm) on the standard 48px bar
|
-- GetHealthBarColor (home/palettes.asm) on the standard 48px bar. It reads
|
||||||
function PaletteFX.barPalName(hp, maxHp)
|
-- the bar's own length, so a caller mid-drain passes the animated `pixels`
|
||||||
local px = maxHp > 0 and math.floor(hp * 48 / maxHp) or 0
|
-- rather than let it be re-derived from hp.
|
||||||
if hp > 0 and px < 1 then px = 1 end
|
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"
|
return px >= 27 and "GREENBAR" or px >= 10 and "YELLOWBAR" or "REDBAR"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
+25
-13
@@ -768,11 +768,12 @@ function Renderer:endFrame(zones, worldZones)
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
-- A post-process pipeline needs the whole composite in a canvas for the
|
-- Post-process pipelines, GBC FX and an enabled final-output owner need the
|
||||||
-- same reason GBC FX does, so either one alone is enough to take the
|
-- whole composite in a canvas. With none of them, the frame draws straight
|
||||||
-- present path; with neither, the frame draws straight to the screen
|
-- to the screen exactly as it always did.
|
||||||
-- exactly as it always did.
|
local hasOutputHook = Runtime.wantsHook("render.output")
|
||||||
local needPresent = GBCFX.active() or Pipelines.wantsPresent()
|
and Runtime.call("render.output_enabled", function() return false end) == true
|
||||||
|
local needPresent = GBCFX.active() or Pipelines.wantsPresent() or hasOutputHook
|
||||||
local present = nil
|
local present = nil
|
||||||
if needPresent then
|
if needPresent then
|
||||||
if not self.presentCanvas or self.presentCanvas:getWidth() ~= ww
|
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.
|
-- this returns `present` unchanged and the frame is byte-identical.
|
||||||
local composed = Pipelines.present(present,
|
local composed = Pipelines.present(present,
|
||||||
{ width = ww, height = wh, scale = Sp, dpi = dpiY, dpiX = dpiX, dpiY = dpiY }) or present
|
{ width = ww, height = wh, scale = Sp, dpi = dpiY, dpiX = dpiX, dpiY = dpiY }) or present
|
||||||
if GBCFX.active() then
|
local outputHandled = hasOutputHook
|
||||||
-- shader grid/shadow math is in framebuffer pixels
|
and Runtime.call("render.output", function() return false end, {
|
||||||
GBCFX.present(composed, Sp)
|
canvas = composed,
|
||||||
else
|
width = ww, height = wh,
|
||||||
-- the present canvas only existed for the post-process, so put the
|
gameX = ox, gameY = oy,
|
||||||
-- result on the screen at the same 1:1 unit mapping it was built at
|
gameWidth = vpw, gameHeight = vph,
|
||||||
love.graphics.setColor(1, 1, 1, 1)
|
scale = Sp, dpiX = dpiX, dpiY = dpiY,
|
||||||
love.graphics.draw(composed, 0, 0)
|
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
|
||||||
end
|
end
|
||||||
self.worldActive = false
|
self.worldActive = false
|
||||||
|
|||||||
@@ -62,6 +62,8 @@ local function getObpImage(path, colors, group)
|
|||||||
return obpCache[key]
|
return obpCache[key]
|
||||||
end
|
end
|
||||||
|
|
||||||
|
SpriteRenderer.obpImage = getObpImage
|
||||||
|
|
||||||
-- hot reload drops the sheets; live instances hold their own image, so
|
-- hot reload drops the sheets; live instances hold their own image, so
|
||||||
-- the world rebuilds them (MapLoader.invalidateAll) rather than this
|
-- the world rebuilds them (MapLoader.invalidateAll) rather than this
|
||||||
function SpriteRenderer.invalidate()
|
function SpriteRenderer.invalidate()
|
||||||
@@ -281,7 +283,7 @@ end
|
|||||||
-- swapped and OAM_XFLIP on each (data/sprites/facings.asm:192-197). Optional
|
-- swapped and OAM_XFLIP on each (data/sprites/facings.asm:192-197). Optional
|
||||||
-- and trailing, so every existing call site is unchanged.
|
-- and trailing, so every existing call site is unchanged.
|
||||||
function SpriteRenderer:draw(px, py, camX, camY, facing, walkPhase, stepFlip,
|
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 x, y = self:getScreenOrigin(px, py, camX, camY)
|
||||||
local image = self.image
|
local image = self.image
|
||||||
local redraw = false
|
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,
|
-- still 3-frame sprites turn to face (the nurse at her machine,
|
||||||
-- facePlayer on STAY NPCs) but never show walk frames.
|
-- facePlayer on STAY NPCs) but never show walk frames.
|
||||||
local frame, flip = pose(self, facing, walkPhase, stepFlip)
|
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
|
if forceFlip then flip = true end
|
||||||
local quad = self.frames[frame]
|
local quad = self.frames[frame]
|
||||||
local drawHeight = self.frameHeight
|
local drawHeight = self.frameHeight
|
||||||
|
|||||||
@@ -100,6 +100,26 @@ function TextBox.new(game, text, onDone, opts)
|
|||||||
return self
|
return self
|
||||||
end
|
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
|
-- The runtime tokens substitute() knows, as handlers the tokens registry
|
||||||
-- serves. Each is fn(game, arg) -> replacement, or nil to drop the token.
|
-- 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
|
-- 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,
|
STRBUF = function(game) return game.stringBuffer end,
|
||||||
RAM = function(game, arg)
|
RAM = function(game, arg)
|
||||||
if arg == "wStringBuffer" then return game.stringBuffer end
|
if arg == "wStringBuffer" then return game.stringBuffer end
|
||||||
|
if arg == "wNameBuffer" then return game.stringBuffer end
|
||||||
if arg == "wBoxNumString" then return game.boxNumString end
|
if arg == "wBoxNumString" then return game.boxNumString end
|
||||||
-- SendNewMonToBox / _SentToBoxText reads the deposited nick here
|
-- SendNewMonToBox / _SentToBoxText reads the deposited nick here
|
||||||
if arg == "wBoxMonNicks" then return game.boxMonNicks end
|
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
|
-- Sits 2 bytes (wPlayerCoins) past O.coins per the walk above; absolute
|
||||||
-- 0x2852 (#763, #857).
|
-- 0x2852 (#763, #857).
|
||||||
O.toggleObjectFlags = O.coins + 2 -- 32B
|
O.toggleObjectFlags = O.coins + 2 -- 32B
|
||||||
|
O.hiddenItemFlags = O.townVisited - 27
|
||||||
-- Play time (wPlayTimeHours/Maxed/Minutes/Seconds/Frames) lives INSIDE the
|
-- Play time (wPlayTimeHours/Maxed/Minutes/Seconds/Frames) lives INSIDE the
|
||||||
-- sMainData window (wMainDataStart..wMainDataEnd is copied verbatim into
|
-- sMainData window (wMainDataStart..wMainDataEnd is copied verbatim into
|
||||||
-- SRAM), 1866 bytes past wMainDataStart -- reached from the checksum-verified
|
-- SRAM), 1866 bytes past wMainDataStart -- reached from the checksum-verified
|
||||||
@@ -770,6 +771,15 @@ function GenSave.decode(bytes, data, opts)
|
|||||||
end
|
end
|
||||||
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:
|
-- FLY destinations. wTownVisitedFlag's bit index IS the town's map index:
|
||||||
-- engine/items/town_map.asm BuildFlyLocationsList loads the 16-bit value
|
-- 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
|
-- 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
|
||||||
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
|
-- FLY destinations back into wTownVisitedFlag (see the decode note), so a
|
||||||
-- save exported from this port is flyable on hardware (#263). A save
|
-- 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
|
-- 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" },
|
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" },
|
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" },
|
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 }
|
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",
|
||||||
"src/save_convert/data/event_flags_yellow.lua",
|
"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 function loadTable(requirePath, filePath)
|
||||||
local ok, mod = pcall(require, requirePath)
|
local ok, mod = pcall(require, requirePath)
|
||||||
@@ -133,6 +138,8 @@ local function ensureData(gameVersion)
|
|||||||
if name ~= "charmap" then
|
if name ~= "charmap" then
|
||||||
if name == "eventFlags" and gameVersion == "yellow" then
|
if name == "eventFlags" and gameVersion == "yellow" then
|
||||||
spec = YELLOW_EVENT_FLAGS -- Yellow's bit numbering differs (#838)
|
spec = YELLOW_EVENT_FLAGS -- Yellow's bit numbering differs (#838)
|
||||||
|
elseif name == "hiddenItems" and gameVersion == "yellow" then
|
||||||
|
spec = YELLOW_HIDDEN_ITEMS
|
||||||
end
|
end
|
||||||
local mod = loadCacheTable(gameVersion, spec[2])
|
local mod = loadCacheTable(gameVersion, spec[2])
|
||||||
if not mod then
|
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
|
ctx.save[key] = value
|
||||||
end
|
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 function toggleObject(ctx, mapId, objName, visible)
|
||||||
local save = ctx.save
|
local save = ctx.save
|
||||||
save.objectToggles = save.objectToggles or {}
|
save.objectToggles = save.objectToggles or {}
|
||||||
@@ -576,6 +592,17 @@ function Commands.play_sound(ctx, soundId)
|
|||||||
require("src.core.Sound").play(ctx.game.data, soundId)
|
require("src.core.Sound").play(ctx.game.data, soundId)
|
||||||
end
|
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
|
-- play_once <songId>: one-shot jingle (Music_PkmnHealed, etc.); blocks
|
||||||
-- until it finishes so heal-rest scripts (Mom, captain text_asm) match
|
-- 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
|
-- 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,
|
onDone = function() runner:resume() end,
|
||||||
})
|
})
|
||||||
runner:yield()
|
runner:yield()
|
||||||
-- TradedForText (sound_get_key_item) then the dialogset's thanks
|
-- TradedForText carries sound_get_key_item after the text, so the jingle
|
||||||
require("src.core.Sound").play(data, "Get_Key_Item")
|
-- 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.tradedFor or "_TradedForText")
|
||||||
say(texts.thanks or "_Thanks" .. dialogset .. "Text")
|
say(texts.thanks or "_Thanks" .. dialogset .. "Text")
|
||||||
end
|
end
|
||||||
@@ -1131,9 +1160,7 @@ end
|
|||||||
-- opts.tempo is the Music_*AlternateTempo override (audio/alternate_tempo.asm
|
-- 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).
|
-- re-points channel 1 at a stub that only changes the song's `tempo`) (#847).
|
||||||
function Commands.play_music(ctx, songId, opts)
|
function Commands.play_music(ctx, songId, opts)
|
||||||
local tempo = opts and opts.tempo
|
require("src.core.Music").play(ctx.game.data, songId, nil, opts)
|
||||||
require("src.core.Music").play(ctx.game.data, songId, nil,
|
|
||||||
tempo and { tempo = tempo } or nil)
|
|
||||||
if opts and opts.keep and ctx.overworld then
|
if opts and opts.keep and ctx.overworld then
|
||||||
ctx.overworld.keepMusicOnce = true
|
ctx.overworld.keepMusicOnce = true
|
||||||
end
|
end
|
||||||
|
|||||||
+24
-11
@@ -33,12 +33,12 @@ local function save_name(game)
|
|||||||
return game.save.player.name
|
return game.save.player.name
|
||||||
end
|
end
|
||||||
|
|
||||||
local function showMessages(game, msgs, onDone)
|
local function showMessages(game, msgs, onDone, opts)
|
||||||
if not msgs or #msgs == 0 then
|
if not msgs or #msgs == 0 then
|
||||||
if onDone then onDone() end
|
if onDone then onDone() end
|
||||||
return
|
return
|
||||||
end
|
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
|
end
|
||||||
|
|
||||||
-- run the use-flow for an item on a chosen target. `picker` is the party
|
-- 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
|
return
|
||||||
end
|
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,
|
-- field POKé FLUTE next to a not-yet-beaten Snorlax: "had effect" text,
|
||||||
-- then the woke-up/battle sequence (data/scripts/story.lua snorlaxWake)
|
-- then the woke-up/battle sequence (data/scripts/story.lua snorlaxWake)
|
||||||
if result == "flute_wake" then
|
if result == "flute_wake" then
|
||||||
@@ -142,12 +150,9 @@ local function useOn(game, battle, id, target, list, moveIndex, picker)
|
|||||||
list:close()
|
list:close()
|
||||||
local ow = game.overworld
|
local ow = game.overworld
|
||||||
local p = ow and ow.player
|
local p = ow and ow.player
|
||||||
if ow and p then
|
if ow and p and ow:facingIsShoreOrWater() then
|
||||||
local fx, fy = p:facingCell()
|
ow:goFishing(id)
|
||||||
if ow.map:inBounds(fx, fy) and ow.map:isWaterCell(fx, fy) then
|
return
|
||||||
ow:goFishing(id)
|
|
||||||
return
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
showMessages(game, { Strings("No good! It's not\neven near water.") })
|
showMessages(game, { Strings("No good! It's not\neven near water.") })
|
||||||
return
|
return
|
||||||
@@ -176,19 +181,27 @@ local function useOn(game, battle, id, target, list, moveIndex, picker)
|
|||||||
end
|
end
|
||||||
if #target.moves < 4 then
|
if #target.moves < 4 then
|
||||||
table.insert(target.moves, { id = moveId, pp = mdef.pp })
|
table.insert(target.moves, { id = moveId, pp = mdef.pp })
|
||||||
|
-- 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
|
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
|
if result == "learn" then consume(game, id) end
|
||||||
|
list.items = buildItems(game)
|
||||||
|
list.index = math.min(list.index, math.max(1, #list.items))
|
||||||
taught()
|
taught()
|
||||||
else
|
else
|
||||||
require("src.ui.Screens").push(game, "MoveLearnMenu", target, moveId,
|
require("src.ui.Screens").push(game, "MoveLearnMenu", target, moveId,
|
||||||
function(learned)
|
function(learned)
|
||||||
if learned and result == "learn" then consume(game, id) end
|
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
|
if learned then taught() end
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
list:close()
|
|
||||||
teach()
|
teach()
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
@@ -324,7 +337,7 @@ local function useOn(game, battle, id, target, list, moveIndex, picker)
|
|||||||
table.insert(target.moves, { id = moveId, pp = mdef.pp })
|
table.insert(target.moves, { id = moveId, pp = mdef.pp })
|
||||||
local name = target.nickname or def.name
|
local name = target.nickname or def.name
|
||||||
showMessages(game, { Strings("%s learned\n%s!", name, mdef.name) },
|
showMessages(game, { Strings("%s learned\n%s!", name, mdef.name) },
|
||||||
nextStep)
|
nextStep, TextBox.soundOpts(game, "Get_Item1"))
|
||||||
else
|
else
|
||||||
require("src.ui.Screens").push(game, "MoveLearnMenu",
|
require("src.ui.Screens").push(game, "MoveLearnMenu",
|
||||||
target, moveId, nextStep)
|
target, moveId, nextStep)
|
||||||
|
|||||||
@@ -119,6 +119,13 @@ local function deposit(game)
|
|||||||
onChoose = function(item, list)
|
onChoose = function(item, list)
|
||||||
local mon = game.save.party[item.value]
|
local mon = game.save.party[item.value]
|
||||||
if not mon then return end
|
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()
|
monSubmenu(game, "DEPOSIT", mon, function()
|
||||||
if #game.save.party <= 1 then
|
if #game.save.party <= 1 then
|
||||||
list.footer = Strings("You need at least\none POKéMON!")
|
list.footer = Strings("You need at least\none POKéMON!")
|
||||||
@@ -167,6 +174,16 @@ local function release(game)
|
|||||||
local mon = box[list.index]
|
local mon = box[list.index]
|
||||||
if not mon then return end
|
if not mon then return end
|
||||||
local name = monName(game, mon)
|
local name = monName(game, mon)
|
||||||
|
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,
|
game.stack:push(TextBox.new(game,
|
||||||
Strings("Once released,\n%s is\ngone forever. OK?", name), nil, {
|
Strings("Once released,\n%s is\ngone forever. OK?", name), nil, {
|
||||||
defaultNo = true, noSound = true,
|
defaultNo = true, noSound = true,
|
||||||
|
|||||||
@@ -46,9 +46,27 @@ function EvolutionState:sgbPalettes(game)
|
|||||||
return P.wholeNamed(game.data, "MEWMON")
|
return P.wholeNamed(game.data, "MEWMON")
|
||||||
end
|
end
|
||||||
|
|
||||||
local FLASH_FRAMES = 220
|
|
||||||
-- evolution.asm EvolveMon delays 80 frames before .animLoop, polling nothing (#968, #1031)
|
-- evolution.asm EvolveMon delays 80 frames before .animLoop, polling nothing (#968, #1031)
|
||||||
local CANCEL_GRACE_FRAMES = 80
|
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 function frontSprite(game, species, mon)
|
||||||
local path, trueColor = require("src.pokemon.Sprites").path(
|
local path, trueColor = require("src.pokemon.Sprites").path(
|
||||||
@@ -140,14 +158,10 @@ function EvolutionState:draw()
|
|||||||
else
|
else
|
||||||
sprite, spriteTrueColor = self.newSprite, self.newSpriteTrueColor
|
sprite, spriteTrueColor = self.newSprite, self.newSpriteTrueColor
|
||||||
end
|
end
|
||||||
|
elseif evoShowsNew(self.t - CANCEL_GRACE_FRAMES) then
|
||||||
|
sprite, spriteTrueColor = self.newSprite, self.newSpriteTrueColor
|
||||||
else
|
else
|
||||||
local period = math.max(4, 28 - math.floor(self.t / 40) * 6)
|
sprite, spriteTrueColor = self.oldSprite, self.oldSpriteTrueColor
|
||||||
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
|
|
||||||
end
|
end
|
||||||
if sprite then
|
if sprite then
|
||||||
local x = math.floor((160 - sprite:getWidth()) / 2)
|
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 Music = require("src.core.Music")
|
||||||
local Sound = require("src.core.Sound")
|
local Sound = require("src.core.Sound")
|
||||||
local Strings = require("src.core.Strings")
|
local Strings = require("src.core.Strings")
|
||||||
|
local Timing = require("src.core.Timing")
|
||||||
|
|
||||||
local IntroMovie = {}
|
local IntroMovie = {}
|
||||||
IntroMovie.__index = IntroMovie
|
IntroMovie.__index = IntroMovie
|
||||||
@@ -232,6 +233,10 @@ function IntroMovie:fightStep()
|
|||||||
self:finish()
|
self:finish()
|
||||||
return
|
return
|
||||||
end
|
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
|
if op.sfx then
|
||||||
Sound.play(self.game.data, op.sfx)
|
Sound.play(self.game.data, op.sfx)
|
||||||
elseif op.pose then
|
elseif op.pose then
|
||||||
@@ -250,6 +255,7 @@ function IntroMovie:fightStep()
|
|||||||
end
|
end
|
||||||
self.opTimer = self.opTimer + 1
|
self.opTimer = self.opTimer + 1
|
||||||
if self.opTimer < (op.px or math.abs(op.dx)) then return end
|
if self.opTimer < (op.px or math.abs(op.dx)) then return end
|
||||||
|
timed = true
|
||||||
elseif op.anim then
|
elseif op.anim then
|
||||||
-- one {dy,dx} delta per 5 frames (AnimateIntroNidorino: DelayFrames 5)
|
-- one {dy,dx} delta per 5 frames (AnimateIntroNidorino: DelayFrames 5)
|
||||||
if self.opTimer % 5 == 0 then
|
if self.opTimer % 5 == 0 then
|
||||||
@@ -259,9 +265,11 @@ function IntroMovie:fightStep()
|
|||||||
end
|
end
|
||||||
self.opTimer = self.opTimer + 1
|
self.opTimer = self.opTimer + 1
|
||||||
if self.opTimer < #ANIM[op.anim] * 5 then return end
|
if self.opTimer < #ANIM[op.anim] * 5 then return end
|
||||||
|
timed = true
|
||||||
elseif op.wait then
|
elseif op.wait then
|
||||||
self.opTimer = self.opTimer + 1
|
self.opTimer = self.opTimer + 1
|
||||||
if self.opTimer < op.wait then return end
|
if self.opTimer < op.wait then return end
|
||||||
|
timed = true
|
||||||
elseif op.fade then
|
elseif op.fade then
|
||||||
self.opTimer = self.opTimer + 1
|
self.opTimer = self.opTimer + 1
|
||||||
self.fade = self.opTimer / op.fade
|
self.fade = self.opTimer / op.fade
|
||||||
@@ -270,6 +278,7 @@ function IntroMovie:fightStep()
|
|||||||
end
|
end
|
||||||
self.opIndex = self.opIndex + 1
|
self.opIndex = self.opIndex + 1
|
||||||
self.opTimer = 0
|
self.opTimer = 0
|
||||||
|
if timed then return end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -284,8 +293,9 @@ function IntroMovie:update(dt)
|
|||||||
return
|
return
|
||||||
end
|
end
|
||||||
local input = self.game.input
|
local input = self.game.input
|
||||||
if input:wasPressed("a") or input:wasPressed("b")
|
if input:wasPressed("a") or input:wasPressed("start") then
|
||||||
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
|
-- PlayIntro still GBFadeOutToWhite's after an interrupted scene; the
|
||||||
-- white hold stands in for that beat before the title is built.
|
-- white hold stands in for that beat before the title is built.
|
||||||
self:exitToTitle()
|
self:exitToTitle()
|
||||||
@@ -300,7 +310,10 @@ function IntroMovie:update(dt)
|
|||||||
end
|
end
|
||||||
if self.timer >= SPLASH_FRAMES then self:startPhase(3) end
|
if self.timer >= SPLASH_FRAMES then self:startPhase(3) end
|
||||||
else
|
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
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -17,12 +17,13 @@ local HM_MOVES = {
|
|||||||
CUT = true, FLY = true, SURF = true, STRENGTH = true, FLASH = true,
|
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)
|
local self = setmetatable({}, MoveLearnMenu)
|
||||||
self.game = game
|
self.game = game
|
||||||
self.mon = mon
|
self.mon = mon
|
||||||
self.newMoveId = newMoveId
|
self.newMoveId = newMoveId
|
||||||
self.onDone = onDone
|
self.onDone = onDone
|
||||||
|
self.learnedSound = learnedSound or "Get_Item1"
|
||||||
self.index = 1
|
self.index = 1
|
||||||
-- forget-list UI only after TryingToLearn YES (learn_move.asm .loop)
|
-- forget-list UI only after TryingToLearn YES (learn_move.asm .loop)
|
||||||
self.selecting = false
|
self.selecting = false
|
||||||
@@ -118,6 +119,7 @@ function MoveLearnMenu:finish(learned)
|
|||||||
self.selecting = false
|
self.selecting = false
|
||||||
game.stack:pop()
|
game.stack:pop()
|
||||||
local msg
|
local msg
|
||||||
|
local opts
|
||||||
if learned then
|
if learned then
|
||||||
-- pokered pages this as four texts in a row; _ForgotAndText carries
|
-- pokered pages this as four texts in a row; _ForgotAndText carries
|
||||||
-- the "And..." tail
|
-- the "And..." tail
|
||||||
@@ -127,13 +129,16 @@ function MoveLearnMenu:finish(learned)
|
|||||||
"\f%s forgot\n%s!\fAnd...", name, self.forgot)
|
"\f%s forgot\n%s!\fAnd...", name, self.forgot)
|
||||||
.. "\f" .. romText(game.data, "_LearnedMove1Text",
|
.. "\f" .. romText(game.data, "_LearnedMove1Text",
|
||||||
"%s learned\n%s!", name, mdef.name)
|
"%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
|
else
|
||||||
msg = romText(game.data, "_DidNotLearnText",
|
msg = romText(game.data, "_DidNotLearnText",
|
||||||
"%s\ndid not learn\v%s!", name, mdef.name)
|
"%s\ndid not learn\v%s!", name, mdef.name)
|
||||||
end
|
end
|
||||||
game.stack:push(TextBox.new(game, msg, function()
|
game.stack:push(TextBox.new(game, msg, function()
|
||||||
if self.onDone then self.onDone(learned) end
|
if self.onDone then self.onDone(learned) end
|
||||||
end))
|
end, opts))
|
||||||
end
|
end
|
||||||
|
|
||||||
function MoveLearnMenu:draw()
|
function MoveLearnMenu:draw()
|
||||||
|
|||||||
+111
-35
@@ -72,6 +72,30 @@ end
|
|||||||
|
|
||||||
local function sameItems(_, items) return items 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
|
-- where DIG escapes work: escape_rope_tilesets.asm (Agatha's room is
|
||||||
-- excluded by map id in ItemUseEscapeRope)
|
-- excluded by map id in ItemUseEscapeRope)
|
||||||
local DIG_TILESETS = { FOREST = true, CEMETERY = true, CAVERN = true,
|
local DIG_TILESETS = { FOREST = true, CEMETERY = true, CAVERN = true,
|
||||||
@@ -367,6 +391,10 @@ function PartyMenu:update(dt)
|
|||||||
self.submenu = nil
|
self.submenu = nil
|
||||||
elseif input:wasPressed("a") then
|
elseif input:wasPressed("a") then
|
||||||
local mon = party[self.index]
|
local mon = party[self.index]
|
||||||
|
if followerUnavailable(self.game, mon) then
|
||||||
|
refuseUnavailable(self)
|
||||||
|
return
|
||||||
|
end
|
||||||
local entry = self.subItems[self.subIndex]
|
local entry = self.subItems[self.subIndex]
|
||||||
local action = entry.action
|
local action = entry.action
|
||||||
if not action and entry.onSelect then
|
if not action and entry.onSelect then
|
||||||
@@ -392,6 +420,23 @@ function PartyMenu:update(dt)
|
|||||||
-- flyTo (OverworldController) validates the fly-warp + runs the
|
-- flyTo (OverworldController) validates the fly-warp + runs the
|
||||||
-- departure/warp, so we just hand it the chosen mapId (#195).
|
-- departure/warp, so we just hand it the chosen mapId (#195).
|
||||||
local ow = self.game.overworld
|
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
|
self.game.stack:pop() -- close the party menu
|
||||||
Screens.push(self.game, "TownMap", { fly = true, onFly = function(mapId)
|
Screens.push(self.game, "TownMap", { fly = true, onFly = function(mapId)
|
||||||
if ow then ow:flyTo(mapId) end
|
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
|
-- over the menu, and the cave is lit when the blink hands the
|
||||||
-- screen back, never under the text (#385).
|
-- screen back, never under the text (#385).
|
||||||
local ow = self.game.overworld
|
local ow = self.game.overworld
|
||||||
|
if ow and not ow:partyKnows("FLASH") then
|
||||||
|
refuseBadge(self)
|
||||||
|
return
|
||||||
|
end
|
||||||
local TextBox = require("src.render.TextBox")
|
local TextBox = require("src.render.TextBox")
|
||||||
local Transition = require("src.render.Transition")
|
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.stack:push(TextBox.new(self.game,
|
||||||
self.game.data.text._FlashLightsAreaText
|
self.game.data.text._FlashLightsAreaText
|
||||||
or Strings("A blinding FLASH\nlights the area!"), function()
|
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
|
-- seconds of per-pixel atlas baking on a phone -- on screen as a
|
||||||
-- solid white frame with nothing under it, which reads as a
|
-- solid white frame with nothing under it, which reads as a
|
||||||
-- lockup (#610).
|
-- lockup (#610).
|
||||||
ow:setDark(false)
|
if wasDark then ow:setDark(false) end
|
||||||
self.game.stack:push(Transition.whiteFlash(self.game))
|
self.game.stack:push(Transition.whiteFlash(self.game))
|
||||||
end))
|
end))
|
||||||
return
|
return
|
||||||
elseif action == "surf" then
|
elseif action == "surf" then
|
||||||
-- start_sub_menus.asm .surf: SOULBADGE-gated (checked at list time
|
-- start_sub_menus.asm .surf: SOULBADGE-gated (useSurfFieldMove),
|
||||||
-- above), then IsSurfingAllowed (the Cycling Road / Seafoam B4F
|
-- then IsSurfingAllowed (the Cycling Road / Seafoam B4F
|
||||||
-- current refusals, both of which loop back to the submenu), then
|
-- current refusals, both of which loop back to the submenu), then
|
||||||
-- ItemUseSurfboard: while surfing it tries to dismount instead;
|
-- ItemUseSurfboard: while surfing it tries to dismount instead;
|
||||||
-- otherwise it mounts only if the FACING tile is water, else
|
-- 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
|
return -- .loop: submenu stays open behind the message
|
||||||
elseif action == "cut" then
|
elseif action == "cut" then
|
||||||
-- start_sub_menus.asm .cut -> predef UsedCut (engine/overworld/cut.asm):
|
-- start_sub_menus.asm .cut -> predef UsedCut (engine/overworld/cut.asm):
|
||||||
-- CASCADEBADGE-gated (list time); _NothingToCutText loops back to the
|
-- CASCADEBADGE-gated (useCutFieldMove); _NothingToCutText loops back
|
||||||
-- submenu when the FACING tile isn't a cuttable tree.
|
-- to the submenu when the FACING tile isn't a cuttable tree.
|
||||||
local ow = self.game.overworld
|
local ow = self.game.overworld
|
||||||
local reason = ow:useCutFieldMove()
|
local reason = ow:useCutFieldMove()
|
||||||
if reason == "ok" then
|
if reason == "ok" then
|
||||||
@@ -503,7 +556,7 @@ function PartyMenu:update(dt)
|
|||||||
self.game.stack:push(TextBox.new(self.game, txt))
|
self.game.stack:push(TextBox.new(self.game, txt))
|
||||||
return -- .loop: submenu stays open behind the message
|
return -- .loop: submenu stays open behind the message
|
||||||
elseif action == "strength" then
|
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
|
-- predef PrintStrengthText (field_move_messages.asm) sets
|
||||||
-- BIT_STRENGTH_ACTIVE of wStatusFlags1 -- the sole gate
|
-- BIT_STRENGTH_ACTIVE of wStatusFlags1 -- the sole gate
|
||||||
-- push_boulder.asm reads -- then prints _UsedStrengthText (no
|
-- push_boulder.asm reads -- then prints _UsedStrengthText (no
|
||||||
@@ -513,6 +566,10 @@ function PartyMenu:update(dt)
|
|||||||
-- .strength, GBPalWhiteOutWithDelay3 blinks the screen white
|
-- .strength, GBPalWhiteOutWithDelay3 blinks the screen white
|
||||||
-- before CloseTextDisplay returns to the map.
|
-- before CloseTextDisplay returns to the map.
|
||||||
local ow = self.game.overworld
|
local ow = self.game.overworld
|
||||||
|
if ow and not ow:partyKnows("STRENGTH") then
|
||||||
|
refuseBadge(self)
|
||||||
|
return
|
||||||
|
end
|
||||||
local TextBox = require("src.render.TextBox")
|
local TextBox = require("src.render.TextBox")
|
||||||
local Transition = require("src.render.Transition")
|
local Transition = require("src.render.Transition")
|
||||||
local def = self.game.data.pokemon[mon.species]
|
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
|
-- centralizes the spin -> fade -> warp so BagMenu's ESCAPE ROPE shares
|
||||||
-- the exact departure; the fade + warp fire when the spin ends.
|
-- the exact departure; the fade + warp fire when the spin ends.
|
||||||
local ow = self.game.overworld
|
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()
|
self.game.stack:pop()
|
||||||
if ow then ow:beginTeleportOut() end
|
if ow then ow:beginTeleportOut() end
|
||||||
return
|
return
|
||||||
@@ -577,6 +661,10 @@ function PartyMenu:update(dt)
|
|||||||
if self.onCancel then self.onCancel() end
|
if self.onCancel then self.onCancel() end
|
||||||
elseif input:wasPressed("a") and #party > 0 then
|
elseif input:wasPressed("a") and #party > 0 then
|
||||||
local mon = party[self.index]
|
local mon = party[self.index]
|
||||||
|
if followerUnavailable(self.game, mon) then
|
||||||
|
refuseUnavailable(self)
|
||||||
|
return
|
||||||
|
end
|
||||||
if self.softboiledFrom then
|
if self.softboiledFrom then
|
||||||
local user = party[self.softboiledFrom]
|
local user = party[self.softboiledFrom]
|
||||||
local heal = math.floor(user.stats.hp / 5)
|
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
|
-- Battle still excludes this list via `not self.battle`. Softboiled
|
||||||
-- can appear for a fainted user; its heal transfer then no-ops.
|
-- can appear for a fainted user; its heal transfer then no-ops.
|
||||||
if not self.battle and ow then
|
if not self.battle and ow then
|
||||||
-- FLY/TELEPORT: CheckIfInOutsideMap (OVERWORLD + PLATEAU --
|
-- GetMonFieldMoves (engine/menus/text_box.asm) matches the mon's
|
||||||
-- Route 23 / Indigo Plateau outdoor), not OVERWORLD alone (#83)
|
-- four moves against FieldMoveDisplayData and nothing else -- no
|
||||||
local outside = Map.isOutside(ow.map.def,
|
-- badge, no map, no tileset test. Every one of those lives in
|
||||||
FieldDefaults.field(self.game.data, "outsideTilesets"))
|
-- .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
|
for _, mv in ipairs(mon.moves) do
|
||||||
if mv.id == "FLY" and outside
|
if mv.id == "FLY" then
|
||||||
and self.game.save.inventory.THUNDERBADGE then
|
|
||||||
table.insert(items, { label = Strings("FLY"), action = "fly" })
|
table.insert(items, { label = Strings("FLY"), action = "fly" })
|
||||||
elseif mv.id == "FLASH" and ow.dark
|
elseif mv.id == "FLASH" then
|
||||||
and self.game.save.inventory.BOULDERBADGE then
|
|
||||||
table.insert(items, { label = Strings("FLASH"), action = "flash" })
|
table.insert(items, { label = Strings("FLASH"), action = "flash" })
|
||||||
elseif mv.id == "CUT" and self.game.save.inventory.CASCADEBADGE then
|
elseif mv.id == "CUT" 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).
|
|
||||||
table.insert(items, { label = Strings("CUT"), action = "cut" })
|
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" })
|
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" })
|
table.insert(items, { label = Strings("STRENGTH"), action = "strength" })
|
||||||
elseif mv.id == "SOFTBOILED" then
|
elseif mv.id == "SOFTBOILED" then
|
||||||
table.insert(items, { label = Strings("SOFTBOILED"), action = "softboiled" })
|
table.insert(items, { label = Strings("SOFTBOILED"), action = "softboiled" })
|
||||||
elseif mv.id == "TELEPORT" and outside then
|
elseif mv.id == "TELEPORT" then
|
||||||
-- TELEPORT works only OUTDOORS (start_sub_menus.asm
|
table.insert(items, { label = Strings("TELEPORT"),
|
||||||
-- .teleport -> CheckIfInOutsideMap); dark maps don't
|
action = "escape", move = "TELEPORT" })
|
||||||
-- block it
|
elseif mv.id == "DIG" then
|
||||||
table.insert(items, { label = Strings("TELEPORT"), action = "escape" })
|
table.insert(items, { label = Strings("DIG"),
|
||||||
elseif mv.id == "DIG" and DIG_TILESETS[ow.map.def.tileset]
|
action = "escape", move = "DIG" })
|
||||||
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" })
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -104,6 +104,27 @@ local function sell(game)
|
|||||||
dialogue = true,
|
dialogue = true,
|
||||||
money = function() return game.save.money end,
|
money = function() return game.save.money end,
|
||||||
footer = greet,
|
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)
|
onChoose = function(item)
|
||||||
local def = game.data.items[item.value]
|
local def = game.data.items[item.value]
|
||||||
-- only key items and HMs are unsellable (pokemart.asm IsKeyItem /
|
-- 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
|
-- POKéMON is always listed (draw_start_menu.asm prints it even with
|
||||||
-- an empty party; selecting it then just no-ops)
|
-- 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
|
if #game.save.party == 0 then return end
|
||||||
Screens.push(game, "PartyMenu", { onCancel = reopen })
|
Screens.push(game, "PartyMenu", { onCancel = reopen })
|
||||||
end })
|
end })
|
||||||
|
|||||||
+15
-8
@@ -200,8 +200,9 @@ function TitleState.new(game, opts)
|
|||||||
self.versionFull = imagePath(title.versionRibbon) ~= nil
|
self.versionFull = imagePath(title.versionRibbon) ~= nil
|
||||||
self.version = tryImage(imagePath(title.versionRibbon or title.version)
|
self.version = tryImage(imagePath(title.versionRibbon or title.version)
|
||||||
or "assets/generated/title/red_version.png")
|
or "assets/generated/title/red_version.png")
|
||||||
self.player = tryImage(imagePath(title.player)
|
self.playerPath = imagePath(title.player)
|
||||||
or "assets/generated/title/player.png")
|
or "assets/generated/title/player.png"
|
||||||
|
self.player = tryImage(self.playerPath)
|
||||||
-- ..(engine/movie/title2.asm ln 85)
|
-- ..(engine/movie/title2.asm ln 85)
|
||||||
if self.player then
|
if self.player then
|
||||||
local pw, ph = self.player:getDimensions()
|
local pw, ph = self.player:getDimensions()
|
||||||
@@ -597,6 +598,12 @@ end
|
|||||||
|
|
||||||
-- ..(engine/movie/title.asm ln 28)
|
-- ..(engine/movie/title.asm ln 28)
|
||||||
function TitleState:draw()
|
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.setColor(1, 1, 1, 1)
|
||||||
love.graphics.rectangle("fill", 0, 0, 160, 144)
|
love.graphics.rectangle("fill", 0, 0, 160, 144)
|
||||||
local scrollY = -(self.scy or 0)
|
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).
|
-- layout has no cycling mon and no Red art (title_yellow.asm).
|
||||||
if spriteTrueColor then
|
if spriteTrueColor then
|
||||||
local cover
|
local cover
|
||||||
if self.player then
|
if playerImage then
|
||||||
local pw, ph = self.player:getDimensions()
|
local pw, ph = playerImage:getDimensions()
|
||||||
cover = { 82, 80, pw, ph }
|
cover = { 82, 80, pw, ph }
|
||||||
end
|
end
|
||||||
markVisibleTrueColor(x, y, w, h, cover)
|
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
|
-- Red is OAM in the original: he draws over the mon's box edge
|
||||||
if self.playerQuads then
|
if self.playerQuads then
|
||||||
for _, part in ipairs(self.playerQuads) do
|
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
|
end
|
||||||
love.graphics.draw(self.player, self.ballQuad, 82, self.ballY)
|
love.graphics.draw(playerImage, self.ballQuad, 82, self.ballY)
|
||||||
elseif self.player then
|
elseif playerImage then
|
||||||
love.graphics.draw(self.player, 82, 80)
|
love.graphics.draw(playerImage, 82, 80)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
self:drawCopyright(136 + (preRibbon and 0 or scrollY))
|
self:drawCopyright(136 + (preRibbon and 0 or scrollY))
|
||||||
|
|||||||
@@ -173,6 +173,18 @@ end
|
|||||||
function BattleState:wantsFillScale() return true end
|
function BattleState:wantsFillScale() return true end
|
||||||
function BattleState:drawsWidescreen() 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
|
-- opts: battle (a Battle), onDone(outcome), save
|
||||||
function BattleState.new(game, opts)
|
function BattleState.new(game, opts)
|
||||||
opts = opts or {}
|
opts = opts or {}
|
||||||
@@ -3079,6 +3091,7 @@ end
|
|||||||
function BattleState:drawHud()
|
function BattleState:drawHud()
|
||||||
local wasBattle = Font.useBattleExtra(true)
|
local wasBattle = Font.useBattleExtra(true)
|
||||||
local enemy, player = self:activeMon("enemy"), self:activeMon("player")
|
local enemy, player = self:activeMon("enemy"), self:activeMon("player")
|
||||||
|
local showStatus = self:statusHUDVisible()
|
||||||
|
|
||||||
-- Enemy HUD (DrawEnemyHUD clears (1,0) 4 rows x 11 cols):
|
-- 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);
|
-- 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.
|
-- 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
|
-- And nothing at all before UpdateEnemyHUD has ever run: the intro bands
|
||||||
-- slide in over a blanked tilemap (core.asm:8554/8564).
|
-- 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)
|
Chrome.print(self:name(enemy), 1, 0)
|
||||||
-- PrintLevel writes <LV> at the coordinate it is given and then LEFT-aligns
|
-- 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
|
-- 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
|
-- BattleMenu's own tutorial arm skips UpdateBattleHuds as well. The DUDE's
|
||||||
-- half of the screen is his back-pic and nothing more.
|
-- half of the screen is his back-pic and nothing more.
|
||||||
-- Nor before SendOutPlayerMon's own UpdatePlayerHUD (core.asm:3838).
|
-- 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)
|
Font.useBattleExtra(wasBattle)
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
@@ -3226,6 +3240,11 @@ function BattleState:drawPanel()
|
|||||||
end
|
end
|
||||||
self:drawHud()
|
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 --
|
-- 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
|
-- 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.
|
-- on top, so the tail of a long name is simply covered.
|
||||||
|
|||||||
@@ -272,9 +272,6 @@ function EvolutionAnim:commit()
|
|||||||
evolved.level, evolved)
|
evolved.level, evolved)
|
||||||
end
|
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()
|
function EvolutionAnim:nextLearn()
|
||||||
self.learnIndex = (self.learnIndex or 0) + 1
|
self.learnIndex = (self.learnIndex or 0) + 1
|
||||||
local moveId = self.pending and self.pending[self.learnIndex]
|
local moveId = self.pending and self.pending[self.learnIndex]
|
||||||
@@ -289,6 +286,17 @@ function EvolutionAnim:nextLearn()
|
|||||||
self.learned[#self.learned + 1] = moveId
|
self.learned[#self.learned + 1] = moveId
|
||||||
self.lines = { self.nick .. " learned", moveName .. "!" }
|
self.lines = { self.nick .. " learned", moveName .. "!" }
|
||||||
elseif reason == "full" then
|
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.full[#self.full + 1] = moveId
|
||||||
self.lines = { self.nick .. " wants to", "learn " .. moveName .. "!" }
|
self.lines = { self.nick .. " wants to", "learn " .. moveName .. "!" }
|
||||||
else
|
else
|
||||||
|
|||||||
@@ -780,6 +780,11 @@ function MartMenu:offerToSell(itemId, count)
|
|||||||
end
|
end
|
||||||
-- ScrollingMenu's .a_button copies the row's own quantity into
|
-- ScrollingMenu's .a_button copies the row's own quantity into
|
||||||
-- wItemQuantity, so the selector's ceiling is how many you hold.
|
-- 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,
|
self.qtyItem = { id = itemId, name = def.name or itemId,
|
||||||
price = def.price or 0 }
|
price = def.price or 0 }
|
||||||
self.qty = 1
|
self.qty = 1
|
||||||
|
|||||||
+15
-12
@@ -354,20 +354,23 @@ function PackMenu:useSelected()
|
|||||||
end
|
end
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
-- engine/items/tmhm.asm:73
|
-- UseItem's FIELD-pack tail (engine/items/tmhm.asm:73): a TM/HM row opens
|
||||||
local def = self.items and self.items[row.id]
|
-- the party to teach, and a field-NOUSE item -- an X ATTACK or a POKé DOLL
|
||||||
if def and def.teaches then
|
-- used from the field PACK -- prints OakThisIsntTheTimeText and goes
|
||||||
self:openTeachParty(row)
|
-- nowhere (UseItem's jumptable's first four entries are all .Oak). Both
|
||||||
return
|
-- checks live behind `world.useFieldItem` on purpose: DepositSellPack (the
|
||||||
end
|
-- mart's SELL, the item PC's DEPOSIT) is a chooser whose jumptable is four
|
||||||
-- UseItem's jumptable runs off ITEMATTR's field-menu nibble, and the first
|
-- ScrollingMenus and never reaches tmhm.asm, so a TM picked there hands
|
||||||
-- four entries are all .Oak -- an X ATTACK or a POKé DOLL used from the
|
-- its row to onChoose like every other item instead of opening the teach
|
||||||
-- field PACK prints OakThisIsntTheTimeText and goes nowhere. Only the
|
-- party. The battle pack returned above, and the catch tutorial's DUDE
|
||||||
-- FIELD pack owns that refusal: the battle pack returned above, and the
|
-- pack carries a stub world with no useFieldItem at all -- its POKE BALL
|
||||||
-- catch tutorial's DUDE pack carries a stub world with no useFieldItem at
|
-- is field-NOUSE and must still reach the throw.
|
||||||
-- all -- its POKE BALL is field-NOUSE and must still reach the throw.
|
|
||||||
if world and world.useFieldItem then
|
if world and world.useFieldItem then
|
||||||
local def = self.items and self.items[row.id]
|
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
|
if def and def.fieldMenu == "ITEMMENU_NOUSE" then
|
||||||
self.message = OAK_THIS_ISNT_THE_TIME
|
self.message = OAK_THIS_ISNT_THE_TIME
|
||||||
return
|
return
|
||||||
|
|||||||
+128
-78
@@ -368,6 +368,7 @@ Kit._navPrevN = 0
|
|||||||
Kit._navSeen = {}
|
Kit._navSeen = {}
|
||||||
Kit._navQueue = nil
|
Kit._navQueue = nil
|
||||||
Kit._activateId = nil
|
Kit._activateId = nil
|
||||||
|
Kit._ringShown = false
|
||||||
|
|
||||||
-- Register a focusable. Returns true when it currently holds the ring.
|
-- Register a focusable. Returns true when it currently holds the ring.
|
||||||
-- Shielded widgets do not register: while a modal owns the frame 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
|
-- First focusable ever drawn adopts the ring, so keyboard users start
|
||||||
-- somewhere rather than nowhere.
|
-- somewhere rather than nowhere.
|
||||||
if Kit.focusId == nil then Kit.focusId = id end
|
if Kit.focusId == nil then Kit.focusId = id end
|
||||||
return Kit.focusId == id
|
return Kit._ringShown and Kit.focusId == id
|
||||||
end
|
end
|
||||||
|
|
||||||
function Kit.navigate(dir)
|
function Kit.navigate(dir)
|
||||||
Kit._navQueue = dir
|
Kit._navQueue = dir
|
||||||
|
Kit._ringShown = true
|
||||||
end
|
end
|
||||||
|
|
||||||
function Kit.activateFocused()
|
function Kit.activateFocused()
|
||||||
@@ -396,6 +398,7 @@ end
|
|||||||
|
|
||||||
function Kit.setFocus(id)
|
function Kit.setFocus(id)
|
||||||
Kit.focusId = id
|
Kit.focusId = id
|
||||||
|
Kit._ringShown = id ~= nil
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Pick the nearest focusable in `dir` from the current one. Candidates must
|
-- 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
|
function Kit.tapMin() return math.floor(30 * Kit.scale) end
|
||||||
|
|
||||||
-- ---------------------------------------------------------------- surfaces
|
-- ---------------------------------------------------------------- surfaces
|
||||||
function Kit.card(x, y, w, h, emphasis)
|
function Kit.card(x, y, w, h, variant)
|
||||||
Theme.card(x, y, w, h, emphasis)
|
Theme.card(x, y, w, h, variant)
|
||||||
end
|
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)
|
function Kit.row(x, y, w, h, selected, id)
|
||||||
audit("row", x, y, w, h, id or "row")
|
audit("row", x, y, w, h, id or "row")
|
||||||
local focused = id and Kit.focusable(id, x, y, w, h) or false
|
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.)
|
-- hairline says the same thing for one rect.)
|
||||||
function Kit.emptyBox(x, y, w, h, message)
|
function Kit.emptyBox(x, y, w, h, message)
|
||||||
if not G then return end
|
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),
|
Kit.textCenter("button", Kit.ellipsize("button", message, w - 24 * Kit.scale),
|
||||||
x, y + (h - Kit.textHeight("button")) / 2, w, PAL.muted)
|
x, y + (h - Kit.textHeight("button")) / 2, w, PAL.muted)
|
||||||
end
|
end
|
||||||
@@ -598,61 +598,130 @@ local KINDS = {
|
|||||||
disabled = { fill = PAL.steel, ink = PAL.inverse, flat = true },
|
disabled = { fill = PAL.steel, ink = PAL.inverse, flat = true },
|
||||||
}
|
}
|
||||||
Kit.KINDS = KINDS
|
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)
|
function Kit.button(x, y, w, h, label, opts)
|
||||||
opts = opts or {}
|
opts = opts or NO_OPTS
|
||||||
local enabled = opts.enabled ~= false
|
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)
|
audit("control", x, y, w, h, label)
|
||||||
local focused = enabled and opts.id
|
local focused = enabled and opts.id
|
||||||
and Kit.focusable(opts.id, x, y, w, h) or false
|
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)
|
local hot = enabled and Kit.hover(x, y, w, h)
|
||||||
|
local face = opts.face or "fill"
|
||||||
if G then
|
local B = Theme.BUTTON
|
||||||
-- The fill IS the control: a rounded, embossed, colour-coded key. A
|
local radius = opts.radius or B.radius
|
||||||
-- disabled button keeps its shape in a dead grey rather than
|
local active = opts.active or opts.on
|
||||||
-- disappearing, so a layout never reflows on state.
|
local invert = false
|
||||||
Theme.fillRounded(x, y, w, h, kind.fill, enabled and 1 or 0.45)
|
local fill, ink, stroke, strokeA, doEmboss, doRing, glowA
|
||||||
Theme.emboss(x, y, w, h, enabled and (hot and 1.3 or 1) or 0.4)
|
if face == "invert" then
|
||||||
if hot or focused then
|
invert = hot
|
||||||
-- White ring outside the fill: legible on green, blue, yellow, red and
|
fill = invert and (opts.hotFill or PAL.ink) or (opts.fill or PAL.surface)
|
||||||
-- white alike, which one darker/lighter shade per colour would not be.
|
ink = invert and (opts.hotInk or PAL.inverse) or (opts.ink or PAL.heading)
|
||||||
Theme.strokeRounded(x - 2, y - 2, w + 4, h + 4, PAL.lineStrong,
|
stroke = opts.stroke or PAL.line
|
||||||
Theme.A.focus, 2, Theme.radius() + 2)
|
strokeA = invert and Theme.A.focus or Theme.A.hairline
|
||||||
elseif opts.glow and enabled then
|
doRing = focused and not hot
|
||||||
-- "Something is waiting for you" (the update button): a pulsing ring.
|
elseif face == "tab" then
|
||||||
-- Pure alpha on one existing stroke -- no extra draw calls, no blend
|
invert = active or focused or hot
|
||||||
-- mode change.
|
local tint = opts.color or opts.fill or PAL.ink
|
||||||
local a = 0.25 + 0.75 * (0.5 + 0.5 * math.sin(Kit.time * 3))
|
fill = invert and tint or PAL.surface
|
||||||
Theme.strokeRounded(x - 2, y - 2, w + 4, h + 4, PAL.lineStrong, a, 2,
|
ink = invert and PAL.inverse or (opts.color or PAL.text)
|
||||||
Theme.radius() + 2)
|
if not invert then
|
||||||
|
stroke = tint
|
||||||
|
strokeA = opts.color and Theme.A.hover or Theme.A.hairline
|
||||||
end
|
end
|
||||||
local fname = opts.font or "button"
|
elseif face == "chip" then
|
||||||
local ink = enabled and kind.ink or PAL.inverse
|
local c = opts.color or PAL.line
|
||||||
local ty = y + (h - Kit.textHeight(fname)) / 2
|
invert = active and true or false
|
||||||
local shown = Kit.ellipsize(fname, label, w - 16 * Kit.scale)
|
if active then
|
||||||
-- Button labels are bold: they are the shortest, most-scanned text on
|
fill = c
|
||||||
-- screen and sit on a saturated fill.
|
ink = PAL.inverse
|
||||||
if opts.align == "left" then
|
doEmboss = true
|
||||||
Kit.textBold(fname, shown, x + 10 * Kit.scale, ty, ink)
|
|
||||||
else
|
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
|
||||||
end
|
end
|
||||||
if not enabled then return false 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)
|
and Kit._activateId == opts.id)
|
||||||
end
|
end
|
||||||
|
|
||||||
-- A small square control: +/- steppers, arrow cyclers, the row X.
|
|
||||||
function Kit.stepper(x, y, w, h, glyph, opts)
|
function Kit.stepper(x, y, w, h, glyph, opts)
|
||||||
opts = opts or {}
|
opts = opts or {}
|
||||||
opts.kind = opts.kind or "ghost"
|
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)
|
return Kit.button(x, y, w, h, glyph, opts)
|
||||||
end
|
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)
|
function Kit.chip(x, y, w, h, label, on, color, id)
|
||||||
audit("control", x, y, w, h, label)
|
CHIP_OPTS.active = on
|
||||||
local focused = id and Kit.focusable(id, x, y, w, h) or false
|
CHIP_OPTS.color = color
|
||||||
local c = color or PAL.line
|
CHIP_OPTS.id = id
|
||||||
if G then
|
return Kit.button(x, y, w, h, label, CHIP_OPTS)
|
||||||
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)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
-- A status label with no interaction: outlined text, the "INSTALLED"/"UPDATE"
|
-- 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
|
-- 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
|
-- outline drawn on the page. These are still flat fills -- the depth comes
|
||||||
-- from the value step plus Theme.shadow, not from a gradient.
|
-- from the value step plus Theme.shadow, not from a gradient.
|
||||||
field = { 16, 8, 10 }, -- the page BEHIND the cards
|
field = { 20, 20, 20 }, -- the page BEHIND the cards
|
||||||
bg = { 0, 0, 0 }, -- true black: button rests, field interiors
|
bg = { 20, 20, 20 }, -- light black
|
||||||
surface = { 28, 21, 24 }, -- card interiors
|
surface = { 28, 21, 24 }, -- card interiors
|
||||||
rowBg = { 20, 14, 17 }, -- rows inside a card, one step below it
|
rowBg = { 20, 14, 17 }, -- rows inside a card, one step below it
|
||||||
raised = { 44, 34, 38 }, -- hover feedback
|
raised = { 44, 34, 38 }, -- hover feedback
|
||||||
@@ -82,6 +82,67 @@ Theme.A = {
|
|||||||
disabled = 0.30,
|
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 G = love and love.graphics or nil
|
||||||
|
|
||||||
local has = {}
|
local has = {}
|
||||||
@@ -123,11 +184,11 @@ end
|
|||||||
-- Controls get the smaller one, containers the larger, so a button never
|
-- Controls get the smaller one, containers the larger, so a button never
|
||||||
-- looks like a card and a card never looks like a button.
|
-- looks like a card and a card never looks like a button.
|
||||||
function Theme.radius()
|
function Theme.radius()
|
||||||
return 8
|
return Theme.BUTTON.radius
|
||||||
end
|
end
|
||||||
|
|
||||||
function Theme.cardRadius()
|
function Theme.cardRadius()
|
||||||
return 14
|
return Theme.CARD.radius
|
||||||
end
|
end
|
||||||
|
|
||||||
-- DROP SHADOW. Three stacked rounded rects at low alpha, each one step wider
|
-- 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
|
-- 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
|
-- field, its own drop shadow, and a white hairline. `emphasis` raises the
|
||||||
-- outline to full white (used for the focused/active card).
|
-- outline to full white (used for the focused/active card).
|
||||||
function Theme.card(x, y, w, h, emphasis)
|
function Theme.card(x, y, w, h, variant)
|
||||||
local r = Theme.cardRadius()
|
local spec = Theme.CARD
|
||||||
Theme.shadow(x, y, w, h, r)
|
local v
|
||||||
Theme.fillRounded(x, y, w, h, PAL.surface, 1, r)
|
if variant == true then
|
||||||
Theme.strokeRounded(x, y, w, h, PAL.line,
|
v = Theme.CARD_VARIANT.emphasis
|
||||||
emphasis and Theme.A.focus or Theme.A.hairline, 1, r)
|
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
|
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)
|
function Theme.row(x, y, w, h, state)
|
||||||
local r = Theme.radius()
|
|
||||||
if state == "selected" then
|
if state == "selected" then
|
||||||
Theme.fillRounded(x, y, w, h, PAL.ink, 1, r)
|
Theme.card(x, y, w, h, "rowSelected")
|
||||||
return PAL.inverse
|
return PAL.inverse
|
||||||
end
|
end
|
||||||
Theme.fillRounded(x, y, w, h,
|
Theme.card(x, y, w, h, state == "hover" and "rowHover" or "row")
|
||||||
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)
|
|
||||||
return PAL.text
|
return PAL.text
|
||||||
end
|
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 = {}
|
local NPC = {}
|
||||||
NPC.__index = NPC
|
NPC.__index = NPC
|
||||||
|
|
||||||
local STEP_FRAMES = 16
|
local STEP_FRAMES = 32
|
||||||
|
|
||||||
local FACING_FROM_RANGE = {
|
local FACING_FROM_RANGE = {
|
||||||
DOWN = "down", UP = "up", LEFT = "left", RIGHT = "right",
|
DOWN = "down", UP = "up", LEFT = "left", RIGHT = "right",
|
||||||
@@ -52,7 +52,16 @@ function NPC:facePlayer(player)
|
|||||||
end
|
end
|
||||||
|
|
||||||
function NPC:update(map, entities)
|
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
|
-- 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
|
-- Pikachu takes the player's own step length, halved while it is more
|
||||||
-- than a cell behind (FastPikachuFollow, engine/pikachu/
|
-- than a cell behind (FastPikachuFollow, engine/pikachu/
|
||||||
@@ -78,7 +87,7 @@ function NPC:update(map, entities)
|
|||||||
return
|
return
|
||||||
end
|
end
|
||||||
local d = Collision.DELTA[self.facing]
|
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
|
-- so the cell still lands on a 16px boundary (Player:update does the
|
||||||
-- same for the bicycle)
|
-- same for the bicycle)
|
||||||
local moved = math.floor(self.progress * 16 * span / stepLen)
|
local moved = math.floor(self.progress * 16 * span / stepLen)
|
||||||
@@ -113,8 +122,9 @@ end
|
|||||||
|
|
||||||
function NPC:walkPhase()
|
function NPC:walkPhase()
|
||||||
if not self.moving then return 0 end
|
if not self.moving then return 0 end
|
||||||
local p = self.progress % 16
|
local stepLen = self.stepFrames or STEP_FRAMES
|
||||||
return (p >= 4 and p < 12) and 1 or 0
|
local p = self.progress % stepLen
|
||||||
|
return (p >= stepLen / 4 and p < stepLen * 3 / 4) and 1 or 0
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Same contract as Player:pose -- the sheet, position, facing and step
|
-- Same contract as Player:pose -- the sheet, position, facing and step
|
||||||
@@ -128,7 +138,8 @@ end
|
|||||||
|
|
||||||
function NPC:draw(camX, camY)
|
function NPC:draw(camX, camY)
|
||||||
local sprite, px, py, facing, phase, flip = self:pose()
|
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
|
end
|
||||||
|
|
||||||
return NPC
|
return NPC
|
||||||
|
|||||||
@@ -384,12 +384,21 @@ function OverworldState:setMap(mapId, x, y, facing, opts)
|
|||||||
-- NPC instances persist across connection crossings in self.npcPool
|
-- NPC instances persist across connection crossings in self.npcPool
|
||||||
-- (keyed by NPC.id): a neighbor map's wandering ghosts ARE the
|
-- (keyed by NPC.id): a neighbor map's wandering ghosts ARE the
|
||||||
-- objects that become the real NPCs when the player crosses 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
|
-- seam, so a map the player is not entering keeps its ghosts alive
|
||||||
-- survey zoom. Warps rebuild from scratch, like the original's
|
-- in view of the survey zoom. The map being entered does not --
|
||||||
-- per-entry sprite init (home/overworld.asm LoadMapHeader
|
-- crossing a seam runs .loadNewMap -> LoadMapHeader, whose
|
||||||
-- .loadSpriteData).
|
-- .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
|
if not (opts and opts.seamless and self.npcPool) then
|
||||||
self.npcPool = {}
|
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
|
end
|
||||||
self.npcs = {}
|
self.npcs = {}
|
||||||
for _, obj in ipairs(self.map.def.objects or {}) do
|
for _, obj in ipairs(self.map.def.objects or {}) do
|
||||||
@@ -1087,6 +1096,7 @@ function OverworldState:update(dt)
|
|||||||
-- the player lands on desk Oak.
|
-- the player lands on desk Oak.
|
||||||
local scripted = self.runner:isRunning() or #self.scriptMoves > 0
|
local scripted = self.runner:isRunning() or #self.scriptMoves > 0
|
||||||
or self.engaging or self.emote or self.teleportOut
|
or self.engaging or self.emote or self.teleportOut
|
||||||
|
or self.flyAnim or self.flyArrive
|
||||||
if not scripted and not self.transitioning then
|
if not scripted and not self.transitioning then
|
||||||
self:checkTrainerSight()
|
self:checkTrainerSight()
|
||||||
-- CheckFightingMapTrainers (home/trainers.asm) zeroes hJoyHeld and
|
-- 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.
|
-- the player can never start another step after being spotted.
|
||||||
scripted = self.runner:isRunning() or #self.scriptMoves > 0
|
scripted = self.runner:isRunning() or #self.scriptMoves > 0
|
||||||
or self.engaging or self.emote or self.teleportOut
|
or self.engaging or self.emote or self.teleportOut
|
||||||
|
or self.flyAnim or self.flyArrive
|
||||||
end
|
end
|
||||||
if not scripted and not self.transitioning then
|
if not scripted and not self.transitioning then
|
||||||
self:handleInput()
|
self:handleInput()
|
||||||
@@ -1664,6 +1675,10 @@ end
|
|||||||
-- Goldeen/Poliwag L10; Super Rod uses the map's extracted fishing group
|
-- Goldeen/Poliwag L10; Super Rod uses the map's extracted fishing group
|
||||||
-- (no group means "Not even a nibble!").
|
-- (no group means "Not even a nibble!").
|
||||||
function OverworldState:goFishing(rod)
|
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 pool, always = fishingPool(Game.data, rod, self.map.id)
|
||||||
local enc
|
local enc
|
||||||
if Runtime.wantsHook("encounter.fishing") then
|
if Runtime.wantsHook("encounter.fishing") then
|
||||||
@@ -1980,10 +1995,12 @@ function OverworldState:tryHiddenObject(fx, fy)
|
|||||||
end
|
end
|
||||||
save.hiddenTaken[key] = true
|
save.hiddenTaken[key] = true
|
||||||
local name = Game.data.items[h.item] and Game.data.items[h.item].name or h.item
|
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)
|
-- hidden items always play SFX_GET_ITEM_2, and FoundHiddenItemText's
|
||||||
require("src.core.Sound").play(Game.data, "Get_Item2")
|
-- text_asm tail runs it as PlaySoundWaitForCurrent +
|
||||||
|
-- WaitForSoundToFinish once the box has printed (hidden_items.asm)
|
||||||
Game.stack:push(TextBox.new(Game,
|
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
|
return true
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -1995,9 +2012,9 @@ function OverworldState:tryHiddenObject(fx, fy)
|
|||||||
if not save.inventory.COIN_CASE then return false end
|
if not save.inventory.COIN_CASE then return false end
|
||||||
save.hiddenTaken[key] = true
|
save.hiddenTaken[key] = true
|
||||||
save.coins = math.min(9999, (save.coins or 0) + h.coins)
|
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,
|
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
|
return true
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -2644,10 +2661,11 @@ function OverworldState:talkTo(npc)
|
|||||||
end
|
end
|
||||||
local name = Game.data.items[d.item] and Game.data.items[d.item].name or d.item
|
local name = Game.data.items[d.item] and Game.data.items[d.item].name or d.item
|
||||||
local ddef = Game.data.items[d.item]
|
local ddef = Game.data.items[d.item]
|
||||||
require("src.core.Sound").play(Game.data,
|
-- FoundItemText: text_far, sound_get_item_1, text_end (pick_up_item.asm)
|
||||||
(ddef and ddef.keyItem) and "Get_Key_Item" or "Get_Item1")
|
|
||||||
Game.stack:push(TextBox.new(Game,
|
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
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -2905,6 +2923,11 @@ end
|
|||||||
-- POKéMON" and "fighting fit".
|
-- POKéMON" and "fighting fit".
|
||||||
function OverworldState:nurseHeal(onDone, npc)
|
function OverworldState:nurseHeal(onDone, npc)
|
||||||
local t = Game.data.text
|
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 bye = t._PokemonCenterFarewellText or romText(Game.data, "_PokemonCenterFarewellText", "We hope to see\nyou again!")
|
||||||
local hello = t._PokemonCenterWelcomeText
|
local hello = t._PokemonCenterWelcomeText
|
||||||
or Strings("Welcome to our\nPOKéMON CENTER!")
|
or Strings("Welcome to our\nPOKéMON CENTER!")
|
||||||
@@ -2978,9 +3001,13 @@ function OverworldState:finishNurseHeal(bye, onDone, npc)
|
|||||||
end))
|
end))
|
||||||
end
|
end
|
||||||
if not npc then farewell() return 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
|
-- 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))
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -2992,6 +3019,11 @@ end
|
|||||||
-- for the original serial handshake; declining prints "Please come again!"
|
-- for the original serial handshake; declining prints "Please come again!"
|
||||||
function OverworldState:cableClubReceptionist(onDone)
|
function OverworldState:cableClubReceptionist(onDone)
|
||||||
local t = Game.data.text
|
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!")
|
local welcome = t._CableClubNPCWelcomeText or romText(Game.data, "_CableClubNPCWelcomeText", "Welcome to the\nCable Club!")
|
||||||
if not Game.save.flags.EVENT_GOT_POKEDEX then
|
if not Game.save.flags.EVENT_GOT_POKEDEX then
|
||||||
-- CableClubNPC .didNotConnect path before the pokedex
|
-- CableClubNPC .didNotConnect path before the pokedex
|
||||||
@@ -3162,6 +3194,58 @@ local function giveVictoryItem(reward)
|
|||||||
return true
|
return true
|
||||||
end
|
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).
|
-- Badges/items awarded after specific battles (data/scripts/victories.lua).
|
||||||
-- `deactivate` retires unfought gym/dojo trainers the way the originals'
|
-- `deactivate` retires unfought gym/dojo trainers the way the originals'
|
||||||
-- SetEvent / SetEventRange do after the leader victory.
|
-- 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)
|
-- retries the hand-over later (offerGymTm via gyms.lua)
|
||||||
tmGiven = giveVictoryItem(reward)
|
tmGiven = giveVictoryItem(reward)
|
||||||
end
|
end
|
||||||
local lines = {}
|
local chain = rewardChain()
|
||||||
if reward.dialogue then
|
if reward.dialogue then
|
||||||
local text = Game.data.text or {}
|
chain.add(reward.dialogue, reward.badgeSound)
|
||||||
for _, label in ipairs(reward.dialogue) do
|
|
||||||
if text[label] and text[label] ~= "" then
|
|
||||||
table.insert(lines, text[label])
|
|
||||||
end
|
|
||||||
end
|
|
||||||
if reward.item then
|
if reward.item then
|
||||||
for _, label in ipairs(reward.tmPre or {}) do
|
chain.add(reward.tmPre)
|
||||||
if text[label] and text[label] ~= "" then
|
|
||||||
table.insert(lines, text[label])
|
|
||||||
end
|
|
||||||
end
|
|
||||||
if tmGiven then
|
if tmGiven then
|
||||||
for _, label in ipairs(reward.tmDialogue or {}) do
|
chain.add(reward.tmDialogue, reward.tmSound)
|
||||||
if text[label] and text[label] ~= "" then
|
else
|
||||||
table.insert(lines, text[label])
|
chain.add({ reward.noRoom })
|
||||||
end
|
|
||||||
end
|
|
||||||
elseif reward.noRoom and text[reward.noRoom] and text[reward.noRoom] ~= "" then
|
|
||||||
table.insert(lines, text[reward.noRoom])
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
elseif reward.badge or reward.item then
|
elseif reward.badge or reward.item then
|
||||||
if reward.badge then
|
if reward.badge then
|
||||||
local name = Game.data.items[reward.badge] and Game.data.items[reward.badge].name
|
local name = Game.data.items[reward.badge] and Game.data.items[reward.badge].name
|
||||||
or reward.badge
|
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
|
end
|
||||||
if tmGiven then
|
if tmGiven then
|
||||||
local name = Game.stringBuffer or reward.item
|
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
|
||||||
end
|
end
|
||||||
if #lines > 0 then
|
chain.push()
|
||||||
Game.stack:push(TextBox.new(Game, table.concat(lines, "\f")))
|
|
||||||
end
|
|
||||||
self:runVictoryHook()
|
self:runVictoryHook()
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -3246,24 +3317,14 @@ end
|
|||||||
-- show again, then the same GiveItem check decides between the received
|
-- show again, then the same GiveItem check decides between the received
|
||||||
-- lines and the "make room" text.
|
-- lines and the "make room" text.
|
||||||
function OverworldState:offerGymTm(reward, done)
|
function OverworldState:offerGymTm(reward, done)
|
||||||
local text = Game.data.text or {}
|
local chain = rewardChain()
|
||||||
local lines = {}
|
chain.add(reward.tmPre)
|
||||||
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
|
|
||||||
if giveVictoryItem(reward) then
|
if giveVictoryItem(reward) then
|
||||||
for _, label in ipairs(reward.tmDialogue or {}) do addLine(label) end
|
chain.add(reward.tmDialogue, reward.tmSound)
|
||||||
else
|
else
|
||||||
addLine(reward.noRoom)
|
chain.add({ reward.noRoom })
|
||||||
end
|
|
||||||
if #lines > 0 then
|
|
||||||
Game.stack:push(TextBox.new(Game, table.concat(lines, "\f"), done))
|
|
||||||
elseif done then
|
|
||||||
done()
|
|
||||||
end
|
end
|
||||||
|
chain.push(done)
|
||||||
end
|
end
|
||||||
|
|
||||||
-- pokered reloads the map after every battle, re-running the map
|
-- pokered reloads the map after every battle, re-running the map
|
||||||
@@ -3641,8 +3702,9 @@ function OverworldState:onStepComplete()
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
-- wild encounters in grass, on water while surfing, or -- on indoor
|
-- wild encounters in grass, on water while surfing (at the map's water
|
||||||
-- maps whose tileset is not FOREST -- on EVERY tile
|
-- 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)
|
-- (wild_encounters.asm: caves, towers, the Mansion, Power Plant)
|
||||||
-- The cooldown is checked after all other step processing so repel and
|
-- The cooldown is checked after all other step processing so repel and
|
||||||
-- movement systems continue to advance during the protected steps.
|
-- 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 encDef = Game.data.encounters[self.map.id]
|
||||||
local enc
|
local enc
|
||||||
local indoor = Game.data.field.indoorEncounters
|
local indoor = Game.data.field.indoorEncounters
|
||||||
if p.surfing and encDef and encDef.water and self.map:isWaterCell(p.cellX, p.cellY) then
|
if self.map:isGrassCell(p.cellX, p.cellY) then
|
||||||
enc = self:rollEncounter({ grass = encDef.water }, "water")
|
|
||||||
elseif self.map:isGrassCell(p.cellX, p.cellY) then
|
|
||||||
enc = self:rollEncounter(encDef, "grass")
|
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
|
elseif indoor and self.map.def.index >= indoor.firstIndoorMap
|
||||||
and self.map.def.tileset ~= indoor.excludedTileset then
|
and self.map.def.tileset ~= indoor.excludedTileset then
|
||||||
enc = self:rollEncounter(encDef, "indoor")
|
enc = self:rollEncounter(encDef, "indoor")
|
||||||
@@ -3774,10 +3836,10 @@ function OverworldState:checkBadgeGate()
|
|||||||
if Game.save.inventory[g.badge] then
|
if Game.save.inventory[g.badge] then
|
||||||
if not Game.save.flags[passedFlag] then
|
if not Game.save.flags[passedFlag] then
|
||||||
Game.save.flags[passedFlag] = true
|
Game.save.flags[passedFlag] = true
|
||||||
-- Route22GateGuardGoRightAheadText plays sound_get_item_1
|
-- Route22GateGuardGoRightAheadText carries sound_get_item_1
|
||||||
require("src.core.Sound").play(Game.data, "Get_Item1")
|
|
||||||
Game.stack:push(TextBox.new(Game,
|
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
|
end
|
||||||
return false
|
return false
|
||||||
end
|
end
|
||||||
@@ -3802,11 +3864,11 @@ function OverworldState:checkBadgeGate()
|
|||||||
and Game.data.items[guard.badge].name or guard.badge
|
and Game.data.items[guard.badge].name or guard.badge
|
||||||
if Game.save.inventory[guard.badge] then
|
if Game.save.inventory[guard.badge] then
|
||||||
Game.save.flags[guard.event] = true
|
Game.save.flags[guard.event] = true
|
||||||
-- Route23OhThatIsTheBadgeText plays sound_get_item_1
|
-- Route23OhThatIsTheBadgeText carries sound_get_item_1
|
||||||
require("src.core.Sound").play(Game.data, "Get_Item1")
|
|
||||||
local text = (t["_" .. g.passText] or
|
local text = (t["_" .. g.passText] or
|
||||||
Strings("Oh! That is the\n{RAM}!")):gsub("{RAM:wNameBuffer}", badgeName)
|
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
|
return false
|
||||||
end
|
end
|
||||||
-- Route23YouDontHaveTheBadgeYetText plays SFX_DENIED
|
-- Route23YouDontHaveTheBadgeYetText plays SFX_DENIED
|
||||||
@@ -4450,7 +4512,7 @@ function OverworldState:scriptMove(entity, dir, tiles, onDone)
|
|||||||
})
|
})
|
||||||
end
|
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
|
-- frames) without translating, keeping its current facing. Ports the
|
||||||
-- NPC_CHANGE_FACING movement byte (engine/overworld/movement.asm
|
-- NPC_CHANGE_FACING movement byte (engine/overworld/movement.asm
|
||||||
-- ChangeFacingDirection -> zero-delta TryWalking), used for Oak marching
|
-- 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
|
-- 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
|
-- 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
|
-- simulated-joypad / NPC scripted movement, with no idle frame between
|
||||||
-- tiles. Phase 1 retires finished moves (which may chain new ones);
|
-- tiles. Phase 1 retires finished moves (which may chain new ones);
|
||||||
-- phase 2 then starts every not-yet-moving move.
|
-- phase 2 then starts every not-yet-moving move.
|
||||||
|
|||||||
@@ -65,6 +65,17 @@ function PikachuFollower.starterInParty(save, needHealthy)
|
|||||||
return nil
|
return nil
|
||||||
end
|
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
|
-- ModifyPikachuHappiness. mon is the party mon the event applied to for
|
||||||
-- the per-mon reasons (IsThisPartyMonStarterPikachu); GYMLEADER and
|
-- the per-mon reasons (IsThisPartyMonStarterPikachu); GYMLEADER and
|
||||||
-- WALKING instead require any healthy starter in the party
|
-- 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
|
-- Bill's House owns a short scripted scene that deliberately keeps
|
||||||
-- Pikachu off the normal trailing loop. A new map instance ends it.
|
-- Pikachu off the normal trailing loop. A new map instance ends it.
|
||||||
ow.pikachuBillsScene = nil
|
ow.pikachuBillsScene = nil
|
||||||
|
ow.pikachuFanClubScene = nil
|
||||||
remove(ow)
|
remove(ow)
|
||||||
if not shouldSpawn(game, ow) then return end
|
if not shouldSpawn(game, ow) then return end
|
||||||
-- opts.keepPikachu is the follower a connection crossing kept alive:
|
-- opts.keepPikachu is the follower a connection crossing kept alive:
|
||||||
@@ -410,7 +422,8 @@ end
|
|||||||
-- (pikachu_follow.asm keeps it one walk step behind)
|
-- (pikachu_follow.asm keeps it one walk step behind)
|
||||||
function PikachuFollower.update(game, ow)
|
function PikachuFollower.update(game, ow)
|
||||||
if ow.pikaHop then return end -- the counter hop owns the follower (#417)
|
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)
|
local npc = findFollower(ow)
|
||||||
if not npc then
|
if not npc then
|
||||||
if shouldSpawn(game, ow) then PikachuFollower.onMapEntered(game, ow) end
|
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
|
ow.player.facing = OPPOSITE[npc.facing] or ow.player.facing
|
||||||
local save = game.save
|
local save = game.save
|
||||||
local emotion = selectEmotion(game, ow, 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]
|
local e = EMOTIONS[emotion] or EMOTIONS[1]
|
||||||
if e.turnAway then
|
if e.turnAway then
|
||||||
npc.facing = ow.player.facing -- pikaemotion_9: back to the player
|
npc.facing = ow.player.facing -- pikaemotion_9: back to the player
|
||||||
@@ -798,10 +818,41 @@ local function movePikachu(ow, npc, steps, onDone)
|
|||||||
nextStep(1)
|
nextStep(1)
|
||||||
end
|
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)
|
function PikachuFollower.onBillsHouseEnter(game, ow)
|
||||||
if not (GameVersion.isYellow() and ow.map and ow.map.id == "BILLS_HOUSE") then
|
if not (GameVersion.isYellow() and ow.map and ow.map.id == "BILLS_HOUSE") then
|
||||||
return
|
return
|
||||||
end
|
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
|
if game.save.flags.EVENT_MET_BILL_2 then return end
|
||||||
-- BillsHouseScript0 (scripts/BillsHouse.asm:41-47) only runs the confused
|
-- BillsHouseScript0 (scripts/BillsHouse.asm:41-47) only runs the confused
|
||||||
-- walk while CheckPikachuStatusCondition comes back clear
|
-- walk while CheckPikachuStatusCondition comes back clear
|
||||||
|
|||||||
+3
-73
@@ -6,8 +6,8 @@
|
|||||||
-- stays unsupported; anything a mod legitimately needs belongs here.
|
-- stays unsupported; anything a mod legitimately needs belongs here.
|
||||||
|
|
||||||
local Logger = require("src.core.Logger")
|
local Logger = require("src.core.Logger")
|
||||||
local Assets = require("src.render.Assets")
|
|
||||||
local MapLoader = require("src.world.MapLoader")
|
local MapLoader = require("src.world.MapLoader")
|
||||||
|
local MapOverview = require("src.world.MapOverview")
|
||||||
local Party = require("src.pokemon.Party")
|
local Party = require("src.pokemon.Party")
|
||||||
local Runtime = require("src.mods.Runtime")
|
local Runtime = require("src.mods.Runtime")
|
||||||
|
|
||||||
@@ -15,59 +15,6 @@ local WorldAPI = {}
|
|||||||
WorldAPI.__index = WorldAPI
|
WorldAPI.__index = WorldAPI
|
||||||
|
|
||||||
local NO_OVERWORLD = "no overworld"
|
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 function acceptsMenuInput(game, ow)
|
||||||
local stack = game and game.stack
|
local stack = game and game.stack
|
||||||
@@ -147,16 +94,7 @@ end
|
|||||||
function WorldAPI:mapOverview()
|
function WorldAPI:mapOverview()
|
||||||
local ow = self:overworld()
|
local ow = self:overworld()
|
||||||
if not ow or not ow.map then return nil, NO_OVERWORLD end
|
if not ow or not ow.map then return nil, NO_OVERWORLD end
|
||||||
local map, rows, markers = ow.map, {}, {}
|
local map, 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 def = map.def or {}
|
local def = map.def or {}
|
||||||
for _, warp in ipairs(def.warps or {}) do
|
for _, warp in ipairs(def.warps or {}) do
|
||||||
markers[#markers + 1] = { kind = "warp", x = warp.x, y = warp.y }
|
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 }
|
markers[#markers + 1] = { kind = "hidden", x = item.x, y = item.y }
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
local tileRows, tileDetailRows = mapTileRows(map)
|
return MapOverview.build(map, markers)
|
||||||
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 }
|
|
||||||
end
|
end
|
||||||
|
|
||||||
-- opts.arrive = "fly" | "teleport" picks the arrival FX; anything else
|
-- opts.arrive = "fly" | "teleport" picks the arrival FX; anything else
|
||||||
|
|||||||
@@ -25,6 +25,8 @@
|
|||||||
local Logger = require("src.core.Logger")
|
local Logger = require("src.core.Logger")
|
||||||
local Movement = require("src.script.gen2.Movement")
|
local Movement = require("src.script.gen2.Movement")
|
||||||
local Runtime = require("src.mods.Runtime")
|
local Runtime = require("src.mods.Runtime")
|
||||||
|
local HiddenItems = require("src.world.gen2.HiddenItems")
|
||||||
|
local MapOverview = require("src.world.MapOverview")
|
||||||
|
|
||||||
local WorldAPI = {}
|
local WorldAPI = {}
|
||||||
WorldAPI.__index = WorldAPI
|
WorldAPI.__index = WorldAPI
|
||||||
@@ -50,6 +52,31 @@ function WorldAPI:current()
|
|||||||
facing = p and p.facing }
|
facing = p and p.facing }
|
||||||
end
|
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
|
-- 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.
|
-- come from the map setup method, so opts.arrive has nothing to select yet.
|
||||||
function WorldAPI:warpTo(mapId, x, y, facing, opts)
|
function WorldAPI:warpTo(mapId, x, y, facing, opts)
|
||||||
|
|||||||
@@ -191,12 +191,12 @@ return function(game)
|
|||||||
return cond()
|
return cond()
|
||||||
end
|
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,
|
-- taken. EvolutionState:update only compares self.t against that constant,
|
||||||
-- so this ends the animation early without touching the palette logic.
|
-- so this ends the animation early without touching the palette logic.
|
||||||
local function skipToEnd()
|
local function skipToEnd()
|
||||||
local st = evoTop()
|
local st = evoTop()
|
||||||
if st then st.t = 214 end
|
if st then st.t = 362 end
|
||||||
end
|
end
|
||||||
|
|
||||||
local function startEvo(species, into)
|
local function startEvo(species, into)
|
||||||
@@ -212,7 +212,7 @@ return function(game)
|
|||||||
|
|
||||||
-- ---- case 1: the reported case, SGB, mid-flash --------------------------
|
-- ---- case 1: the reported case, SGB, mid-flash --------------------------
|
||||||
local weedle = startEvo("WEEDLE", "KAKUNA")
|
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")
|
U.shot(game, DIR .. "/evo279_1_flash_sgb.png")
|
||||||
local c1 = probe("flash/SGB", {
|
local c1 = probe("flash/SGB", {
|
||||||
monYellow1 = YELLOW[2], monYellow2 = YELLOW[3],
|
monYellow1 = YELLOW[2], monYellow2 = YELLOW[3],
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
-- LINK_STATE_TRADING) skip that poll and cannot be cancelled.
|
-- LINK_STATE_TRADING) skip that poll and cannot be cancelled.
|
||||||
--
|
--
|
||||||
-- Case 1 (level path, cancelable): open EvolutionState directly, wait past
|
-- 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"
|
-- press B, and assert the mon stays CATERPIE with the "stopped evolving"
|
||||||
-- text on screen.
|
-- text on screen.
|
||||||
-- Case 1b: after cancel, checkParty with no level-ups must not re-offer;
|
-- 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)
|
Evolution.evolve(game, mon, "METAPOD", function() done1 = true end)
|
||||||
|
|
||||||
if not waitFor(evoTop, 300) then error("EvolutionState never opened (case1)") 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.log("case1 flash", "t=", top().t, "species=", mon.species)
|
||||||
U.shot(game, DIR .. "/evo213_1_evolving.png")
|
U.shot(game, DIR .. "/evo213_1_evolving.png")
|
||||||
|
|
||||||
@@ -133,8 +133,8 @@ return function(game)
|
|||||||
local done2 = false
|
local done2 = false
|
||||||
Evolution.evolve(game, mon2, "METAPOD", function() done2 = true end)
|
Evolution.evolve(game, mon2, "METAPOD", function() done2 = true end)
|
||||||
if not waitFor(evoTop, 300) then error("EvolutionState never opened (case2)") end
|
if not waitFor(evoTop, 300) then error("EvolutionState never opened (case2)") end
|
||||||
-- let the full flash run (FLASH_FRAMES=220) without pressing B
|
-- let the full flash run (FLASH_FRAMES=368) without pressing B
|
||||||
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
|
if not waitFor(function() return findText("evolved into") ~= nil end, 120) then
|
||||||
error("Congratulations text not shown (case2)")
|
error("Congratulations text not shown (case2)")
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -89,8 +89,8 @@ return function(game)
|
|||||||
Evolution.evolve(game, mon, "GYARADOS", function() done1 = true end)
|
Evolution.evolve(game, mon, "GYARADOS", function() done1 = true end)
|
||||||
|
|
||||||
if not waitFor(evoTop, 300) then error("EvolutionState never opened (case1)") 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
|
-- let the full flash run (FLASH_FRAMES=368) with no input, then apply
|
||||||
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
|
if not waitFor(function() return findText("evolved into") ~= nil end, 120) then
|
||||||
error("Congratulations text not shown (case1)")
|
error("Congratulations text not shown (case1)")
|
||||||
end
|
end
|
||||||
@@ -124,7 +124,7 @@ return function(game)
|
|||||||
local done2 = false
|
local done2 = false
|
||||||
Evolution.evolve(game, mon2, "GYARADOS", function() done2 = true end)
|
Evolution.evolve(game, mon2, "GYARADOS", function() done2 = true end)
|
||||||
if not waitFor(evoTop, 300) then error("EvolutionState never opened (case2)") 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
|
if not waitFor(function() return findText("evolved into") ~= nil end, 120) then
|
||||||
error("Congratulations text not shown (case2)")
|
error("Congratulations text not shown (case2)")
|
||||||
end
|
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