mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-21 13:09:54 +02:00
Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 03c5a1eddf | |||
| ada0d8abe1 | |||
| dbecc345e3 | |||
| 0f8f6d0e4f | |||
| 1e6613e2de | |||
| debfaf28e6 | |||
| 83463a5a59 | |||
| d66a72ac95 | |||
| 1b659dab01 | |||
| 9e01fe2c2c | |||
| 5fa5005786 | |||
| 1ac5b867bb | |||
| 69ef1bfc77 | |||
| 72592665d7 | |||
| 8f88d01cf2 | |||
| 2279617b29 | |||
| 17fbf6cec4 | |||
| 34c4481f96 | |||
| bff40a5d90 | |||
| 354a8b476d |
@@ -373,10 +373,31 @@ jobs:
|
|||||||
unzip -l dist/win/gen1recomp-win64.zip | grep -F gen1tls.dll \
|
unzip -l dist/win/gen1recomp-win64.zip | grep -F gen1tls.dll \
|
||||||
|| { echo "::error::Windows zip is missing gen1tls.dll"; exit 1; }
|
|| { echo "::error::Windows zip is missing gen1tls.dll"; exit 1; }
|
||||||
|
|
||||||
- name: Build Android
|
- name: Materialize Android release signing key
|
||||||
|
env:
|
||||||
|
KEYSTORE_B64: ${{ secrets.ANDROID_RELEASE_KEYSTORE_B64 }}
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
scripts/build_android.sh --version "${{ needs.version.outputs.version }}"
|
[ -n "$KEYSTORE_B64" ] || {
|
||||||
|
echo "::error::ANDROID_RELEASE_KEYSTORE_B64 is required for a publishable Android update"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
python3 - <<'PY'
|
||||||
|
import base64, os, pathlib
|
||||||
|
encoded = os.environ["KEYSTORE_B64"]
|
||||||
|
path = pathlib.Path(os.environ["RUNNER_TEMP"]) / "gen1recomp-android-release.keystore"
|
||||||
|
path.write_bytes(base64.b64decode(encoded, validate=True))
|
||||||
|
PY
|
||||||
|
|
||||||
|
- name: Build Android
|
||||||
|
env:
|
||||||
|
GEN1RECOMP_ANDROID_KEYSTORE: ${{ runner.temp }}/gen1recomp-android-release.keystore
|
||||||
|
GEN1RECOMP_ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_RELEASE_KEYSTORE_PASSWORD }}
|
||||||
|
GEN1RECOMP_ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_RELEASE_KEY_ALIAS }}
|
||||||
|
GEN1RECOMP_ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_RELEASE_KEY_PASSWORD }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
scripts/build_android.sh --release --version "${{ needs.version.outputs.version }}"
|
||||||
|
|
||||||
- name: Install xcbeautify
|
- name: Install xcbeautify
|
||||||
run: |
|
run: |
|
||||||
@@ -498,8 +519,8 @@ jobs:
|
|||||||
[ -f "$arm64_appimage" ] || { echo "::error::$arm64_appimage not found (expected from the linux-arm64 job)"; exit 1; }
|
[ -f "$arm64_appimage" ] || { echo "::error::$arm64_appimage not found (expected from the linux-arm64 job)"; exit 1; }
|
||||||
cp "$arm64_appimage" "$outdir/gen1recomp-${v}-linux-arm64.AppImage"
|
cp "$arm64_appimage" "$outdir/gen1recomp-${v}-linux-arm64.AppImage"
|
||||||
chmod +x "$outdir/gen1recomp-${v}-linux-arm64.AppImage"
|
chmod +x "$outdir/gen1recomp-${v}-linux-arm64.AppImage"
|
||||||
apk="$(find dist/android/debug -name '*.apk' | head -1)"
|
apk="$(find dist/android/release -name '*.apk' | head -1)"
|
||||||
[ -n "$apk" ] || { echo "::error::no Android APK found under dist/android/debug"; exit 1; }
|
[ -n "$apk" ] || { echo "::error::no Android APK found under dist/android/release"; exit 1; }
|
||||||
cp "$apk" "$outdir/gen1recomp-${v}-android.apk"
|
cp "$apk" "$outdir/gen1recomp-${v}-android.apk"
|
||||||
|
|
||||||
ipa="dist/ios/gen1recomp++.ipa"
|
ipa="dist/ios/gen1recomp++.ipa"
|
||||||
|
|||||||
+20
-2
@@ -38,6 +38,22 @@ local function retryTmGive(game, ow, victoryKey, done)
|
|||||||
return true
|
return true
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- The badge line + its jingle, armed for the battle screen the way
|
||||||
|
-- SaveEndBattleTextPointers does (PewterGym.asm:117-119) (#1606)
|
||||||
|
local function badgeEndBattleText(game, victoryKey)
|
||||||
|
local reward = victoryKey and require("data.scripts.victories")[victoryKey]
|
||||||
|
if not (reward and reward.dialogue) then return nil end
|
||||||
|
local text = game.data.text or {}
|
||||||
|
local pages = {}
|
||||||
|
for _, label in ipairs(reward.dialogue) do
|
||||||
|
if text[label] and text[label] ~= "" then
|
||||||
|
pages[#pages + 1] = text[label]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if #pages == 0 then return nil end
|
||||||
|
return table.concat(pages, "\f"), reward.badgeSound
|
||||||
|
end
|
||||||
|
|
||||||
-- scripts/PewterGym.asm PewterGymBrockText (text_asm): CheckEvent
|
-- scripts/PewterGym.asm PewterGymBrockText (text_asm): CheckEvent
|
||||||
-- EVENT_BEAT_BROCK branches his dialogue. Before the badge he prints
|
-- EVENT_BEAT_BROCK branches his dialogue. Before the badge he prints
|
||||||
-- _PewterGymBrockPreBattleText and engages the leader battle
|
-- _PewterGymBrockPreBattleText and engages the leader battle
|
||||||
@@ -58,7 +74,8 @@ M.PEWTER_GYM.talk = {
|
|||||||
game.data.text._PewterGymBrockPostBattleAdviceText
|
game.data.text._PewterGymBrockPostBattleAdviceText
|
||||||
or "Go to the GYM in\nCERULEAN and test\nyour abilities!", done))
|
or "Go to the GYM in\nCERULEAN and test\nyour abilities!", done))
|
||||||
else
|
else
|
||||||
ow:engageTrainer(npc, done)
|
local text, sound = badgeEndBattleText(game, "OPP_BROCK#1")
|
||||||
|
ow:engageTrainer(npc, done, text, nil, sound)
|
||||||
end
|
end
|
||||||
end,
|
end,
|
||||||
}
|
}
|
||||||
@@ -91,7 +108,8 @@ local function leaderTalk(beatFlag, adviceLabel, fallback, afterAdvice, victoryK
|
|||||||
game.stack:push(TextBox.new(game,
|
game.stack:push(TextBox.new(game,
|
||||||
game.data.text[adviceLabel] or fallback, finish))
|
game.data.text[adviceLabel] or fallback, finish))
|
||||||
else
|
else
|
||||||
ow:engageTrainer(npc, done)
|
local text, sound = badgeEndBattleText(game, victoryKey)
|
||||||
|
ow:engageTrainer(npc, done, text, nil, sound)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -837,13 +837,14 @@ M.SILPH_CO_11F = {
|
|||||||
-- every Silph rocket leaves off-screen (the street rockets are
|
-- every Silph rocket leaves off-screen (the street rockets are
|
||||||
-- handled by M.SAFFRON_CITY.onEnter in story4.lua). Queued, not
|
-- handled by M.SAFFRON_CITY.onEnter in story4.lua). Queued, not
|
||||||
-- run here: the battle's own callbacks are still unwinding, so
|
-- run here: the battle's own callbacks are still unwinding, so
|
||||||
-- queueScript starts it on the first idle overworld frame --
|
-- queueScript starts it on the first idle overworld frame (#722).
|
||||||
-- after the end-battle "Arrgh!!" box victories.lua OPP_GIOVANNI#2
|
|
||||||
-- pushes (#722).
|
|
||||||
if game.save.flags.EVENT_BEAT_SILPH_CO_GIOVANNI then
|
if game.save.flags.EVENT_BEAT_SILPH_CO_GIOVANNI then
|
||||||
ow:queueScript(silphAftermathRows())
|
ow:queueScript(silphAftermathRows())
|
||||||
end
|
end
|
||||||
end, nil, true)
|
end,
|
||||||
|
-- "Arrgh!!" is armed for the battle screen, not the map
|
||||||
|
-- (scripts/SilphCo11F.asm:264-266 SaveEndBattleTextPointers) #1606
|
||||||
|
game.data.text._SilphCo10FGiovanniILostAgainText, true)
|
||||||
end)
|
end)
|
||||||
end))
|
end))
|
||||||
return true
|
return true
|
||||||
|
|||||||
@@ -216,7 +216,10 @@ local function dojoMasterGate(game, ow, x, y)
|
|||||||
if not master or ow:trainerDefeated(master) then return false end
|
if not master or ow:trainerDefeated(master) then return false end
|
||||||
ow.player.facing = "right"
|
ow.player.facing = "right"
|
||||||
master:facePlayer(ow.player)
|
master:facePlayer(ow.player)
|
||||||
ow:engageTrainer(master)
|
-- scripts/FightingDojo.asm:117-119 SaveEndBattleTextPointers (#1606)
|
||||||
|
ow:engageTrainer(master, nil,
|
||||||
|
((game.data or {}).text or {})._FightingDojoKarateMasterDefeatedText,
|
||||||
|
nil, nil, false)
|
||||||
return true
|
return true
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
+17
-15
@@ -647,27 +647,29 @@ end
|
|||||||
local rocketRows = {
|
local rocketRows = {
|
||||||
{ "face_player" }, -- 1
|
{ "face_player" }, -- 1
|
||||||
{ "check_flag", "EVENT_GOT_TM28" }, -- 2
|
{ "check_flag", "EVENT_GOT_TM28" }, -- 2
|
||||||
{ "jump_if_true", 15 }, -- 3 → CeruleanHideRocket
|
{ "jump_if_true", 16 }, -- 3 → CeruleanHideRocket
|
||||||
{ "check_flag", "EVENT_BEAT_CERULEAN_ROCKET_THIEF" }, -- 4
|
{ "check_flag", "EVENT_BEAT_CERULEAN_ROCKET_THIEF" }, -- 4
|
||||||
{ "jump_if_true", 9 }, -- 5
|
{ "jump_if_true", 10 }, -- 5
|
||||||
{ "show_text", "_CeruleanCityRocketText" }, -- 6
|
{ "show_text", "_CeruleanCityRocketText" }, -- 6
|
||||||
{ "start_battle", "trainer", "OPP_ROCKET", 5 }, -- 7
|
-- scripts/CeruleanCity.asm:297 SaveEndBattleTextPointers
|
||||||
{ "jump_if_false", "end" }, -- 8
|
{ "save_end_battle_text", "_CeruleanCityRocketIGiveUpText" }, -- 7
|
||||||
{ "show_text", "_CeruleanCityRocketIllReturnTheTMText" }, -- 9
|
{ "start_battle", "trainer", "OPP_ROCKET", 5 }, -- 8
|
||||||
{ "set_flag", "EVENT_BEAT_CERULEAN_ROCKET_THIEF" }, -- 10
|
{ "jump_if_false", "end" }, -- 9
|
||||||
{ "give_item", "TM_DIG", 1, false }, -- 11 (row 13 prints)
|
{ "show_text", "_CeruleanCityRocketIllReturnTheTMText" }, -- 10
|
||||||
{ "set_flag", "EVENT_GOT_TM28" }, -- 12
|
{ "set_flag", "EVENT_BEAT_CERULEAN_ROCKET_THIEF" }, -- 11
|
||||||
{ "show_text", "_CeruleanCityRocketReceivedTM28Text" }, -- 13
|
{ "give_item", "TM_DIG", 1, false }, -- 12 (row 14 prints)
|
||||||
{ "show_text", "_CeruleanCityRocketIBetterGetMovingText" }, -- 14
|
{ "set_flag", "EVENT_GOT_TM28" }, -- 13
|
||||||
{ "fade", "out" }, -- 15 GBFadeOutToBlack
|
{ "show_text", "_CeruleanCityRocketReceivedTM28Text" }, -- 14
|
||||||
|
{ "show_text", "_CeruleanCityRocketIBetterGetMovingText" }, -- 15
|
||||||
|
{ "fade", "out" }, -- 16 GBFadeOutToBlack
|
||||||
-- CeruleanHideRocket while black: GUARD1 (28,12) appears, GUARD2
|
-- CeruleanHideRocket while black: GUARD1 (28,12) appears, GUARD2
|
||||||
-- (27,12) and the ROCKET go. GUARD2 blocks the trashed-house south
|
-- (27,12) and the ROCKET go. GUARD2 blocks the trashed-house south
|
||||||
-- door neighbour -- the swap reconnects the city (Bill's ticket does
|
-- door neighbour -- the swap reconnects the city (Bill's ticket does
|
||||||
-- the same in story.lua; either route is enough).
|
-- the same in story.lua; either route is enough).
|
||||||
{ "show_object", "CERULEAN_CITY", "CERULEANCITY_GUARD1" }, -- 16
|
{ "show_object", "CERULEAN_CITY", "CERULEANCITY_GUARD1" }, -- 17
|
||||||
{ "hide_object", "CERULEAN_CITY", "CERULEANCITY_GUARD2" }, -- 17
|
{ "hide_object", "CERULEAN_CITY", "CERULEANCITY_GUARD2" }, -- 18
|
||||||
{ "hide_object", "CERULEAN_CITY", "CERULEANCITY_ROCKET" }, -- 18
|
{ "hide_object", "CERULEAN_CITY", "CERULEANCITY_ROCKET" }, -- 19
|
||||||
{ "fade", "in" }, -- 19 GBFadeInFromBlack
|
{ "fade", "in" }, -- 20 GBFadeInFromBlack
|
||||||
}
|
}
|
||||||
|
|
||||||
M.CERULEAN_CITY = {
|
M.CERULEAN_CITY = {
|
||||||
|
|||||||
+23
-4
@@ -64,7 +64,8 @@ mounted or deleted as stale; the launcher directs the player to a full package.
|
|||||||
|
|
||||||
Each tagged release `vX.Y.Z` carries the existing per-platform archives
|
Each tagged release `vX.Y.Z` carries the existing per-platform archives
|
||||||
(`gen1recomp-X.Y.Z-macos.zip`, `-windows.zip`, `-linux.zip`,
|
(`gen1recomp-X.Y.Z-macos.zip`, `-windows.zip`, `-linux.zip`,
|
||||||
`-android.apk`) plus two assets the updater itself consumes:
|
`-linux-arm64.AppImage`, `-android.apk`, `-ios.ipa`, `-switch.zip`, Xbox and
|
||||||
|
PortMaster archives) plus two assets the updater itself consumes:
|
||||||
|
|
||||||
- `gen1recomp-X.Y.Z.love` - the payload, matched by the exact pattern
|
- `gen1recomp-X.Y.Z.love` - the payload, matched by the exact pattern
|
||||||
`gen1recomp-<version>.love` (see `isPayloadName` in `Boot.lua` and
|
`gen1recomp-<version>.love` (see `isPayloadName` in `Boot.lua` and
|
||||||
@@ -75,8 +76,9 @@ Each tagged release `vX.Y.Z` carries the existing per-platform archives
|
|||||||
filename otherwise to match the asset name exactly.
|
filename otherwise to match the asset name exactly.
|
||||||
|
|
||||||
A release missing either asset is treated as "no in-place update available":
|
A release missing either asset is treated as "no in-place update available":
|
||||||
`Check` reports `needs_full` and sends the player to `Check.releaseUrl()`
|
`Check` reports `needs_full`. It also selects the exact current platform asset
|
||||||
(`https://github.com/bryanthaboi/gen1recomp/releases/latest`).
|
from the same release and persists the requirement, so it is visible again on
|
||||||
|
every launch, including offline launches.
|
||||||
|
|
||||||
## Save-directory layout
|
## Save-directory layout
|
||||||
|
|
||||||
@@ -85,6 +87,7 @@ Under the save directory (identity `pokemon-love2d`):
|
|||||||
```
|
```
|
||||||
updates/gen1recomp-<X.Y.Z>.love downloaded payload(s)
|
updates/gen1recomp-<X.Y.Z>.love downloaded payload(s)
|
||||||
updates/pending.txt crash-guard marker
|
updates/pending.txt crash-guard marker
|
||||||
|
updates/full-update.json persistent native-package requirement
|
||||||
```
|
```
|
||||||
|
|
||||||
`pending.txt` holds the filename of the payload currently being chainloaded.
|
`pending.txt` holds the filename of the payload currently being chainloaded.
|
||||||
@@ -106,7 +109,8 @@ bundled game, in that case.
|
|||||||
against the GitHub releases API; safe to call every frame, it is a no-op
|
against the GitHub releases API; safe to call every frame, it is a no-op
|
||||||
once a check is in flight or has reached a terminal state. `Check.state()`
|
once a check is in flight or has reached a terminal state. `Check.state()`
|
||||||
reports `idle | checking | uptodate | available | downloading | ready |
|
reports `idle | checking | uptodate | available | downloading | ready |
|
||||||
needs_full | error` plus the latest version and download progress.
|
needs_full | full_downloading | full_ready | error` plus the latest version,
|
||||||
|
download progress, and (when applicable) the selected full-package asset.
|
||||||
3. **Download + verify**: on `available`, `Check.download()` tells the
|
3. **Download + verify**: on `available`, `Check.download()` tells the
|
||||||
worker to fetch the payload, polling the growing `.part` file for
|
worker to fetch the payload, polling the growing `.part` file for
|
||||||
progress. On completion the worker re-fetches `sha256sums.txt`, verifies
|
progress. On completion the worker re-fetches `sha256sums.txt`, verifies
|
||||||
@@ -117,6 +121,14 @@ bundled game, in that case.
|
|||||||
4. **Restart to apply**: a `ready` payload just sits in `updates/` until the
|
4. **Restart to apply**: a `ready` payload just sits in `updates/` until the
|
||||||
player relaunches; the next launch's Boot step (1) is what actually
|
player relaunches; the next launch's Boot step (1) is what actually
|
||||||
mounts and runs it. There is no in-session hot-swap.
|
mounts and runs it. There is no in-session hot-swap.
|
||||||
|
5. **Native-package requirement**: when `minShell` or `payloadHost` is
|
||||||
|
incompatible, the worker writes `full-update.json` and surfaces a
|
||||||
|
persistent launcher control. Android downloads the release APK, verifies
|
||||||
|
its SHA-256 entry from `sha256sums.txt`, then invokes Android's Package
|
||||||
|
Installer. The installer asks the user for consent and enforces package,
|
||||||
|
version-code, and signing-certificate compatibility. iOS links the
|
||||||
|
sideload repository for a re-sideload; Xbox, desktop, and PortMaster builds
|
||||||
|
link their correctly named full package. Switch keeps its native OTA flow.
|
||||||
|
|
||||||
## Known limitations
|
## Known limitations
|
||||||
|
|
||||||
@@ -140,6 +152,13 @@ bundled game, in that case.
|
|||||||
still need a full reinstall (`minShell` / `payloadHost` gate →
|
still need a full reinstall (`minShell` / `payloadHost` gate →
|
||||||
`needs_full`). Applying a downloaded payload on Android relaunches via
|
`needs_full`). Applying a downloaded payload on Android relaunches via
|
||||||
`love.system.restartApp`; iOS still uses in-process `quit("restart")`.
|
`love.system.restartApp`; iOS still uses in-process `quit("restart")`.
|
||||||
|
- **Android full updates are user-confirmed and certificate-bound.** The app
|
||||||
|
uses a private `FileProvider` cache path plus
|
||||||
|
`Intent.ACTION_INSTALL_PACKAGE`, checks Android 8+'s per-app
|
||||||
|
"install unknown apps" setting, and never requests a silent install. The
|
||||||
|
release job must use the original long-lived Android signing key; a new key
|
||||||
|
causes Android to reject an in-place update and requires a one-time manual
|
||||||
|
reinstall. See [mobile/ANDROID.md](../mobile/ANDROID.md).
|
||||||
- **Dev/source runs never self-update.** `Boot.run` returns immediately when
|
- **Dev/source runs never self-update.** `Boot.run` returns immediately when
|
||||||
`love.filesystem.isFused()` is false, and a working tree's `engine` is the
|
`love.filesystem.isFused()` is false, and a working tree's `engine` is the
|
||||||
`"0.0.0-dev"` placeholder that always reports up to date, so a source
|
`"0.0.0-dev"` placeholder that always reports up to date, so a source
|
||||||
|
|||||||
@@ -327,6 +327,7 @@ local function returnToLauncher()
|
|||||||
if love.audio and love.audio.stop then
|
if love.audio and love.audio.stop then
|
||||||
pcall(love.audio.stop)
|
pcall(love.audio.stop)
|
||||||
end
|
end
|
||||||
|
pcall(function() require("src.render.SecondScreen").setEnabled(false) end)
|
||||||
|
|
||||||
local GameVersion = require("src.core.GameVersion")
|
local GameVersion = require("src.core.GameVersion")
|
||||||
local currentVersion = GameVersion.get()
|
local currentVersion = GameVersion.get()
|
||||||
|
|||||||
+15
-5
@@ -93,8 +93,9 @@ transport, exactly as a missing curl does.
|
|||||||
love-android 11.5a expects:
|
love-android 11.5a expects:
|
||||||
|
|
||||||
- **JDK 17**
|
- **JDK 17**
|
||||||
- Android SDK with **API 34**
|
- Android SDK with **API 36** (Android 16; latest 36.x Build-Tools)
|
||||||
- NDK **25.2.9519653** (Apple Silicon host supported)
|
- NDK **25.2.9519653** (Apple Silicon host supported)
|
||||||
|
- **minSdk 19** (Android 4.4), **targetSdk 36** (Android 16)
|
||||||
|
|
||||||
Set `ANDROID_SDK_ROOT` (or `ANDROID_HOME`), or let the script write
|
Set `ANDROID_SDK_ROOT` (or `ANDROID_HOME`), or let the script write
|
||||||
`local.properties` when it finds `~/Library/Android/sdk`.
|
`local.properties` when it finds `~/Library/Android/sdk`.
|
||||||
@@ -122,15 +123,24 @@ scripts, tests, and mobile build sources are excluded.
|
|||||||
| `app.application_id` | `com.theboisclub.pokemonred` |
|
| `app.application_id` | `com.theboisclub.pokemonred` |
|
||||||
| `app.name` | Pokemon Red |
|
| `app.name` | Pokemon Red |
|
||||||
| `app.orientation` | `fullUser`. This is only the manifest default: SDL requests FULL_SENSOR at window creation (resizable window, no `SDL_HINT_ORIENTATIONS`), and `GameActivity.setOrientationBis` remaps that to FULL_USER so the device's rotation lock is honoured. |
|
| `app.orientation` | `fullUser`. This is only the manifest default: SDL requests FULL_SENSOR at window creation (resizable window, no `SDL_HINT_ORIENTATIONS`), and `GameActivity.setOrientationBis` remaps that to FULL_USER so the device's rotation lock is honoured. |
|
||||||
| `app.version_name` / `app.version_code` | set from `--version X.Y.Z` (code = major*10000 + minor*100 + patch); left as-is if `--version` is omitted |
|
| `app.version_name` / `app.version_code` | set from `--version X.Y.Z` (code = major*1,000,000 + minor*1,000 + patch); left as-is if `--version` is omitted |
|
||||||
| Permissions | RECORD_AUDIO / WRITE_EXTERNAL_STORAGE stripped; VIBRATE + BLUETOOTH + INTERNET (link play, mod index) + ACTIVITY_RECOGNITION (step bridge) kept |
|
| Permissions | RECORD_AUDIO / WRITE_EXTERNAL_STORAGE stripped; VIBRATE + BLUETOOTH + INTERNET (link play, mod index) + ACTIVITY_RECOGNITION (step bridge) kept; REQUEST_INSTALL_PACKAGES is limited to the user-confirmed full-update installer |
|
||||||
|
|
||||||
## Releases
|
## Releases
|
||||||
|
|
||||||
`.github/workflows/release.yml` builds the APK with `--version` set to the
|
`.github/workflows/release.yml` builds the APK with `--version` set to the
|
||||||
release version and publishes it alongside the macOS/Windows/Linux builds as
|
release version and publishes it alongside the macOS/Windows/Linux builds as
|
||||||
`PokemonRed-<version>-android.apk`.
|
`gen1recomp-<version>-android.apk`.
|
||||||
|
|
||||||
## Signing
|
## Signing
|
||||||
|
|
||||||
Signed with the default Android keystore (no setup required).
|
Production APKs are built with `scripts/build_android.sh --release`. They must
|
||||||
|
be signed with the same long-lived certificate as the currently installed app:
|
||||||
|
Android's Package Installer rejects an update with a different signing
|
||||||
|
certificate. Store that keystore and its passwords only in CI secrets, expose
|
||||||
|
them as `GEN1RECOMP_ANDROID_KEYSTORE`,
|
||||||
|
`GEN1RECOMP_ANDROID_KEYSTORE_PASSWORD`, `GEN1RECOMP_ANDROID_KEY_ALIAS`, and
|
||||||
|
`GEN1RECOMP_ANDROID_KEY_PASSWORD`, and never commit the keystore. A newly
|
||||||
|
created certificate cannot update users who have an APK signed by a different
|
||||||
|
legacy key; those users need one final manual reinstall before in-app updates
|
||||||
|
can take over.
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ Quick Start:
|
|||||||
Before you start, install JDK 17 (not later not earlier). If you intend to build from Android Studio, skip this step as
|
Before you start, install JDK 17 (not later not earlier). If you intend to build from Android Studio, skip this step as
|
||||||
Android Studio bundles its own JDK 17.
|
Android Studio bundles its own JDK 17.
|
||||||
|
|
||||||
Install Android SDK with SDK API 34 (34.x.y) and Android NDK 25.2.9519653, set the environment variable
|
Install Android SDK with SDK API 36 (latest 36.x Build-Tools) and Android NDK 25.2.9519653, set the environment variable
|
||||||
`ANDROID_SDK_ROOT` to your Android SDK location and run:
|
`ANDROID_SDK_ROOT` to your Android SDK location and run:
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -10,9 +10,12 @@ android {
|
|||||||
applicationId project.properties["app.application_id"]
|
applicationId project.properties["app.application_id"]
|
||||||
versionCode project.properties["app.version_code"].toInteger()
|
versionCode project.properties["app.version_code"].toInteger()
|
||||||
versionName project.properties["app.version_name"]
|
versionName project.properties["app.version_name"]
|
||||||
minSdk 16
|
// NDK r25 no longer supports API 16; API 19 is Android 4.4 and keeps
|
||||||
compileSdk 34
|
// the native toolchain and package-installer bridge on a supported ABI.
|
||||||
targetSdk 34
|
minSdk 19
|
||||||
|
// Android 16 / API 36: current Android distribution target.
|
||||||
|
compileSdk 36
|
||||||
|
targetSdk 36
|
||||||
|
|
||||||
def getAppName = {
|
def getAppName = {
|
||||||
def nameArray = project.properties["app.name_byte_array"]
|
def nameArray = project.properties["app.name_byte_array"]
|
||||||
@@ -38,10 +41,31 @@ android {
|
|||||||
ORIENTATION:project.properties["app.orientation"],
|
ORIENTATION:project.properties["app.orientation"],
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
// Release signing lives outside the repository. The release build script
|
||||||
|
// requires all five values below, while debug builds intentionally remain
|
||||||
|
// usable without them.
|
||||||
|
def releaseStore = System.getenv("GEN1RECOMP_ANDROID_KEYSTORE")
|
||||||
|
def releaseStorePassword = System.getenv("GEN1RECOMP_ANDROID_KEYSTORE_PASSWORD")
|
||||||
|
def releaseKeyAlias = System.getenv("GEN1RECOMP_ANDROID_KEY_ALIAS")
|
||||||
|
def releaseKeyPassword = System.getenv("GEN1RECOMP_ANDROID_KEY_PASSWORD")
|
||||||
|
def hasReleaseSigning = releaseStore && releaseStorePassword && releaseKeyAlias && releaseKeyPassword
|
||||||
|
|
||||||
|
if (hasReleaseSigning) {
|
||||||
|
signingConfigs {
|
||||||
|
release {
|
||||||
|
storeFile file(releaseStore)
|
||||||
|
storePassword releaseStorePassword
|
||||||
|
keyAlias releaseKeyAlias
|
||||||
|
keyPassword releaseKeyPassword
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
buildTypes {
|
buildTypes {
|
||||||
release {
|
release {
|
||||||
minifyEnabled true
|
minifyEnabled true
|
||||||
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
|
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
|
||||||
|
if (hasReleaseSigning) signingConfig signingConfigs.release
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
flavorDimensions = ['mode', 'recording']
|
flavorDimensions = ['mode', 'recording']
|
||||||
|
|||||||
@@ -8,6 +8,10 @@
|
|||||||
the link screen shows as "(Operation not permitted)" (issue #287).
|
the link screen shows as "(Operation not permitted)" (issue #287).
|
||||||
scripts/build_android.sh must not strip this again. -->
|
scripts/build_android.sh must not strip this again. -->
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
<!-- Required only to hand a checksum-verified, user-selected GitHub release
|
||||||
|
APK to Android's own Package Installer. Android still shows the install
|
||||||
|
confirmation and enforces package/signing-key/version compatibility. -->
|
||||||
|
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||||
<!-- Step bridge: love.system.syncHealthSteps reads the hardware step
|
<!-- Step bridge: love.system.syncHealthSteps reads the hardware step
|
||||||
counter, which Android 10+ gates behind this runtime permission.
|
counter, which Android 10+ gates behind this runtime permission.
|
||||||
Requested only on the first sync call (the Pokéwalker mod's SYNC
|
Requested only on the first sync call (the Pokéwalker mod's SYNC
|
||||||
@@ -35,6 +39,18 @@
|
|||||||
<meta-data
|
<meta-data
|
||||||
android:name="android.allow_multiple_resumed_activities"
|
android:name="android.allow_multiple_resumed_activities"
|
||||||
android:value="true" />
|
android:value="true" />
|
||||||
|
<!-- The full-update APK is copied into this small cache subdirectory
|
||||||
|
before it is handed to Package Installer. Keep the provider private
|
||||||
|
and expose only that directory, never a storage root. -->
|
||||||
|
<provider
|
||||||
|
android:name="androidx.core.content.FileProvider"
|
||||||
|
android:authorities="${applicationId}.full_update_provider"
|
||||||
|
android:exported="false"
|
||||||
|
android:grantUriPermissions="true">
|
||||||
|
<meta-data
|
||||||
|
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||||
|
android:resource="@xml/full_update_paths" />
|
||||||
|
</provider>
|
||||||
<activity
|
<activity
|
||||||
android:name="org.love2d.android.GameActivity"
|
android:name="org.love2d.android.GameActivity"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- Deliberately narrow: FileProvider may grant only the staged APK, never
|
||||||
|
arbitrary app, external, or shared storage. -->
|
||||||
|
<paths xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<cache-path name="full_update" path="full-update/" />
|
||||||
|
</paths>
|
||||||
@@ -18,7 +18,8 @@ buildscript {
|
|||||||
mavenCentral()
|
mavenCentral()
|
||||||
}
|
}
|
||||||
dependencies {
|
dependencies {
|
||||||
classpath 'com.android.tools.build:gradle:8.1.1'
|
// Android 16 / API 36 requires Android Gradle Plugin 8.9+.
|
||||||
|
classpath 'com.android.tools.build:gradle:8.9.2'
|
||||||
|
|
||||||
// NOTE: Do not place your application dependencies here; they belong
|
// NOTE: Do not place your application dependencies here; they belong
|
||||||
// in the individual module build.gradle files
|
// in the individual module build.gradle files
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ app.version_name=11.5a
|
|||||||
# No need to modify anything past this line!
|
# No need to modify anything past this line!
|
||||||
android.enableJetifier=false
|
android.enableJetifier=false
|
||||||
android.useAndroidX=true
|
android.useAndroidX=true
|
||||||
android.defaults.buildfeatures.buildconfig=true
|
|
||||||
android.nonTransitiveRClass=true
|
android.nonTransitiveRClass=true
|
||||||
android.nonFinalResIds=true
|
android.nonFinalResIds=true
|
||||||
app.name=gen1recomp
|
app.name=gen1recomp
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
distributionBase=GRADLE_USER_HOME
|
distributionBase=GRADLE_USER_HOME
|
||||||
distributionPath=wrapper/dists
|
distributionPath=wrapper/dists
|
||||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.1-bin.zip
|
distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip
|
||||||
networkTimeout=10000
|
networkTimeout=10000
|
||||||
zipStoreBase=GRADLE_USER_HOME
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
zipStorePath=wrapper/dists
|
zipStorePath=wrapper/dists
|
||||||
|
|||||||
@@ -10,9 +10,9 @@ android {
|
|||||||
ndkVersion '25.2.9519653'
|
ndkVersion '25.2.9519653'
|
||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
minSdk 16
|
minSdk 19
|
||||||
compileSdk 34
|
compileSdk 36
|
||||||
targetSdk 34
|
targetSdk 36
|
||||||
externalNativeBuild {
|
externalNativeBuild {
|
||||||
ndkBuild {
|
ndkBuild {
|
||||||
arguments "-j" + Runtime.runtime.availableProcessors()
|
arguments "-j" + Runtime.runtime.availableProcessors()
|
||||||
|
|||||||
@@ -283,6 +283,40 @@ bool restartApp()
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool installApk(const char *path)
|
||||||
|
{
|
||||||
|
if (path == nullptr || path[0] == '\0')
|
||||||
|
return false;
|
||||||
|
|
||||||
|
JNIEnv *env = (JNIEnv*) SDL_AndroidGetJNIEnv();
|
||||||
|
// This may be called from Lua's main thread, but use the activity object
|
||||||
|
// class just like httpDownload so a future worker caller does not depend on
|
||||||
|
// the system JNI class loader finding the app class.
|
||||||
|
void *rawActivity = SDL_AndroidGetActivity();
|
||||||
|
if (rawActivity == nullptr)
|
||||||
|
return false;
|
||||||
|
jobject activityObj = (jobject) rawActivity;
|
||||||
|
jclass activity = env->GetObjectClass(activityObj);
|
||||||
|
env->DeleteLocalRef(activityObj);
|
||||||
|
|
||||||
|
jmethodID method = env->GetStaticMethodID(activity, "installApk",
|
||||||
|
"(Ljava/lang/String;Ljava/lang/String;)Z");
|
||||||
|
if (method == nullptr)
|
||||||
|
{
|
||||||
|
env->ExceptionClear();
|
||||||
|
env->DeleteLocalRef(activity);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
jstring jpath = env->NewStringUTF(path);
|
||||||
|
jstring jroot = env->NewStringUTF(bridgeSaveDirectory());
|
||||||
|
jboolean result = env->CallStaticBooleanMethod(activity, method, jpath, jroot);
|
||||||
|
env->DeleteLocalRef(jroot);
|
||||||
|
env->DeleteLocalRef(jpath);
|
||||||
|
env->DeleteLocalRef(activity);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
bool updateAppShortcuts(const std::vector<std::string> &versions)
|
bool updateAppShortcuts(const std::vector<std::string> &versions)
|
||||||
{
|
{
|
||||||
JNIEnv *env = (JNIEnv*) SDL_AndroidGetJNIEnv();
|
JNIEnv *env = (JNIEnv*) SDL_AndroidGetJNIEnv();
|
||||||
|
|||||||
@@ -90,6 +90,12 @@ bool syncHealthSteps();
|
|||||||
**/
|
**/
|
||||||
bool restartApp();
|
bool restartApp();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stages a checksum-verified APK from the current save directory and starts
|
||||||
|
* Android's user-confirmed Package Installer flow. Android-only.
|
||||||
|
**/
|
||||||
|
bool installApk(const char *path);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Dynamic App Shortcuts: updates Android ShortcutManager with ready game versions.
|
* Dynamic App Shortcuts: updates Android ShortcutManager with ready game versions.
|
||||||
**/
|
**/
|
||||||
|
|||||||
@@ -245,6 +245,16 @@ bool System::restartApp() const
|
|||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool System::installApk(const char *path) const
|
||||||
|
{
|
||||||
|
#ifdef LOVE_ANDROID
|
||||||
|
return love::android::installApk(path);
|
||||||
|
#else
|
||||||
|
LOVE_UNUSED(path);
|
||||||
|
return false;
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
bool System::updateShortcuts(const std::vector<std::string> &versions) const
|
bool System::updateShortcuts(const std::vector<std::string> &versions) const
|
||||||
{
|
{
|
||||||
#ifdef LOVE_ANDROID
|
#ifdef LOVE_ANDROID
|
||||||
|
|||||||
@@ -143,6 +143,9 @@ public:
|
|||||||
**/
|
**/
|
||||||
virtual bool restartApp() const;
|
virtual bool restartApp() const;
|
||||||
|
|
||||||
|
/** Starts Android's user-confirmed install flow for a verified APK. */
|
||||||
|
virtual bool installApk(const char *path) const;
|
||||||
|
|
||||||
virtual bool updateShortcuts(const std::vector<std::string> &versions) const;
|
virtual bool updateShortcuts(const std::vector<std::string> &versions) const;
|
||||||
virtual std::string getLaunchGame() const;
|
virtual std::string getLaunchGame() const;
|
||||||
|
|
||||||
|
|||||||
@@ -132,6 +132,13 @@ int w_restartApp(lua_State *L)
|
|||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
int w_installApk(lua_State *L)
|
||||||
|
{
|
||||||
|
const char *path = luaL_checkstring(L, 1);
|
||||||
|
luax_pushboolean(L, instance()->installApk(path));
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
int w_httpDownload(lua_State *L)
|
int w_httpDownload(lua_State *L)
|
||||||
{
|
{
|
||||||
const char *url = luaL_checkstring(L, 1);
|
const char *url = luaL_checkstring(L, 1);
|
||||||
@@ -325,6 +332,7 @@ static const luaL_Reg functions[] =
|
|||||||
{ "createFile", w_createFile },
|
{ "createFile", w_createFile },
|
||||||
{ "syncHealthSteps", w_syncHealthSteps },
|
{ "syncHealthSteps", w_syncHealthSteps },
|
||||||
{ "restartApp", w_restartApp },
|
{ "restartApp", w_restartApp },
|
||||||
|
{ "installApk", w_installApk },
|
||||||
{ "updateShortcuts", w_updateShortcuts },
|
{ "updateShortcuts", w_updateShortcuts },
|
||||||
{ "getLaunchGame", w_getLaunchGame },
|
{ "getLaunchGame", w_getLaunchGame },
|
||||||
{ "httpDownload", w_httpDownload },
|
{ "httpDownload", w_httpDownload },
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ import android.app.AlarmManager;
|
|||||||
import android.app.AlertDialog;
|
import android.app.AlertDialog;
|
||||||
import android.app.PendingIntent;
|
import android.app.PendingIntent;
|
||||||
import android.content.Context;
|
import android.content.Context;
|
||||||
|
import android.content.ClipData;
|
||||||
import android.content.DialogInterface;
|
import android.content.DialogInterface;
|
||||||
import android.content.Intent;
|
import android.content.Intent;
|
||||||
import android.content.SharedPreferences;
|
import android.content.SharedPreferences;
|
||||||
@@ -77,6 +78,7 @@ import android.view.*;
|
|||||||
|
|
||||||
import androidx.annotation.Keep;
|
import androidx.annotation.Keep;
|
||||||
import androidx.core.app.ActivityCompat;
|
import androidx.core.app.ActivityCompat;
|
||||||
|
import androidx.core.content.FileProvider;
|
||||||
|
|
||||||
public class GameActivity extends SDLActivity {
|
public class GameActivity extends SDLActivity {
|
||||||
private static DisplayMetrics metrics = null;
|
private static DisplayMetrics metrics = null;
|
||||||
@@ -398,11 +400,15 @@ public class GameActivity extends SDLActivity {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
protected void onDestroy() {
|
protected void onDestroy() {
|
||||||
|
secondaryHostResumed = false;
|
||||||
if (vibrator != null) {
|
if (vibrator != null) {
|
||||||
Log.d("GameActivity", "Cancelling vibration");
|
Log.d("GameActivity", "Cancelling vibration");
|
||||||
vibrator.cancel();
|
vibrator.cancel();
|
||||||
}
|
}
|
||||||
unregisterSecondaryDisplayListener();
|
unregisterSecondaryDisplayListener();
|
||||||
|
teardownSecondaryDisplay();
|
||||||
|
secondaryEnabled = false;
|
||||||
|
synchronized (secondaryFrameLock) { secondaryFrame = null; }
|
||||||
unregisterAudioDeviceCallback();
|
unregisterAudioDeviceCallback();
|
||||||
abandonAudioFocus();
|
abandonAudioFocus();
|
||||||
onHostDestroy();
|
onHostDestroy();
|
||||||
@@ -411,6 +417,7 @@ public class GameActivity extends SDLActivity {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
protected void onPause() {
|
protected void onPause() {
|
||||||
|
secondaryHostResumed = false;
|
||||||
if (vibrator != null) {
|
if (vibrator != null) {
|
||||||
Log.d("GameActivity", "Cancelling vibration");
|
Log.d("GameActivity", "Cancelling vibration");
|
||||||
vibrator.cancel();
|
vibrator.cancel();
|
||||||
@@ -426,6 +433,7 @@ public class GameActivity extends SDLActivity {
|
|||||||
@Override
|
@Override
|
||||||
public void onResume() {
|
public void onResume() {
|
||||||
super.onResume();
|
super.onResume();
|
||||||
|
secondaryHostResumed = true;
|
||||||
onHostResume();
|
onHostResume();
|
||||||
requestGameAudioFocus();
|
requestGameAudioFocus();
|
||||||
registerAudioDeviceCallback();
|
registerAudioDeviceCallback();
|
||||||
@@ -690,6 +698,103 @@ public class GameActivity extends SDLActivity {
|
|||||||
return true; // unreachable, but keeps the JNI signature honest
|
return true; // unreachable, but keeps the JNI signature honest
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stages a verified release APK in cache and asks Android's Package
|
||||||
|
* Installer to update this package. This never silently installs an APK:
|
||||||
|
* the platform owns both the unknown-sources consent and final install
|
||||||
|
* confirmation. `updateRoot` comes from the native save directory and is
|
||||||
|
* checked before any file is read, so a Lua caller cannot turn this into a
|
||||||
|
* general-purpose local-file sharing bridge.
|
||||||
|
*/
|
||||||
|
@Keep
|
||||||
|
public static boolean installApk(final String sourcePath, final String updateRoot) {
|
||||||
|
final GameActivity self = (GameActivity) mSingleton;
|
||||||
|
if (self == null || sourcePath == null || updateRoot == null) return false;
|
||||||
|
final File source;
|
||||||
|
try {
|
||||||
|
source = new File(sourcePath).getCanonicalFile();
|
||||||
|
File root = new File(updateRoot, "updates").getCanonicalFile();
|
||||||
|
String rootPath = root.getPath() + File.separator;
|
||||||
|
if (!source.getPath().startsWith(rootPath)
|
||||||
|
|| !source.isFile() || source.length() == 0
|
||||||
|
|| !source.getName().matches("gen1recomp-[0-9]+\\.[0-9]+\\.[0-9]+-android\\.apk")) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} catch (IOException e) {
|
||||||
|
Log.d("GameActivity", "invalid update APK path: " + e.getMessage());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Android 8+ lets the user decide whether this app is trusted to
|
||||||
|
// request package installs. Send them to the per-app setting first;
|
||||||
|
// they deliberately tap Install again after granting it.
|
||||||
|
if (android.os.Build.VERSION.SDK_INT >= 26
|
||||||
|
&& !self.getPackageManager().canRequestPackageInstalls()) {
|
||||||
|
try {
|
||||||
|
Intent settings = new Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES,
|
||||||
|
Uri.parse("package:" + self.getPackageName()));
|
||||||
|
self.startActivity(settings);
|
||||||
|
return true;
|
||||||
|
} catch (Exception e) {
|
||||||
|
Log.d("GameActivity", "could not open install-source settings: " + e.getMessage());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copying an APK can be large; keep both I/O and checksum-verified
|
||||||
|
// source access off the UI thread. The FileProvider exposes this cache
|
||||||
|
// child only after it has been fully written and renamed.
|
||||||
|
new Thread(new Runnable() {
|
||||||
|
@Override public void run() {
|
||||||
|
File stagedDir = new File(self.getCacheDir(), "full-update");
|
||||||
|
File partial = new File(stagedDir, "update.apk.part");
|
||||||
|
File staged = new File(stagedDir, "update.apk");
|
||||||
|
try {
|
||||||
|
if (!stagedDir.exists() && !stagedDir.mkdirs()) return;
|
||||||
|
copyFile(source, partial);
|
||||||
|
if (staged.exists() && !staged.delete()) return;
|
||||||
|
if (!partial.renameTo(staged)) return;
|
||||||
|
self.runOnUiThread(new Runnable() {
|
||||||
|
@Override public void run() { launchPackageInstaller(self, staged); }
|
||||||
|
});
|
||||||
|
} catch (Exception e) {
|
||||||
|
Log.d("GameActivity", "could not stage update APK: " + e.getMessage());
|
||||||
|
} finally {
|
||||||
|
if (partial.exists()) partial.delete();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, "gen1recomp-apk-stage").start();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void copyFile(File source, File destination) throws IOException {
|
||||||
|
InputStream in = new BufferedInputStream(new FileInputStream(source));
|
||||||
|
OutputStream out = new BufferedOutputStream(new FileOutputStream(destination));
|
||||||
|
try {
|
||||||
|
byte[] buffer = new byte[32768];
|
||||||
|
int count;
|
||||||
|
while ((count = in.read(buffer)) != -1) out.write(buffer, 0, count);
|
||||||
|
} finally {
|
||||||
|
try { out.close(); } catch (IOException ignored) {}
|
||||||
|
try { in.close(); } catch (IOException ignored) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void launchPackageInstaller(GameActivity activity, File apk) {
|
||||||
|
try {
|
||||||
|
Context context = activity.getApplicationContext();
|
||||||
|
Uri uri = FileProvider.getUriForFile(context,
|
||||||
|
context.getPackageName() + ".full_update_provider", apk);
|
||||||
|
Intent install = new Intent(Intent.ACTION_INSTALL_PACKAGE);
|
||||||
|
install.setData(uri);
|
||||||
|
install.setClipData(ClipData.newRawUri("apk", uri));
|
||||||
|
install.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
|
||||||
|
activity.startActivity(install);
|
||||||
|
} catch (Exception e) {
|
||||||
|
Log.d("GameActivity", "could not open package installer: " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Keep
|
@Keep
|
||||||
public static String getLaunchGame() {
|
public static String getLaunchGame() {
|
||||||
return initialGame != null ? initialGame : "";
|
return initialGame != null ? initialGame : "";
|
||||||
@@ -1933,6 +2038,7 @@ public class GameActivity extends SDLActivity {
|
|||||||
private static volatile int secondaryActivityTarget = Display.INVALID_DISPLAY;
|
private static volatile int secondaryActivityTarget = Display.INVALID_DISPLAY;
|
||||||
private static volatile long secondaryRetryAfter;
|
private static volatile long secondaryRetryAfter;
|
||||||
private static volatile boolean secondaryEnabled = false;
|
private static volatile boolean secondaryEnabled = false;
|
||||||
|
private static volatile boolean secondaryHostResumed = false;
|
||||||
private static volatile int secondaryTarget = SECONDARY_TARGET_AUTO;
|
private static volatile int secondaryTarget = SECONDARY_TARGET_AUTO;
|
||||||
private static volatile int dualScreenDisplayMode = -1;
|
private static volatile int dualScreenDisplayMode = -1;
|
||||||
private static volatile byte[] secondaryFrame;
|
private static volatile byte[] secondaryFrame;
|
||||||
@@ -1963,7 +2069,7 @@ public class GameActivity extends SDLActivity {
|
|||||||
if (self == null) return;
|
if (self == null) return;
|
||||||
self.runOnUiThread(new Runnable() {
|
self.runOnUiThread(new Runnable() {
|
||||||
@Override public void run() {
|
@Override public void run() {
|
||||||
if (on) {
|
if (on && secondaryHostResumed) {
|
||||||
self.refreshDualScreenDisplayMode();
|
self.refreshDualScreenDisplayMode();
|
||||||
self.registerSecondaryDisplayListener();
|
self.registerSecondaryDisplayListener();
|
||||||
rebindSecondaryDisplay();
|
rebindSecondaryDisplay();
|
||||||
@@ -2035,9 +2141,11 @@ public class GameActivity extends SDLActivity {
|
|||||||
|
|
||||||
private static void rebindSecondaryDisplay() {
|
private static void rebindSecondaryDisplay() {
|
||||||
GameActivity self = (GameActivity) mSingleton;
|
GameActivity self = (GameActivity) mSingleton;
|
||||||
if (self == null || !secondaryEnabled || secondaryOutputIsPreferred(self)) return;
|
if (self == null || !secondaryHostResumed || !secondaryEnabled
|
||||||
|
|| secondaryOutputIsPreferred(self)) return;
|
||||||
self.runOnUiThread(() -> {
|
self.runOnUiThread(() -> {
|
||||||
if (!secondaryEnabled || secondaryOutputIsPreferred(self)) return;
|
if (!secondaryHostResumed || !secondaryEnabled
|
||||||
|
|| secondaryOutputIsPreferred(self)) return;
|
||||||
teardownSecondaryDisplay();
|
teardownSecondaryDisplay();
|
||||||
setupSecondaryDisplay();
|
setupSecondaryDisplay();
|
||||||
});
|
});
|
||||||
@@ -2045,7 +2153,8 @@ public class GameActivity extends SDLActivity {
|
|||||||
|
|
||||||
private static void setupSecondaryDisplay() {
|
private static void setupSecondaryDisplay() {
|
||||||
GameActivity self = (GameActivity) mSingleton;
|
GameActivity self = (GameActivity) mSingleton;
|
||||||
if (self == null || !secondaryEnabled || secondaryPresentation != null
|
if (self == null || !secondaryHostResumed || !secondaryEnabled
|
||||||
|
|| secondaryPresentation != null
|
||||||
|| secondaryActivity != null || secondaryActivityPending
|
|| secondaryActivity != null || secondaryActivityPending
|
||||||
|| android.os.SystemClock.elapsedRealtime() < secondaryRetryAfter) return;
|
|| android.os.SystemClock.elapsedRealtime() < secondaryRetryAfter) return;
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -12,6 +12,20 @@
|
|||||||
"tintColor": "3b5ca8",
|
"tintColor": "3b5ca8",
|
||||||
"category": "games",
|
"category": "games",
|
||||||
"versions": [
|
"versions": [
|
||||||
|
{
|
||||||
|
"version": "0.2.12",
|
||||||
|
"date": "2026-08-20",
|
||||||
|
"size": 13737259,
|
||||||
|
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.2.12/gen1recomp++-0.2.12-ios.ipa",
|
||||||
|
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #1582 Sync not working between steamdeck and windows\n- #1583 Can’t sync between iOS and windows\n\n## Contributors\n\n- @AverageConsumer\n- @bryanthaboi\n- @thibautbus"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "0.2.11",
|
||||||
|
"date": "2026-08-20",
|
||||||
|
"size": 13735190,
|
||||||
|
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.2.11/gen1recomp++-0.2.11-ios.ipa",
|
||||||
|
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #393 silph co. npc missing some dialogue\n- #1600 allow my uncle's neighbor to sit at the big kids table\n- #1603 pocket taco - type option \"screen position\"\n\n## Contributors\n\n- @1Jamie\n- @bryanthaboi\n- @dburton95\n- @mleo2003\n- @thibautbus"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"version": "0.2.10",
|
"version": "0.2.10",
|
||||||
"date": "2026-08-19",
|
"date": "2026-08-19",
|
||||||
|
|||||||
+39
-10
@@ -1,19 +1,20 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# Packages the LÖVE2D Pokémon Red port into an Android APK via love-android 11.5a.
|
# Packages the LÖVE2D Pokémon Red port into an Android APK via love-android 11.5a.
|
||||||
#
|
#
|
||||||
# Usage: scripts/build_android.sh [--version X.Y.Z] [--package-only]
|
# Usage: scripts/build_android.sh [--version X.Y.Z] [--release] [--package-only]
|
||||||
#
|
#
|
||||||
# --version X.Y.Z set app.version_name / app.version_code (else left as-is)
|
# --version X.Y.Z set app.version_name / app.version_code (else left as-is)
|
||||||
|
# --release build the production-signed release APK (requires the
|
||||||
|
# GEN1RECOMP_ANDROID_* signing environment variables)
|
||||||
# --package-only zip game.love + apply branding; skip gradle
|
# --package-only zip game.love + apply branding; skip gradle
|
||||||
#
|
#
|
||||||
# Prerequisites:
|
# Prerequisites:
|
||||||
# - mobile/android vendored love-android tree at tag 11.5a (in-repo; see mobile/ANDROID.md)
|
# - mobile/android vendored love-android tree at tag 11.5a (in-repo; see mobile/ANDROID.md)
|
||||||
# - Android SDK + NDK (SDK API 34, NDK 25.2.9519653)
|
# - Android SDK + NDK (SDK API 36, NDK 25.2.9519653)
|
||||||
# - JDK 17
|
# - JDK 17
|
||||||
#
|
#
|
||||||
# Output (after gradle):
|
# Output (after gradle):
|
||||||
# dist/android/debug/*.apk (convenience copy)
|
# dist/android/debug/*.apk (normal local build) or dist/android/release/*.apk
|
||||||
# mobile/android/app/build/outputs/apk/embedNoRecord/debug/*.apk
|
|
||||||
|
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
@@ -26,6 +27,7 @@ APP_NAME="gen1recomp"
|
|||||||
APPLICATION_ID="com.theboisclub.pokemonred"
|
APPLICATION_ID="com.theboisclub.pokemonred"
|
||||||
LOVE_ANDROID_VERSION="11.5a"
|
LOVE_ANDROID_VERSION="11.5a"
|
||||||
NDK_VERSION="25.2.9519653"
|
NDK_VERSION="25.2.9519653"
|
||||||
|
ANDROID_API="36"
|
||||||
YELLOW_MANIFEST_RELATIVE="tools/rom_manifest_yellow.json"
|
YELLOW_MANIFEST_RELATIVE="tools/rom_manifest_yellow.json"
|
||||||
YELLOW_MANIFEST_URL="${YELLOW_MANIFEST_URL:-https://raw.githubusercontent.com/bryanthaboi/gen1recomp/main/tools/rom_manifest_yellow.json}"
|
YELLOW_MANIFEST_URL="${YELLOW_MANIFEST_URL:-https://raw.githubusercontent.com/bryanthaboi/gen1recomp/main/tools/rom_manifest_yellow.json}"
|
||||||
GOLD_MANIFEST_RELATIVE="tools/rom_manifest_gold.json"
|
GOLD_MANIFEST_RELATIVE="tools/rom_manifest_gold.json"
|
||||||
@@ -35,6 +37,7 @@ SILVER_MANIFEST_URL="${SILVER_MANIFEST_URL:-https://raw.githubusercontent.com/br
|
|||||||
|
|
||||||
VERSION=""
|
VERSION=""
|
||||||
PACKAGE_ONLY=false
|
PACKAGE_ONLY=false
|
||||||
|
RELEASE=false
|
||||||
|
|
||||||
say() { printf '\033[1;32m==>\033[0m %s\n' "$*"; }
|
say() { printf '\033[1;32m==>\033[0m %s\n' "$*"; }
|
||||||
warn() { printf '\033[1;33mwarn:\033[0m %s\n' "$*" >&2; }
|
warn() { printf '\033[1;33mwarn:\033[0m %s\n' "$*" >&2; }
|
||||||
@@ -44,11 +47,12 @@ while [ $# -gt 0 ]; do
|
|||||||
case "$1" in
|
case "$1" in
|
||||||
--version) VERSION="$2"; shift ;;
|
--version) VERSION="$2"; shift ;;
|
||||||
--package-only) PACKAGE_ONLY=true ;;
|
--package-only) PACKAGE_ONLY=true ;;
|
||||||
|
--release) RELEASE=true ;;
|
||||||
-h|--help)
|
-h|--help)
|
||||||
sed -n '2,20p' "$0"
|
sed -n '2,20p' "$0"
|
||||||
exit 0
|
exit 0
|
||||||
;;
|
;;
|
||||||
*) fail "unknown argument: $1 (try --version X.Y.Z or --package-only)" ;;
|
*) fail "unknown argument: $1 (try --version X.Y.Z, --release, or --package-only)" ;;
|
||||||
esac
|
esac
|
||||||
shift
|
shift
|
||||||
done
|
done
|
||||||
@@ -62,7 +66,22 @@ if [ -n "$VERSION" ]; then
|
|||||||
rest="${VERSION#*.}"
|
rest="${VERSION#*.}"
|
||||||
minor="${rest%%.*}"
|
minor="${rest%%.*}"
|
||||||
patch="${rest##*.}"
|
patch="${rest##*.}"
|
||||||
VERSION_CODE=$((major * 10000 + minor * 100 + patch))
|
# Reserve three digits for each lower component. This stays monotonic across
|
||||||
|
# 1.0.100 -> 1.1.0, unlike the old two-digit encoding, and remains inside
|
||||||
|
# Android's signed 32-bit versionCode range for normal release versions.
|
||||||
|
if [ "$minor" -gt 999 ] || [ "$patch" -gt 999 ] || [ "$major" -gt 2099 ]; then
|
||||||
|
fail "--version components exceed Android versionCode limits"
|
||||||
|
fi
|
||||||
|
VERSION_CODE=$((major * 1000000 + minor * 1000 + patch))
|
||||||
|
fi
|
||||||
|
|
||||||
|
if $RELEASE; then
|
||||||
|
for var in GEN1RECOMP_ANDROID_KEYSTORE GEN1RECOMP_ANDROID_KEYSTORE_PASSWORD \
|
||||||
|
GEN1RECOMP_ANDROID_KEY_ALIAS GEN1RECOMP_ANDROID_KEY_PASSWORD; do
|
||||||
|
[ -n "${!var:-}" ] || fail "--release requires $var"
|
||||||
|
done
|
||||||
|
[ -f "$GEN1RECOMP_ANDROID_KEYSTORE" ] \
|
||||||
|
|| fail "Android signing keystore does not exist: $GEN1RECOMP_ANDROID_KEYSTORE"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# --------------------------------------------------------------- preconditions
|
# --------------------------------------------------------------- preconditions
|
||||||
@@ -389,13 +408,18 @@ require_android_sdk() {
|
|||||||
export ANDROID_SDK_ROOT=\$HOME/Library/Android/sdk
|
export ANDROID_SDK_ROOT=\$HOME/Library/Android/sdk
|
||||||
or create mobile/android/local.properties with:
|
or create mobile/android/local.properties with:
|
||||||
sdk.dir=/path/to/Android/sdk
|
sdk.dir=/path/to/Android/sdk
|
||||||
love-android $LOVE_ANDROID_VERSION expects SDK API 34 and NDK $NDK_VERSION
|
love-android $LOVE_ANDROID_VERSION expects SDK API $ANDROID_API and NDK $NDK_VERSION
|
||||||
(see mobile/ANDROID.md)."
|
(see mobile/ANDROID.md)."
|
||||||
fi
|
fi
|
||||||
|
|
||||||
export ANDROID_SDK_ROOT="$sdk"
|
export ANDROID_SDK_ROOT="$sdk"
|
||||||
export ANDROID_HOME="$sdk"
|
export ANDROID_HOME="$sdk"
|
||||||
|
|
||||||
|
if [ ! -d "$sdk/platforms/android-$ANDROID_API" ]; then
|
||||||
|
fail "Android SDK platform android-$ANDROID_API is not installed.
|
||||||
|
Install Android $ANDROID_API (and the latest 36.x Build-Tools) in SDK Manager."
|
||||||
|
fi
|
||||||
|
|
||||||
local props="$ANDROID_DIR/local.properties"
|
local props="$ANDROID_DIR/local.properties"
|
||||||
# Always rewrite so a leftover Docker sdk.dir=/opt/android-sdk cannot stick.
|
# Always rewrite so a leftover Docker sdk.dir=/opt/android-sdk cannot stick.
|
||||||
printf 'sdk.dir=%s\n' "$sdk" > "$props"
|
printf 'sdk.dir=%s\n' "$sdk" > "$props"
|
||||||
@@ -412,7 +436,12 @@ require_android_sdk() {
|
|||||||
|
|
||||||
# --------------------------------------------------------------- gradle
|
# --------------------------------------------------------------- gradle
|
||||||
run_gradle() {
|
run_gradle() {
|
||||||
local task="assembleEmbedNoRecordDebug"
|
local variant="debug"
|
||||||
|
$RELEASE && variant="release"
|
||||||
|
# Keep this compatible with macOS's bundled Bash 3.2 (no ${var^}).
|
||||||
|
local variant_title="Debug"
|
||||||
|
$RELEASE && variant_title="Release"
|
||||||
|
local task="assembleEmbedNoRecord$variant_title"
|
||||||
local build_dir="$ANDROID_DIR"
|
local build_dir="$ANDROID_DIR"
|
||||||
|
|
||||||
# ndk-build is GNU make underneath and cannot cope with spaces anywhere in
|
# ndk-build is GNU make underneath and cannot cope with spaces anywhere in
|
||||||
@@ -447,12 +476,12 @@ run_gradle() {
|
|||||||
You can still iterate on the .love payload with: scripts/build_android.sh --package-only"
|
You can still iterate on the .love payload with: scripts/build_android.sh --package-only"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
local out_dir="$build_dir/app/build/outputs/apk/embedNoRecord/debug"
|
local out_dir="$build_dir/app/build/outputs/apk/embedNoRecord/$variant"
|
||||||
if [ -d "$out_dir" ]; then
|
if [ -d "$out_dir" ]; then
|
||||||
say "APK output:"
|
say "APK output:"
|
||||||
find "$out_dir" -name '*.apk' -exec ls -lh {} \;
|
find "$out_dir" -name '*.apk' -exec ls -lh {} \;
|
||||||
|
|
||||||
local dist_dir="$DIST/debug"
|
local dist_dir="$DIST/$variant"
|
||||||
rm -rf "$dist_dir"
|
rm -rf "$dist_dir"
|
||||||
mkdir -p "$dist_dir"
|
mkdir -p "$dist_dir"
|
||||||
find "$out_dir" -name '*.apk' -exec cp {} "$dist_dir/" \;
|
find "$out_dir" -name '*.apk' -exec cp {} "$dist_dir/" \;
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ local BattleSafety = {}
|
|||||||
local BATTLE_BUSY_FIELDS = {
|
local BATTLE_BUSY_FIELDS = {
|
||||||
"current", "afterQueue", "nextInsert", "pendingHit", "waitingUI",
|
"current", "afterQueue", "nextInsert", "pendingHit", "waitingUI",
|
||||||
"waitingSound", "waitFrames", "draining", "animPlaying", "growIn",
|
"waitingSound", "waitFrames", "draining", "animPlaying", "growIn",
|
||||||
|
"shrinkOut",
|
||||||
"introSlide", "ghostReveal", "mimicCtx", "mimicMoves", "result",
|
"introSlide", "ghostReveal", "mimicCtx", "mimicMoves", "result",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+166
-38
@@ -1364,7 +1364,7 @@ function BattleState:updateQueue()
|
|||||||
-- subanimation (or just the coarse fx when animations are off).
|
-- subanimation (or just the coarse fx when animations are off).
|
||||||
-- item.hit carries the target's blink + damage sound, applied when
|
-- item.hit carries the target's blink + damage sound, applied when
|
||||||
-- the animation ends (hitRow rows carry a hit with no animation --
|
-- the animation ends (hitRow rows carry a hit with no animation --
|
||||||
-- thrash/rage continuation turns that skip the announcement).
|
-- Mimic, whose animation waits on a successful copy).
|
||||||
if item.anim or item.hitRow then
|
if item.anim or item.hitRow then
|
||||||
-- PlayMoveAnimation writes wAnimationID, calls Delay3, and only then
|
-- PlayMoveAnimation writes wAnimationID, calls Delay3, and only then
|
||||||
-- jumps to MoveAnimation (core.asm:6635-6640), so three frames pass
|
-- jumps to MoveAnimation (core.asm:6635-6640), so three frames pass
|
||||||
@@ -1718,9 +1718,14 @@ function BattleState:enter()
|
|||||||
-- _PlayerBlackedOutText2 (data/text/text_2.asm:896): the two paragraphs
|
-- _PlayerBlackedOutText2 (data/text/text_2.asm:896): the two paragraphs
|
||||||
-- playerMonFainted queues on the battle screen; there is no battle
|
-- playerMonFainted queues on the battle screen; there is no battle
|
||||||
-- screen to queue them on here, so they print over the map.
|
-- screen to queue them on here, so they print over the map.
|
||||||
|
-- _PlayerBlackedOutText (no "2") extracts to the identical wording from
|
||||||
|
-- a different ROM address and is unused anywhere in this engine -- not
|
||||||
|
-- a fallback for this one, just pokered printing the same paragraph
|
||||||
|
-- from a second call site elsewhere.
|
||||||
self.game.stack:push(require("src.render.TextBox").new(self.game,
|
self.game.stack:push(require("src.render.TextBox").new(self.game,
|
||||||
Strings("%s is out of\nuseable POKéMON!", name) .. "\f"
|
self:romText("_PlayerBlackedOutText2",
|
||||||
.. Strings("%s blacked\nout!", name), blackedOut))
|
"%s is out of\nuseable POKéMON!\f%s blacked\nout!", name, name),
|
||||||
|
blackedOut))
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
self.musicKind = self:computeMusicKind()
|
self.musicKind = self:computeMusicKind()
|
||||||
@@ -1857,7 +1862,8 @@ function BattleState:enter()
|
|||||||
self:slidePic("foe")
|
self:slidePic("foe")
|
||||||
end)
|
end)
|
||||||
-- _TrainerSentOutText ends `done`, not `prompt` (data/text/text_2.asm:923)
|
-- _TrainerSentOutText ends `done`, not `prompt` (data/text/text_2.asm:923)
|
||||||
self:sayAuto(Strings("%s sent\nout %s!", foeName, self.enemy.name))
|
self:sayAuto(self:romText("_TrainerSentOutText", "%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
|
||||||
@@ -2489,9 +2495,12 @@ function BattleState:openOldManBag()
|
|||||||
-- POKé BALLs; pokeyellow's SimulatedInputBattleItemList, shared by
|
-- POKé BALLs; pokeyellow's SimulatedInputBattleItemList, shared by
|
||||||
-- the Viridian tutorial and Oak's catch, has one.
|
-- the Viridian tutorial and Oak's catch, has one.
|
||||||
local qty = require("src.core.GameVersion").isYellow() and "x1" or "x50"
|
local qty = require("src.core.GameVersion").isYellow() and "x1" or "x50"
|
||||||
|
-- the tutorial bag rides DisplayBagMenu's LIST_MENU_BOX over the battle
|
||||||
|
-- screen (engine/battle/core.asm:2210)
|
||||||
list = ListMenu.new(game, "ITEMS", {
|
list = ListMenu.new(game, "ITEMS", {
|
||||||
{ value = "POKE_BALL", label = Strings("POKé BALL"), right = qty },
|
{ value = "POKE_BALL", label = Strings("POKé BALL"), right = qty },
|
||||||
}, {
|
}, {
|
||||||
|
itemBox = true,
|
||||||
script = function(l)
|
script = function(l)
|
||||||
l.scriptTimer = (l.scriptTimer or 0) + 1
|
l.scriptTimer = (l.scriptTimer or 0) + 1
|
||||||
if l.scriptTimer == 81 then
|
if l.scriptTimer == 81 then
|
||||||
@@ -2702,9 +2711,11 @@ function BattleState:resolveSwitch(newMon)
|
|||||||
self.afterQueue = "menu"
|
self.afterQueue = "menu"
|
||||||
self:act(function()
|
self:act(function()
|
||||||
-- SwitchPlayerMon (core.asm:2419-2423): RetreatMon prints over the
|
-- SwitchPlayerMon (core.asm:2419-2423): RetreatMon prints over the
|
||||||
-- outgoing pic and holds 50 frames before the mon is recalled
|
-- outgoing pic and holds 50 frames, then AnimateRetreatingPlayerMon
|
||||||
|
-- runs before the mon is recalled
|
||||||
self:sayNextAuto(self:withdrawText(self.player.name),
|
self:sayNextAuto(self:withdrawText(self.player.name),
|
||||||
Timing.SWITCH_PLAYER_MON)
|
Timing.SWITCH_PLAYER_MON)
|
||||||
|
self:queueRetreatAnim()
|
||||||
self:actNext(function()
|
self:actNext(function()
|
||||||
self:restoreMimicked(self.player) -- the battle copy leaves with it
|
self:restoreMimicked(self.player) -- the battle copy leaves with it
|
||||||
local previous = self.player
|
local previous = self.player
|
||||||
@@ -3316,6 +3327,26 @@ function BattleState:queueSendOutAnim(append)
|
|||||||
if append then self:act(fn) else self:actNext(fn) end
|
if append then self:act(fn) else self:actNext(fn) end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- AnimateRetreatingPlayerMon (core.asm:1769-1796); the Yellow starter Pikachu
|
||||||
|
-- slides off instead (pokeyellow core.asm:1862-1866, animations.asm:1259)
|
||||||
|
function BattleState:queueRetreatAnim()
|
||||||
|
if self:starterPikachuSendOut() then
|
||||||
|
self:actNext(function() self:slidePic("playerMon", 0, -64, 8, 3) end)
|
||||||
|
self:waitNext(24)
|
||||||
|
self:actNext(function()
|
||||||
|
-- .clearScreenArea keeps the 7x7 area blank until the swap
|
||||||
|
-- (pokeyellow core.asm:1867-1871) (#1545)
|
||||||
|
self.sendingOut = true
|
||||||
|
self:slidePic("playerMon")
|
||||||
|
end)
|
||||||
|
else
|
||||||
|
self:actNext(function()
|
||||||
|
self.shrinkOut = { battler = self.player, frame = 0 }
|
||||||
|
end)
|
||||||
|
self:waitNext(7)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
-- Should the low-health alarm sound this frame? pokered keys it off
|
-- Should the low-health alarm sound this frame? pokered keys it off
|
||||||
-- the drawn bar color: DrawPlayerHUDAndHPBar (core.asm:1846-1875) sets
|
-- the drawn bar color: DrawPlayerHUDAndHPBar (core.asm:1846-1875) sets
|
||||||
-- wLowHealthAlarm bit 7 when GetHealthBarColor says the player bar is
|
-- wLowHealthAlarm bit 7 when GetHealthBarColor says the player bar is
|
||||||
@@ -3532,6 +3563,12 @@ function BattleState:updateFx()
|
|||||||
self.growIn.frame = self.growIn.frame + 1
|
self.growIn.frame = self.growIn.frame + 1
|
||||||
if self.growIn.frame >= 12 then self.growIn = nil end
|
if self.growIn.frame >= 12 then self.growIn = nil end
|
||||||
end
|
end
|
||||||
|
-- the retreat shrink (AnimateRetreatingPlayerMon): 4+3 frames, then the
|
||||||
|
-- 7x7 area holds cleared (scale 0) until the swap replaces the battler
|
||||||
|
if self.shrinkOut then
|
||||||
|
self.shrinkOut.frame = self.shrinkOut.frame + 1
|
||||||
|
if self.shrinkOut.battler ~= self.player then self.shrinkOut = nil end
|
||||||
|
end
|
||||||
-- low-HP alarm (audio/low_health_alarm.asm): the two-tone siren
|
-- low-HP alarm (audio/low_health_alarm.asm): the two-tone siren
|
||||||
-- loops while the player's bar is red; see lowHealthAlarmActive
|
-- loops while the player's bar is red; see lowHealthAlarmActive
|
||||||
local Sound = require("src.core.Sound")
|
local Sound = require("src.core.Sound")
|
||||||
@@ -3638,12 +3675,13 @@ function BattleState:executeAction(user, target, action)
|
|||||||
})
|
})
|
||||||
self.aiUses = self:aiUsesFor()
|
self.aiUses = self:aiUsesFor()
|
||||||
markSeen(self.game, self.enemy.mon.species)
|
markSeen(self.game, self.enemy.mon.species)
|
||||||
-- _AIBattleWithdrawText: "X with-/drew Y!"
|
self:sayNext(self:romText("_AIBattleWithdrawText", "%s with-\ndrew %s!",
|
||||||
self:sayNext(Strings("%s with-\ndrew %s!", self.trainer.name, oldName))
|
self.trainer.name, oldName))
|
||||||
-- EnemySendOut falls into EnemySendOutFirstMon: TrainerSentOutText,
|
-- EnemySendOut falls into EnemySendOutFirstMon: TrainerSentOutText,
|
||||||
-- then AnimateSendingOutMon and PlayCry (core.asm:1276-1434)
|
-- then AnimateSendingOutMon and PlayCry (core.asm:1276-1434)
|
||||||
self.enemySendingOut = true
|
self.enemySendingOut = true
|
||||||
self:sayNextAuto(Strings("%s sent\nout %s!", self.trainer.name, self.enemy.name))
|
self:sayNextAuto(self:romText("_TrainerSentOutText", "%s sent\nout %s!",
|
||||||
|
self.trainer.name, self.enemy.name))
|
||||||
self:actNext(function()
|
self:actNext(function()
|
||||||
self.enemySendingOut = false
|
self.enemySendingOut = false
|
||||||
self:startGrowIn(self.enemy)
|
self:startGrowIn(self.enemy)
|
||||||
@@ -3795,6 +3833,8 @@ function BattleState:statusInterrupt(user, target, selectedId)
|
|||||||
{ rng = self.rng, forceCrit = false, typeless = true,
|
{ rng = self.rng, forceCrit = false, typeless = true,
|
||||||
screens = target })
|
screens = target })
|
||||||
self:sayNext(self:romText("_HurtItselfText", "It hurt itself in\nits confusion!"))
|
self:sayNext(self:romText("_HurtItselfText", "It hurt itself in\nits confusion!"))
|
||||||
|
-- HandleSelfConfusionDamage (core.asm:3706-3714, enemy side :5807-5811)
|
||||||
|
self:animNext("POUND", not user.isPlayer)
|
||||||
self:clearVolatiles(user, true)
|
self:clearVolatiles(user, true)
|
||||||
self:applyDamage(user, dmg)
|
self:applyDamage(user, dmg)
|
||||||
if user.mon.hp <= 0 then self:onFaint(user) end
|
if user.mon.hp <= 0 then self:onFaint(user) end
|
||||||
@@ -3887,25 +3927,38 @@ function BattleState:performMove(user, target, moveInst, isCalled)
|
|||||||
end
|
end
|
||||||
|
|
||||||
self.moveAnimRow = nil
|
self.moveAnimRow = nil
|
||||||
if not (user.thrashTurns and moveInst == user.thrashMove and user.thrashAnnounced) then
|
local thrashing = user.thrashTurns and moveInst == user.thrashMove
|
||||||
|
and user.thrashAnnounced or false
|
||||||
|
if thrashing then
|
||||||
|
-- .ThrashingAboutCheck (core.asm:3531-3552)
|
||||||
|
self:sayNextAuto(self:romText("_ThrashingAboutText", "%s's\nthrashing about!",
|
||||||
|
displayName(user)))
|
||||||
|
user.thrashTurns = user.thrashTurns - 1
|
||||||
|
if user.thrashTurns <= 0 then
|
||||||
|
user.thrashTurns, user.thrashMove, user.thrashAnnounced = nil, nil, nil
|
||||||
|
if not user.confusedTurns then user.confusedTurns = self.rng(2, 5) end
|
||||||
|
end
|
||||||
|
else
|
||||||
self:sayNextAuto(self:romText("_ItemUseText001", "%s\nused %s!", displayName(user), move.name))
|
self:sayNextAuto(self:romText("_ItemUseText001", "%s\nused %s!", displayName(user), move.name))
|
||||||
-- the move's animation plays right after the announcement; the
|
end
|
||||||
-- damage path attaches the target's hit blink to this row so the
|
-- PlayCurrentMoveAnimation follows the announcement; Mimic (announceAnim
|
||||||
-- blink follows the animation (pokered's order). Mimic is the
|
-- = false) queues it from applyMimic after a successful copy
|
||||||
-- exception (announceAnim = false): PlayCurrentMoveAnimation runs
|
|
||||||
-- only after a successful copy, never on a miss -- applyMimic queues it
|
|
||||||
if not (record and record.announceAnim == false) then
|
if not (record and record.announceAnim == false) then
|
||||||
self.nextInsert = (self.nextInsert or 0) + 1
|
self.nextInsert = (self.nextInsert or 0) + 1
|
||||||
self.moveAnimRow = { anim = move.id, attackerIsPlayer = user.isPlayer }
|
-- ld a, THRASH / ld [wPlayerMoveNum] (core.asm:3534-3535, :5909-5910) #1577
|
||||||
|
self.moveAnimRow = { anim = thrashing and "THRASH" or move.id,
|
||||||
|
attackerIsPlayer = user.isPlayer }
|
||||||
table.insert(self.queue, self.nextInsert, self.moveAnimRow)
|
table.insert(self.queue, self.nextInsert, self.moveAnimRow)
|
||||||
end
|
end
|
||||||
end
|
|
||||||
Runtime.emit("battle.move_used", {
|
Runtime.emit("battle.move_used", {
|
||||||
battle = self, user = user, target = target, move = move,
|
battle = self, user = user, target = target, move = move,
|
||||||
isCalled = isCalled or false,
|
isCalled = isCalled or false,
|
||||||
})
|
})
|
||||||
|
|
||||||
local ctx = EffectRegistry.makeCtx(self, user, target, move, moveInst, isCalled)
|
local ctx = EffectRegistry.makeCtx(self, user, target, move, moveInst, isCalled)
|
||||||
|
-- .ThrashingAboutCheck jumps past JumpMoveEffect into PlayerCalcMoveDamage
|
||||||
|
-- (core.asm:3540), so SpecialEffectsCont never re-runs on a locked turn
|
||||||
|
ctx.thrashing = thrashing
|
||||||
|
|
||||||
-- Metronome / Mirror Move re-entry; a nil pick means the record
|
-- Metronome / Mirror Move re-entry; a nil pick means the record
|
||||||
-- already said its failure text
|
-- already said its failure text
|
||||||
@@ -4142,8 +4195,12 @@ function BattleState:onFaint(battler)
|
|||||||
-- acknowledged core.asm:797-798 bug.)
|
-- acknowledged core.asm:797-798 bug.)
|
||||||
self:actNext(function() self:playVictoryMusic() end)
|
self:actNext(function() self:playVictoryMusic() end)
|
||||||
end
|
end
|
||||||
-- _EnemyMonFaintedText "Enemy X fainted!" / _PlayerMonFaintedText
|
-- _EnemyMonFaintedText already carries its own "Enemy" wording, so this
|
||||||
self:sayNext(Strings("%s\nfainted!", displayName(battler)))
|
-- passes the raw name -- displayName's separate Strings("Enemy %s", ...)
|
||||||
|
-- would double it up
|
||||||
|
self:sayNext(battler.isPlayer
|
||||||
|
and self:romText("_PlayerMonFaintedText", "%s\nfainted!", battler.name)
|
||||||
|
or self:romText("_EnemyMonFaintedText", "Enemy %s\nfainted!", battler.name))
|
||||||
if battler.isPlayer then
|
if battler.isPlayer then
|
||||||
self:act(function() self:playerMonFainted() end)
|
self:act(function() self:playerMonFainted() end)
|
||||||
else
|
else
|
||||||
@@ -4174,8 +4231,11 @@ function BattleState:awardExp()
|
|||||||
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 playerId = self.game.save.player and self.game.save.player.id
|
||||||
local traded = mon.traded == true
|
-- GainExperience (engine/battle/experience.asm:69-88) compares the
|
||||||
or (mon.otId ~= nil and playerId ~= nil and mon.otId ~= playerId)
|
-- stored MON_OTID against wPlayerID every award; no persistent flag
|
||||||
|
-- mon.traded covers otId-less mons (repairTradedOtIds, old link peers) #1488
|
||||||
|
local traded = playerId ~= nil and ((mon.otId ~= nil and mon.otId ~= playerId)
|
||||||
|
or (mon.otId == nil and mon.traded == true))
|
||||||
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, traded)
|
split, traded)
|
||||||
@@ -4319,22 +4379,44 @@ function BattleState:enemyMonFainted()
|
|||||||
-- "X is" off so "about to use" stays above the name, instead of the
|
-- "X is" off so "about to use" stays above the name, instead of the
|
||||||
-- page ending on a bare nick (#565). Then para "Will PLAYER" /
|
-- page ending on a bare nick (#565). Then para "Will PLAYER" /
|
||||||
-- "change POKéMON?" with YES/NO.
|
-- "change POKéMON?" with YES/NO.
|
||||||
|
--
|
||||||
|
-- _TrainerAboutToUseText combines both \f-paged, but unlike
|
||||||
|
-- _ItemUseBallText00's say()+say() merge above, this is say()+
|
||||||
|
-- sayChoice(): tried merging into one romText/sayChoice call and
|
||||||
|
-- confirmed via tests/engine/trainer_shift_prompt_bug565.lua that
|
||||||
|
-- the battle queue's own \f handling (not TextBox.lua's) does not
|
||||||
|
-- page a sayChoice string the same way -- left as two calls.
|
||||||
self:say(Strings("%s is\nabout to use\v%s!", self.trainer.name, nextName))
|
self:say(Strings("%s is\nabout to use\v%s!", self.trainer.name, nextName))
|
||||||
|
-- EnemySendOutFirstMon .next9/.next8 (core.asm:1390-1409) and
|
||||||
|
-- HasMonFainted's NoWillText (core.asm:1473-1488)
|
||||||
self:sayChoice(
|
self:sayChoice(
|
||||||
Strings("Will %s\nchange POKéMON?", self.game.save.player.name),
|
Strings("Will %s\nchange POKéMON?", self.game.save.player.name),
|
||||||
function(yes)
|
function(yes)
|
||||||
if not yes then return end
|
if not yes then return end
|
||||||
local game = self.game
|
local game = self.game
|
||||||
Screens.push(game, "PartyMenu", {
|
local shiftOpts, reopenShift
|
||||||
|
reopenShift = function(text)
|
||||||
|
table.insert(self.queue, 1, { ui = function()
|
||||||
|
return self:buildScreen("PartyMenu", shiftOpts)
|
||||||
|
end })
|
||||||
|
table.insert(self.queue, 1, { text = text })
|
||||||
|
end
|
||||||
|
shiftOpts = {
|
||||||
battle = self,
|
battle = self,
|
||||||
party = self:playerPartyView(),
|
party = self:playerPartyView(),
|
||||||
forceSwitch = true,
|
forceSwitch = true,
|
||||||
onSwitch = function(mon)
|
onSwitch = function(mon)
|
||||||
if mon ~= self.player.mon and mon.hp > 0 then
|
if mon == self.player.mon then
|
||||||
|
reopenShift(self:romText("_AlreadyOutText",
|
||||||
|
"%s is\nalready out!", self.player.name))
|
||||||
|
elseif mon.hp <= 0 then
|
||||||
|
reopenShift(self:romText("_NoWillText", "There's no will\nto fight!"))
|
||||||
|
else
|
||||||
shiftSwitchMon = mon
|
shiftSwitchMon = mon
|
||||||
end
|
end
|
||||||
end,
|
end,
|
||||||
})
|
}
|
||||||
|
Screens.push(game, "PartyMenu", shiftOpts)
|
||||||
end, { box = Theme.trainerSwitchBox })
|
end, { box = Theme.trainerSwitchBox })
|
||||||
end
|
end
|
||||||
self:act(function()
|
self:act(function()
|
||||||
@@ -4362,7 +4444,8 @@ function BattleState:enemyMonFainted()
|
|||||||
-- (AnimateSendingOutMon) with the cry; no POOF -- that animation
|
-- (AnimateSendingOutMon) with the cry; no POOF -- that animation
|
||||||
-- belongs to the player-side SendOutMon (core.asm:1757-1762)
|
-- belongs to the player-side SendOutMon (core.asm:1757-1762)
|
||||||
self.enemySendingOut = true
|
self.enemySendingOut = true
|
||||||
self:sayNextAuto(Strings("%s sent\nout %s!", self.trainer.name, self.enemy.name))
|
self:sayNextAuto(self:romText("_TrainerSentOutText", "%s sent\nout %s!",
|
||||||
|
self.trainer.name, self.enemy.name))
|
||||||
self:actNext(function()
|
self:actNext(function()
|
||||||
self.enemySendingOut = false
|
self.enemySendingOut = false
|
||||||
self:startGrowIn(self.enemy)
|
self:startGrowIn(self.enemy)
|
||||||
@@ -4376,10 +4459,11 @@ function BattleState:enemyMonFainted()
|
|||||||
local mon = shiftSwitchMon
|
local mon = shiftSwitchMon
|
||||||
if not mon then return end
|
if not mon then return end
|
||||||
-- SwitchPlayerMon (core.asm:2419-2423): RetreatMon, the 50-frame
|
-- SwitchPlayerMon (core.asm:2419-2423): RetreatMon, the 50-frame
|
||||||
-- hold, then the recall and the send-out
|
-- hold, AnimateRetreatingPlayerMon, then the recall and the send-out
|
||||||
self.nextInsert = 0
|
self.nextInsert = 0
|
||||||
self:sayNextAuto(self:withdrawText(self.player.name),
|
self:sayNextAuto(self:withdrawText(self.player.name),
|
||||||
Timing.SWITCH_PLAYER_MON)
|
Timing.SWITCH_PLAYER_MON)
|
||||||
|
self:queueRetreatAnim()
|
||||||
self:actNext(function()
|
self:actNext(function()
|
||||||
local previous = self.player
|
local previous = self.player
|
||||||
self.player = makeBattler(self.data, mon, true, self.game.save)
|
self.player = makeBattler(self.data, mon, true, self.game.save)
|
||||||
@@ -4451,9 +4535,22 @@ function BattleState:enemyMonFainted()
|
|||||||
-- TrainerNamePointers aims those entries at wTrainerName). The tag
|
-- TrainerNamePointers aims those entries at wTrainerName). The tag
|
||||||
-- prints once, so a `para` page carries no second copy (#566).
|
-- prints once, so a `para` page carries no second copy (#566).
|
||||||
local tag = self.trainer and self.trainer.name
|
local tag = self.trainer and self.trainer.name
|
||||||
|
-- the badge jingle (sound_get_item_1 and friends) rides the armed
|
||||||
|
-- line's first page, as the script's text command would (#1606)
|
||||||
|
local sfx = self.endBattleSound
|
||||||
|
local data = self.data
|
||||||
for page in (self.endBattleText .. "\f"):gmatch("(.-)\f") do
|
for page in (self.endBattleText .. "\f"):gmatch("(.-)\f") do
|
||||||
if page ~= "" then
|
if page ~= "" then
|
||||||
self:sayNext(tag and (tag .. ": " .. page) or page)
|
local line = tag and (tag .. ": " .. page) or page
|
||||||
|
if sfx then
|
||||||
|
local id = sfx
|
||||||
|
self:sayNextWaitSfx(line, function()
|
||||||
|
return require("src.core.Sound").play(data, id)
|
||||||
|
end)
|
||||||
|
sfx = nil
|
||||||
|
else
|
||||||
|
self:sayNext(line)
|
||||||
|
end
|
||||||
tag = nil
|
tag = nil
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -4878,7 +4975,8 @@ function BattleState:storeCaughtMon()
|
|||||||
-- text_promptbutton (item_effects.asm:624-629), so the fanfare follows
|
-- text_promptbutton (item_effects.asm:624-629), so the fanfare follows
|
||||||
-- the box rather than firing when the dex bit is set
|
-- the box rather than firing when the dex bit is set
|
||||||
self:sayNextWaitSfx(
|
self:sayNextWaitSfx(
|
||||||
Strings("New POKéDEX data\nwill be added for\n%s!", self.enemy.name),
|
self:romText("_ItemUseBallText06",
|
||||||
|
"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)
|
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)
|
||||||
@@ -4899,9 +4997,12 @@ function BattleState:storeCaughtMon()
|
|||||||
if boxNum then
|
if boxNum then
|
||||||
askCaughtNickname()
|
askCaughtNickname()
|
||||||
-- _ItemUseBallText07/08 keyed on EVENT_MET_BILL
|
-- _ItemUseBallText07/08 keyed on EVENT_MET_BILL
|
||||||
local pc = (game.save.flags and game.save.flags.EVENT_MET_BILL)
|
local metBill = game.save.flags and game.save.flags.EVENT_MET_BILL
|
||||||
and "BILL's PC" or Strings("someone's PC")
|
self:sayNext(self:romText(
|
||||||
self:sayNext(Strings("%s was\ntransferred to\n%s!", self.enemy.name, pc))
|
metBill and "_ItemUseBallText07" or "_ItemUseBallText08",
|
||||||
|
metBill and "%s was\ntransferred to\nBILL's PC!"
|
||||||
|
or "%s was\ntransferred to\nsomeone's PC!",
|
||||||
|
self.enemy.name))
|
||||||
else
|
else
|
||||||
self:sayNext(Strings("But every BOX\nis full!"))
|
self:sayNext(Strings("But every BOX\nis full!"))
|
||||||
end
|
end
|
||||||
@@ -5007,8 +5108,18 @@ function BattleState:throwBall(ball)
|
|||||||
-- RESTLESS SOUL dodges balls even once the scope has revealed it,
|
-- RESTLESS SOUL dodges balls even once the scope has revealed it,
|
||||||
-- so it is not a ghost battle any more (#444)
|
-- so it is not a ghost battle any more (#444)
|
||||||
self:animNext(self:tossAnimFor(ball), true, nil, ball)
|
self:animNext(self:tossAnimFor(ball), true, nil, ball)
|
||||||
self:sayNext(Strings("It dodged the\nthrown BALL!"))
|
-- _ItemUseBallText00 is one label for both lines, \f-paged. Unlike
|
||||||
self:sayNext(Strings("This POKéMON\ncan't be caught!"))
|
-- TextBox.new() (which splits \f itself), the battle queue's own
|
||||||
|
-- startMessage() only splits on \n/\v -- confirmed live: the \f
|
||||||
|
-- landed mid-line and the second sentence overflowed off the box
|
||||||
|
-- instead of starting a fresh page. Resolve the label once, then
|
||||||
|
-- split it the same way TextBox.lua does and queue one sayNext per
|
||||||
|
-- page, so the two ROM sentences still render as two pages.
|
||||||
|
local dodgeText = self:romText("_ItemUseBallText00",
|
||||||
|
"It dodged the\nthrown BALL!\fThis POKéMON\ncan't be caught!")
|
||||||
|
for page in (dodgeText .. "\f"):gmatch("(.-)\f") do
|
||||||
|
self:sayNext(page)
|
||||||
|
end
|
||||||
self:act(function()
|
self:act(function()
|
||||||
self:executeAction(self.enemy, self.player, self:enemyAction())
|
self:executeAction(self.enemy, self.player, self:enemyAction())
|
||||||
end)
|
end)
|
||||||
@@ -5055,10 +5166,14 @@ function BattleState:openParty()
|
|||||||
battle = self,
|
battle = self,
|
||||||
party = self:playerPartyView(),
|
party = self:playerPartyView(),
|
||||||
onSwitch = function(mon)
|
onSwitch = function(mon)
|
||||||
|
-- PartyMenuOrRockOrRun's SWITCH .partyMonDeselected (core.asm:2396-2408)
|
||||||
if mon == self.player.mon then
|
if mon == self.player.mon then
|
||||||
self:say(Strings("%s is\nalready out!", self.player.name))
|
self:say(self:romText("_AlreadyOutText",
|
||||||
|
"%s is\nalready out!", self.player.name))
|
||||||
|
self:act(function() self:openParty() end)
|
||||||
elseif mon.hp <= 0 then
|
elseif mon.hp <= 0 then
|
||||||
self:say(self:romText("_NoWillText", "There's no will\nto fight!"))
|
self:say(self:romText("_NoWillText", "There's no will\nto fight!"))
|
||||||
|
self:act(function() self:openParty() end)
|
||||||
else
|
else
|
||||||
self:resolveSwitch(mon)
|
self:resolveSwitch(mon)
|
||||||
end
|
end
|
||||||
@@ -5202,6 +5317,16 @@ function BattleState:growInScale(battler)
|
|||||||
return f < 3 and 0 or f < 7 and 3 / 7 or 5 / 7
|
return f < 3 and 0 or f < 7 and 3 / 7 or 5 / 7
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- AnimateRetreatingPlayerMon's CopyDownscaledMonTiles stages
|
||||||
|
-- (core.asm:1769-1796)
|
||||||
|
function BattleState:shrinkOutScale(battler)
|
||||||
|
local shrink = self.shrinkOut
|
||||||
|
if not shrink or shrink.battler ~= battler then return nil end
|
||||||
|
-- scale 0 past Delay3: the area stays cleared until the swap
|
||||||
|
-- (core.asm:1790-1796) (#1563)
|
||||||
|
return shrink.frame < 4 and 5 / 7 or shrink.frame < 7 and 3 / 7 or 0
|
||||||
|
end
|
||||||
|
|
||||||
-- battler hidden this frame? (damage blink)
|
-- battler hidden this frame? (damage blink)
|
||||||
--
|
--
|
||||||
-- AnimationBlinkMon hides the pic, waits DelayFrames 5, shows it, waits
|
-- AnimationBlinkMon hides the pic, waits DelayFrames 5, shows it, waits
|
||||||
@@ -5838,15 +5963,18 @@ function BattleState:drawPicsLayer(slide, sx, sy, onlySide, skipMenuClip)
|
|||||||
local s = BattleState.resolveBattleScale(self.data, "back",
|
local s = BattleState.resolveBattleScale(self.data, "back",
|
||||||
imagePathOf(self.player.sprite),
|
imagePathOf(self.player.sprite),
|
||||||
self.player.mon and self.player.mon.species)
|
self.player.mon and self.player.mon.species)
|
||||||
local gs = self:growInScale(self.player)
|
local gs = self:growInScale(self.player) or self:shrinkOutScale(self.player)
|
||||||
if gs then
|
if gs then
|
||||||
-- the player-side AnimateSendingOutMon grow (after the poof,
|
-- the player-side AnimateSendingOutMon grow (core.asm:1757-1762) and
|
||||||
-- core.asm:1757-1762): feet pinned at y=96, horizontal centre
|
-- the AnimateRetreatingPlayerMon shrink (core.asm:1769-1796)
|
||||||
-- pinned, mod scale composed with the grow stage
|
|
||||||
local eff = s * gs
|
local eff = s * gs
|
||||||
if eff > 0 then
|
if eff > 0 then
|
||||||
|
-- the retreat stages sit one tile right of the grow-in's
|
||||||
|
-- (hlcoord 3,7 / 4,9 vs 2,7 / 3,9, core.asm:1770-1788) (#1563)
|
||||||
|
local shrinkX = self.shrinkOut
|
||||||
|
and self.shrinkOut.battler == self.player and 8 or 0
|
||||||
love.graphics.draw(img,
|
love.graphics.draw(img,
|
||||||
8 - padL * s + img:getWidth() * s * (1 - gs) / 2 + sx,
|
8 + shrinkX - padL * s + img:getWidth() * s * (1 - gs) / 2 + sx,
|
||||||
96 - (img:getHeight() - pad) * eff + sy, 0, eff, eff)
|
96 - (img:getHeight() - pad) * eff + sy, 0, eff, eff)
|
||||||
end
|
end
|
||||||
else
|
else
|
||||||
|
|||||||
@@ -108,13 +108,17 @@ end
|
|||||||
|
|
||||||
-- The damaging pipeline, extracted from the performMove monolith: every
|
-- The damaging pipeline, extracted from the performMove monolith: every
|
||||||
-- stage keeps the original's exact check order and rng consumption
|
-- stage keeps the original's exact check order and rng consumption
|
||||||
-- (invulnerability -> gate -> hit count -> pre-accuracy -> accuracy ->
|
-- (pre-accuracy -> invulnerability -> gate -> hit count -> accuracy ->
|
||||||
-- damage choice -> hits -> messages -> after-damage -> secondary run).
|
-- damage choice -> hits -> messages -> after-damage -> secondary run).
|
||||||
function EffectRegistry.runDamaging(battle, ctx, record)
|
function EffectRegistry.runDamaging(battle, ctx, record)
|
||||||
local user, target = ctx.user, ctx.target
|
local user, target = ctx.user, ctx.target
|
||||||
local move, moveInst = ctx.move, ctx.moveInst
|
local move, moveInst = ctx.move, ctx.moveInst
|
||||||
local neverMiss = record and record.neverMiss
|
local neverMiss = record and record.neverMiss
|
||||||
|
|
||||||
|
-- SpecialEffectsCont's JumpMoveEffect (core.asm:3129-3133) runs before
|
||||||
|
-- MoveHitTest's INVULNERABLE test (:3150), mid-Fly/Dig included (#1565)
|
||||||
|
if record and record.beforeAccuracy then record.beforeAccuracy(ctx) end
|
||||||
|
|
||||||
-- Swift ignores semi-invulnerability (MoveHitTest returns hit for
|
-- Swift ignores semi-invulnerability (MoveHitTest returns hit for
|
||||||
-- SWIFT_EFFECT before the INVULNERABLE check)
|
-- SWIFT_EFFECT before the INVULNERABLE check)
|
||||||
if target.invulnerable and not neverMiss then
|
if target.invulnerable and not neverMiss then
|
||||||
@@ -143,8 +147,6 @@ function EffectRegistry.runDamaging(battle, ctx, record)
|
|||||||
|
|
||||||
local hits = hitCount(ctx, record)
|
local hits = hitCount(ctx, record)
|
||||||
|
|
||||||
if record and record.beforeAccuracy then record.beforeAccuracy(ctx) end
|
|
||||||
|
|
||||||
if not neverMiss then
|
if not neverMiss then
|
||||||
if not battle:accuracyRoll(move, user, target) then
|
if not battle:accuracyRoll(move, user, target) then
|
||||||
-- Explosion/Selfdestruct still animate on a miss (HandleIfPlayerMoveMissed)
|
-- Explosion/Selfdestruct still animate on a miss (HandleIfPlayerMoveMissed)
|
||||||
@@ -221,7 +223,7 @@ function EffectRegistry.runDamaging(battle, ctx, record)
|
|||||||
-- replay PlayMoveAnimation per strike (pokered: GetPlayerAnimationType
|
-- replay PlayMoveAnimation per strike (pokered: GetPlayerAnimationType
|
||||||
-- / GetEnemyAnimationType loop on wNumAttacksLeft); hit 1 reuses the
|
-- / GetEnemyAnimationType loop on wNumAttacksLeft); hit 1 reuses the
|
||||||
-- announcement-time moveAnimRow, later hits queue fresh anim rows.
|
-- announcement-time moveAnimRow, later hits queue fresh anim rows.
|
||||||
-- Thrash/rage continuations have no announcement anim -- a bare
|
-- Mimic queues no announcement anim (announceAnim = false) -- a bare
|
||||||
-- hitRow carries the blink instead.
|
-- hitRow carries the blink instead.
|
||||||
-- PlayApplyingAttackSound (engine/battle/animations.asm, the routine after
|
-- PlayApplyingAttackSound (engine/battle/animations.asm, the routine after
|
||||||
-- PlayApplyingAttackAnimation) picks the sound off wDamageMultipliers -- 10
|
-- PlayApplyingAttackAnimation) picks the sound off wDamageMultipliers -- 10
|
||||||
|
|||||||
@@ -582,30 +582,15 @@ MoveEffects.full = {
|
|||||||
},
|
},
|
||||||
THRASH_PETAL_DANCE_EFFECT = {
|
THRASH_PETAL_DANCE_EFFECT = {
|
||||||
-- ThrashPetalDanceEffect (effects.asm:791-808) runs before damage
|
-- ThrashPetalDanceEffect (effects.asm:791-808) runs before damage
|
||||||
-- (data/battle/special_effects.asm:22) and animates the setup turn
|
-- (data/battle/special_effects.asm:22, core.asm:3531-3552)
|
||||||
beforeAccuracy = function(ctx)
|
beforeAccuracy = function(ctx)
|
||||||
local user = ctx.user
|
local user = ctx.user
|
||||||
if not user.thrashTurns then
|
if ctx.thrashing or user.thrashTurns then return end
|
||||||
ctx.battle:animBeforeMove(
|
|
||||||
user.isPlayer and "SHRINKING_SQUARE_ANIM" or "ANIM_B1", user.isPlayer)
|
|
||||||
end
|
|
||||||
end,
|
|
||||||
afterDamage = function(ctx)
|
|
||||||
local user = ctx.user
|
|
||||||
if not user.thrashTurns then
|
|
||||||
user.thrashTurns = ctx.rng(2, 3) -- 3-4 attacks total, then confusion
|
user.thrashTurns = ctx.rng(2, 3) -- 3-4 attacks total, then confusion
|
||||||
user.thrashMove = ctx.moveInst
|
user.thrashMove = ctx.moveInst
|
||||||
user.thrashAnnounced = true
|
user.thrashAnnounced = true
|
||||||
else
|
ctx.battle:animBeforeMove(
|
||||||
user.thrashTurns = user.thrashTurns - 1
|
user.isPlayer and "SHRINKING_SQUARE_ANIM" or "ANIM_B1", user.isPlayer)
|
||||||
if user.thrashTurns <= 0 then
|
|
||||||
user.thrashTurns, user.thrashMove, user.thrashAnnounced = nil, nil, nil
|
|
||||||
if not user.confusedTurns then
|
|
||||||
user.confusedTurns = ctx.rng(2, 5)
|
|
||||||
ctx.say(romText(ctx.battle.data, "_BecameConfusedText", "%s\nbecame confused!", displayName(user)))
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end,
|
end,
|
||||||
},
|
},
|
||||||
JUMP_KICK_EFFECT = {
|
JUMP_KICK_EFFECT = {
|
||||||
|
|||||||
+43
-13
@@ -166,6 +166,9 @@ Battle.SUBSTATUS_ITEMS = {
|
|||||||
-- be run from or Roared away.
|
-- be run from or Roared away.
|
||||||
Battle.BATTLETYPE_FORCESHINY = 7
|
Battle.BATTLETYPE_FORCESHINY = 7
|
||||||
Battle.BATTLETYPE_TRAP = 9
|
Battle.BATTLETYPE_TRAP = 9
|
||||||
|
-- LostBattle's .canlose arm (engine/battle/core.asm:2766): the only battle
|
||||||
|
-- type whose loss still prints the trainer's own line instead of a whiteout.
|
||||||
|
Battle.BATTLETYPE_CANLOSE = 1
|
||||||
|
|
||||||
-- BadgeStatBoosts (engine/battle/core.asm:6534): each of these Johto badges
|
-- BadgeStatBoosts (engine/battle/core.asm:6534): each of these Johto badges
|
||||||
-- raises the PLAYER's in-battle stat by 1/8. The routine walks every other
|
-- raises the PLAYER's in-battle stat by 1/8. The routine walks every other
|
||||||
@@ -889,10 +892,13 @@ Battle.PRIORITY = {
|
|||||||
EFFECT_ENDURE = 3,
|
EFFECT_ENDURE = 3,
|
||||||
EFFECT_COUNTER = -1,
|
EFFECT_COUNTER = -1,
|
||||||
EFFECT_MIRROR_COAT = -1,
|
EFFECT_MIRROR_COAT = -1,
|
||||||
EFFECT_VITAL_THROW = -1,
|
EFFECT_FORCE_SWITCH = -1, -- Whirlwind, Roar: priority 0, below BASE
|
||||||
}
|
}
|
||||||
|
|
||||||
function Battle:movePriority(moveId)
|
function Battle:movePriority(moveId)
|
||||||
|
-- GetMovePriority `cp VITAL_THROW / ld a, 0 / ret z`
|
||||||
|
-- (engine/battle/core.asm:787-789).
|
||||||
|
if moveId == "VITAL_THROW" then return -1 end
|
||||||
local def = self:moveDef(moveId)
|
local def = self:moveDef(moveId)
|
||||||
return (def and Battle.PRIORITY[def.effect]) or 0
|
return (def and Battle.PRIORITY[def.effect]) or 0
|
||||||
end
|
end
|
||||||
@@ -2403,10 +2409,11 @@ Battle.MOVE_EFFECTS.EFFECT_BATON_PASS = function(self, attacker)
|
|||||||
self.enemy = party[target]
|
self.enemy = party[target]
|
||||||
self.enemy.volatile = carried
|
self.enemy.volatile = carried
|
||||||
end
|
end
|
||||||
self:emit({ kind = "send", side = side,
|
local sent = side == "player" and self.player or self.enemy
|
||||||
mon = side == "player" and self.player or self.enemy,
|
self:emit({ kind = "send", side = side, mon = sent,
|
||||||
text = "Go! " .. self:monName(side == "player" and self.player
|
hp = sent.hp or 0, status = sent.status or false,
|
||||||
or self.enemy) .. "!" })
|
level = sent.level, experience = sent.experience,
|
||||||
|
text = "Go! " .. self:monName(sent) .. "!" })
|
||||||
end
|
end
|
||||||
|
|
||||||
-- BattleCommand_TrapTarget's .Traps table, one line per move: target first,
|
-- BattleCommand_TrapTarget's .Traps table, one line per move: target first,
|
||||||
@@ -2665,6 +2672,8 @@ Battle.MOVE_EFFECTS.EFFECT_FORCE_SWITCH = function(self, attacker, defender,
|
|||||||
self.stages.enemy = Battle.newStages()
|
self.stages.enemy = Battle.newStages()
|
||||||
end
|
end
|
||||||
self:emit({ kind = "send", side = self:sideOf(incoming), mon = incoming,
|
self:emit({ kind = "send", side = self:sideOf(incoming), mon = incoming,
|
||||||
|
hp = incoming.hp or 0, status = incoming.status or false,
|
||||||
|
level = incoming.level, experience = incoming.experience,
|
||||||
text = self:monName(incoming) .. " was dragged out!" })
|
text = self:monName(incoming) .. " was dragged out!" })
|
||||||
self:breakTrapsOnSend(incoming)
|
self:breakTrapsOnSend(incoming)
|
||||||
self:spikesDamage(incoming)
|
self:spikesDamage(incoming)
|
||||||
@@ -3084,6 +3093,7 @@ function Battle:resolveFaints()
|
|||||||
if self.trainer then
|
if self.trainer then
|
||||||
self:emit({ kind = "message",
|
self:emit({ kind = "message",
|
||||||
text = (self.trainer.name or "TRAINER") .. " was defeated!" })
|
text = (self.trainer.name or "TRAINER") .. " was defeated!" })
|
||||||
|
self:printWinLossText("win")
|
||||||
self:awardPrizeMoney()
|
self:awardPrizeMoney()
|
||||||
end
|
end
|
||||||
-- CheckPayDay, on the win arm only (engine/battle/core.asm:7971-7976,
|
-- CheckPayDay, on the win arm only (engine/battle/core.asm:7971-7976,
|
||||||
@@ -3110,6 +3120,8 @@ function Battle:resolveFaints()
|
|||||||
-- can offer a shift on (engine/battle/core.asm:2241-2278).
|
-- can offer a shift on (engine/battle/core.asm:2241-2278).
|
||||||
self:emit({ kind = "send", side = "enemy", mon = self.enemy,
|
self:emit({ kind = "send", side = "enemy", mon = self.enemy,
|
||||||
replacement = true,
|
replacement = true,
|
||||||
|
hp = self.enemy.hp or 0, status = self.enemy.status or false,
|
||||||
|
level = self.enemy.level, experience = self.enemy.experience,
|
||||||
text = (self.trainer and self.trainer.name or "Foe") .. " sent out "
|
text = (self.trainer and self.trainer.name or "Foe") .. " sent out "
|
||||||
.. self:monName(self.enemy) .. "!" })
|
.. self:monName(self.enemy) .. "!" })
|
||||||
Runtime.emit("battle.battler_switched", {
|
Runtime.emit("battle.battler_switched", {
|
||||||
@@ -3152,6 +3164,11 @@ function Battle:resolveFaints()
|
|||||||
local nextIndex = Battle.firstHealthy(self.party)
|
local nextIndex = Battle.firstHealthy(self.party)
|
||||||
if not nextIndex then
|
if not nextIndex then
|
||||||
self:emit({ kind = "message", text = "You have no more POKéMON!" })
|
self:emit({ kind = "message", text = "You have no more POKéMON!" })
|
||||||
|
-- LostBattle (engine/battle/core.asm:2763-2782): only BATTLETYPE_CANLOSE
|
||||||
|
-- reaches PrintWinLossText on a loss; every other loss whites out.
|
||||||
|
if self.battleType == Battle.BATTLETYPE_CANLOSE then
|
||||||
|
self:printWinLossText("lose")
|
||||||
|
end
|
||||||
self:endBattle("lose")
|
self:endBattle("lose")
|
||||||
return true
|
return true
|
||||||
end
|
end
|
||||||
@@ -3177,14 +3194,23 @@ function Battle:resolveFaints()
|
|||||||
return false
|
return false
|
||||||
end
|
end
|
||||||
|
|
||||||
-- WinTrainerBattle's money arm, which runs after BattleText_EnemyWasDefeated
|
-- WinTrainerBattle (engine/battle/core.asm:2310-2323), LostBattle's .canlose
|
||||||
-- and the frontpic slide: the four quarters are dealt between the wallet and
|
-- arm (:2769-2782), PrintWinLossText (home/trainers.asm:230)
|
||||||
-- Mom's savings and then one StdBattleTextbox names the figure.
|
function Battle:printWinLossText(result)
|
||||||
--
|
local trainer = self.trainer
|
||||||
-- The `ld a, [wDebugFlags] / bit DEBUG_BATTLE_F` skip in front of
|
if not trainer then return end
|
||||||
-- PrintWinLossText is the trainer's own after-battle line, which this port
|
-- The DEBUG_BATTLE_F skip sits in front of PrintWinLossText alone, behind
|
||||||
-- runs from the script on the way out of the battle rather than from here.
|
-- the slide (engine/battle/core.asm:2310, :2320-2323).
|
||||||
-- The payout is not gated on it either way.
|
-- The CANLOSE loss arm runs ClearBox first (:2770-2773).
|
||||||
|
self:emit({ kind = "trainer-return", cleared = result == "lose" or nil })
|
||||||
|
local text = (result == "lose") and trainer.lossText or trainer.winText
|
||||||
|
if type(text) ~= "string" or text == "" then return end
|
||||||
|
-- FarPrintText prints the pointer alone: no trainer-name tag in front of
|
||||||
|
-- it, unlike Gen 1's TrainerEndBattleText (pokered home/trainers.asm:355).
|
||||||
|
self:emit({ kind = "win-text", text = text })
|
||||||
|
end
|
||||||
|
|
||||||
|
-- WinTrainerBattle's money arm (engine/battle/core.asm:2310-2323)
|
||||||
function Battle:awardPrizeMoney()
|
function Battle:awardPrizeMoney()
|
||||||
local save = self.save
|
local save = self.save
|
||||||
if not (save and save.player) then return nil end
|
if not (save and save.player) then return nil end
|
||||||
@@ -3505,6 +3531,8 @@ function Battle:switch(index)
|
|||||||
self.participants[index] = true
|
self.participants[index] = true
|
||||||
self.stages.player = Battle.newStages()
|
self.stages.player = Battle.newStages()
|
||||||
self:emit({ kind = "send", side = "player", mon = mon,
|
self:emit({ kind = "send", side = "player", mon = mon,
|
||||||
|
hp = mon.hp or 0, status = mon.status or false,
|
||||||
|
level = mon.level, experience = mon.experience,
|
||||||
text = "Go! " .. self:monName(mon) .. "!" })
|
text = "Go! " .. self:monName(mon) .. "!" })
|
||||||
-- battle.battler_switched, the payload BattleState:resolveSwitch emits on
|
-- battle.battler_switched, the payload BattleState:resolveSwitch emits on
|
||||||
-- Gen 1: the side record, whoever walked in, and whoever walked out.
|
-- Gen 1: the side record, whoever walked in, and whoever walked out.
|
||||||
@@ -3969,6 +3997,8 @@ function Battle:enemyTrySwitchOrItem()
|
|||||||
self:clearVolatile(self.enemy)
|
self:clearVolatile(self.enemy)
|
||||||
self.stages.enemy = Battle.newStages()
|
self.stages.enemy = Battle.newStages()
|
||||||
self:emit({ kind = "send", side = "enemy", mon = self.enemy,
|
self:emit({ kind = "send", side = "enemy", mon = self.enemy,
|
||||||
|
hp = self.enemy.hp or 0, status = self.enemy.status or false,
|
||||||
|
level = self.enemy.level, experience = self.enemy.experience,
|
||||||
text = (self.trainer.name or "TRAINER") .. " sent out "
|
text = (self.trainer.name or "TRAINER") .. " sent out "
|
||||||
.. self:monName(self.enemy) .. "!" })
|
.. self:monName(self.enemy) .. "!" })
|
||||||
Runtime.emit("battle.battler_switched", {
|
Runtime.emit("battle.battler_switched", {
|
||||||
|
|||||||
+21
-6
@@ -609,9 +609,10 @@ function Game:draw()
|
|||||||
-- ...and for the same reason the UI's own scale has to know the world is
|
-- ...and for the same reason the UI's own scale has to know the world is
|
||||||
-- still the backdrop while an opaque menu covers it. Renderer:uiScale
|
-- still the backdrop while an opaque menu covers it. Renderer:uiScale
|
||||||
-- steps the UI down with the survey zoom only while a world is behind it,
|
-- steps the UI down with the survey zoom only while a world is behind it,
|
||||||
-- gated on this frame's world pass -- which the party menu and the bag end
|
-- gated on this frame's world pass -- which the party menu ends by being
|
||||||
-- by being opaque. Without this hold they lose the step-down and blit at
|
-- opaque (the bag's item box shows the map around it, #1521). Without
|
||||||
-- full fit scale over a battle drawn at the zoomed-out one.
|
-- this hold it loses the step-down and blits at full fit scale over a
|
||||||
|
-- battle drawn at the zoomed-out one.
|
||||||
Renderer.uiWorldHold = Renderer.battleDim ~= nil
|
Renderer.uiWorldHold = Renderer.battleDim ~= nil
|
||||||
-- ...and a battle keeps its dialogue box and YES/NO inside its own screen
|
-- ...and a battle keeps its dialogue box and YES/NO inside its own screen
|
||||||
-- instead of letting them dock to the window edge.
|
-- instead of letting them dock to the window edge.
|
||||||
@@ -982,7 +983,11 @@ end
|
|||||||
-- parked the player until every direction was re-pressed (#799).
|
-- parked the player until every direction was re-pressed (#799).
|
||||||
function Game:focus(f)
|
function Game:focus(f)
|
||||||
Input:reset()
|
Input:reset()
|
||||||
if f then Input:reconcile() end
|
if f then
|
||||||
|
Input:reconcile()
|
||||||
|
local eng = self:syncEngine()
|
||||||
|
if eng then pcall(eng.noteResumed, eng) end
|
||||||
|
end
|
||||||
TouchControls:reset()
|
TouchControls:reset()
|
||||||
self:cancelPointers()
|
self:cancelPointers()
|
||||||
end
|
end
|
||||||
@@ -1002,6 +1007,8 @@ function Game:onResume()
|
|||||||
Input:reconcile()
|
Input:reconcile()
|
||||||
TouchControls:reset()
|
TouchControls:reset()
|
||||||
self:cancelPointers()
|
self:cancelPointers()
|
||||||
|
local eng = self:syncEngine()
|
||||||
|
if eng then pcall(eng.noteResumed, eng) end
|
||||||
-- Chip music may survive NX suspend as a duplicate stream; stop it and let
|
-- Chip music may survive NX suspend as a duplicate stream; stop it and let
|
||||||
-- the active screen re-cue on the next frame (hardware audio check: T19).
|
-- the active screen re-cue on the next frame (hardware audio check: T19).
|
||||||
-- Desktop/mobile window-visible flips must not kill overworld music.
|
-- Desktop/mobile window-visible flips must not kill overworld music.
|
||||||
@@ -1209,18 +1216,26 @@ end
|
|||||||
|
|
||||||
function Game:syncEngine()
|
function Game:syncEngine()
|
||||||
if self._syncOff then return nil end
|
if self._syncOff then return nil end
|
||||||
if self._syncEngineRef then return self._syncEngineRef end
|
local eng = self._syncEngineRef
|
||||||
|
if not eng then
|
||||||
local ok, SyncEngine = pcall(require, "src.sync.SyncEngine")
|
local ok, SyncEngine = pcall(require, "src.sync.SyncEngine")
|
||||||
if not ok or type(SyncEngine) ~= "table" then
|
if not ok or type(SyncEngine) ~= "table" then
|
||||||
self._syncOff = true
|
self._syncOff = true
|
||||||
return nil
|
return nil
|
||||||
end
|
end
|
||||||
local eng = SyncEngine.shared()
|
eng = SyncEngine.shared()
|
||||||
if not eng then
|
if not eng then
|
||||||
self._syncOff = true
|
self._syncOff = true
|
||||||
return nil
|
return nil
|
||||||
end
|
end
|
||||||
self._syncEngineRef = eng
|
self._syncEngineRef = eng
|
||||||
|
end
|
||||||
|
if type(eng.protectPlaythrough) == "function" then
|
||||||
|
local meta = self.save and self.save.meta
|
||||||
|
eng:protectPlaythrough(
|
||||||
|
(self.save and self.save.version) or require("src.core.GameVersion").get(),
|
||||||
|
type(meta) == "table" and meta.playthroughId or nil)
|
||||||
|
end
|
||||||
return eng
|
return eng
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -220,6 +220,9 @@ function Save.newGame(opts)
|
|||||||
phoneContacts = {},
|
phoneContacts = {},
|
||||||
tradeFlags = {},
|
tradeFlags = {},
|
||||||
pokedex = { seen = {}, caught = {} },
|
pokedex = { seen = {}, caught = {} },
|
||||||
|
-- wLastDexMode (engine/pokedex/pokedex.asm:59-61): the sort mode the
|
||||||
|
-- #DEX reopens in. NEW_MODE is the cart's zero byte.
|
||||||
|
lastDexMode = "NEW",
|
||||||
-- wUnownDex: the distinct Unown FORMS caught, in catching order. A second
|
-- wUnownDex: the distinct Unown FORMS caught, in catching order. A second
|
||||||
-- record beside the #DEX because the #DEX knows only the species
|
-- record beside the #DEX because the #DEX knows only the species
|
||||||
-- (src/core/gen2/Unown.lua).
|
-- (src/core/gen2/Unown.lua).
|
||||||
@@ -685,6 +688,12 @@ function Save.validate(save)
|
|||||||
scrubEvents(save, report)
|
scrubEvents(save, report)
|
||||||
scrubMapScenes(save, report)
|
scrubMapScenes(save, report)
|
||||||
scrubPlayerState(save, report)
|
scrubPlayerState(save, report)
|
||||||
|
-- wLastDexMode: only the three modes the #DEX has (PokedexMenu MODES);
|
||||||
|
-- a hand-edited value falls back to NEW_MODE, the cart's zero byte
|
||||||
|
if save.lastDexMode ~= "NEW" and save.lastDexMode ~= "OLD"
|
||||||
|
and save.lastDexMode ~= "A-Z" then
|
||||||
|
save.lastDexMode = "NEW"
|
||||||
|
end
|
||||||
-- The `mailmsg` structs get the same treatment for the same reason: their
|
-- The `mailmsg` structs get the same treatment for the same reason: their
|
||||||
-- `type` byte is an item id nothing else in the save vouches for, and a
|
-- `type` byte is an item id nothing else in the save vouches for, and a
|
||||||
-- party key outside 1..6 or a MAILBOX past MAILBOX_CAPACITY is a region the
|
-- party key outside 1..6 or a MAILBOX past MAILBOX_CAPACITY is a region the
|
||||||
|
|||||||
+101
-16
@@ -124,12 +124,48 @@ function LauncherView.detach(imp)
|
|||||||
Kit.clearCaches()
|
Kit.clearCaches()
|
||||||
end
|
end
|
||||||
|
|
||||||
|
local function markNoDrag(imp, x, y, w, h)
|
||||||
|
if Kit.blockClicks then return end
|
||||||
|
local t = imp._noDragRects
|
||||||
|
if not t then t = {}; imp._noDragRects = t end
|
||||||
|
local n = (imp._noDragN or 0) + 1
|
||||||
|
imp._noDragN = n
|
||||||
|
local r = t[n]
|
||||||
|
if not r then r = {}; t[n] = r end
|
||||||
|
r.x, r.y, r.w, r.h = x, y, w, h
|
||||||
|
end
|
||||||
|
|
||||||
|
local function noDragAt(imp, x, y)
|
||||||
|
local rects = imp._noDragRects
|
||||||
|
for i = 1, imp._noDragN or 0 do
|
||||||
|
if inRect(rects[i], x, y) then return true end
|
||||||
|
end
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
|
local function armMouse(imp, x, y)
|
||||||
|
if noDragAt(imp, x, y) then
|
||||||
|
imp._clickPt = { x = x, y = y }
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local shielded = imp._modalUpNow
|
||||||
|
imp._mouseAt = {
|
||||||
|
x = x, y = y,
|
||||||
|
region = not shielded and tabScrollMax(imp) > 0
|
||||||
|
and inRect(imp._tabRegionRect, x, y) or false,
|
||||||
|
page = not shielded and (imp._pageScrollMax or 0) > 0 or false,
|
||||||
|
}
|
||||||
|
Kit.dragBegin(x, y)
|
||||||
|
end
|
||||||
|
|
||||||
-- ---------------------------------------------------------------- input
|
-- ---------------------------------------------------------------- input
|
||||||
-- The kit is polled, not evented: update() samples the mouse and turns a
|
-- The kit is polled, not evented: update() samples the mouse. A press arms
|
||||||
-- rising edge into a click point that the next draw consumes. Host-forwarded
|
-- a drag that scrolls like a finger, and the click dispatches on RELEASE so
|
||||||
-- mousepressed stays unused, exactly as before, so Android's synthesized
|
-- the drag can disqualify it, exactly like the touch path below; only the
|
||||||
-- mouse path cannot double-fire a tap (#553) -- the dedup window below is the
|
-- cartridge (which owns its own spin-drag) keeps the press-down click.
|
||||||
-- other half of that guarantee.
|
-- Host-forwarded mousepressed stays unused, exactly as before, so Android's
|
||||||
|
-- synthesized mouse path cannot double-fire a tap (#553) -- the dedup window
|
||||||
|
-- below is the other half of that guarantee.
|
||||||
function LauncherView.update(imp, dt)
|
function LauncherView.update(imp, dt)
|
||||||
if not imp._flex then return end
|
if not imp._flex then return end
|
||||||
if imp._launchFade then return end
|
if imp._launchFade then return end
|
||||||
@@ -154,7 +190,39 @@ function LauncherView.update(imp, dt)
|
|||||||
if not touching and now >= (imp._suppressMouseUntil or 0)
|
if not touching and now >= (imp._suppressMouseUntil or 0)
|
||||||
and now >= (imp._suppressClickUntil or 0) then
|
and now >= (imp._suppressClickUntil or 0) then
|
||||||
local mx, my = love.mouse.getPosition()
|
local mx, my = love.mouse.getPosition()
|
||||||
imp._clickPt = { x = mx, y = my }
|
armMouse(imp, mx, my)
|
||||||
|
end
|
||||||
|
elseif down and imp._mouseAt then
|
||||||
|
local start = imp._mouseAt
|
||||||
|
local mx, my = love.mouse.getPosition()
|
||||||
|
local ddx, ddy = mx - start.x, my - start.y
|
||||||
|
if ddx * ddx + ddy * ddy > TAP_SLOP2 then
|
||||||
|
start.dragged = true
|
||||||
|
end
|
||||||
|
if start.dragged then
|
||||||
|
local last = start.lastY or start.y
|
||||||
|
local move = -(my - last)
|
||||||
|
if move ~= 0 and start.region then
|
||||||
|
local at, leftover = Kit.scrollHandoff(tabScrollAt(imp),
|
||||||
|
tabScrollMax(imp), move)
|
||||||
|
setTabScroll(imp, at)
|
||||||
|
move = leftover
|
||||||
|
end
|
||||||
|
if move ~= 0 and start.page and (imp._pageScrollMax or 0) > 0 then
|
||||||
|
local at, leftover = Kit.scrollHandoff(imp._pageScroll or 0,
|
||||||
|
imp._pageScrollMax, move)
|
||||||
|
imp._pageScroll = at
|
||||||
|
move = leftover
|
||||||
|
end
|
||||||
|
if move ~= 0 then Kit.dragAdd(move) end
|
||||||
|
end
|
||||||
|
start.lastY = my
|
||||||
|
elseif not down and imp._mouseAt then
|
||||||
|
local start = imp._mouseAt
|
||||||
|
imp._mouseAt = nil
|
||||||
|
Kit.dragEnd()
|
||||||
|
if not start.dragged then
|
||||||
|
imp._clickPt = { x = start.x, y = start.y }
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
imp._prevMouseDown = down
|
imp._prevMouseDown = down
|
||||||
@@ -233,10 +301,12 @@ function LauncherView.clickAt(imp, x, y)
|
|||||||
imp._clickPt = { x = x, y = y }
|
imp._clickPt = { x = x, y = y }
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Event-driven click: a macOS trackpad tap delivers press+release inside one
|
-- Event-driven press: a macOS trackpad tap delivers press+release inside one
|
||||||
-- frame, so update()'s love.mouse.isDown poll never sees it. Mint the click
|
-- frame, so update()'s love.mouse.isDown poll never sees it. Arm the drag
|
||||||
-- from the press event under the poll's own suppression rules, and mark the
|
-- from the press event under the poll's own suppression rules -- the poll's
|
||||||
-- press seen so the poll cannot mint a second one when isDown does catch it.
|
-- release branch then mints the tap, still within the same frame for a
|
||||||
|
-- one-frame tap -- and mark the press seen so the poll cannot arm a second
|
||||||
|
-- one when isDown does catch it.
|
||||||
function LauncherView.mousepressed(imp, x, y)
|
function LauncherView.mousepressed(imp, x, y)
|
||||||
if not imp._flex then return end
|
if not imp._flex then return end
|
||||||
local now = love.timer.getTime()
|
local now = love.timer.getTime()
|
||||||
@@ -245,7 +315,7 @@ function LauncherView.mousepressed(imp, x, y)
|
|||||||
or now < (imp._suppressClickUntil or 0) then
|
or now < (imp._suppressClickUntil or 0) then
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
imp._clickPt = { x = x, y = y }
|
if not imp._mouseAt then armMouse(imp, x, y) end
|
||||||
imp._prevMouseDown = true
|
imp._prevMouseDown = true
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -495,6 +565,7 @@ end
|
|||||||
|
|
||||||
local function cartridgeButton(imp, x, y, w, h, key, version, gameName, action)
|
local function cartridgeButton(imp, x, y, w, h, key, version, gameName, action)
|
||||||
local state = cartridgeState(imp, version)
|
local state = cartridgeState(imp, version)
|
||||||
|
markNoDrag(imp, x, y, w, h)
|
||||||
local focused = Kit.focusable(key, x, y, w, h)
|
local focused = Kit.focusable(key, x, y, w, h)
|
||||||
local hot = Kit.hover(x, y, w, h)
|
local hot = Kit.hover(x, y, w, h)
|
||||||
local active = state.active
|
local active = state.active
|
||||||
@@ -1245,15 +1316,27 @@ function LauncherView._updateControl(imp)
|
|||||||
elseif status == "downloading" then
|
elseif status == "downloading" then
|
||||||
local pct = st.progress and math.floor(st.progress * 100) or 0
|
local pct = st.progress and math.floor(st.progress * 100) or 0
|
||||||
return status, Strings("Updating %d%%", pct), nil, false
|
return status, Strings("Updating %d%%", pct), nil, false
|
||||||
|
elseif status == "full_downloading" then
|
||||||
|
local pct = st.progress and math.floor(st.progress * 100) or 0
|
||||||
|
return status, Strings("Downloading app %d%%", pct), nil, false
|
||||||
elseif status == "available" then
|
elseif status == "available" then
|
||||||
return status, st.latest and (Strings("Update v") .. st.latest)
|
return status, st.latest and (Strings("Update v") .. st.latest)
|
||||||
or Strings("Update"), function() pcall(imp.Check.download) end, true
|
or Strings("Update"), function() pcall(imp.Check.download) end, true
|
||||||
elseif status == "ready" then
|
elseif status == "ready" then
|
||||||
return status, Strings("Restart to update"),
|
return status, Strings("Restart to update"),
|
||||||
function() require("src.core.HostShell").restart() end, true
|
function() require("src.core.HostShell").restart() end, true
|
||||||
elseif status == "needs_full" then
|
elseif status == "needs_full" or status == "full_ready" then
|
||||||
return status, Strings("Open releases"),
|
local action = imp.Check.fullUpdateAction and imp.Check.fullUpdateAction()
|
||||||
function() love.system.openURL(imp.Check.releaseUrl()) end, true
|
local label = action and action.label or "Open releases"
|
||||||
|
local url = action and action.url or imp.Check.releaseUrl()
|
||||||
|
return status, Strings(label),
|
||||||
|
function()
|
||||||
|
if action and action.kind and imp.Check.performFullUpdate then
|
||||||
|
pcall(imp.Check.performFullUpdate)
|
||||||
|
else
|
||||||
|
love.system.openURL(url)
|
||||||
|
end
|
||||||
|
end, true
|
||||||
end
|
end
|
||||||
-- idle / uptodate / error: offer a manual check, with no glow.
|
-- idle / uptodate / error: offer a manual check, with no glow.
|
||||||
return status, Strings("Check for updates"),
|
return status, Strings("Check for updates"),
|
||||||
@@ -4483,7 +4566,7 @@ local function buildSyncHome(imp, m, eng)
|
|||||||
local linked = eng:linked()
|
local linked = eng:linked()
|
||||||
local codes = eng.codes
|
local codes = eng.codes
|
||||||
local body = linked
|
local body = linked
|
||||||
and Strings("This device is linked. Saves and the mod list sync when the launcher opens and a few seconds after each save.")
|
and Strings("This device is linked. Saves sync when the launcher opens, a few seconds after each save, and every few minutes while the app is running.")
|
||||||
or Strings(SYNC_HINT)
|
or Strings(SYNC_HINT)
|
||||||
local innerW = w - 2 * pad
|
local innerW = w - 2 * pad
|
||||||
local hintH = Kit.wrapHeight("small", body, innerW, 5)
|
local hintH = Kit.wrapHeight("small", body, innerW, 5)
|
||||||
@@ -4868,13 +4951,15 @@ function LauncherView.draw(imp)
|
|||||||
Kit.beginFrame(mx, my, click ~= nil, imp._wheelY or 0)
|
Kit.beginFrame(mx, my, click ~= nil, imp._wheelY or 0)
|
||||||
imp._clickPt = nil
|
imp._clickPt = nil
|
||||||
imp._wheelY = 0
|
imp._wheelY = 0
|
||||||
|
imp._noDragN = 0
|
||||||
|
|
||||||
Theme.field()
|
Theme.field()
|
||||||
|
|
||||||
-- Everything from here to buildModals sits UNDER any open modal, so the
|
-- Everything from here to buildModals sits UNDER any open modal, so the
|
||||||
-- whole stage draws shielded (no clicks, no hover, no focus ring) while
|
-- whole stage draws shielded (no clicks, no hover, no focus ring) while
|
||||||
-- one is up; buildModals lowers the shield for the modal's own controls.
|
-- one is up; buildModals lowers the shield for the modal's own controls.
|
||||||
Kit.blockClicks = modalUp(imp)
|
imp._modalUpNow = modalUp(imp)
|
||||||
|
Kit.blockClicks = imp._modalUpNow
|
||||||
|
|
||||||
local step = Kit.scrollStep(m.s)
|
local step = Kit.scrollStep(m.s)
|
||||||
do
|
do
|
||||||
|
|||||||
@@ -3439,6 +3439,18 @@ function RomExtractorGen2:extractScriptsAndText(maps, stdScripts)
|
|||||||
elseif info.name == "givepoke" then
|
elseif info.name == "givepoke" then
|
||||||
cmd.species, cmd.level, cmd.item, cmd.trainer =
|
cmd.species, cmd.level, cmd.item, cmd.trainer =
|
||||||
args[1], args[2], args[3], args[4]
|
args[1], args[2], args[3], args[4]
|
||||||
|
-- Script_givepoke (engine/overworld/scripting.asm:1806)
|
||||||
|
if size == 8 then
|
||||||
|
local function readAt(lo, hi)
|
||||||
|
local addr = (args[lo] or 0) + (args[hi] or 0) * 0x100
|
||||||
|
if not romAddrOk(bank, addr) then return nil end
|
||||||
|
local okStr, str = pcall(self.rom.readString, self.rom,
|
||||||
|
bank, addr, charmap, 0x50, 16)
|
||||||
|
return okStr and str or nil
|
||||||
|
end
|
||||||
|
cmd.name = readAt(5, 6)
|
||||||
|
cmd.otName = readAt(7, 8)
|
||||||
|
end
|
||||||
elseif info.name == "pokepic" or info.name == "disappear" then
|
elseif info.name == "pokepic" or info.name == "disappear" then
|
||||||
cmd.species = args[1] -- pokepic
|
cmd.species = args[1] -- pokepic
|
||||||
cmd.object = args[1] -- disappear (same byte)
|
cmd.object = args[1] -- disappear (same byte)
|
||||||
@@ -5231,6 +5243,20 @@ function RomExtractorGen2:extractMenuGfx()
|
|||||||
end
|
end
|
||||||
if eggHatch.egg or eggHatch.shell then out.eggHatch = eggHatch end
|
if eggHatch.egg or eggHatch.shell then out.eggHatch = eggHatch end
|
||||||
|
|
||||||
|
-- StatsScreenPageTilesGFX (gfx/font.asm:23), the 17 tiles
|
||||||
|
-- LoadStatsScreenPageTilesGFX lands at vTiles2 $31 (engine/gfx/load_font.asm:90).
|
||||||
|
local hpBarBorder = self.symbols["EnemyHPBarBorderGFX"]
|
||||||
|
if hpBarBorder then
|
||||||
|
local address = hpBarBorder[2] - 17 * 16
|
||||||
|
self:write2bpp(self.rom:bytes(hpBarBorder[1], address, 17 * 16),
|
||||||
|
17 * 8, 8, "menu/stats_tiles.png")
|
||||||
|
out.stats = {
|
||||||
|
sheet = "assets/generated/menu/stats_tiles.png",
|
||||||
|
tiles = 17,
|
||||||
|
firstTile = 0x31,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
-- Goldenrod Game Corner: Slot Machine graphics assets
|
-- Goldenrod Game Corner: Slot Machine graphics assets
|
||||||
if self.symbols["Slots1LZ"] then
|
if self.symbols["Slots1LZ"] then
|
||||||
local raw1 = self:decompressLz3Symbol("Slots1LZ")
|
local raw1 = self:decompressLz3Symbol("Slots1LZ")
|
||||||
|
|||||||
@@ -1537,6 +1537,9 @@ function RomImporter:focus(f)
|
|||||||
self._modPress = nil
|
self._modPress = nil
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
if type(self._sync) == "table" then
|
||||||
|
pcall(self._sync.noteResumed, self._sync)
|
||||||
|
end
|
||||||
if not (f and self.android and self.workState ~= "working") then return end
|
if not (f and self.android and self.workState ~= "working") then return end
|
||||||
-- SAF create-document finished: GameActivity wrote export_done.flag.
|
-- SAF create-document finished: GameActivity wrote export_done.flag.
|
||||||
if love.filesystem.getInfo("export_done.flag", "file") then
|
if love.filesystem.getInfo("export_done.flag", "file") then
|
||||||
|
|||||||
@@ -93,6 +93,12 @@ function ItemEffects.healsHP(id)
|
|||||||
or id == "REVIVE" or id == "MAX_REVIVE"
|
or id == "REVIVE" or id == "MAX_REVIVE"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- .useRareCandy prints over the still-drawn party menu
|
||||||
|
-- (engine/items/item_effects.asm:1392-1418)
|
||||||
|
function ItemEffects.keepsPartyMenuOpen(id)
|
||||||
|
return ItemEffects.healsHP(id) or id == "RARE_CANDY"
|
||||||
|
end
|
||||||
|
|
||||||
function ItemEffects.isBattleMedicine(id)
|
function ItemEffects.isBattleMedicine(id)
|
||||||
return HEAL_AMOUNT[id] ~= nil or STATUS_HEAL[id] ~= nil
|
return HEAL_AMOUNT[id] ~= nil or STATUS_HEAL[id] ~= nil
|
||||||
or id == "MAX_POTION" or id == "FULL_RESTORE"
|
or id == "MAX_POTION" or id == "FULL_RESTORE"
|
||||||
|
|||||||
@@ -262,7 +262,8 @@ function LinkBattle.new(game, net, opts)
|
|||||||
self.opponentName = theirName
|
self.opponentName = theirName
|
||||||
-- _TrainerWantsToFightText (data/text/text_2.asm:1257): wIsInBattle == 2
|
-- _TrainerWantsToFightText (data/text/text_2.asm:1257): wIsInBattle == 2
|
||||||
-- takes PrintBeginningBattleText's .trainerBattle arm, link included
|
-- takes PrintBeginningBattleText's .trainerBattle arm, link included
|
||||||
self.introText = Strings("%s wants\nto fight!", theirName)
|
self.introText = self:romText("_TrainerWantsToFightText",
|
||||||
|
"%s wants\nto fight!", theirName)
|
||||||
self.remoteHashes = {}
|
self.remoteHashes = {}
|
||||||
self.localHashes = {}
|
self.localHashes = {}
|
||||||
self.remoteParts = {}
|
self.remoteParts = {}
|
||||||
|
|||||||
+37
-11
@@ -19,6 +19,9 @@ local romText = require("src.core.RomText")
|
|||||||
|
|
||||||
local Evolution = {}
|
local Evolution = {}
|
||||||
|
|
||||||
|
-- engine/pokemon/evos_moves.asm:122-123 (ld c, 50 / call DelayFrames)
|
||||||
|
local EVOLVING_TEXT_FRAMES = 50
|
||||||
|
|
||||||
Evolution.METHODS = {
|
Evolution.METHODS = {
|
||||||
LEVEL = {
|
LEVEL = {
|
||||||
check = function(game, mon, evo, trigger)
|
check = function(game, mon, evo, trigger)
|
||||||
@@ -157,27 +160,50 @@ end
|
|||||||
-- Play the evolution movie (flashing forms), then apply + text.
|
-- Play the evolution movie (flashing forms), then apply + text.
|
||||||
-- Headless (no real graphics) falls back to the plain text flow.
|
-- Headless (no real graphics) falls back to the plain text flow.
|
||||||
function Evolution.evolve(game, mon, newSpecies, onDone, via)
|
function Evolution.evolve(game, mon, newSpecies, onDone, via)
|
||||||
|
local oldName = mon.nickname or game.data.pokemon[mon.species].name
|
||||||
|
-- IsEvolvingText, DelayFrames 50; ClearScreenArea then wipes rows 0-11
|
||||||
|
-- ONLY, so the box rides through EvolveMon (evos_moves.asm:120-134)
|
||||||
|
local isEvolving = romText(game.data, "_IsEvolvingText",
|
||||||
|
"What?\n%s is\nevolving!", oldName)
|
||||||
if love.image and love.image.newImageData then
|
if love.image and love.image.newImageData then
|
||||||
-- forward `via` so EvolutionState can keep trade evolutions
|
local intro
|
||||||
-- non-cancelable (LINK_STATE_TRADING) while others accept B (#213)
|
intro = TextBox.new(game, isEvolving, nil, { stay = {
|
||||||
Screens.push(game, "EvolutionState", mon, newSpecies, onDone, via)
|
onShown = function()
|
||||||
|
-- DelayFrames 50 with the box and the old screen still up
|
||||||
|
-- (evos_moves.asm:122-123)
|
||||||
|
local hold = { t = 0 }
|
||||||
|
hold.update = function()
|
||||||
|
hold.t = hold.t + 1
|
||||||
|
if hold.t < EVOLVING_TEXT_FRAMES then return end
|
||||||
|
game.stack:pop() -- this hold
|
||||||
|
-- forward `via` so trade evolutions stay non-cancelable while
|
||||||
|
-- others accept B (evos_moves.asm:72-75) (#213)
|
||||||
|
Screens.push(game, "EvolutionState", mon, newSpecies, function()
|
||||||
|
-- the result/cancel box owns the intro box's pop (#1596)
|
||||||
|
if game.stack:top() == intro then game.stack:pop() end
|
||||||
|
if onDone then onDone() end
|
||||||
|
end, via)
|
||||||
|
end
|
||||||
|
hold.draw = function() end
|
||||||
|
game.stack:push(hold)
|
||||||
|
end,
|
||||||
|
} })
|
||||||
|
game.stack:push(intro)
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
Music.play(game.data, Music.special(game.data, "evolution"))
|
Music.play(game.data, Music.special(game.data, "evolution"))
|
||||||
local oldName = mon.nickname or game.data.pokemon[mon.species].name
|
|
||||||
Evolution.apply(game, mon, newSpecies, via)
|
Evolution.apply(game, mon, newSpecies, via)
|
||||||
-- the congrats page keeps the engine wording: _EvolvedText extracts
|
-- EvolvedText then IntoText in the same box (evos_moves.asm:136-150)
|
||||||
-- truncated (it stops at a dynamic marker the decoder does not follow)
|
local msg = isEvolving .. "\f"
|
||||||
local msg = romText(game.data, "_IsEvolvingText",
|
.. romText(game.data, "_EvolvedText", "%s evolved", oldName)
|
||||||
"What?\n%s is\nevolving!", oldName)
|
.. romText(game.data, "_IntoText", "\ninto %s!",
|
||||||
.. "\f" .. Strings("Congratulations!\nYour %s\nevolved into\n%s!",
|
game.data.pokemon[newSpecies].name)
|
||||||
oldName, game.data.pokemon[newSpecies].name)
|
|
||||||
game.stack:push(TextBox.new(game, msg, function()
|
game.stack:push(TextBox.new(game, msg, function()
|
||||||
Music.restoreMap(game.data)
|
Music.restoreMap(game.data)
|
||||||
-- re-run the evolved species' level-up learn check before onDone
|
-- re-run the evolved species' level-up learn check before onDone
|
||||||
-- (evos_moves.asm EvolveMon -> learn_move.asm LearnMoveFromLevelUp, #12)
|
-- (evos_moves.asm EvolveMon -> learn_move.asm LearnMoveFromLevelUp, #12)
|
||||||
Evolution.learnEvolutionMoves(game, mon, onDone)
|
Evolution.learnEvolutionMoves(game, mon, onDone)
|
||||||
end))
|
end, TextBox.soundOpts(game, "Get_Item2")))
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Entry point for mods whose methods fire outside the vanilla moments
|
-- Entry point for mods whose methods fire outside the vanilla moments
|
||||||
|
|||||||
+13
-2
@@ -44,7 +44,8 @@ local NAME_DELAYS = { FAST = 1, MID = 3, SLOW = 5 }
|
|||||||
-- waits for nothing, shows no blinking arrow, and never pops itself --
|
-- waits for nothing, shows no blinking arrow, and never pops itself --
|
||||||
-- whoever pushed it owns the pop. stay.onShown fires once, on the frame
|
-- whoever pushed it owns the pop. stay.onShown fires once, on the frame
|
||||||
-- the last page finishes typing, which is where the caller pushes whatever
|
-- the last page finishes typing, which is where the caller pushes whatever
|
||||||
-- goes on top of it (#591).
|
-- goes on top of it (#591). stay.prompt waits out one arrowed A/B press
|
||||||
|
-- first (TextCommand_PROMPT_BUTTON, home/text.asm:434-444) (#1511).
|
||||||
function TextBox.new(game, text, onDone, opts)
|
function TextBox.new(game, text, onDone, opts)
|
||||||
local self = setmetatable({}, TextBox)
|
local self = setmetatable({}, TextBox)
|
||||||
self.game = game
|
self.game = game
|
||||||
@@ -296,6 +297,15 @@ function TextBox:update(dt)
|
|||||||
-- exactly once (#591)
|
-- exactly once (#591)
|
||||||
if self.stay then
|
if self.stay then
|
||||||
if not self.stayShown then
|
if not self.stayShown then
|
||||||
|
-- stay.prompt: arrowed A/B wait, then the box stays up
|
||||||
|
-- (TextCommand_PROMPT_BUTTON, home/text.asm:434-444)
|
||||||
|
if self.stay.prompt
|
||||||
|
and not (input:wasPressed("a") or input:wasPressed("b")) then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
if self.stay.prompt then
|
||||||
|
require("src.core.Sound").play(self.game.data, "Press_AB")
|
||||||
|
end
|
||||||
self.stayShown = true
|
self.stayShown = true
|
||||||
if self.stay.onShown then self.stay.onShown() end
|
if self.stay.onShown then self.stay.onShown() end
|
||||||
end
|
end
|
||||||
@@ -494,7 +504,8 @@ function TextBox:draw()
|
|||||||
Font.draw(money, 152 - Font.width(money), 8)
|
Font.draw(money, 152 - Font.width(money), 8)
|
||||||
end
|
end
|
||||||
if (self.waiting or (self.done and not self.choice and not self.auto
|
if (self.waiting or (self.done and not self.choice and not self.auto
|
||||||
and not self.stay))
|
and (not self.stay
|
||||||
|
or (self.stay.prompt and not self.stayShown))))
|
||||||
and self.blink < 30 then
|
and self.blink < 30 then
|
||||||
-- page-advance cursor: glyph $EE by default, the blinking down arrow
|
-- page-advance cursor: glyph $EE by default, the blinking down arrow
|
||||||
-- the original prints via `ld a, "▼"` (home/text.asm)
|
-- the original prints via `ld a, "▼"` (home/text.asm)
|
||||||
|
|||||||
@@ -1694,7 +1694,8 @@ H.RandomPhoneWildMon = function(vm)
|
|||||||
local entry = contact and contact.map and grass and grass[contact.map]
|
local entry = contact and contact.map and grass and grass[contact.map]
|
||||||
local slots = entry and entry.slots
|
local slots = entry and entry.slots
|
||||||
if not slots then return end
|
if not slots then return end
|
||||||
local daytime = (w and w.daytime) or "DAY"
|
-- wTimeOfDay, not the palette pin (wildmons.asm:861)
|
||||||
|
local daytime = (w and (w.tod or w.daytime)) or "DAY"
|
||||||
if daytime == "DARK" then daytime = "NITE" end
|
if daytime == "DARK" then daytime = "NITE" end
|
||||||
local slot = (slots[daytime] or slots.DAY or {})[Specials.random(4)]
|
local slot = (slots[daytime] or slots.DAY or {})[Specials.random(4)]
|
||||||
if slot and slot.species then nameSpecies(vm, slot.species) end
|
if slot and slot.species then nameSpecies(vm, slot.species) end
|
||||||
|
|||||||
+15
-6
@@ -543,10 +543,14 @@ local function runCmd(self, cmd, op)
|
|||||||
local species = cmd.species or arg1(cmd)
|
local species = cmd.species or arg1(cmd)
|
||||||
local level = cmd.level or (cmd.args and cmd.args[2]) or 5
|
local level = cmd.level or (cmd.args and cmd.args[2]) or 5
|
||||||
local item = cmd.item or (cmd.args and cmd.args[3]) or 0
|
local item = cmd.item or (cmd.args and cmd.args[3]) or 0
|
||||||
if self.givePokeFn then
|
|
||||||
local mon = self.givePokeFn(species, level, item)
|
|
||||||
-- engine/pokemon/move_mon.asm:1753-1757
|
|
||||||
local trainer = cmd.trainer or (cmd.args and cmd.args[4]) or 0
|
local trainer = cmd.trainer or (cmd.args and cmd.args[4]) or 0
|
||||||
|
if self.givePokeFn then
|
||||||
|
-- engine/pokemon/move_mon.asm:1695-1736: the trainer arm copies the
|
||||||
|
-- script's own nickname and OT name in instead of asking for one.
|
||||||
|
local named = trainer ~= 0
|
||||||
|
and { nickname = cmd.name, otName = cmd.otName } or nil
|
||||||
|
local mon = self.givePokeFn(species, level, item, named)
|
||||||
|
-- engine/pokemon/move_mon.asm:1753-1757
|
||||||
if mon and trainer == 0 then
|
if mon and trainer == 0 then
|
||||||
Specials.askNickname(self, mon)
|
Specials.askNickname(self, mon)
|
||||||
end
|
end
|
||||||
@@ -1114,7 +1118,9 @@ local function runCmd(self, cmd, op)
|
|||||||
-- really does run here.
|
-- really does run here.
|
||||||
if self.reloadMapFn then self.reloadMapFn(true) end
|
if self.reloadMapFn then self.reloadMapFn(true) end
|
||||||
elseif op == "winlosstext" then
|
elseif op == "winlosstext" then
|
||||||
-- Overrides the struct's win/loss text for this battle only.
|
-- Overrides the struct's win/loss text for this battle only; a 0
|
||||||
|
-- argument zeroes that pointer (engine/overworld/scripting.asm:651)
|
||||||
|
self.winLossArmed = true
|
||||||
self.winTextOverride = cmd.winText
|
self.winTextOverride = cmd.winText
|
||||||
self.lossTextOverride = cmd.lossText
|
self.lossTextOverride = cmd.lossText
|
||||||
elseif op == "trainertext" then
|
elseif op == "trainertext" then
|
||||||
@@ -1122,9 +1128,11 @@ local function runCmd(self, cmd, op)
|
|||||||
local obj = self.trainerObject or {}
|
local obj = self.trainerObject or {}
|
||||||
local key
|
local key
|
||||||
if which == 1 then
|
if which == 1 then
|
||||||
key = self.winTextOverride or obj.winText
|
key = self.winLossArmed and self.winTextOverride
|
||||||
|
or (not self.winLossArmed and obj.winText or nil)
|
||||||
elseif which == 2 then
|
elseif which == 2 then
|
||||||
key = self.lossTextOverride or obj.lossText
|
key = self.winLossArmed and self.lossTextOverride
|
||||||
|
or (not self.winLossArmed and obj.lossText or nil)
|
||||||
else
|
else
|
||||||
key = obj.seenText
|
key = obj.seenText
|
||||||
end
|
end
|
||||||
@@ -2425,6 +2433,7 @@ function Vm:start(scriptKey)
|
|||||||
self.battleOutcome = nil
|
self.battleOutcome = nil
|
||||||
self.winTextOverride = nil
|
self.winTextOverride = nil
|
||||||
self.lossTextOverride = nil
|
self.lossTextOverride = nil
|
||||||
|
self.winLossArmed = nil
|
||||||
-- The whiteout abort is per-run too: a script that ended because the player
|
-- The whiteout abort is per-run too: a script that ended because the player
|
||||||
-- was wiped must not stop the next one before it starts.
|
-- was wiped must not stop the next one before it starts.
|
||||||
self.aborted = false
|
self.aborted = false
|
||||||
|
|||||||
+25
-2
@@ -7,6 +7,7 @@ SyncEngine.__index = SyncEngine
|
|||||||
|
|
||||||
SyncEngine.UPLOAD_DEBOUNCE = 5
|
SyncEngine.UPLOAD_DEBOUNCE = 5
|
||||||
SyncEngine.AUTO_INTERVAL = 300
|
SyncEngine.AUTO_INTERVAL = 300
|
||||||
|
SyncEngine.RESUME_MIN_GAP = 60
|
||||||
SyncEngine.MAX_STEPS_PER_UPDATE = 8
|
SyncEngine.MAX_STEPS_PER_UPDATE = 8
|
||||||
|
|
||||||
local IDLE_STATUS = "Ready"
|
local IDLE_STATUS = "Ready"
|
||||||
@@ -133,6 +134,7 @@ function SyncEngine.new(opts)
|
|||||||
eng.modPlan = nil
|
eng.modPlan = nil
|
||||||
eng.shareCode = nil
|
eng.shareCode = nil
|
||||||
eng.clock = 0
|
eng.clock = 0
|
||||||
|
eng.autoAt = SyncEngine.AUTO_INTERVAL
|
||||||
eng.queue = {}
|
eng.queue = {}
|
||||||
eng.pending = nil
|
eng.pending = nil
|
||||||
eng.uploadAt = nil
|
eng.uploadAt = nil
|
||||||
@@ -258,6 +260,11 @@ function SyncEngine:update(dt)
|
|||||||
self.uploadAt = nil
|
self.uploadAt = nil
|
||||||
if self.state.enabled and self:linked() then self:syncNow() end
|
if self.state.enabled and self:linked() then self:syncNow() end
|
||||||
end
|
end
|
||||||
|
if self.clock >= self.autoAt and not self:busy()
|
||||||
|
and (self.phase == "idle" or self.phase == "error")
|
||||||
|
and self.state.enabled and self:linked() then
|
||||||
|
self:syncNow()
|
||||||
|
end
|
||||||
local steps = 0
|
local steps = 0
|
||||||
while not self.pending and #self.queue > 0
|
while not self.pending and #self.queue > 0
|
||||||
and steps < SyncEngine.MAX_STEPS_PER_UPDATE do
|
and steps < SyncEngine.MAX_STEPS_PER_UPDATE do
|
||||||
@@ -296,6 +303,7 @@ function SyncEngine:createAccount(label)
|
|||||||
eng.phase = "idle"
|
eng.phase = "idle"
|
||||||
eng.status = "Sync account created"
|
eng.status = "Sync account created"
|
||||||
eng:_persist()
|
eng:_persist()
|
||||||
|
eng:syncNow()
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -385,9 +393,24 @@ function SyncEngine:setEnabled(enabled)
|
|||||||
return self.state.enabled
|
return self.state.enabled
|
||||||
end
|
end
|
||||||
|
|
||||||
|
function SyncEngine:protectPlaythrough(version, playthroughId)
|
||||||
|
self.protectedKey = SyncState.key(version, playthroughId)
|
||||||
|
end
|
||||||
|
|
||||||
|
function SyncEngine:noteResumed()
|
||||||
|
if not (self.state.enabled and self:linked()) then return end
|
||||||
|
if self:busy() or self.phase == "conflict" then return end
|
||||||
|
if self.now() - (tonumber(self.state.lastSyncAt) or 0)
|
||||||
|
< SyncEngine.RESUME_MIN_GAP then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
self:syncNow()
|
||||||
|
end
|
||||||
|
|
||||||
function SyncEngine:syncNow()
|
function SyncEngine:syncNow()
|
||||||
if not self:linked() then return false, "this device is not linked" end
|
if not self:linked() then return false, "this device is not linked" end
|
||||||
if self.pending then return false, "sync is busy" end
|
if self.pending then return false, "sync is busy" end
|
||||||
|
self.autoAt = self.clock + SyncEngine.AUTO_INTERVAL
|
||||||
self.queue = {}
|
self.queue = {}
|
||||||
self.conflicts = {}
|
self.conflicts = {}
|
||||||
self.state.pendingConflicts = {}
|
self.state.pendingConflicts = {}
|
||||||
@@ -437,13 +460,13 @@ function SyncEngine:_planFrom(remoteState)
|
|||||||
self:_addConflict(entry, key, row)
|
self:_addConflict(entry, key, row)
|
||||||
elseif localChanged then
|
elseif localChanged then
|
||||||
self:_queueUpload(entry, key, false)
|
self:_queueUpload(entry, key, false)
|
||||||
elseif remoteChanged then
|
elseif remoteChanged and key ~= self.protectedKey then
|
||||||
self:_queueDownload(key, entry.version, entry.playthroughId, "replace")
|
self:_queueDownload(key, entry.version, entry.playthroughId, "replace")
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
for key, row in pairs(remote) do
|
for key, row in pairs(remote) do
|
||||||
if not seen[key] then
|
if not seen[key] and key ~= self.protectedKey then
|
||||||
local version, id = SyncState.splitKey(key)
|
local version, id = SyncState.splitKey(key)
|
||||||
if version and id then
|
if version and id then
|
||||||
self:_queueDownload(key, version, id, "replace", tonumber(row.rev))
|
self:_queueDownload(key, version, id, "replace", tonumber(row.rev))
|
||||||
|
|||||||
+16
-7
@@ -17,10 +17,13 @@ local function buildItems(game)
|
|||||||
local items = {}
|
local items = {}
|
||||||
for _, id in ipairs(Bag.order(game.save)) do
|
for _, id in ipairs(Bag.order(game.save)) do
|
||||||
local def = game.data.items[id]
|
local def = game.data.items[id]
|
||||||
|
-- PrintListMenuEntries skips the quantity for anything IsKeyItem_ owns:
|
||||||
|
-- the KeyItemFlags bitfield plus the HMs (item_effects.asm:2616-2641)
|
||||||
|
local unsellable = (def and def.keyItem) or id:find("^HM_") ~= nil
|
||||||
table.insert(items, {
|
table.insert(items, {
|
||||||
value = id,
|
value = id,
|
||||||
label = def and def.name or id,
|
label = def and def.name or id,
|
||||||
right = "x" .. game.save.inventory[id],
|
right = (not unsellable) and ("x" .. game.save.inventory[id]) or nil,
|
||||||
})
|
})
|
||||||
end
|
end
|
||||||
return items
|
return items
|
||||||
@@ -334,8 +337,13 @@ local function vanillaUseOn(game, battle, id, target, list, moveIndex, picker)
|
|||||||
local Evolution = require("src.pokemon.Evolution")
|
local Evolution = require("src.pokemon.Evolution")
|
||||||
local evoTo, evo = Evolution.pendingFor(game, target,
|
local evoTo, evo = Evolution.pendingFor(game, target,
|
||||||
{ kind = "levelup" })
|
{ kind = "levelup" })
|
||||||
|
-- the party menu stays up through TryEvolvingMon and only
|
||||||
|
-- comes down at RemoveUsedItem (item_effects.asm:1392-1418)
|
||||||
if evoTo then
|
if evoTo then
|
||||||
Evolution.evolve(game, target, evoTo, nil, evo and evo.method)
|
Evolution.evolve(game, target, evoTo, closePicker,
|
||||||
|
evo and evo.method)
|
||||||
|
else
|
||||||
|
closePicker()
|
||||||
end
|
end
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
@@ -399,10 +407,9 @@ local function pickTargetAndUse(game, battle, id, list)
|
|||||||
local opts = {
|
local opts = {
|
||||||
pickOnly = true,
|
pickOnly = true,
|
||||||
battle = battle,
|
battle = battle,
|
||||||
-- HP medicine animates its bar with the picker still up (#252). Only
|
-- HP medicine animates with the picker up (#252), RARE CANDY prints over
|
||||||
-- out of battle: the in-battle tail closes the bag list underneath
|
-- the party menu (item_effects.asm:1392-1418)
|
||||||
-- first, which needs the picker already gone.
|
keepOpen = (not battle) and ItemEffects.keepsPartyMenuOpen(id),
|
||||||
keepOpen = (not battle) and ItemEffects.healsHP(id),
|
|
||||||
onSwitch = function(mon, picker)
|
onSwitch = function(mon, picker)
|
||||||
if not wantsMove then
|
if not wantsMove then
|
||||||
useOn(game, battle, id, mon, list, nil, picker)
|
useOn(game, battle, id, mon, list, nil, picker)
|
||||||
@@ -470,7 +477,9 @@ function BagMenu.new(game, opts)
|
|||||||
local list
|
local list
|
||||||
list = ListMenu.new(game, "ITEMS", buildItems(game), {
|
list = ListMenu.new(game, "ITEMS", buildItems(game), {
|
||||||
kind = "bag",
|
kind = "bag",
|
||||||
footer = ("¥%d"):format(game.save.money),
|
-- StartMenu_Item zeroes wPrintItemPrices and draws no money box: the
|
||||||
|
-- LIST_MENU_BOX floats over the map (engine/menus/start_sub_menus.asm)
|
||||||
|
itemBox = true,
|
||||||
-- B returns to the start menu when the bag was opened from it
|
-- B returns to the start menu when the bag was opened from it
|
||||||
onCancel = opts.onCancel,
|
onCancel = opts.onCancel,
|
||||||
-- SELECT reorders items like the original bag (swap_items.asm)
|
-- SELECT reorders items like the original bag (swap_items.asm)
|
||||||
|
|||||||
+4
-2
@@ -185,14 +185,16 @@ local function release(game)
|
|||||||
return
|
return
|
||||||
end
|
end
|
||||||
game.stack:push(TextBox.new(game,
|
game.stack:push(TextBox.new(game,
|
||||||
Strings("Once released,\n%s is\ngone forever. OK?", name), nil, {
|
(t._OnceReleasedText or Strings("Once released,\n%s is\ngone forever. OK?", name))
|
||||||
|
:gsub("{RAM:wStringBuffer}", name), nil, {
|
||||||
defaultNo = true, noSound = true,
|
defaultNo = true, noSound = true,
|
||||||
choice = function(yes)
|
choice = function(yes)
|
||||||
if not yes then return end
|
if not yes then return end
|
||||||
table.remove(box, list.index)
|
table.remove(box, list.index)
|
||||||
require("src.core.Sound").playCry(game.data, mon.species)
|
require("src.core.Sound").playCry(game.data, mon.species)
|
||||||
game.stack:push(TextBox.new(game,
|
game.stack:push(TextBox.new(game,
|
||||||
Strings("%s was\nreleased outside.\fBye %s!", name, name)))
|
((t._MonWasReleasedText or Strings("%s was\nreleased outside.\fBye %s!", name, name))
|
||||||
|
:gsub("{RAM:wStringBuffer}", name))))
|
||||||
list:removeCurrent()
|
list:removeCurrent()
|
||||||
end,
|
end,
|
||||||
}))
|
}))
|
||||||
|
|||||||
+13
-19
@@ -1,6 +1,7 @@
|
|||||||
-- The evolution movie (engine/movie/evolution.asm): the mon's pic
|
-- The evolution movie (engine/movie/evolution.asm): the mon's pic
|
||||||
-- flashes back and forth with the evolved form, speeding up, then the
|
-- flashes back and forth with the evolved form, speeding up, then the
|
||||||
-- new form appears with its cry and the congratulations text.
|
-- new form appears with its cry and the "evolved into" text
|
||||||
|
-- (engine/pokemon/evos_moves.asm:120-128).
|
||||||
-- pokered engine/movie/evolution.asm (Evolution_CheckForCancel) polls the
|
-- pokered engine/movie/evolution.asm (Evolution_CheckForCancel) polls the
|
||||||
-- joypad during the flash: a fresh B press aborts the evolution -- the mon
|
-- joypad during the flash: a fresh B press aborts the evolution -- the mon
|
||||||
-- keeps its species and _StoppedEvolvingText ("Huh? MON stopped evolving!")
|
-- keeps its species and _StoppedEvolvingText ("Huh? MON stopped evolving!")
|
||||||
@@ -9,14 +10,13 @@
|
|||||||
-- and stone evolutions, where the B press is read but thrown away because
|
-- and stone evolutions, where the B press is read but thrown away because
|
||||||
-- ItemUseEvoStone left wForceEvolution set (#290).
|
-- ItemUseEvoStone left wForceEvolution set (#290).
|
||||||
|
|
||||||
local Font = require("src.render.Font")
|
|
||||||
local Music = require("src.core.Music")
|
local Music = require("src.core.Music")
|
||||||
local Strings = require("src.core.Strings")
|
|
||||||
local romText = require("src.core.RomText")
|
local romText = require("src.core.RomText")
|
||||||
|
|
||||||
|
-- Not opaque: ClearScreenArea wipes rows 0-11 only (evos_moves.asm:126-128),
|
||||||
|
-- so the "is evolving!" box beneath stays visible through the flash (#1596).
|
||||||
local EvolutionState = {}
|
local EvolutionState = {}
|
||||||
EvolutionState.__index = EvolutionState
|
EvolutionState.__index = EvolutionState
|
||||||
EvolutionState.isOpaque = true
|
|
||||||
|
|
||||||
-- SGB: SetPal_PokemonWholeScreen for the mon on display
|
-- SGB: SetPal_PokemonWholeScreen for the mon on display
|
||||||
function EvolutionState:sgbPalettes(game)
|
function EvolutionState:sgbPalettes(game)
|
||||||
@@ -127,11 +127,11 @@ function EvolutionState:update(dt)
|
|||||||
require("src.core.Sound").playCry(game.data, self.newSpecies)
|
require("src.core.Sound").playCry(game.data, self.newSpecies)
|
||||||
local TextBox = require("src.render.TextBox")
|
local TextBox = require("src.render.TextBox")
|
||||||
local newName = game.data.pokemon[self.newSpecies].name
|
local newName = game.data.pokemon[self.newSpecies].name
|
||||||
-- _EvolvedText extracts truncated (it stops at a dynamic marker the
|
-- EvolvedText then IntoText in the same box (PrintText_NoCreatingTextBox),
|
||||||
-- decoder does not follow), so the engine's wording stands here
|
-- then SFX_GET_ITEM_2 (engine/pokemon/evos_moves.asm:136-153)
|
||||||
game.stack:push(TextBox.new(game,
|
local msg = romText(game.data, "_EvolvedText", "%s evolved", self.oldName)
|
||||||
Strings("Congratulations!\nYour %s\nevolved into\n%s!",
|
.. romText(game.data, "_IntoText", "\ninto %s!", newName)
|
||||||
self.oldName, newName),
|
game.stack:push(TextBox.new(game, msg,
|
||||||
function()
|
function()
|
||||||
Music.restoreMap(game.data)
|
Music.restoreMap(game.data)
|
||||||
game.stack:pop() -- the evolution screen itself
|
game.stack:pop() -- the evolution screen itself
|
||||||
@@ -141,13 +141,15 @@ function EvolutionState:update(dt)
|
|||||||
-- first so the "learned MOVE!" text / forget prompt push onto the
|
-- first so the "learned MOVE!" text / forget prompt push onto the
|
||||||
-- overworld / battle-return, not this state.
|
-- overworld / battle-return, not this state.
|
||||||
Evolution.learnEvolutionMoves(game, self.mon, self.onDone)
|
Evolution.learnEvolutionMoves(game, self.mon, self.onDone)
|
||||||
end))
|
end,
|
||||||
|
TextBox.soundOpts(game, "Get_Item2")))
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
function EvolutionState:draw()
|
function EvolutionState:draw()
|
||||||
love.graphics.setColor(1, 1, 1, 1)
|
love.graphics.setColor(1, 1, 1, 1)
|
||||||
love.graphics.rectangle("fill", 0, 0, 160, 144)
|
-- rows 0-11 only (hlcoord 0,0 / lb bc, 12, 20, evos_moves.asm:126-128)
|
||||||
|
love.graphics.rectangle("fill", 0, 0, 160, 96)
|
||||||
|
|
||||||
-- accelerating flash between the two forms
|
-- accelerating flash between the two forms
|
||||||
local sprite, spriteTrueColor
|
local sprite, spriteTrueColor
|
||||||
@@ -172,14 +174,6 @@ function EvolutionState:draw()
|
|||||||
require("src.render.PaletteFX").markTrueColor(x, y, sprite:getDimensions())
|
require("src.render.PaletteFX").markTrueColor(x, y, sprite:getDimensions())
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
love.graphics.setColor(0, 0, 0, 1)
|
|
||||||
if not self.done then
|
|
||||||
Font.draw(Strings("What?"), 8, 104)
|
|
||||||
Font.draw(self.oldName .. " is", 8, 114)
|
|
||||||
Font.draw(Strings("evolving!"), 8, 124)
|
|
||||||
end
|
|
||||||
love.graphics.setColor(1, 1, 1, 1)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
return EvolutionState
|
return EvolutionState
|
||||||
|
|||||||
+66
-3
@@ -17,6 +17,14 @@ function ListMenu:sgbPalettes(game)
|
|||||||
end
|
end
|
||||||
|
|
||||||
local ROWS = 7
|
local ROWS = 7
|
||||||
|
-- LIST_MENU_BOX 4,2 - 19,12 (data/text_boxes.asm:13); 4 names from
|
||||||
|
-- hlcoord 6,4 two rows apart (home/list_menu.asm:51-52, 364-365, 471-479)
|
||||||
|
local ITEM_BOX = { tx = 4, ty = 2, tw = 16, th = 11 }
|
||||||
|
local ITEM_ROWS = 4
|
||||||
|
local ITEM_NAME_X, ITEM_TOP_Y = 48, 32
|
||||||
|
local ITEM_CURSOR_X = 40
|
||||||
|
local ITEM_QTY_X, ITEM_QTY_END = 112, 136
|
||||||
|
local ITEM_MORE_X, ITEM_MORE_Y = 144, 88
|
||||||
-- frames to wait before key-repeat kicks in, then between repeats
|
-- frames to wait before key-repeat kicks in, then between repeats
|
||||||
local REPEAT_DELAY = 16
|
local REPEAT_DELAY = 16
|
||||||
local REPEAT_RATE = 4
|
local REPEAT_RATE = 4
|
||||||
@@ -83,7 +91,20 @@ function ListMenu.new(game, title, items, opts)
|
|||||||
-- for their whole run (engine/menus/pc.asm, engine/menus/players_pc.asm),
|
-- for their whole run (engine/menus/pc.asm, engine/menus/players_pc.asm),
|
||||||
-- so their lists opt out of the A/B beep the same way Menu's noSound does
|
-- so their lists opt out of the A/B beep the same way Menu's noSound does
|
||||||
self.noSound = opts.noSound or false
|
self.noSound = opts.noSound or false
|
||||||
self.rows = opts.rows or ((opts.dialogue or opts.messageBox) and 4 or ROWS)
|
-- the bag's item list: a partial box the map stays visible around, not a
|
||||||
|
-- screen of its own (home/list_menu.asm:29-31)
|
||||||
|
self.itemBox = opts.itemBox or false
|
||||||
|
if self.itemBox then
|
||||||
|
self.isOpaque = false
|
||||||
|
-- keep RunDefaultPaletteCommand's last palette: ItemMenuLoop never sets
|
||||||
|
-- its own (engine/menus/start_sub_menus.asm:300)
|
||||||
|
self.sgbPalettes = false
|
||||||
|
-- wMaxMenuItem is 2 for item lists; the fourth printed row is a
|
||||||
|
-- look-ahead the cursor cannot reach (home/list_menu.asm:46-48)
|
||||||
|
self.cursorRows = 3
|
||||||
|
end
|
||||||
|
self.rows = opts.rows or (self.itemBox and ITEM_ROWS)
|
||||||
|
or ((opts.dialogue or opts.messageBox) and 4 or ROWS)
|
||||||
return self
|
return self
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -100,8 +121,9 @@ local function moveIndex(self, delta)
|
|||||||
end
|
end
|
||||||
|
|
||||||
local function syncScroll(self)
|
local function syncScroll(self)
|
||||||
if self.index - self.scroll > self.rows then
|
local maxRow = self.cursorRows or self.rows
|
||||||
self.scroll = self.index - self.rows
|
if self.index - self.scroll > maxRow then
|
||||||
|
self.scroll = self.index - maxRow
|
||||||
end
|
end
|
||||||
if self.index - self.scroll < 1 then self.scroll = self.index - 1 end
|
if self.index - self.scroll < 1 then self.scroll = self.index - 1 end
|
||||||
end
|
end
|
||||||
@@ -206,7 +228,48 @@ function ListMenu:close()
|
|||||||
if top == self then self.game.stack:pop() end
|
if top == self then self.game.stack:pop() end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- PrintListMenuEntries, minus the price column StartMenu_Item never asks for
|
||||||
|
-- (wPrintItemPrices = 0, engine/menus/start_sub_menus.asm)
|
||||||
|
function ListMenu:drawItemBox()
|
||||||
|
love.graphics.setColor(1, 1, 1, 1)
|
||||||
|
Font.drawBox(ITEM_BOX.tx, ITEM_BOX.ty, ITEM_BOX.tw, ITEM_BOX.th)
|
||||||
|
love.graphics.setColor(0, 0, 0, 1)
|
||||||
|
if #self.items == 0 then
|
||||||
|
Font.draw(Strings("Nothing here."), ITEM_NAME_X, ITEM_TOP_Y)
|
||||||
|
end
|
||||||
|
local shown = 0
|
||||||
|
for row = 1, self.rows do
|
||||||
|
local i = self.scroll + row
|
||||||
|
local item = self.items[i]
|
||||||
|
if not item then break end
|
||||||
|
shown = shown + 1
|
||||||
|
local y = ITEM_TOP_Y + (row - 1) * 16
|
||||||
|
Font.draw(item.label, ITEM_NAME_X, y)
|
||||||
|
if item.right then
|
||||||
|
-- '×' at column 14, PrintNumber's two right-aligned digits after it
|
||||||
|
-- (home/list_menu.asm:479-490)
|
||||||
|
local count = item.right:sub(2)
|
||||||
|
Font.draw(item.right:sub(1, 1), ITEM_QTY_X, y + 8)
|
||||||
|
Font.draw(count, ITEM_QTY_END - Font.width(count), y + 8)
|
||||||
|
end
|
||||||
|
if i == self.index then
|
||||||
|
Font.drawCode(self.hollowIndex == i
|
||||||
|
and Theme.cursorHollow or Theme.cursor, ITEM_CURSOR_X, y)
|
||||||
|
end
|
||||||
|
if self.swapIndex == i and i ~= self.index then
|
||||||
|
Font.drawCode(Theme.cursorHollow, ITEM_CURSOR_X, y)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
-- the terminator prints CANCEL and returns before the '▼'
|
||||||
|
-- (home/list_menu.asm:372, 518-524)
|
||||||
|
if shown == self.rows then
|
||||||
|
Font.drawCode(Theme.moreArrow, ITEM_MORE_X, ITEM_MORE_Y)
|
||||||
|
end
|
||||||
|
love.graphics.setColor(1, 1, 1, 1)
|
||||||
|
end
|
||||||
|
|
||||||
function ListMenu:draw()
|
function ListMenu:draw()
|
||||||
|
if self.itemBox then return self:drawItemBox() 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)
|
||||||
love.graphics.setColor(0, 0, 0, 1)
|
love.graphics.setColor(0, 0, 0, 1)
|
||||||
|
|||||||
+21
-5
@@ -34,6 +34,9 @@ function Menu.new(game, items, opts)
|
|||||||
if self.tx + self.tw > 20 then self.tx = math.max(0, 20 - self.tw) end
|
if self.tx + self.tw > 20 then self.tx = math.max(0, 20 - self.tw) end
|
||||||
end
|
end
|
||||||
self.rowStep = opts.rowStep or 2
|
self.rowStep = opts.rowStep or 2
|
||||||
|
-- engine/movie/oak_speech/oak_speech2.asm:162 (DisplayIntroNameTextBox)
|
||||||
|
self.title = opts.title
|
||||||
|
self.itemY = opts.itemY
|
||||||
-- maxVisible: cap the box to this many rows and scroll the rest instead
|
-- maxVisible: cap the box to this many rows and scroll the rest instead
|
||||||
-- of growing past it (e.g. the start menu, whose row count varies with
|
-- of growing past it (e.g. the start menu, whose row count varies with
|
||||||
-- save state and mod hooks); nil/unset keeps every caller's old
|
-- save state and mod hooks); nil/unset keeps every caller's old
|
||||||
@@ -116,7 +119,17 @@ function Menu:draw()
|
|||||||
self.tw * 8, self.th * 8, self.anchor)
|
self.tw * 8, self.th * 8, self.anchor)
|
||||||
end
|
end
|
||||||
Font.drawBox(self.tx, self.ty, self.tw, self.th)
|
Font.drawBox(self.tx, self.ty, self.tw, self.th)
|
||||||
|
-- PlaceString at hlcoord 3,0 writes over the border row it was just
|
||||||
|
-- drawn on (oak_speech2.asm:162-170)
|
||||||
|
if self.title then
|
||||||
|
love.graphics.setColor(1, 1, 1, 1)
|
||||||
|
love.graphics.rectangle("fill", (self.tx + 3) * 8, self.ty * 8,
|
||||||
|
#Font.split(self.title) * 8, 8)
|
||||||
|
end
|
||||||
love.graphics.setColor(0, 0, 0, 1)
|
love.graphics.setColor(0, 0, 0, 1)
|
||||||
|
if self.title then
|
||||||
|
Font.draw(self.title, (self.tx + 3) * 8, self.ty * 8)
|
||||||
|
end
|
||||||
local visible = (self.maxVisible and math.min(self.maxVisible, #self.items))
|
local visible = (self.maxVisible and math.min(self.maxVisible, #self.items))
|
||||||
or #self.items
|
or #self.items
|
||||||
-- Row Y: pokered's boxed menus anchor the choices to the BOTTOM interior
|
-- Row Y: pokered's boxed menus anchor the choices to the BOTTOM interior
|
||||||
@@ -130,15 +143,18 @@ function Menu:draw()
|
|||||||
-- USE/TOSS is th = 5 for two choices (#284, matching text_boxes.asm's
|
-- USE/TOSS is th = 5 for two choices (#284, matching text_boxes.asm's
|
||||||
-- USE_TOSS_MENU_TEMPLATE rows 10..14), and a top anchor pushed TOSS onto
|
-- USE_TOSS_MENU_TEMPLATE rows 10..14), and a top anchor pushed TOSS onto
|
||||||
-- the bottom border (#564, #572).
|
-- the bottom border (#564, #572).
|
||||||
|
local function rowY(row)
|
||||||
|
if self.itemY then
|
||||||
|
return (self.ty + self.itemY + (row - 1) * self.rowStep) * 8
|
||||||
|
end
|
||||||
|
return (self.ty + self.th - 2 - (visible - row) * self.rowStep) * 8
|
||||||
|
end
|
||||||
for row = 1, visible do
|
for row = 1, visible do
|
||||||
local item = self.items[self.scroll + row]
|
local item = self.items[self.scroll + row]
|
||||||
if not item then break end
|
if not item then break end
|
||||||
Font.draw(item.label, (self.tx + 2) * 8,
|
Font.draw(item.label, (self.tx + 2) * 8, rowY(row))
|
||||||
(self.ty + self.th - 2 - (visible - row) * self.rowStep) * 8)
|
|
||||||
end
|
end
|
||||||
local cursorRow = self.index - self.scroll
|
Font.drawCode(Theme.cursor, (self.tx + 1) * 8, rowY(self.index - self.scroll))
|
||||||
Font.drawCode(Theme.cursor, (self.tx + 1) * 8,
|
|
||||||
(self.ty + self.th - 2 - (visible - cursorRow) * self.rowStep) * 8)
|
|
||||||
-- moreArrow ($EE): the same "more below" glyph OptionRows/ManagerState
|
-- moreArrow ($EE): the same "more below" glyph OptionRows/ManagerState
|
||||||
-- use, sat on the bottom border like TextBox's page-advance cursor. It
|
-- use, sat on the bottom border like TextBox's page-advance cursor. It
|
||||||
-- has to be the border row, not ty + th - 2: that is the last interior
|
-- has to be the border row, not ty + th - 2: that is the last interior
|
||||||
|
|||||||
+14
-2
@@ -67,6 +67,7 @@ function NamingScreen.new(game, opts)
|
|||||||
self.game = game
|
self.game = game
|
||||||
self.title = opts.title or Strings("YOUR NAME?")
|
self.title = opts.title or Strings("YOUR NAME?")
|
||||||
self.presets = opts.presets
|
self.presets = opts.presets
|
||||||
|
self.introBox = opts.introBox
|
||||||
self.maxLen = opts.maxLen or 7
|
self.maxLen = opts.maxLen or 7
|
||||||
self.default = opts.default
|
self.default = opts.default
|
||||||
self.onDone = opts.onDone
|
self.onDone = opts.onDone
|
||||||
@@ -95,15 +96,26 @@ function NamingScreen:enter()
|
|||||||
onSelect = function()
|
onSelect = function()
|
||||||
-- the menu already popped itself; pop the naming screen too
|
-- the menu already popped itself; pop the naming screen too
|
||||||
self.game.stack:pop()
|
self.game.stack:pop()
|
||||||
if self.onDone then self.onDone(preset) end
|
if self.onDone then self.onDone(preset, false) end
|
||||||
end,
|
end,
|
||||||
})
|
})
|
||||||
end
|
end
|
||||||
|
if self.introBox then
|
||||||
|
-- DisplayIntroNameTextBox (oak_speech2.asm:162): TextBoxBorder at
|
||||||
|
-- hlcoord 0,0 with b=$a c=$9, "NAME" at hlcoord 3,0, list at hlcoord 2,2
|
||||||
|
-- TextBoxBorder's b = $a is a fixed 12-row box, whatever the preset
|
||||||
|
-- list's length (oak_speech2.asm:163-166)
|
||||||
|
self.game.stack:push(Menu.new(self.game, items, {
|
||||||
|
tx = 0, ty = 0, tw = 11, th = 12,
|
||||||
|
itemY = 2, title = Strings("NAME"), cancelable = false,
|
||||||
|
}))
|
||||||
|
else
|
||||||
self.game.stack:push(Menu.new(self.game, items, {
|
self.game.stack:push(Menu.new(self.game, items, {
|
||||||
tx = 4, ty = 0, tw = 12, th = #items * 2 + 2, cancelable = false,
|
tx = 4, ty = 0, tw = 12, th = #items * 2 + 2, cancelable = false,
|
||||||
}))
|
}))
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
end
|
||||||
|
|
||||||
function NamingScreen:confirm()
|
function NamingScreen:confirm()
|
||||||
local name = table.concat(self.glyphs)
|
local name = table.concat(self.glyphs)
|
||||||
@@ -125,7 +137,7 @@ function NamingScreen:confirm()
|
|||||||
end
|
end
|
||||||
Sound.play(self.game.data, "Press_AB")
|
Sound.play(self.game.data, "Press_AB")
|
||||||
self.game.stack:pop()
|
self.game.stack:pop()
|
||||||
if self.onDone then self.onDone(name) end
|
if self.onDone then self.onDone(name, true) end
|
||||||
end
|
end
|
||||||
|
|
||||||
function NamingScreen:grid()
|
function NamingScreen:grid()
|
||||||
|
|||||||
+89
-2
@@ -35,6 +35,29 @@ OakSpeech.letterboxWhite = true
|
|||||||
local FADE_FRAMES = 24
|
local FADE_FRAMES = 24
|
||||||
local WIPE_FRAMES = 32
|
local WIPE_FRAMES = 32
|
||||||
|
|
||||||
|
-- OakSpeechSlidePicRight / OakSpeechSlidePicLeft (oak_speech2.asm:67-89)
|
||||||
|
local SLIDE_TILES = 6
|
||||||
|
local SLIDE_FRAMES = 3
|
||||||
|
|
||||||
|
local PicSlide = {}
|
||||||
|
PicSlide.__index = PicSlide
|
||||||
|
|
||||||
|
function PicSlide:update(dt)
|
||||||
|
-- OakSpeechSlidePicLeft: ClearScreenArea, ld c, 10 / DelayFrames, Delay3
|
||||||
|
-- before the first slide step (oak_speech2.asm:69-78)
|
||||||
|
if (self.delay or 0) > 0 then
|
||||||
|
self.delay = self.delay - 1
|
||||||
|
return
|
||||||
|
end
|
||||||
|
self.t = self.t + 1
|
||||||
|
local tiles = math.min(SLIDE_TILES, math.floor(self.t / SLIDE_FRAMES))
|
||||||
|
self.speech.picSlide = (self.dir > 0 and tiles or (SLIDE_TILES - tiles)) * 8
|
||||||
|
if tiles >= SLIDE_TILES then
|
||||||
|
self.game.stack:pop()
|
||||||
|
if self.onDone then self.onDone() end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
-- naming presets are boot config (field.boot.namePresets), which a total
|
-- naming presets are boot config (field.boot.namePresets), which a total
|
||||||
-- conversion replaces; the Red/Blue lists remain the fallback
|
-- conversion replaces; the Red/Blue lists remain the fallback
|
||||||
local function namePresets(game, who, fallback)
|
local function namePresets(game, who, fallback)
|
||||||
@@ -155,6 +178,10 @@ function OakSpeech.defaultSteps(speech)
|
|||||||
kind = "say",
|
kind = "say",
|
||||||
textKey = "_IntroducePlayerText",
|
textKey = "_IntroducePlayerText",
|
||||||
pic = "player",
|
pic = "player",
|
||||||
|
-- oak_speech.asm:89-92: MovePicLeft, then IntroducePlayerText's
|
||||||
|
-- `prompt` (text_2.asm:1730) waits for A and leaves the box up
|
||||||
|
reveal = "wipe",
|
||||||
|
stay = true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id = "name_player",
|
id = "name_player",
|
||||||
@@ -171,12 +198,19 @@ function OakSpeech.defaultSteps(speech)
|
|||||||
id = "confirm_player_name",
|
id = "confirm_player_name",
|
||||||
kind = "say",
|
kind = "say",
|
||||||
textKey = "_YourNameIsText",
|
textKey = "_YourNameIsText",
|
||||||
|
-- _YourNameIsText's `prompt` (text_2.asm:1766), then GBFadeOutToWhite
|
||||||
|
-- / ClearScreen with the box still up (oak_speech.asm:93-94)
|
||||||
|
fadeOut = true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id = "ask_rival_name",
|
id = "ask_rival_name",
|
||||||
kind = "say",
|
kind = "say",
|
||||||
textKey = "_IntroduceRivalText",
|
textKey = "_IntroduceRivalText",
|
||||||
pic = "rival",
|
pic = "rival",
|
||||||
|
-- oak_speech.asm:98-101: FadeInIntroPic, then IntroduceRivalText's
|
||||||
|
-- `prompt` (text_2.asm:1740) leaves the box up for ChooseRivalName
|
||||||
|
reveal = "fade",
|
||||||
|
stay = true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id = "name_rival",
|
id = "name_rival",
|
||||||
@@ -191,6 +225,9 @@ function OakSpeech.defaultSteps(speech)
|
|||||||
id = "confirm_rival_name",
|
id = "confirm_rival_name",
|
||||||
kind = "say",
|
kind = "say",
|
||||||
textKey = "_HisNameIsText",
|
textKey = "_HisNameIsText",
|
||||||
|
-- _HisNameIsText's `prompt` (text_2.asm:1772) then the .skipSpeech
|
||||||
|
-- fade with the box up (oak_speech.asm:103-104)
|
||||||
|
fadeOut = true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id = "legend",
|
id = "legend",
|
||||||
@@ -378,7 +415,25 @@ function OakSpeech:runStep(step)
|
|||||||
self:applyPic(step)
|
self:applyPic(step)
|
||||||
self:afterReveal(step, function()
|
self:afterReveal(step, function()
|
||||||
self:runCry(step)
|
self:runCry(step)
|
||||||
|
if step.stay or step.fadeOut then
|
||||||
|
local box = TextBox.new(self.game, self:stepText(step), nil,
|
||||||
|
{ stay = { prompt = true, onShown = function()
|
||||||
|
if step.fadeOut then
|
||||||
|
-- GBFadeOutToWhite / ClearScreen (oak_speech.asm:93-94)
|
||||||
|
self.game.stack:push(require("src.render.Transition")
|
||||||
|
.whiteFlash(self.game, nil, function()
|
||||||
|
self:closeHoldBox()
|
||||||
|
self:advance()
|
||||||
|
end))
|
||||||
|
else
|
||||||
|
self:advance()
|
||||||
|
end
|
||||||
|
end } })
|
||||||
|
self.holdBox = box
|
||||||
|
self.game.stack:push(box)
|
||||||
|
else
|
||||||
self:sayText(self:stepText(step), function() self:advance() end)
|
self:sayText(self:stepText(step), function() self:advance() end)
|
||||||
|
end
|
||||||
end)
|
end)
|
||||||
elseif kind == "demo" then
|
elseif kind == "demo" then
|
||||||
-- NIDORINO show-off: mirrored front sprite + wipe + cry + text 2A
|
-- NIDORINO show-off: mirrored front sprite + wipe + cry + text 2A
|
||||||
@@ -394,20 +449,36 @@ function OakSpeech:runStep(step)
|
|||||||
local presets = step.presets
|
local presets = step.presets
|
||||||
or namePresets(self.game, step.presetsWho or who,
|
or namePresets(self.game, step.presetsWho or who,
|
||||||
step.presetsFallback or { "RED" })
|
step.presetsFallback or { "RED" })
|
||||||
|
local function openNaming()
|
||||||
require("src.ui.Screens").push(self.game, "NamingScreen", {
|
require("src.ui.Screens").push(self.game, "NamingScreen", {
|
||||||
title = step.title or (who == "rival" and "HIS NAME?" or Strings("YOUR NAME?")),
|
title = step.title or (who == "rival" and "HIS NAME?" or Strings("YOUR NAME?")),
|
||||||
presets = presets,
|
presets = presets,
|
||||||
|
introBox = true,
|
||||||
maxLen = step.maxLen or self.nameLen,
|
maxLen = step.maxLen or self.nameLen,
|
||||||
onDone = function(name)
|
onDone = function(name, custom)
|
||||||
if who == "rival" then
|
if who == "rival" then
|
||||||
self.game.save.player.rival = name
|
self.game.save.player.rival = name
|
||||||
else
|
else
|
||||||
self.game.save.player.name = name
|
self.game.save.player.name = name
|
||||||
end
|
end
|
||||||
self:recordAnswer(step, 1, name, name)
|
self:recordAnswer(step, 1, name, name)
|
||||||
|
-- YourNameIsText / HisNameIsText print into the box this one
|
||||||
|
-- held (oak_speech2.asm:26-28, :59-61)
|
||||||
|
self:closeHoldBox()
|
||||||
|
if custom then
|
||||||
|
-- .customName: ClearScreen / Delay3 / pic recentered, no
|
||||||
|
-- slide-back (oak_speech2.asm:21-25)
|
||||||
|
self.picSlide = 0
|
||||||
self:advance()
|
self:advance()
|
||||||
|
else
|
||||||
|
-- OakSpeechSlidePicLeft's 13-frame pre-slide beat
|
||||||
|
-- (oak_speech2.asm:69-78)
|
||||||
|
self:slidePic(-1, function() self:advance() end, 13)
|
||||||
|
end
|
||||||
end,
|
end,
|
||||||
})
|
})
|
||||||
|
end
|
||||||
|
self:slidePic(1, openNaming)
|
||||||
elseif kind == "choice" then
|
elseif kind == "choice" then
|
||||||
self:applyPic(step)
|
self:applyPic(step)
|
||||||
self:afterReveal(step, function()
|
self:afterReveal(step, function()
|
||||||
@@ -518,6 +589,22 @@ function OakSpeech:revealPic(kind, next)
|
|||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- ..(engine/movie/oak_speech/oak_speech2.asm ln 67)
|
||||||
|
function OakSpeech:slidePic(dir, onDone, delay)
|
||||||
|
self.picSlide = (dir > 0 and 0 or SLIDE_TILES * 8)
|
||||||
|
self.game.stack:push(setmetatable({
|
||||||
|
game = self.game, speech = self, dir = dir, t = 0, onDone = onDone,
|
||||||
|
delay = delay,
|
||||||
|
}, PicSlide))
|
||||||
|
end
|
||||||
|
|
||||||
|
-- IntroducePlayerText's text_end box (oak_speech.asm:90) is ours to close
|
||||||
|
function OakSpeech:closeHoldBox()
|
||||||
|
local box = self.holdBox
|
||||||
|
self.holdBox = nil
|
||||||
|
if box and self.game.stack:top() == box then self.game.stack:pop() end
|
||||||
|
end
|
||||||
|
|
||||||
function OakSpeech:advance()
|
function OakSpeech:advance()
|
||||||
self.step = self.step + 1
|
self.step = self.step + 1
|
||||||
-- picFlip belongs to the pic, not to the step: OakSpeechText2 prints 2A
|
-- picFlip belongs to the pic, not to the step: OakSpeechText2 prints 2A
|
||||||
@@ -615,7 +702,7 @@ function OakSpeech:draw()
|
|||||||
-- it like the sprite buffer does ((8 - w) >> 1) tiles across,
|
-- it like the sprite buffer does ((8 - w) >> 1) tiles across,
|
||||||
-- bottom-aligned
|
-- bottom-aligned
|
||||||
local w, h = self.pic:getDimensions()
|
local w, h = self.pic:getDimensions()
|
||||||
local x = 48 + math.floor((8 - w / 8) / 2) * 8
|
local x = 48 + math.floor((8 - w / 8) / 2) * 8 + (self.picSlide or 0)
|
||||||
local y = 32 + (7 - h / 8) * 8
|
local y = 32 + (7 - h / 8) * 8
|
||||||
local reveal = self.picReveal
|
local reveal = self.picReveal
|
||||||
local off = 0
|
local off = 0
|
||||||
|
|||||||
+5
-2
@@ -13,6 +13,7 @@ local ListMenu = require("src.ui.ListMenu")
|
|||||||
local Menu = require("src.ui.Menu")
|
local Menu = require("src.ui.Menu")
|
||||||
local QuantityBox = require("src.ui.QuantityBox")
|
local QuantityBox = require("src.ui.QuantityBox")
|
||||||
local Strings = require("src.core.Strings")
|
local Strings = require("src.core.Strings")
|
||||||
|
local romText = require("src.core.RomText")
|
||||||
|
|
||||||
local ShopMenu = {}
|
local ShopMenu = {}
|
||||||
|
|
||||||
@@ -57,7 +58,8 @@ local function buy(game, stock)
|
|||||||
end
|
end
|
||||||
local cost = qty * def.price
|
local cost = qty * def.price
|
||||||
-- _PokemartTellBuyPriceText + yes/no confirm
|
-- _PokemartTellBuyPriceText + yes/no confirm
|
||||||
list.footer = Strings("%s?\nThat will be\n¥%d. OK?", def.name, cost)
|
list.footer = romText(game.data, "_PokemartTellBuyPriceText",
|
||||||
|
"%s?\nThat will be\n¥%d. OK?", def.name, cost)
|
||||||
game.stack:push(ChoiceBox.new(game, function(yes)
|
game.stack:push(ChoiceBox.new(game, function(yes)
|
||||||
if not yes then
|
if not yes then
|
||||||
list.footer = greet
|
list.footer = greet
|
||||||
@@ -147,7 +149,8 @@ local function sell(game)
|
|||||||
return
|
return
|
||||||
end
|
end
|
||||||
-- _PokemartTellSellPriceText + yes/no confirm
|
-- _PokemartTellSellPriceText + yes/no confirm
|
||||||
list.footer = Strings("I can pay you\n¥%d for that.", unit * qty)
|
list.footer = romText(game.data, "_PokemartTellSellPriceText",
|
||||||
|
"I can pay you\n¥%d for that.", unit * qty)
|
||||||
game.stack:push(ChoiceBox.new(game, function(yes)
|
game.stack:push(ChoiceBox.new(game, function(yes)
|
||||||
if not yes then
|
if not yes then
|
||||||
list.footer = greet
|
list.footer = greet
|
||||||
|
|||||||
@@ -46,6 +46,7 @@
|
|||||||
local Font = require("src.render.Font")
|
local Font = require("src.render.Font")
|
||||||
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 romText = require("src.core.RomText")
|
||||||
|
|
||||||
local SlotMachine = {}
|
local SlotMachine = {}
|
||||||
SlotMachine.__index = SlotMachine
|
SlotMachine.__index = SlotMachine
|
||||||
@@ -357,7 +358,8 @@ function SlotMachine:resolveWin(win)
|
|||||||
-- SlotReward300Func prints "Yeah!" (text_pause) before the flash; the port
|
-- SlotReward300Func prints "Yeah!" (text_pause) before the flash; the port
|
||||||
-- shows it in the box while the screen flashes. LinedUpText follows.
|
-- shows it in the box while the screen flashes. LinedUpText follows.
|
||||||
self.yeah = (sym == "7")
|
self.yeah = (sym == "7")
|
||||||
self.message = Strings("%s lined up!\nScored %d coins!", sym, pay)
|
self.message = sym .. romText(self.game.data, "_LinedUpText",
|
||||||
|
" lined up!\nScored %d coins!", pay)
|
||||||
-- .flashScreenLoop: flip rBGP, wait 5 frames, b times. The coins are not
|
-- .flashScreenLoop: flip rBGP, wait 5 frames, b times. The coins are not
|
||||||
-- credited until the player dismisses the "lined up" text (see startPayout).
|
-- credited until the player dismisses the "lined up" text (see startPayout).
|
||||||
self.stage = "flash"
|
self.stage = "flash"
|
||||||
|
|||||||
+50
-9
@@ -11,6 +11,7 @@ local Renderer = require("src.render.Renderer")
|
|||||||
local Runtime = require("src.mods.Runtime")
|
local Runtime = require("src.mods.Runtime")
|
||||||
local Screens = require("src.ui.Screens")
|
local Screens = require("src.ui.Screens")
|
||||||
local Strings = require("src.core.Strings")
|
local Strings = require("src.core.Strings")
|
||||||
|
local Theme = require("src.ui.Theme")
|
||||||
|
|
||||||
local StartMenu = {}
|
local StartMenu = {}
|
||||||
|
|
||||||
@@ -19,6 +20,7 @@ local function sameItems(_, items) return items end
|
|||||||
function StartMenu.new(game)
|
function StartMenu.new(game)
|
||||||
local flags = game.save.flags or {}
|
local flags = game.save.flags or {}
|
||||||
local items = {}
|
local items = {}
|
||||||
|
local menu
|
||||||
|
|
||||||
-- vanilla start submenus return here on B (RedisplayStartMenu): the
|
-- vanilla start submenus return here on B (RedisplayStartMenu): the
|
||||||
-- generic Menu pops the start menu when a row is selected, so each
|
-- generic Menu pops the start menu when a row is selected, so each
|
||||||
@@ -51,22 +53,59 @@ function StartMenu.new(game)
|
|||||||
end })
|
end })
|
||||||
|
|
||||||
-- SAVE shows the player/badges/dex/time panel then asks to confirm
|
-- SAVE shows the player/badges/dex/time panel then asks to confirm
|
||||||
-- (PrintSaveScreenText)
|
-- (PrintSaveScreenText); StartMenu_SaveReset never clears the START menu
|
||||||
table.insert(items, { label = Strings("SAVE"), onSelect = function()
|
-- box, so it stays on screen beside the panel (start_sub_menus.asm:641-647)
|
||||||
|
table.insert(items, { label = Strings("SAVE"), keepOpen = true,
|
||||||
|
onSelect = function()
|
||||||
local TextBox = require("src.render.TextBox")
|
local TextBox = require("src.render.TextBox")
|
||||||
local badges = require("src.inventory.Badges").count(game.data, game.save)
|
local badges = require("src.inventory.Badges").count(game.data, game.save)
|
||||||
local owned = 0
|
local owned = 0
|
||||||
for _ in pairs(game.save.pokedex and game.save.pokedex.owned or {}) do
|
for _ in pairs(game.save.pokedex and game.save.pokedex.owned or {}) do
|
||||||
owned = owned + 1
|
owned = owned + 1
|
||||||
end
|
end
|
||||||
|
-- the panel is a static snapshot; the cart prints it once
|
||||||
|
-- (main_menu.asm:390-401)
|
||||||
local t = math.floor(game.save.playTime or 0)
|
local t = math.floor(game.save.playTime or 0)
|
||||||
local panel = Strings("PLAYER %s\nBADGES %d\nPOKéDEX %3d\nTIME %6d:%02d",
|
-- PrintSaveScreenText draws its own border at hlcoord 4,0 (b=8, c=$e) and
|
||||||
game.save.player.name or "RED", badges, owned,
|
-- leaves it up under the prompt -- engine/menus/main_menu.asm:381-405
|
||||||
math.floor(t / 3600), math.floor(t / 60) % 60)
|
local panel
|
||||||
|
panel = {
|
||||||
|
delay = 0,
|
||||||
|
update = function()
|
||||||
|
-- ld c, 30 / jp DelayFrames: the bare panel holds before the
|
||||||
|
-- prompt (main_menu.asm:404-405)
|
||||||
|
panel.delay = panel.delay + 1
|
||||||
|
if panel.delay == 30 then panel.openPrompt() end
|
||||||
|
end,
|
||||||
|
draw = function()
|
||||||
|
Font.drawBox(4, 0, 16, 10)
|
||||||
|
love.graphics.setColor(0, 0, 0, 1)
|
||||||
|
Font.draw(Strings("PLAYER"), 5 * 8, 2 * 8)
|
||||||
|
Font.draw(game.save.player.name or "RED", 12 * 8, 2 * 8)
|
||||||
|
Font.draw(Strings("BADGES"), 5 * 8, 4 * 8)
|
||||||
|
Font.draw(("%2d"):format(badges), 17 * 8, 4 * 8)
|
||||||
|
Font.draw(Strings("POKéDEX"), 5 * 8, 6 * 8)
|
||||||
|
Font.draw(("%3d"):format(owned), 16 * 8, 6 * 8)
|
||||||
|
Font.draw(Strings("TIME"), 5 * 8, 8 * 8)
|
||||||
|
Font.draw(("%3d:%02d"):format(math.floor(t / 3600),
|
||||||
|
math.floor(t / 60) % 60), 13 * 8, 8 * 8)
|
||||||
|
love.graphics.setColor(1, 1, 1, 1)
|
||||||
|
end,
|
||||||
|
}
|
||||||
|
local function closePanel()
|
||||||
|
if game.stack:top() == panel then game.stack:pop() end
|
||||||
|
-- SaveMenu returns into HoldTextDisplayOpen, not RedisplayStartMenu
|
||||||
|
-- (start_sub_menus.asm:645-647): the kept-open START menu goes too
|
||||||
|
if menu and game.stack:top() == menu then game.stack:pop() end
|
||||||
|
end
|
||||||
|
panel.openPrompt = function()
|
||||||
game.stack:push(TextBox.new(game,
|
game.stack:push(TextBox.new(game,
|
||||||
panel .. Strings("\fWould you like to\nSAVE the game?"), nil, {
|
Strings("Would you like to\nSAVE the game?"), nil, {
|
||||||
|
-- SaveTheGame_YesOrNo pins its TWO_OPTION_MENU at hlcoord 0, 7 rather
|
||||||
|
-- than the shared right-hand one -- engine/menus/save.asm:186-192
|
||||||
|
choiceBox = Theme.saveBox,
|
||||||
choice = function(yes)
|
choice = function(yes)
|
||||||
if not yes then return end
|
if not yes then closePanel() return end
|
||||||
-- SaveMenu .save (engine/menus/save.asm:164-181): "Now saving..."
|
-- SaveMenu .save (engine/menus/save.asm:164-181): "Now saving..."
|
||||||
-- is a bare PlaceString held by DelayFrames 120, then GameSavedText,
|
-- is a bare PlaceString held by DelayFrames 120, then GameSavedText,
|
||||||
-- which ends in `done` and so never reaches TX_PROMPT_BUTTON.
|
-- which ends in `done` and so never reaches TX_PROMPT_BUTTON.
|
||||||
@@ -78,7 +117,7 @@ function StartMenu.new(game)
|
|||||||
game:writeSave()
|
game:writeSave()
|
||||||
game.stack:push(TextBox.new(game,
|
game.stack:push(TextBox.new(game,
|
||||||
Strings("%s saved\nthe game!", game.save.player.name or "RED"),
|
Strings("%s saved\nthe game!", game.save.player.name or "RED"),
|
||||||
nil, { auto = {
|
closePanel, { auto = {
|
||||||
sound = function()
|
sound = function()
|
||||||
return require("src.core.Sound").play(game.data, "Save")
|
return require("src.core.Sound").play(game.data, "Save")
|
||||||
end,
|
end,
|
||||||
@@ -87,6 +126,8 @@ function StartMenu.new(game)
|
|||||||
end, { auto = { delay = 120 } }))
|
end, { auto = { delay = 120 } }))
|
||||||
end,
|
end,
|
||||||
}))
|
}))
|
||||||
|
end
|
||||||
|
game.stack:push(panel)
|
||||||
end })
|
end })
|
||||||
|
|
||||||
table.insert(items, { label = Strings("OPTION"), onSelect = function()
|
table.insert(items, { label = Strings("OPTION"), onSelect = function()
|
||||||
@@ -142,7 +183,7 @@ function StartMenu.new(game)
|
|||||||
-- with Menu's moreArrow showing while there's more below.
|
-- with Menu's moreArrow showing while there's more below.
|
||||||
local rowStep = 2
|
local rowStep = 2
|
||||||
local maxVisible = math.floor((Renderer.HEIGHT / 8 - 2) / rowStep)
|
local maxVisible = math.floor((Renderer.HEIGHT / 8 - 2) / rowStep)
|
||||||
local menu = Menu.new(game, items,
|
menu = Menu.new(game, items,
|
||||||
-- the START menu hugs the top-right corner of the SCREEN, not of a
|
-- the START menu hugs the top-right corner of the SCREEN, not of a
|
||||||
-- centred letterbox: at 9,0 x 11 it is already flush with the top and
|
-- centred letterbox: at 9,0 x 11 it is already flush with the top and
|
||||||
-- right of the 20x18 grid, so the anchor keeps it flush when the view
|
-- right of the 20x18 grid, so the anchor keeps it flush when the view
|
||||||
|
|||||||
@@ -23,6 +23,9 @@ local Theme = {
|
|||||||
-- EnemySendOutFirstMon inlines its own TWO_OPTION_MENU at hlcoord 0, 7
|
-- EnemySendOutFirstMon inlines its own TWO_OPTION_MENU at hlcoord 0, 7
|
||||||
-- instead of the shared right-hand one -- engine/battle/core.asm:1378-1384
|
-- instead of the shared right-hand one -- engine/battle/core.asm:1378-1384
|
||||||
trainerSwitchBox = { tx = 0, ty = 7, tw = 6, th = 5 },
|
trainerSwitchBox = { tx = 0, ty = 7, tw = 6, th = 5 },
|
||||||
|
-- SaveTheGame_YesOrNo pins its TWO_OPTION_MENU at hlcoord 0, 7 too --
|
||||||
|
-- engine/menus/save.asm:186-192
|
||||||
|
saveBox = { tx = 0, ty = 7, tw = 6, th = 5 },
|
||||||
}
|
}
|
||||||
|
|
||||||
function Theme.load(data)
|
function Theme.load(data)
|
||||||
|
|||||||
+80
-12
@@ -392,6 +392,17 @@ function TitleState:updateSequence()
|
|||||||
self.phase = "loop"
|
self.phase = "loop"
|
||||||
self.blinkTimer = 0
|
self.blinkTimer = 0
|
||||||
end
|
end
|
||||||
|
elseif self.phase == "exitCry" then
|
||||||
|
-- .finishedWaiting: PlayCry then WaitForSoundToFinish before the
|
||||||
|
-- white-out (engine/movie/title.asm:241-243)
|
||||||
|
self.timer = self.timer + 1
|
||||||
|
local playing = self.exitCrySrc and self.exitCrySrc.isPlaying
|
||||||
|
and self.exitCrySrc:isPlaying()
|
||||||
|
if self.timer >= 3 and (not playing or self.timer > 180) then
|
||||||
|
self.exitCrySrc = nil
|
||||||
|
self.phase = "loop"
|
||||||
|
self:toMenu()
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -460,8 +471,8 @@ function ContinueInfo:update(dt)
|
|||||||
self.game.stack:pop()
|
self.game.stack:pop()
|
||||||
if self.title.onContinue then self.title.onContinue() end
|
if self.title.onContinue then self.title.onContinue() end
|
||||||
elseif input:wasPressed("b") then
|
elseif input:wasPressed("b") then
|
||||||
|
-- the CONTINUE / NEW GAME menu is still open underneath (main_menu.asm:91-92)
|
||||||
self.game.stack:pop()
|
self.game.stack:pop()
|
||||||
self.title:openMenu()
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -497,7 +508,10 @@ function TitleState:openMenu()
|
|||||||
local game = self.game
|
local game = self.game
|
||||||
local items = {}
|
local items = {}
|
||||||
if hasSave() then
|
if hasSave() then
|
||||||
table.insert(items, { label = Strings("CONTINUE"), onSelect = function()
|
-- DisplayContinueGameInfo leaves the menu box up behind the info window
|
||||||
|
-- (engine/menus/main_menu.asm:36-39, :91-92)
|
||||||
|
table.insert(items, { label = Strings("CONTINUE"), keepOpen = true,
|
||||||
|
onSelect = function()
|
||||||
-- peek at the save for the info window; fall through if the
|
-- peek at the save for the info window; fall through if the
|
||||||
-- file can't be read
|
-- file can't be read
|
||||||
local ok, loaded = pcall(require("src.core.SaveData").load)
|
local ok, loaded = pcall(require("src.core.SaveData").load)
|
||||||
@@ -511,7 +525,13 @@ function TitleState:openMenu()
|
|||||||
table.insert(items, { label = Strings("NEW GAME"), onSelect = function()
|
table.insert(items, { label = Strings("NEW GAME"), onSelect = function()
|
||||||
if self.onNewGame then self.onNewGame() end
|
if self.onNewGame then self.onNewGame() end
|
||||||
end })
|
end })
|
||||||
table.insert(items, { label = Strings("OPTION"), onSelect = function()
|
-- DisplayOptionMenu returns to .mainMenuLoop, which redraws the box
|
||||||
|
-- (engine/menus/main_menu.asm ln 87-90)
|
||||||
|
local menu
|
||||||
|
table.insert(items, { label = Strings("OPTION"), keepOpen = true,
|
||||||
|
onSelect = function()
|
||||||
|
-- .mainMenuLoop re-zeroes wCurrentMenuItem on re-entry (main_menu.asm:56-57)
|
||||||
|
if menu then menu.index = 1 end
|
||||||
require("src.ui.Screens").push(game, "OptionsMenu")
|
require("src.ui.Screens").push(game, "OptionsMenu")
|
||||||
end })
|
end })
|
||||||
table.insert(items, { label = Strings("EXIT GAME"), onSelect = function()
|
table.insert(items, { label = Strings("EXIT GAME"), onSelect = function()
|
||||||
@@ -529,7 +549,14 @@ function TitleState:openMenu()
|
|||||||
type(hooked))
|
type(hooked))
|
||||||
end
|
end
|
||||||
local th = #items * 2 + 2
|
local th = #items * 2 + 2
|
||||||
local menu = Menu.new(game, items, { tx = 0, ty = 0, tw = 13, th = th })
|
menu = Menu.new(game, items, { tx = 0, ty = 0, tw = 13, th = th })
|
||||||
|
-- .mainMenuLoop's B branch jumps back to DisplayTitleScreen, which opens
|
||||||
|
-- with GBPalWhiteOut and reruns the whole boot cinematic
|
||||||
|
-- (engine/menus/main_menu.asm:69-70, title.asm:29)
|
||||||
|
menu.onCancel = function()
|
||||||
|
game.stack:push(require("src.render.Transition").whiteFlash(game, nil,
|
||||||
|
function() self:restartSequence() end))
|
||||||
|
end
|
||||||
-- full-width title LOGO zones would recolor this box; see sgbPalettes.
|
-- full-width title LOGO zones would recolor this box; see sgbPalettes.
|
||||||
-- Menu.new may have grown tw for longer (e.g. localized) labels, so the
|
-- Menu.new may have grown tw for longer (e.g. localized) labels, so the
|
||||||
-- recolor zone follows the box's real width instead of the vanilla 13.
|
-- recolor zone follows the box's real width instead of the vanilla 13.
|
||||||
@@ -537,6 +564,38 @@ function TitleState:openMenu()
|
|||||||
game.stack:push(menu)
|
game.stack:push(menu)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- .mainMenuLoop's B branch: DisplayTitleScreen from the top
|
||||||
|
-- (main_menu.asm:70, title.asm:39-222)
|
||||||
|
function TitleState:restartSequence()
|
||||||
|
self.menuOpen = false
|
||||||
|
pcall(Music.stop)
|
||||||
|
self.scy = 0x40
|
||||||
|
self.phase = "drop"
|
||||||
|
self.dropStep, self.dropLeft = 1, nil
|
||||||
|
self.showBubble = not self.yellowLayout
|
||||||
|
self.timer = 0
|
||||||
|
self.blinkTimer = 0
|
||||||
|
self.blinkAt = nil
|
||||||
|
self.cycleIndex = 1
|
||||||
|
self.scrollPhase = "hold"
|
||||||
|
self.scrollFrame = 1
|
||||||
|
self.monOffset = 0
|
||||||
|
self.ballY = BALL_REST
|
||||||
|
self.ribbonOffset = nil
|
||||||
|
self.whooshSrc, self.crySrc, self.exitCrySrc = nil, nil, nil
|
||||||
|
end
|
||||||
|
|
||||||
|
-- .finishedWaiting: GBPalWhiteOutWithDelay3 then ClearScreen before MainMenu,
|
||||||
|
-- which clears again itself (engine/movie/title.asm ln 243, main_menu.asm ln 26)
|
||||||
|
function TitleState:toMenu()
|
||||||
|
local game = self.game
|
||||||
|
game.stack:push(require("src.render.Transition").whiteFlash(game, nil,
|
||||||
|
function()
|
||||||
|
self.menuOpen = true
|
||||||
|
self:openMenu()
|
||||||
|
end))
|
||||||
|
end
|
||||||
|
|
||||||
-- ..(engine/movie/title.asm ln 271)
|
-- ..(engine/movie/title.asm ln 271)
|
||||||
function TitleState:pickNewMon()
|
function TitleState:pickNewMon()
|
||||||
if #self.cycleSpecies < 2 then return end
|
if #self.cycleSpecies < 2 then return end
|
||||||
@@ -589,6 +648,11 @@ function TitleState:updateCycle()
|
|||||||
end
|
end
|
||||||
|
|
||||||
function TitleState:update(dt)
|
function TitleState:update(dt)
|
||||||
|
-- an onSelect that handed control back without a new state (a failed
|
||||||
|
-- CONTINUE load, a mod row) re-runs DisplayTitleScreen (main_menu.asm:70)
|
||||||
|
if self.menuOpen and self.game.stack:top() == self then
|
||||||
|
self:restartSequence()
|
||||||
|
end
|
||||||
if self.phase ~= "loop" then
|
if self.phase ~= "loop" then
|
||||||
self:updateSequence()
|
self:updateSequence()
|
||||||
return
|
return
|
||||||
@@ -599,10 +663,10 @@ function TitleState:update(dt)
|
|||||||
if input:wasPressed("start") or input:wasPressed("a") then
|
if input:wasPressed("start") or input:wasPressed("a") then
|
||||||
-- .go_to_main_menu voices PikachuCry11 on the way out
|
-- .go_to_main_menu voices PikachuCry11 on the way out
|
||||||
local Sound = require("src.core.Sound")
|
local Sound = require("src.core.Sound")
|
||||||
if not Sound.playPikaCry(self.game.data, 11) then
|
self.exitCrySrc = Sound.playPikaCry(self.game.data, 11)
|
||||||
Sound.playCry(self.game.data, "PIKACHU")
|
or Sound.playCry(self.game.data, "PIKACHU")
|
||||||
end
|
self.phase = "exitCry"
|
||||||
self:openMenu()
|
self.timer = 0
|
||||||
end
|
end
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
@@ -613,23 +677,27 @@ function TitleState:update(dt)
|
|||||||
if input:wasPressed("start") or input:wasPressed("a") then
|
if input:wasPressed("start") or input:wasPressed("a") then
|
||||||
-- the title mon cries when you leave the title (.finishedWaiting);
|
-- the title mon cries when you leave the title (.finishedWaiting);
|
||||||
-- Yellow's fixed Pikachu title always cries Pikachu.
|
-- Yellow's fixed Pikachu title always cries Pikachu.
|
||||||
require("src.core.Sound").playCry(self.game.data,
|
self.exitCrySrc = require("src.core.Sound").playCry(self.game.data,
|
||||||
self.yellowLayout and "PIKACHU"
|
self.yellowLayout and "PIKACHU"
|
||||||
or self.cycleSpecies[self.cycleIndex])
|
or self.cycleSpecies[self.cycleIndex])
|
||||||
self:openMenu()
|
self.phase = "exitCry"
|
||||||
|
self.timer = 0
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
-- ..(engine/movie/title.asm ln 28)
|
-- ..(engine/movie/title.asm ln 28)
|
||||||
function TitleState:draw()
|
function TitleState:draw()
|
||||||
|
love.graphics.setColor(1, 1, 1, 1)
|
||||||
|
love.graphics.rectangle("fill", 0, 0, 160, 144)
|
||||||
|
-- MainMenu's own ClearScreen wipes the logo, mon and sprites before the
|
||||||
|
-- CONTINUE / NEW GAME border is drawn (engine/menus/main_menu.asm ln 26)
|
||||||
|
if self.menuOpen then return end
|
||||||
local PaletteFX = require("src.render.PaletteFX")
|
local PaletteFX = require("src.render.PaletteFX")
|
||||||
local playerImage = self.player
|
local playerImage = self.player
|
||||||
if playerImage and PaletteFX.usesSpriteObp() then
|
if playerImage and PaletteFX.usesSpriteObp() then
|
||||||
playerImage = require("src.render.SpriteRenderer").obpImage(
|
playerImage = require("src.render.SpriteRenderer").obpImage(
|
||||||
self.playerPath, PaletteFX.ogObj())
|
self.playerPath, PaletteFX.ogObj())
|
||||||
end
|
end
|
||||||
love.graphics.setColor(1, 1, 1, 1)
|
|
||||||
love.graphics.rectangle("fill", 0, 0, 160, 144)
|
|
||||||
local scrollY = -(self.scy or 0)
|
local scrollY = -(self.scy or 0)
|
||||||
-- ..(engine/movie/title.asm ln 28)
|
-- ..(engine/movie/title.asm ln 28)
|
||||||
local preRibbon = not self.yellowLayout
|
local preRibbon = not self.yellowLayout
|
||||||
|
|||||||
+120
-19
@@ -38,6 +38,10 @@ local Sound = require("src.core.Sound")
|
|||||||
-- Only for playerPic: the player.sprite raiser both generations share.
|
-- Only for playerPic: the player.sprite raiser both generations share.
|
||||||
local Sprites = require("src.pokemon.Sprites")
|
local Sprites = require("src.pokemon.Sprites")
|
||||||
local Strings = require("src.core.Strings")
|
local Strings = require("src.core.Strings")
|
||||||
|
local SummaryMenu = require("src.ui.gen2.SummaryMenu")
|
||||||
|
-- Only for TextBox.substitute: the {PLAYER} / {RIVAL} markers a map text
|
||||||
|
-- carries into the battle box (PrintWinLossText, home/trainers.asm:230).
|
||||||
|
local TextBox = require("src.render.TextBox")
|
||||||
local Unown = require("src.core.gen2.Unown")
|
local Unown = require("src.core.gen2.Unown")
|
||||||
|
|
||||||
local BattleState = {}
|
local BattleState = {}
|
||||||
@@ -87,6 +91,20 @@ local TRAINER_SLIDE_STEPS = 8
|
|||||||
local TRAINER_SLIDE_FRAMES_PER_STEP = 2
|
local TRAINER_SLIDE_FRAMES_PER_STEP = 2
|
||||||
local TRAINER_SLIDE_FRAMES = TRAINER_SLIDE_STEPS * TRAINER_SLIDE_FRAMES_PER_STEP
|
local TRAINER_SLIDE_FRAMES = TRAINER_SLIDE_STEPS * TRAINER_SLIDE_FRAMES_PER_STEP
|
||||||
|
|
||||||
|
-- BattleWinSlideInEnemyTrainerFrontpic (engine/battle/core.asm:6279-6318) and
|
||||||
|
-- WinTrainerBattle's DelayFrames 40 (:2311)
|
||||||
|
local WIN_SLIDE_STEPS = 6
|
||||||
|
local WIN_SLIDE_FRAMES_PER_STEP = 4
|
||||||
|
local WIN_SLIDE_FRAMES = WIN_SLIDE_STEPS * WIN_SLIDE_FRAMES_PER_STEP
|
||||||
|
local WIN_SLIDE_REST_TILES = 2
|
||||||
|
local WIN_SLIDE_DELAY_FRAMES = 40
|
||||||
|
|
||||||
|
local function winSlideTiles(frames)
|
||||||
|
local step = math.min(WIN_SLIDE_STEPS,
|
||||||
|
math.floor(frames / WIN_SLIDE_FRAMES_PER_STEP) + 1)
|
||||||
|
return WIN_SLIDE_STEPS + WIN_SLIDE_REST_TILES - step
|
||||||
|
end
|
||||||
|
|
||||||
-- MonFaintedAnimation (engine/battle/core.asm), which PlayerMonFaintedAnimation
|
-- MonFaintedAnimation (engine/battle/core.asm), which PlayerMonFaintedAnimation
|
||||||
-- and EnemyMonFaintedAnimation both fall into with the fainted side's pic
|
-- and EnemyMonFaintedAnimation both fall into with the fainted side's pic
|
||||||
-- corner: the pic's tilemap rows are copied DOWN one row per step and the row
|
-- corner: the pic's tilemap rows are copied DOWN one row per step and the row
|
||||||
@@ -141,6 +159,10 @@ local MENU_COL_SPACING = 6
|
|||||||
-- the count PrintNum writes after it (two digits, leading zeros) at (13,16).
|
-- the count PrintNum writes after it (two digits, leading zeros) at (13,16).
|
||||||
local CONTEST_MENU_BOX_X = 2
|
local CONTEST_MENU_BOX_X = 2
|
||||||
local CONTEST_MENU_COL_SPACING = 12
|
local CONTEST_MENU_COL_SPACING = 12
|
||||||
|
|
||||||
|
-- PrintMoveType prints the type table's own names; only these two differ from
|
||||||
|
-- the constant (data/types/names.asm).
|
||||||
|
local TYPE_NAMES = SummaryMenu.TYPE_NAMES
|
||||||
-- charmap.asm's quantity glyph, spelled the way MartMenu spells it.
|
-- charmap.asm's quantity glyph, spelled the way MartMenu spells it.
|
||||||
local CONTEST_BALL_LABEL = "PARKBALL\xc3\x97"
|
local CONTEST_BALL_LABEL = "PARKBALL\xc3\x97"
|
||||||
|
|
||||||
@@ -508,6 +530,14 @@ function BattleState:pushAll(events)
|
|||||||
for _, event in ipairs(events or {}) do self:push(event) end
|
for _, event in ipairs(events or {}) do self:push(event) end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- LearnMove returns before HandleEnemyMonFaint's send-out/prize arms
|
||||||
|
-- (engine/battle/core.asm:1959-2010).
|
||||||
|
function BattleState:pushFront(events)
|
||||||
|
for i = #(events or {}), 1, -1 do
|
||||||
|
table.insert(self.queue, 1, events[i])
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
function BattleState:pic(mon, back)
|
function BattleState:pic(mon, back)
|
||||||
local def = self.pokemon and mon and self.pokemon[mon.species]
|
local def = self.pokemon and mon and self.pokemon[mon.species]
|
||||||
local path = def and (back and def.spriteBack or def.spriteFront)
|
local path = def and (back and def.spriteBack or def.spriteFront)
|
||||||
@@ -686,6 +716,8 @@ function BattleState:drawPic(mon, back)
|
|||||||
-- One tile per two frames to the right, SlideBattlePicOut's own step.
|
-- One tile per two frames to the right, SlideBattlePicOut's own step.
|
||||||
if enemyTrainer and self.trainerSlide then
|
if enemyTrainer and self.trainerSlide then
|
||||||
px = px + math.floor(self.trainerSlide / TRAINER_SLIDE_FRAMES_PER_STEP) * 8
|
px = px + math.floor(self.trainerSlide / TRAINER_SLIDE_FRAMES_PER_STEP) * 8
|
||||||
|
elseif enemyTrainer and self.winSlide then
|
||||||
|
px = px + winSlideTiles(self.winSlide) * 8
|
||||||
end
|
end
|
||||||
-- The pic's own scale (battle_sprite_scales, then the species record, then
|
-- The pic's own scale (battle_sprite_scales, then the species record, then
|
||||||
-- 1x) composed with whatever square BattleBGEffect_RunPicResizeScript has
|
-- 1x) composed with whatever square BattleBGEffect_RunPicResizeScript has
|
||||||
@@ -1371,7 +1403,7 @@ function BattleState:advanceQueue()
|
|||||||
and self.shownHp[event.side] ~= event.hp then
|
and self.shownHp[event.side] ~= event.hp then
|
||||||
self.hpAnim = { side = event.side, to = event.hp }
|
self.hpAnim = { side = event.side, to = event.hp }
|
||||||
elseif event.kind == "send" and event.side and event.mon and self.shownHp then
|
elseif event.kind == "send" and event.side and event.mon and self.shownHp then
|
||||||
self.shownHp[event.side] = event.mon.hp or 0
|
self.shownHp[event.side] = event.hp or event.mon.hp or 0
|
||||||
if self.hpAnim and self.hpAnim.side == event.side then self.hpAnim = nil end
|
if self.hpAnim and self.hpAnim.side == event.side then self.hpAnim = nil end
|
||||||
end
|
end
|
||||||
-- And the same lag for the status tag (home/battle.asm:150); a send snaps it
|
-- And the same lag for the status tag (home/battle.asm:150); a send snaps it
|
||||||
@@ -1379,8 +1411,9 @@ function BattleState:advanceQueue()
|
|||||||
if self.shownStatus and event.side
|
if self.shownStatus and event.side
|
||||||
and (event.kind == "status"
|
and (event.kind == "status"
|
||||||
or (event.kind == "send" and event.mon)) then
|
or (event.kind == "send" and event.mon)) then
|
||||||
self.shownStatus[event.side] =
|
local shown = event.status
|
||||||
(event.kind == "send" and event.mon.status or event.status) or false
|
if event.kind == "send" and shown == nil then shown = event.mon.status end
|
||||||
|
self.shownStatus[event.side] = shown or false
|
||||||
end
|
end
|
||||||
-- AnimateExpBar (engine/battle/core.asm:7191) is called from INSIDE
|
-- AnimateExpBar (engine/battle/core.asm:7191) is called from INSIDE
|
||||||
-- GiveExperiencePoints before the exp is committed (the call at :6888 sits
|
-- GiveExperiencePoints before the exp is committed (the call at :6888 sits
|
||||||
@@ -1419,12 +1452,12 @@ function BattleState:advanceQueue()
|
|||||||
-- inside SendOutPlayerMon and nothing on the enemy's path touches them.
|
-- inside SendOutPlayerMon and nothing on the enemy's path touches them.
|
||||||
self.menuIndex = 1
|
self.menuIndex = 1
|
||||||
self.moveIndex = 1
|
self.moveIndex = 1
|
||||||
-- The incoming mon's own level and exp bar: SendOutPlayerMon reloads
|
-- SendOutPlayerMon reloads wBattleMon* from the party slot (:3838):
|
||||||
-- wBattleMon* from the party slot and UpdatePlayerHUD draws them at its
|
-- snap from the emit-time snapshot, not the live table (#1514).
|
||||||
-- tail (:3838), so both snap here the way shownHp does above.
|
local level = event.level or event.mon.level or 1
|
||||||
self.shownLevel = event.mon.level or 1
|
self.shownLevel = level
|
||||||
self.shownExp = self:expPixels(event.mon, event.mon.level,
|
self.shownExp = self:expPixels(event.mon, level,
|
||||||
event.mon.experience)
|
event.experience or event.mon.experience)
|
||||||
self.expAnim = nil
|
self.expAnim = nil
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -1435,6 +1468,30 @@ function BattleState:advanceQueue()
|
|||||||
self.trainerSlide = 0
|
self.trainerSlide = 0
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
-- BattleWinSlideInEnemyTrainerFrontpic and the DelayFrames 40 behind it
|
||||||
|
-- (engine/battle/core.asm:2310-2312)
|
||||||
|
if event.kind == "trainer-return" then
|
||||||
|
-- LostBattle's ClearBox wipes the live foe pic and HUD before the slide
|
||||||
|
-- (engine/battle/core.asm:2770-2773)
|
||||||
|
if event.cleared then
|
||||||
|
self.showEnemyHud = false
|
||||||
|
self.ballRows.enemy = false
|
||||||
|
end
|
||||||
|
if not self.enemyTrainerImage then return self:advanceQueue() end
|
||||||
|
self.showEnemyTrainer = true
|
||||||
|
self.picHidden.enemy = false
|
||||||
|
self.winSlide = 0
|
||||||
|
self.winSliding = true
|
||||||
|
return
|
||||||
|
end
|
||||||
|
-- PrintWinLossText (home/trainers.asm:230): one FarPrintText of the trainer
|
||||||
|
-- struct's own line, paged and held for A/B like any other map text.
|
||||||
|
if event.kind == "win-text" then
|
||||||
|
local text = event.text
|
||||||
|
if self.game then text = TextBox.substitute(self.game, text) end
|
||||||
|
self:showPages(text)
|
||||||
|
return
|
||||||
|
end
|
||||||
-- The shiny sparkle: hBattleTurn 1 and wBattleAnimParam 1 pick
|
-- The shiny sparkle: hBattleTurn 1 and wBattleAnimParam 1 pick
|
||||||
-- BattleAnim_SendOutMon's `.Shiny` arm on the enemy (core.asm:8708-8715).
|
-- BattleAnim_SendOutMon's `.Shiny` arm on the enemy (core.asm:8708-8715).
|
||||||
if event.kind == "shiny-flash" then
|
if event.kind == "shiny-flash" then
|
||||||
@@ -1917,6 +1974,17 @@ function BattleState:update(_dt)
|
|||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- BattleWinSlideInEnemyTrainerFrontpic plus WinTrainerBattle's DelayFrames
|
||||||
|
-- 40 (engine/battle/core.asm:6279-6318, :2310-2312)
|
||||||
|
if self.winSliding then
|
||||||
|
self.winSlide = self.winSlide + 1
|
||||||
|
if self.winSlide >= WIN_SLIDE_FRAMES + WIN_SLIDE_DELAY_FRAMES then
|
||||||
|
self.winSliding = nil
|
||||||
|
self:advanceQueue()
|
||||||
|
end
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
-- SlideBattlePicOut is a plain loop with DelayFrames in it, so it owns the
|
-- SlideBattlePicOut is a plain loop with DelayFrames in it, so it owns the
|
||||||
-- screen the same way (engine/battle/core.asm:2882).
|
-- screen the same way (engine/battle/core.asm:2882).
|
||||||
if self.trainerSlide then
|
if self.trainerSlide then
|
||||||
@@ -2004,6 +2072,8 @@ function BattleState:update(_dt)
|
|||||||
self.phase = "stats-box"
|
self.phase = "stats-box"
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
-- PrintWinLossText's line pages like any map text (home/text.asm:403-448)
|
||||||
|
if self:nextPage() then return end
|
||||||
self:advanceQueue()
|
self:advanceQueue()
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
@@ -2292,7 +2362,7 @@ function BattleState:update(_dt)
|
|||||||
learn.move, learn.moveName)
|
learn.move, learn.moveName)
|
||||||
self.pendingLearn = nil
|
self.pendingLearn = nil
|
||||||
self.phase = "resolving"
|
self.phase = "resolving"
|
||||||
self:pushAll(self.battle:takeEvents())
|
self:pushFront(self.battle:takeEvents())
|
||||||
self:advanceQueue()
|
self:advanceQueue()
|
||||||
end
|
end
|
||||||
return
|
return
|
||||||
@@ -2872,7 +2942,7 @@ function BattleState:finishDecline()
|
|||||||
self.pendingLearn = nil
|
self.pendingLearn = nil
|
||||||
self.phase = "resolving"
|
self.phase = "resolving"
|
||||||
if learn then self.battle:declineForget(learn.index, learn.moveName) end
|
if learn then self.battle:declineForget(learn.index, learn.moveName) end
|
||||||
self:pushAll(self.battle:takeEvents())
|
self:pushFront(self.battle:takeEvents())
|
||||||
self:advanceQueue()
|
self:advanceQueue()
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -3475,6 +3545,23 @@ function BattleState:printMessage()
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- MoveInfoBox (engine/battle/core.asm:5403-5478): "TYPE/" at (1,9), the type
|
||||||
|
-- at (2,10), cur/max PP at (5,11), or "Disabled!" at (1,10).
|
||||||
|
function BattleState:drawMoveInfoBox(move)
|
||||||
|
if not move then return end
|
||||||
|
local fighter = self.battle and self.battle.player
|
||||||
|
if fighter and self.battle:moveDisabled(fighter, move.id) then
|
||||||
|
Chrome.print("Disabled!", 1, 10)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local def = self.game and self.game.data and self.game.data.moves
|
||||||
|
and self.game.data.moves[move.id]
|
||||||
|
Chrome.print("TYPE/", 1, 9)
|
||||||
|
local moveType = def and def.type
|
||||||
|
Chrome.print(moveType and (TYPE_NAMES[moveType] or moveType) or "", 2, 10)
|
||||||
|
Chrome.print(("%2d/%2d"):format(move.pp or 0, move.maxPp or 0), 5, 11)
|
||||||
|
end
|
||||||
|
|
||||||
function BattleState:drawPanel()
|
function BattleState:drawPanel()
|
||||||
Chrome.clear()
|
Chrome.clear()
|
||||||
-- A tutorial battle legitimately has no player mon, so only the enemy is
|
-- A tutorial battle legitimately has no player mon, so only the enemy is
|
||||||
@@ -3494,7 +3581,15 @@ function BattleState:drawPanel()
|
|||||||
-- 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.
|
||||||
|
-- MoveSelectionScreen type 0 is two boxes: the name-only list
|
||||||
|
-- (engine/battle/core.asm:5074-5084) and MoveInfoBox's (:5407-5410).
|
||||||
|
local moveMenu = self.phase == "moves"
|
||||||
Chrome.box(0, 12, 20, 6)
|
Chrome.box(0, 12, 20, 6)
|
||||||
|
if moveMenu then
|
||||||
|
-- List box first (core.asm:5074-5084), MoveInfoBox on top (:5157).
|
||||||
|
Chrome.box(4, 12, 16, 6)
|
||||||
|
Chrome.box(0, 8, 11, 5)
|
||||||
|
end
|
||||||
if self.phase == "menu" then
|
if self.phase == "menu" then
|
||||||
self:printMessage()
|
self:printMessage()
|
||||||
local boxX = self.contest and CONTEST_MENU_BOX_X or MENU_BOX_X
|
local boxX = self.contest and CONTEST_MENU_BOX_X or MENU_BOX_X
|
||||||
@@ -3520,24 +3615,30 @@ function BattleState:drawPanel()
|
|||||||
moves = (mon and mon.moves) or moves
|
moves = (mon and mon.moves) or moves
|
||||||
end
|
end
|
||||||
local cursorRow = forgetting and self.forgetIndex or self.moveIndex
|
local cursorRow = forgetting and self.forgetIndex or self.moveIndex
|
||||||
|
-- w2DMenuCursorInitX 5 with the names at hlcoord 6 (core.asm:5086-5107).
|
||||||
|
local cursorCol = moveMenu and 5 or 1
|
||||||
|
local nameCol = moveMenu and 6 or 2
|
||||||
for i, move in ipairs(moves) do
|
for i, move in ipairs(moves) do
|
||||||
local ty = 13 + (i - 1)
|
local ty = 13 + (i - 1)
|
||||||
-- Cursor in the box's own gutter, not clipped against the border.
|
-- Cursor in the box's own gutter, not clipped against the border.
|
||||||
if i == cursorRow then Chrome.cursor(1, ty) end
|
if i == cursorRow then Chrome.cursor(cursorCol, ty) end
|
||||||
-- The held slot's marker. `.battle_player_moves` writes '▷' into the
|
-- The held slot's marker. `.battle_player_moves` writes '▷' into the
|
||||||
-- row wSwappingMove names (engine/battle/core.asm:5157-5165) so a move
|
-- row wSwappingMove names (engine/battle/core.asm:5157-5165) so a move
|
||||||
-- picked up for a swap is visible while the cursor moves off it. It
|
-- picked up for a swap is visible while the cursor moves off it. It
|
||||||
-- sits a column right of the cursor gutter, where the cart puts it
|
-- hlcoord 5, 13 is the cursor's own gutter, so PlaceMenuCursor covers
|
||||||
-- (hlcoord 5, 13 against the cursor's own column), and only while the
|
-- the marker on the cursor's row.
|
||||||
-- move list itself is up -- the forget picker has no swapping.
|
if not forgetting and self.moveSwapIndex == i and i ~= cursorRow then
|
||||||
if not forgetting and self.moveSwapIndex == i then
|
Chrome.print("\u{25B7}", cursorCol, ty)
|
||||||
Chrome.print("\u{25B7}", 0, ty)
|
|
||||||
end
|
end
|
||||||
local def = self.game and self.game.data and self.game.data.moves
|
local def = self.game and self.game.data and self.game.data.moves
|
||||||
and self.game.data.moves[move.id]
|
and self.game.data.moves[move.id]
|
||||||
Chrome.print((def and def.name) or move.id, 2, ty)
|
Chrome.print((def and def.name) or move.id, nameCol, ty)
|
||||||
Chrome.printRight(("%d/%d"):format(move.pp or 0, move.maxPp or 0), 19, ty)
|
if not moveMenu then
|
||||||
|
Chrome.printRight(("%d/%d"):format(move.pp or 0, move.maxPp or 0),
|
||||||
|
19, ty)
|
||||||
end
|
end
|
||||||
|
end
|
||||||
|
if moveMenu then self:drawMoveInfoBox(moves[cursorRow]) end
|
||||||
else
|
else
|
||||||
-- Battle messages wrap inside the box rather than running off the frame.
|
-- Battle messages wrap inside the box rather than running off the frame.
|
||||||
self:printMessage()
|
self:printMessage()
|
||||||
|
|||||||
@@ -337,10 +337,15 @@ function PackMenu:useSelected()
|
|||||||
return
|
return
|
||||||
end
|
end
|
||||||
local world = self.world
|
local world = self.world
|
||||||
local result = world and world.useFieldItem and world:useFieldItem(row.id)
|
local result, extra = nil, nil
|
||||||
|
if world and world.useFieldItem then result, extra = world:useFieldItem(row.id) end
|
||||||
if result then
|
if result then
|
||||||
if result == "nowhere" then
|
if result == "nowhere" then
|
||||||
self.message = OAK_THIS_ISNT_THE_TIME
|
self.message = OAK_THIS_ISNT_THE_TIME
|
||||||
|
elseif result == "coin_case" then
|
||||||
|
-- _CoinCaseCountText (data/text/common_3.asm:336): "Coins:" then the
|
||||||
|
-- count, text_decimal 4 digits with PRINTNUM_LEFTALIGN_F so no padding.
|
||||||
|
self.message = { "Coins:", tostring(extra or 0) }
|
||||||
elseif result == "repel_used" then
|
elseif result == "repel_used" then
|
||||||
-- ItemUsedText (data/text/common_3.asm): "<PLAYER> used the\n<ITEM>."
|
-- ItemUsedText (data/text/common_3.asm): "<PLAYER> used the\n<ITEM>."
|
||||||
-- World already wrote the counter and took the item out of the bag, so
|
-- World already wrote the counter and took the item out of the bag, so
|
||||||
|
|||||||
@@ -150,7 +150,11 @@ function PokedexMenu.new(game, opts)
|
|||||||
self.pokemon = opts.pokemon or data.pokemon
|
self.pokemon = opts.pokemon or data.pokemon
|
||||||
self.palettes = opts.palettes or data.gen2Palettes
|
self.palettes = opts.palettes or data.gen2Palettes
|
||||||
self.onClose = opts.onClose
|
self.onClose = opts.onClose
|
||||||
|
-- InitPokedex: wLastDexMode -> wCurDexMode (engine/pokedex/pokedex.asm:97).
|
||||||
self.modeIndex = 1
|
self.modeIndex = 1
|
||||||
|
for i, name in ipairs(MODES) do
|
||||||
|
if self.save and name == self.save.lastDexMode then self.modeIndex = i end
|
||||||
|
end
|
||||||
self.index = 1
|
self.index = 1
|
||||||
self.scroll = 0
|
self.scroll = 0
|
||||||
self.view = "list" -- list | entry | area | option | search | results | unown
|
self.view = "list" -- list | entry | area | option | search | results | unown
|
||||||
@@ -317,6 +321,13 @@ function PokedexMenu:cursorVisible()
|
|||||||
return ((self.entryBlink or 0) % 32) < 20
|
return ((self.entryBlink or 0) % 32) < 20
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- Pokedex: wCurDexMode -> wLastDexMode on the way out
|
||||||
|
-- (engine/pokedex/pokedex.asm:60), which lives in the saved game data.
|
||||||
|
function PokedexMenu:close()
|
||||||
|
if self.save then self.save.lastDexMode = MODES[self.modeIndex] end
|
||||||
|
if self.onClose then self.onClose() end
|
||||||
|
end
|
||||||
|
|
||||||
function PokedexMenu:update(_dt)
|
function PokedexMenu:update(_dt)
|
||||||
self.entryBlink = (self.entryBlink or 0) + 1
|
self.entryBlink = (self.entryBlink or 0) + 1
|
||||||
local input = self.game and self.game.input
|
local input = self.game and self.game.input
|
||||||
@@ -328,8 +339,8 @@ function PokedexMenu:update(_dt)
|
|||||||
if input:wasPressed("a") or input:wasPressed("b") then
|
if input:wasPressed("a") or input:wasPressed("b") then
|
||||||
if self.page == 1 then
|
if self.page == 1 then
|
||||||
self.page = 2
|
self.page = 2
|
||||||
elseif self.onClose then
|
else
|
||||||
self.onClose()
|
self:close()
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
return
|
return
|
||||||
@@ -367,7 +378,7 @@ function PokedexMenu:update(_dt)
|
|||||||
if self.view == "search" then return self:updateSearch(input) end
|
if self.view == "search" then return self:updateSearch(input) end
|
||||||
if self.view == "unown" then return self:updateUnown(input) end
|
if self.view == "unown" then return self:updateUnown(input) end
|
||||||
if input:wasPressed("b") then
|
if input:wasPressed("b") then
|
||||||
if self.onClose then self.onClose() end
|
self:close()
|
||||||
return
|
return
|
||||||
elseif input:wasPressed("select") then
|
elseif input:wasPressed("select") then
|
||||||
-- Pokedex_UpdateMainScreen: SELECT opens the OPTION screen and START the
|
-- Pokedex_UpdateMainScreen: SELECT opens the OPTION screen and START the
|
||||||
|
|||||||
@@ -1382,7 +1382,8 @@ end
|
|||||||
-- wTimeOfDay, as the cart numbers it: MORN 0, DAY 1, NITE 2, DARK 3.
|
-- wTimeOfDay, as the cart numbers it: MORN 0, DAY 1, NITE 2, DARK 3.
|
||||||
function Pokegear:timeOfDayIndex()
|
function Pokegear:timeOfDayIndex()
|
||||||
local world = self.game and self.game.world
|
local world = self.game and self.game.world
|
||||||
local daytime = (world and world.daytime)
|
-- the unpinned clock split, not the palette pin (pokegear.asm:1456, :1957)
|
||||||
|
local daytime = (world and (world.tod or world.daytime))
|
||||||
or Palettes.clockDaytime(self.clock and self.clock.hour or nil)
|
or Palettes.clockDaytime(self.clock and self.clock.hour or nil)
|
||||||
return (Palettes.DAYTIME_ID[daytime] or 2) - 1
|
return (Palettes.DAYTIME_ID[daytime] or 2) - 1
|
||||||
end
|
end
|
||||||
@@ -2280,7 +2281,9 @@ function Pokegear:drawPanel()
|
|||||||
self:drawClock()
|
self:drawClock()
|
||||||
end
|
end
|
||||||
-- Last: the arrow is an OBJ and composites over whatever the card drew.
|
-- Last: the arrow is an OBJ and composites over whatever the card drew.
|
||||||
self:drawModeArrow()
|
-- _FlyMap has no card strip and never animates it
|
||||||
|
-- (engine/pokegear/pokegear.asm:1999).
|
||||||
|
if not self.fly then self:drawModeArrow() end
|
||||||
G.setColor(1, 1, 1, 1)
|
G.setColor(1, 1, 1, 1)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -36,9 +36,8 @@
|
|||||||
-- $3f the shiny ⁂ icon (stats_tiles tile 14)
|
-- $3f the shiny ⁂ icon (stats_tiles tile 14)
|
||||||
-- $40 / $41 the left and right HP/exp bar end caps
|
-- $40 / $41 the left and right HP/exp bar end caps
|
||||||
--
|
--
|
||||||
-- The extractor does not carry that sheet yet, so `pageTile` draws those seven
|
-- The extractor writes that sheet as menu_gfx.stats, which `pageTile` draws.
|
||||||
-- shapes directly and takes the sheet the moment menu_gfx grows a `stats`
|
-- Everything that IS a glyph goes through the font: ◀ ($71), ▶ ($ed),
|
||||||
-- entry. Everything that IS a glyph goes through the font: ◀ ($71), ▶ ($ed),
|
|
||||||
-- № ($74), <ID> ($73), <LV> ($6e) and the row-7 rule's $62 (the empty HP/exp
|
-- № ($74), <ID> ($73), <LV> ($6e) and the row-7 rule's $62 (the empty HP/exp
|
||||||
-- bar cell, which is FontBattleExtra's -- hence Font.useBattleExtra(true)
|
-- bar cell, which is FontBattleExtra's -- hence Font.useBattleExtra(true)
|
||||||
-- around the whole screen, exactly as the party menu does).
|
-- around the whole screen, exactly as the party menu does).
|
||||||
@@ -95,6 +94,14 @@ local TILE_BAR_CAP_RIGHT = 0x41
|
|||||||
-- made of (StatsScreen_PlaceHorizontalDivider).
|
-- made of (StatsScreen_PlaceHorizontalDivider).
|
||||||
local TILE_HORIZONTAL_DIVIDER = 0x62
|
local TILE_HORIZONTAL_DIVIDER = 0x62
|
||||||
|
|
||||||
|
-- gfx/stats/pages.pal, the three palettes _CGB_StatsScreenHPPals copies to
|
||||||
|
-- wBGPals1 slots 3-5 (engine/gfx/cgb_layouts.asm:199-212)
|
||||||
|
local PAGE_PALETTES = {
|
||||||
|
{ { 255, 255, 255 }, { 255, 156, 255 }, { 255, 123, 255 }, { 0, 0, 0 } },
|
||||||
|
{ { 255, 255, 255 }, { 173, 255, 115 }, { 140, 255, 0 }, { 0, 0, 0 } },
|
||||||
|
{ { 255, 255, 255 }, { 140, 255, 255 }, { 140, 255, 255 }, { 0, 0, 0 } },
|
||||||
|
}
|
||||||
|
|
||||||
-- PrintTempMonStats' .StatNames, and the wTempMon fields it prints beside
|
-- PrintTempMonStats' .StatNames, and the wTempMon fields it prints beside
|
||||||
-- them. <NEXT> steps two rows, so the five labels are 2 rows apart and the
|
-- them. <NEXT> steps two rows, so the five labels are 2 rows apart and the
|
||||||
-- values start one row below the first label.
|
-- values start one row below the first label.
|
||||||
@@ -821,12 +828,37 @@ end
|
|||||||
|
|
||||||
-- ----------------------------------------------------------------- drawing
|
-- ----------------------------------------------------------------- drawing
|
||||||
|
|
||||||
-- A tile out of StatsScreenPageTilesGFX. The extractor does not carry that
|
-- menu_gfx.stats, the 17 tiles LoadStatsScreenPageTilesGFX lands at vTiles2
|
||||||
-- sheet, so each of the seven shapes it needs is drawn here; the moment
|
-- tile $31 (engine/gfx/load_font.asm:90-95)
|
||||||
-- menu_gfx grows a `stats` entry this can take the real tiles instead.
|
function SummaryMenu:statsTiles()
|
||||||
|
if self.statsSheet ~= nil then return self.statsSheet or nil end
|
||||||
|
local gfx = (self.menuGfx or {}).stats
|
||||||
|
local image = gfx and self:picImage(gfx.sheet)
|
||||||
|
if not image then
|
||||||
|
self.statsSheet = false
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
local w, h = image:getDimensions()
|
||||||
|
local quads = {}
|
||||||
|
for index = 0, (gfx.tiles or 17) - 1 do
|
||||||
|
quads[(gfx.firstTile or 0x31) + index] =
|
||||||
|
love.graphics.newQuad(index * 8, 0, 8, 8, w, h)
|
||||||
|
end
|
||||||
|
self.statsSheet = { image = image, quads = quads }
|
||||||
|
return self.statsSheet
|
||||||
|
end
|
||||||
|
|
||||||
|
-- A tile out of StatsScreenPageTilesGFX. The fallback arm draws each of the
|
||||||
|
-- seven shapes by hand for a cache built before menu_gfx.stats existed.
|
||||||
function SummaryMenu:pageTile(id, tx, ty)
|
function SummaryMenu:pageTile(id, tx, ty)
|
||||||
local G = love.graphics
|
local G = love.graphics
|
||||||
local px, py = tx * 8, ty * 8
|
local px, py = tx * 8, ty * 8
|
||||||
|
local sheet = self:statsTiles()
|
||||||
|
if sheet and sheet.quads[id] then
|
||||||
|
G.setColor(1, 1, 1, 1)
|
||||||
|
G.draw(sheet.image, sheet.quads[id], px, py)
|
||||||
|
return
|
||||||
|
end
|
||||||
G.setColor(0, 0, 0, 1)
|
G.setColor(0, 0, 0, 1)
|
||||||
if id == TILE_VERTICAL_DIVIDER then
|
if id == TILE_VERTICAL_DIVIDER then
|
||||||
G.rectangle("fill", px + 3, py, 2, 8)
|
G.rectangle("fill", px + 3, py, 2, 8)
|
||||||
@@ -847,11 +879,30 @@ end
|
|||||||
-- (17,5), all small ($36) first, then the one for this page redrawn large
|
-- (17,5), all small ($36) first, then the one for this page redrawn large
|
||||||
-- ($3a). The routine writes the four tiles as [hli]/[hld], a row down, then
|
-- ($3a). The routine writes the four tiles as [hli]/[hld], a row down, then
|
||||||
-- [hli]/[hl] -- which is why it is a 2x2 block and not a 2x1 strip.
|
-- [hli]/[hl] -- which is why it is a 2x2 block and not a 2x1 strip.
|
||||||
function SummaryMenu:drawPageSquare(tx, ty, large)
|
function SummaryMenu:drawPageSquare(tx, ty, large, colors)
|
||||||
local G = love.graphics
|
local G = love.graphics
|
||||||
local px, py = tx * 8, ty * 8
|
local px, py = tx * 8, ty * 8
|
||||||
-- $3a..$3d for the page that is up, $36..$39 for the other two.
|
-- $3a..$3d for the page that is up, $36..$39 for the other two.
|
||||||
local first = large and TILE_SQUARE_LARGE or TILE_SQUARE_SMALL
|
local first = large and TILE_SQUARE_LARGE or TILE_SQUARE_SMALL
|
||||||
|
local sheet = self:statsTiles()
|
||||||
|
if sheet and sheet.quads[first] then
|
||||||
|
-- [hli] / [hld], a row down, [hli] / [hl]: the four tiles in that
|
||||||
|
-- order (engine/pokemon/stats_screen.asm:841-853).
|
||||||
|
local function body()
|
||||||
|
G.setColor(1, 1, 1, 1)
|
||||||
|
G.draw(sheet.image, sheet.quads[first], px, py)
|
||||||
|
G.draw(sheet.image, sheet.quads[first + 1], px + 8, py)
|
||||||
|
G.draw(sheet.image, sheet.quads[first + 2], px, py + 8)
|
||||||
|
G.draw(sheet.image, sheet.quads[first + 3], px + 8, py + 8)
|
||||||
|
end
|
||||||
|
if colors and GbcPalette.available() then
|
||||||
|
GbcPalette.with(colors, body)
|
||||||
|
else
|
||||||
|
body()
|
||||||
|
end
|
||||||
|
G.setColor(1, 1, 1, 1)
|
||||||
|
return
|
||||||
|
end
|
||||||
local inset = first == TILE_SQUARE_LARGE and 2 or 5
|
local inset = first == TILE_SQUARE_LARGE and 2 or 5
|
||||||
local size = 16 - inset * 2
|
local size = 16 - inset * 2
|
||||||
G.setColor(0, 0, 0, 1)
|
G.setColor(0, 0, 0, 1)
|
||||||
@@ -862,7 +913,7 @@ end
|
|||||||
function SummaryMenu:drawPageIndicators()
|
function SummaryMenu:drawPageIndicators()
|
||||||
local columns = { 13, 15, 17 }
|
local columns = { 13, 15, 17 }
|
||||||
for i, tx in ipairs(columns) do
|
for i, tx in ipairs(columns) do
|
||||||
self:drawPageSquare(tx, 5, i == self.page)
|
self:drawPageSquare(tx, 5, i == self.page, PAGE_PALETTES[i])
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -1134,6 +1185,7 @@ end
|
|||||||
SummaryMenu.STAT_LABELS = STAT_LABELS
|
SummaryMenu.STAT_LABELS = STAT_LABELS
|
||||||
SummaryMenu.STAT_KEYS = STAT_KEYS
|
SummaryMenu.STAT_KEYS = STAT_KEYS
|
||||||
SummaryMenu.TYPE_NAMES = TYPE_NAMES
|
SummaryMenu.TYPE_NAMES = TYPE_NAMES
|
||||||
|
SummaryMenu.PAGE_PALETTES = PAGE_PALETTES
|
||||||
SummaryMenu.levelText = levelText
|
SummaryMenu.levelText = levelText
|
||||||
|
|
||||||
return SummaryMenu
|
return SummaryMenu
|
||||||
|
|||||||
+46
-4
@@ -918,16 +918,58 @@ function Kit.rowsThatFit(h, rowH, gap, minRows, maxRows)
|
|||||||
return math.max(minRows or 1, math.min(maxRows or 99, per))
|
return math.max(minRows or 1, math.min(maxRows or 99, per))
|
||||||
end
|
end
|
||||||
|
|
||||||
|
Kit.dragX = nil
|
||||||
|
Kit.dragY = nil
|
||||||
|
Kit.dragAccum = 0
|
||||||
|
|
||||||
|
function Kit.dragBegin(x, y)
|
||||||
|
Kit.dragX, Kit.dragY, Kit.dragAccum = x, y, 0
|
||||||
|
end
|
||||||
|
|
||||||
|
function Kit.dragAdd(dy)
|
||||||
|
if Kit.dragX then Kit.dragAccum = Kit.dragAccum + (dy or 0) end
|
||||||
|
end
|
||||||
|
|
||||||
|
function Kit.dragEnd()
|
||||||
|
Kit.dragX, Kit.dragY, Kit.dragAccum = nil, nil, 0
|
||||||
|
end
|
||||||
|
|
||||||
|
local function dragOriginIn(x, y, w, h)
|
||||||
|
if not Kit.dragX then return false end
|
||||||
|
local x1, y1, x2, y2 = x, y, x + w, y + h
|
||||||
|
local c = Kit._clipRect
|
||||||
|
if c then
|
||||||
|
x1, y1 = math.max(x1, c.x), math.max(y1, c.y)
|
||||||
|
x2, y2 = math.min(x2, c.x + c.w), math.min(y2, c.y + c.h)
|
||||||
|
end
|
||||||
|
return Kit.dragX >= x1 and Kit.dragX <= x2
|
||||||
|
and Kit.dragY >= y1 and Kit.dragY <= y2
|
||||||
|
end
|
||||||
|
|
||||||
-- Mouse wheel over a paginated list turns PAGES. The wheel still has to do
|
-- Mouse wheel over a paginated list turns PAGES. The wheel still has to do
|
||||||
-- something (users expect it), but it moves a bounded page index rather than
|
-- something (users expect it), but it moves a bounded page index rather than
|
||||||
-- driving a pixel offset, so there is no scroll state and no interpolation.
|
-- driving a pixel offset, so there is no scroll state and no interpolation.
|
||||||
function Kit.wheelPage(x, y, w, h, page, total, perPage)
|
function Kit.wheelPage(x, y, w, h, page, total, perPage)
|
||||||
if Kit.blockClicks or (Kit.wheelY or 0) == 0 then return page end
|
if Kit.blockClicks then return page end
|
||||||
if not Kit.hit(x, y, w, h) then return page end
|
|
||||||
local pages = math.max(1, math.ceil(total / math.max(1, perPage)))
|
local pages = math.max(1, math.ceil(total / math.max(1, perPage)))
|
||||||
local moved = Theme.clamp((page or 1) + (Kit.wheelY > 0 and -1 or 1), 1, pages)
|
local out = page
|
||||||
|
if (Kit.wheelY or 0) ~= 0 and Kit.hit(x, y, w, h) then
|
||||||
|
out = math.floor(Theme.clamp((out or 1) + (Kit.wheelY > 0 and -1 or 1),
|
||||||
|
1, pages))
|
||||||
Kit.wheelY = 0
|
Kit.wheelY = 0
|
||||||
return math.floor(moved)
|
end
|
||||||
|
local acc = Kit.dragAccum or 0
|
||||||
|
if acc ~= 0 and dragOriginIn(x, y, w, h) then
|
||||||
|
local stepPx = math.max(1, math.floor((h or 0) / 2))
|
||||||
|
local flips = acc >= 0 and math.floor(acc / stepPx)
|
||||||
|
or -math.floor(-acc / stepPx)
|
||||||
|
if flips ~= 0 then
|
||||||
|
local want = (out or 1) + flips
|
||||||
|
out = math.floor(Theme.clamp(want, 1, pages))
|
||||||
|
Kit.dragAccum = out ~= want and 0 or acc - flips * stepPx
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return out
|
||||||
end
|
end
|
||||||
|
|
||||||
function Kit.scrollExtent(contentH, viewH)
|
function Kit.scrollExtent(contentH, viewH)
|
||||||
|
|||||||
+137
-5
@@ -4,7 +4,8 @@
|
|||||||
-- on a background love.thread worker (src/update/check_worker.lua); this module
|
-- on a background love.thread worker (src/update/check_worker.lua); this module
|
||||||
-- is only the thin main-thread state machine the UI polls. Two channels carry
|
-- is only the thin main-thread state machine the UI polls. Two channels carry
|
||||||
-- the conversation:
|
-- the conversation:
|
||||||
-- "update_check_cmd" main -> worker: { cmd = "check" | "download" | "quit" }
|
-- "update_check_cmd" main -> worker: { cmd = "check" | "download" |
|
||||||
|
-- "download_full" | "quit" }
|
||||||
-- "update_check_state" worker -> main: { status, latest, progress, error }
|
-- "update_check_state" worker -> main: { status, latest, progress, error }
|
||||||
--
|
--
|
||||||
-- Nothing here ever blocks or throws into the game loop: when love.thread is
|
-- Nothing here ever blocks or throws into the game loop: when love.thread is
|
||||||
@@ -21,6 +22,42 @@ local Platform = require("src.core.Platform")
|
|||||||
|
|
||||||
Check.REPO = "bryanthaboi/gen1recomp"
|
Check.REPO = "bryanthaboi/gen1recomp"
|
||||||
|
|
||||||
|
-- Full native packages are deliberately named by release target, not by the
|
||||||
|
-- generic .love payload. Keeping the mapping here makes the release parser,
|
||||||
|
-- worker and launcher agree on exactly which asset a platform may offer.
|
||||||
|
-- Switch owns its native OTA launcher and therefore never reaches this code.
|
||||||
|
local function fullAssetName(version, osName, arch, port)
|
||||||
|
if port == "rg34xxsp" then
|
||||||
|
return "gen1recomp-" .. version .. "-rg34xxsp-stockos64-mod.zip"
|
||||||
|
elseif port == "portmaster" then
|
||||||
|
return "gen1recomp-" .. version .. "-sbc-portmaster.zip"
|
||||||
|
elseif osName == "Android" then
|
||||||
|
return "gen1recomp-" .. version .. "-android.apk"
|
||||||
|
elseif osName == "iOS" then
|
||||||
|
return "gen1recomp++-" .. version .. "-ios.ipa"
|
||||||
|
elseif osName == "OS X" or osName == "macOS" then
|
||||||
|
return "gen1recomp-" .. version .. "-macos.zip"
|
||||||
|
elseif osName == "Windows" then
|
||||||
|
return "gen1recomp-" .. version .. "-windows.zip"
|
||||||
|
elseif osName == "UWP" then
|
||||||
|
return "gen1recomp-" .. version .. "-xbox-uwp.zip"
|
||||||
|
elseif osName == "NX" then
|
||||||
|
return "gen1recomp-" .. version .. "-switch.zip"
|
||||||
|
elseif osName == "Linux" and (arch == "arm64" or arch == "aarch64") then
|
||||||
|
return "gen1recomp-" .. version .. "-linux-arm64.AppImage"
|
||||||
|
elseif osName == "Linux" then
|
||||||
|
return "gen1recomp-" .. version .. "-linux.zip"
|
||||||
|
end
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
function Check.fullAssetName(version, osName, arch, port)
|
||||||
|
if type(version) ~= "string" or not version:match("^%d+%.%d+%.%d+$") then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
return fullAssetName(version, osName, arch, port)
|
||||||
|
end
|
||||||
|
|
||||||
local CMD = "update_check_cmd"
|
local CMD = "update_check_cmd"
|
||||||
local STATE = "update_check_state"
|
local STATE = "update_check_state"
|
||||||
|
|
||||||
@@ -50,7 +87,7 @@ end
|
|||||||
-- document is not a release with a strict X.Y.Z tag. Json is injected so the
|
-- document is not a release with a strict X.Y.Z tag. Json is injected so the
|
||||||
-- worker can pass a filesystem-loaded codec; on the main thread / in tests it
|
-- worker can pass a filesystem-loaded codec; on the main thread / in tests it
|
||||||
-- falls back to require.
|
-- falls back to require.
|
||||||
function Check.parseRelease(jsonText, Json)
|
function Check.parseRelease(jsonText, Json, target)
|
||||||
Json = Json or require("src.link.Json")
|
Json = Json or require("src.link.Json")
|
||||||
local notJson = Json.describeUnexpected(jsonText)
|
local notJson = Json.describeUnexpected(jsonText)
|
||||||
if notJson then return nil, notJson end
|
if notJson then return nil, notJson end
|
||||||
@@ -66,11 +103,15 @@ function Check.parseRelease(jsonText, Json)
|
|||||||
return nil, "release tag is not X.Y.Z: " .. tostring(doc.tag_name)
|
return nil, "release tag is not X.Y.Z: " .. tostring(doc.tag_name)
|
||||||
end
|
end
|
||||||
local payloadName = "gen1recomp-" .. version .. ".love"
|
local payloadName = "gen1recomp-" .. version .. ".love"
|
||||||
|
target = type(target) == "table" and target or {}
|
||||||
|
local fullName = fullAssetName(version, target.os, target.arch, target.port)
|
||||||
return {
|
return {
|
||||||
version = version,
|
version = version,
|
||||||
payloadName = payloadName,
|
payloadName = payloadName,
|
||||||
payload = Check.pickAsset(doc.assets, payloadName),
|
payload = Check.pickAsset(doc.assets, payloadName),
|
||||||
sums = Check.pickAsset(doc.assets, "sha256sums.txt"),
|
sums = Check.pickAsset(doc.assets, "sha256sums.txt"),
|
||||||
|
fullName = fullName,
|
||||||
|
full = fullName and Check.pickAsset(doc.assets, fullName) or nil,
|
||||||
-- GitHub release body: already fetched with the update check, shown by
|
-- GitHub release body: already fetched with the update check, shown by
|
||||||
-- the launcher's Patch notes footer button.
|
-- the launcher's Patch notes footer button.
|
||||||
notes = type(doc.body) == "string" and doc.body or "",
|
notes = type(doc.body) == "string" and doc.body or "",
|
||||||
@@ -106,6 +147,37 @@ local workerReady -- nil = untried, true = running, false = unavailable
|
|||||||
local requested -- a check has been asked for this session
|
local requested -- a check has been asked for this session
|
||||||
local cache = { status = "idle" } -- newest snapshot from the worker
|
local cache = { status = "idle" } -- newest snapshot from the worker
|
||||||
|
|
||||||
|
local function target()
|
||||||
|
local osName = love and love.system and love.system.getOS and love.system.getOS() or nil
|
||||||
|
local arch = jit and jit.arch or nil
|
||||||
|
local port = os.getenv("POKEPORT_PORTMASTER")
|
||||||
|
return { os = osName, arch = arch, port = port }
|
||||||
|
end
|
||||||
|
|
||||||
|
local function readPersistedFullRequirement()
|
||||||
|
if not (love and love.filesystem and love.filesystem.getInfo) then return nil end
|
||||||
|
local path = "updates/full-update.json"
|
||||||
|
if not love.filesystem.getInfo(path) then return nil end
|
||||||
|
local text = love.filesystem.read(path)
|
||||||
|
if type(text) ~= "string" then return nil end
|
||||||
|
local ok, Json = pcall(require, "src.link.Json")
|
||||||
|
if not ok or not Json then return nil end
|
||||||
|
local decodedOk, requirement = pcall(Json.decode, text)
|
||||||
|
if not decodedOk or type(requirement) ~= "table" then return nil end
|
||||||
|
if type(requirement.version) ~= "string" then return nil end
|
||||||
|
return requirement
|
||||||
|
end
|
||||||
|
|
||||||
|
local persistedRequirement = readPersistedFullRequirement()
|
||||||
|
if persistedRequirement then
|
||||||
|
cache = {
|
||||||
|
status = "needs_full",
|
||||||
|
latest = persistedRequirement.version,
|
||||||
|
reason = persistedRequirement.reason,
|
||||||
|
full = persistedRequirement.full,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
local function ensureWorker()
|
local function ensureWorker()
|
||||||
if workerReady ~= nil then return workerReady end
|
if workerReady ~= nil then return workerReady end
|
||||||
if not Platform.networkValidated() then
|
if not Platform.networkValidated() then
|
||||||
@@ -167,11 +239,13 @@ function Check.start(force)
|
|||||||
end
|
end
|
||||||
requested = true
|
requested = true
|
||||||
cache = { status = "checking", notes = cache.notes, latest = cache.latest }
|
cache = { status = "checking", notes = cache.notes, latest = cache.latest }
|
||||||
cmdCh:push({ cmd = "check" })
|
cmdCh:push({ cmd = "check", target = target() })
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Current snapshot: { status, latest, progress, error, notes }. status is one of
|
-- Current snapshot: { status, latest, progress, error, notes, reason, full }.
|
||||||
-- idle | checking | uptodate | available | downloading | ready | needs_full | error.
|
-- status is one of
|
||||||
|
-- idle | checking | uptodate | available | downloading | ready | needs_full |
|
||||||
|
-- full_downloading | full_ready | error.
|
||||||
function Check.state()
|
function Check.state()
|
||||||
drain()
|
drain()
|
||||||
return {
|
return {
|
||||||
@@ -180,9 +254,67 @@ function Check.state()
|
|||||||
progress = cache.progress,
|
progress = cache.progress,
|
||||||
error = cache.error,
|
error = cache.error,
|
||||||
notes = cache.notes,
|
notes = cache.notes,
|
||||||
|
reason = cache.reason,
|
||||||
|
full = cache.full,
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- The full-update record is intentionally persistent. An offline launch still
|
||||||
|
-- tells the player why this native shell cannot run the downloaded release.
|
||||||
|
function Check.fullUpdateAction()
|
||||||
|
drain()
|
||||||
|
local st = Check.state()
|
||||||
|
if st.status ~= "needs_full" and st.status ~= "full_ready" then return nil end
|
||||||
|
local osName = love and love.system and love.system.getOS and love.system.getOS() or ""
|
||||||
|
if osName == "Android" and type(love.system.installApk) == "function"
|
||||||
|
and type(st.full) == "table" and type(st.full.url) == "string" then
|
||||||
|
if st.status == "full_ready" and type(st.full.path) == "string" then
|
||||||
|
return { label = "Install Android update", kind = "install" }
|
||||||
|
end
|
||||||
|
return { label = "Download Android update", kind = "download" }
|
||||||
|
end
|
||||||
|
if osName == "iOS" then
|
||||||
|
return { label = "Re-sideload app", url =
|
||||||
|
"https://github.com/bryanthaboi/gen1recomp/raw/refs/heads/main/mobile/ios/app-repo.json" }
|
||||||
|
elseif osName == "UWP" then
|
||||||
|
return { label = "Open Xbox install guide", url = Check.releaseUrl() }
|
||||||
|
elseif type(st.full) == "table" and type(st.full.url) == "string" then
|
||||||
|
return { label = "Download full update", url = st.full.url }
|
||||||
|
end
|
||||||
|
return { label = "Open releases", url = Check.releaseUrl() }
|
||||||
|
end
|
||||||
|
|
||||||
|
function Check.downloadFull()
|
||||||
|
drain()
|
||||||
|
if not cmdCh or cache.status ~= "needs_full" then return false end
|
||||||
|
if not (cache.full and cache.full.url) then return false end
|
||||||
|
cache = { status = "full_downloading", latest = cache.latest, progress = 0,
|
||||||
|
reason = cache.reason, full = cache.full, notes = cache.notes }
|
||||||
|
cmdCh:push({ cmd = "download_full" })
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
|
function Check.installFull()
|
||||||
|
drain()
|
||||||
|
if cache.status ~= "full_ready" then return false end
|
||||||
|
local path = cache.full and cache.full.path
|
||||||
|
if type(path) ~= "string" or path == "" then return false end
|
||||||
|
if not (love and love.system and type(love.system.installApk) == "function") then return false end
|
||||||
|
local ok, started = pcall(love.system.installApk, path)
|
||||||
|
return ok and started == true
|
||||||
|
end
|
||||||
|
|
||||||
|
function Check.performFullUpdate()
|
||||||
|
local action = Check.fullUpdateAction()
|
||||||
|
if not action then return false end
|
||||||
|
if action.kind == "download" then return Check.downloadFull() end
|
||||||
|
if action.kind == "install" then return Check.installFull() end
|
||||||
|
if action.url and love and love.system and love.system.openURL then
|
||||||
|
return pcall(love.system.openURL, action.url)
|
||||||
|
end
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
-- Start downloading the payload announced by an "available" check. A no-op in
|
-- Start downloading the payload announced by an "available" check. A no-op in
|
||||||
-- any other state (the worker still holds the release info from the check).
|
-- any other state (the worker still holds the release info from the check).
|
||||||
function Check.download()
|
function Check.download()
|
||||||
|
|||||||
+112
-15
@@ -2,7 +2,8 @@
|
|||||||
--
|
--
|
||||||
-- Runs on a love.thread so no curl call, sha256 pass or archive probe ever
|
-- Runs on a love.thread so no curl call, sha256 pass or archive probe ever
|
||||||
-- touches the render thread. Talks over two channels:
|
-- touches the render thread. Talks over two channels:
|
||||||
-- "update_check_cmd" in: { cmd = "check" | "download" | "quit" }
|
-- "update_check_cmd" in: { cmd = "check" | "download" |
|
||||||
|
-- "download_full" | "quit" }
|
||||||
-- "update_check_state" out: { status, latest, progress, error }
|
-- "update_check_state" out: { status, latest, progress, error }
|
||||||
--
|
--
|
||||||
-- Transport is HostShell: curl via io.popen on desktop, the JNI
|
-- Transport is HostShell: curl via io.popen on desktop, the JNI
|
||||||
@@ -44,6 +45,11 @@ local Boot = loadModule("src/update/Boot.lua")
|
|||||||
local cmdCh = love.thread.getChannel("update_check_cmd")
|
local cmdCh = love.thread.getChannel("update_check_cmd")
|
||||||
local stateCh = love.thread.getChannel("update_check_state")
|
local stateCh = love.thread.getChannel("update_check_state")
|
||||||
|
|
||||||
|
-- The release chosen by the last check. Declare this before post() so status
|
||||||
|
-- messages consistently preserve its release notes instead of accidentally
|
||||||
|
-- reading a global named `pending`.
|
||||||
|
local pending = nil
|
||||||
|
|
||||||
local function post(t)
|
local function post(t)
|
||||||
if pending and type(t) == "table" and t.notes == nil then
|
if pending and type(t) == "table" and t.notes == nil then
|
||||||
t.notes = pending.notes
|
t.notes = pending.notes
|
||||||
@@ -57,10 +63,6 @@ local saveDir = love.filesystem.getSaveDirectory()
|
|||||||
|
|
||||||
local API_URL = "https://api.github.com/repos/bryanthaboi/gen1recomp/releases/latest"
|
local API_URL = "https://api.github.com/repos/bryanthaboi/gen1recomp/releases/latest"
|
||||||
|
|
||||||
-- the release picked by the last "check"; kept between commands so "download"
|
|
||||||
-- knows the payload url/size/name without re-fetching
|
|
||||||
local pending = nil
|
|
||||||
|
|
||||||
-- ---------------------------------------------------------------------------
|
-- ---------------------------------------------------------------------------
|
||||||
-- shell / fetch
|
-- shell / fetch
|
||||||
-- ---------------------------------------------------------------------------
|
-- ---------------------------------------------------------------------------
|
||||||
@@ -138,6 +140,10 @@ local function verifyPayload(rel, payloadName, sumsText)
|
|||||||
return true
|
return true
|
||||||
end
|
end
|
||||||
|
|
||||||
|
local function verifyFullPackage(rel, assetName, sumsText)
|
||||||
|
return verifyPayload(rel, assetName, sumsText)
|
||||||
|
end
|
||||||
|
|
||||||
-- true = ok to run, false = payload needs a newer shell (needs_full). When Boot
|
-- true = ok to run, false = payload needs a newer shell (needs_full). When Boot
|
||||||
-- cannot probe (module missing during parallel dev, or a probe failure) we allow
|
-- cannot probe (module missing during parallel dev, or a probe failure) we allow
|
||||||
-- it: Boot.run's crash-guard handles a payload that turns out unrunnable.
|
-- it: Boot.run's crash-guard handles a payload that turns out unrunnable.
|
||||||
@@ -147,8 +153,34 @@ local function gatePasses(rel)
|
|||||||
if not info then return true end
|
if not info then return true end
|
||||||
local shell = (Version and Version.shell) or 1
|
local shell = (Version and Version.shell) or 1
|
||||||
local payloadHost = (Version and Version.payloadHost) or "love"
|
local payloadHost = (Version and Version.payloadHost) or "love"
|
||||||
if Boot.canHost then return Boot.canHost(info, shell, payloadHost) end
|
if info.payloadHost and info.payloadHost ~= payloadHost then return false, "payload_host" end
|
||||||
return not (info.minShell and info.minShell > shell)
|
if info.minShell and info.minShell > shell then return false, "min_shell" end
|
||||||
|
if Boot.canHost and not Boot.canHost(info, shell, payloadHost) then return false, "shell_gate" end
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
|
local function persistFullRequirement(rel, reason)
|
||||||
|
if not (rel and rel.version and Json) then return end
|
||||||
|
local full = rel.full
|
||||||
|
local record = {
|
||||||
|
version = rel.version,
|
||||||
|
reason = reason or "full_package_required",
|
||||||
|
full = full and { name = rel.fullName, url = full.url, size = full.size } or nil,
|
||||||
|
}
|
||||||
|
pcall(function()
|
||||||
|
love.filesystem.createDirectory("updates")
|
||||||
|
love.filesystem.write("updates/full-update.json", Json.encode(record))
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function postFullRequirement(rel, reason)
|
||||||
|
persistFullRequirement(rel, reason)
|
||||||
|
post({ status = "needs_full", latest = rel and rel.version, reason = reason,
|
||||||
|
full = rel and rel.full and { name = rel.fullName, url = rel.full.url, size = rel.full.size } or nil })
|
||||||
|
end
|
||||||
|
|
||||||
|
local function clearFullRequirement()
|
||||||
|
pcall(function() love.filesystem.remove("updates/full-update.json") end)
|
||||||
end
|
end
|
||||||
|
|
||||||
local function cacheNotes(ver, notes)
|
local function cacheNotes(ver, notes)
|
||||||
@@ -177,7 +209,7 @@ end
|
|||||||
-- check
|
-- check
|
||||||
-- ---------------------------------------------------------------------------
|
-- ---------------------------------------------------------------------------
|
||||||
|
|
||||||
local function doCheck()
|
local function doCheck(target)
|
||||||
post({ status = "checking" })
|
post({ status = "checking" })
|
||||||
|
|
||||||
if not canFetch() then
|
if not canFetch() then
|
||||||
@@ -193,7 +225,7 @@ local function doCheck()
|
|||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
local rel, perr = Check.parseRelease(body, Json)
|
local rel, perr = Check.parseRelease(body, Json, target)
|
||||||
if not rel then
|
if not rel then
|
||||||
post({ status = "error", error = perr or "bad release json" })
|
post({ status = "error", error = perr or "bad release json" })
|
||||||
return
|
return
|
||||||
@@ -212,6 +244,9 @@ local function doCheck()
|
|||||||
end
|
end
|
||||||
|
|
||||||
if compareVersions(rel.version, currentEngine) <= 0 then
|
if compareVersions(rel.version, currentEngine) <= 0 then
|
||||||
|
-- We are now running a native shell at least as new as GitHub's latest
|
||||||
|
-- release, so a former minShell/payloadHost prompt no longer applies.
|
||||||
|
clearFullRequirement()
|
||||||
post({ status = "uptodate", latest = rel.version })
|
post({ status = "uptodate", latest = rel.version })
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
@@ -219,7 +254,7 @@ local function doCheck()
|
|||||||
-- A newer release, but without the .love payload or its sums we cannot do an
|
-- A newer release, but without the .love payload or its sums we cannot do an
|
||||||
-- in-place update: send the user to the full installers.
|
-- in-place update: send the user to the full installers.
|
||||||
if not (rel.payload and rel.payload.url and rel.sums and rel.sums.url) then
|
if not (rel.payload and rel.payload.url and rel.sums and rel.sums.url) then
|
||||||
post({ status = "needs_full", latest = rel.version })
|
postFullRequirement(rel, "payload_missing")
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -229,9 +264,10 @@ local function doCheck()
|
|||||||
if love.filesystem.getInfo(finalRel) then
|
if love.filesystem.getInfo(finalRel) then
|
||||||
local sums = fetchText(rel.sums.url)
|
local sums = fetchText(rel.sums.url)
|
||||||
if sums and verifyPayload(finalRel, rel.payloadName, sums) then
|
if sums and verifyPayload(finalRel, rel.payloadName, sums) then
|
||||||
if gatePasses(finalRel) == false then
|
local allowed, reason = gatePasses(finalRel)
|
||||||
|
if allowed == false then
|
||||||
love.filesystem.remove(finalRel)
|
love.filesystem.remove(finalRel)
|
||||||
post({ status = "needs_full", latest = rel.version })
|
postFullRequirement(rel, reason)
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
post({ status = "ready", latest = rel.version })
|
post({ status = "ready", latest = rel.version })
|
||||||
@@ -353,9 +389,10 @@ local function doDownload()
|
|||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
if gatePasses(partRel) == false then
|
local allowed, reason = gatePasses(partRel)
|
||||||
|
if allowed == false then
|
||||||
love.filesystem.remove(partRel)
|
love.filesystem.remove(partRel)
|
||||||
post({ status = "needs_full", latest = rel.version })
|
postFullRequirement(rel, reason)
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -374,6 +411,63 @@ local function doDownload()
|
|||||||
post({ status = "ready", latest = rel.version })
|
post({ status = "ready", latest = rel.version })
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- Full native-package download. At present Android consumes the verified file
|
||||||
|
-- through its Package Installer bridge. Other platforms retain the same
|
||||||
|
-- release metadata and fall back to their platform-specific external update
|
||||||
|
-- channel rather than attempting to overwrite a running executable.
|
||||||
|
local function doDownloadFull()
|
||||||
|
if not (pending and pending.full and pending.full.url and pending.fullName
|
||||||
|
and pending.sums and pending.sums.url) then
|
||||||
|
post({ status = "error", error = "full package is unavailable" })
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
local rel = pending
|
||||||
|
local asset = rel.full
|
||||||
|
local name = rel.fullName
|
||||||
|
love.filesystem.createDirectory("updates")
|
||||||
|
local partRel = "updates/" .. name .. ".part"
|
||||||
|
local doneRel = "updates/" .. name
|
||||||
|
local partAbs = saveDir .. "/" .. partRel
|
||||||
|
local doneAbs = saveDir .. "/" .. doneRel
|
||||||
|
love.filesystem.remove(partRel)
|
||||||
|
love.filesystem.remove(doneRel)
|
||||||
|
post({ status = "full_downloading", latest = rel.version, progress = 0,
|
||||||
|
reason = "full_package_required", full = { name = name, url = asset.url, size = asset.size } })
|
||||||
|
|
||||||
|
local ok = HostShell and HostShell.httpDownload(asset.url, partAbs, UA, nil, 900)
|
||||||
|
if not ok then
|
||||||
|
love.filesystem.remove(partRel)
|
||||||
|
postFullRequirement(rel, "full_download_failed")
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
local sums = fetchText(rel.sums.url)
|
||||||
|
if not sums then
|
||||||
|
love.filesystem.remove(partRel)
|
||||||
|
postFullRequirement(rel, "full_checksum_fetch_failed")
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local valid, err = verifyFullPackage(partRel, name, sums)
|
||||||
|
if not valid then
|
||||||
|
love.filesystem.remove(partRel)
|
||||||
|
post({ status = "error", error = err or "full package verification failed" })
|
||||||
|
return
|
||||||
|
end
|
||||||
|
if not os.rename(partAbs, doneAbs) then
|
||||||
|
local data = love.filesystem.read(partRel)
|
||||||
|
if not data then
|
||||||
|
post({ status = "error", error = "full package finalize failed" })
|
||||||
|
return
|
||||||
|
end
|
||||||
|
love.filesystem.write(doneRel, data)
|
||||||
|
love.filesystem.remove(partRel)
|
||||||
|
end
|
||||||
|
persistFullRequirement(rel, "full_package_required")
|
||||||
|
post({ status = "full_ready", latest = rel.version, reason = "full_package_required",
|
||||||
|
full = { name = name, url = asset.url, size = asset.size, path = doneAbs } })
|
||||||
|
end
|
||||||
|
|
||||||
-- ---------------------------------------------------------------------------
|
-- ---------------------------------------------------------------------------
|
||||||
-- command loop
|
-- command loop
|
||||||
-- ---------------------------------------------------------------------------
|
-- ---------------------------------------------------------------------------
|
||||||
@@ -384,11 +478,14 @@ while true do
|
|||||||
if cmd.cmd == "quit" then
|
if cmd.cmd == "quit" then
|
||||||
break
|
break
|
||||||
elseif cmd.cmd == "check" then
|
elseif cmd.cmd == "check" then
|
||||||
local ok, err = pcall(doCheck)
|
local ok, err = pcall(doCheck, cmd.target)
|
||||||
if not ok then post({ status = "error", error = tostring(err) }) end
|
if not ok then post({ status = "error", error = tostring(err) }) end
|
||||||
elseif cmd.cmd == "download" then
|
elseif cmd.cmd == "download" then
|
||||||
local ok, err = pcall(doDownload)
|
local ok, err = pcall(doDownload)
|
||||||
if not ok then post({ status = "error", error = tostring(err) }) end
|
if not ok then post({ status = "error", error = tostring(err) }) end
|
||||||
|
elseif cmd.cmd == "download_full" then
|
||||||
|
local ok, err = pcall(doDownloadFull)
|
||||||
|
if not ok then post({ status = "error", error = tostring(err) }) end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -846,15 +846,20 @@ function OverworldState:useSoftboiledFieldMove(user, target)
|
|||||||
if not user or not user.stats or not target or not target.stats
|
if not user or not user.stats or not target or not target.stats
|
||||||
or target == user or target.hp <= 0
|
or target == user or target.hp <= 0
|
||||||
or target.hp >= target.stats.hp or user.hp <= heal then
|
or target.hp >= target.stats.hp or user.hp <= heal then
|
||||||
Game.stack:push(TextBox.new(Game, Strings("It won't have\nany effect.")))
|
Game.stack:push(TextBox.new(Game,
|
||||||
|
romText(Game.data, "_ItemUseNoEffectText", "It won't have\nany effect.")))
|
||||||
return false
|
return false
|
||||||
end
|
end
|
||||||
|
local before = target.hp
|
||||||
user.hp = user.hp - heal
|
user.hp = user.hp - heal
|
||||||
target.hp = math.min(target.stats.hp, target.hp + heal)
|
target.hp = math.min(target.stats.hp, target.hp + heal)
|
||||||
require("src.core.Sound").play(Game.data, "Heal_HP")
|
require("src.core.Sound").play(Game.data, "Heal_HP")
|
||||||
local def = Game.data.pokemon[target.species]
|
local def = Game.data.pokemon[target.species]
|
||||||
|
-- _PotionText's second slot is the recovered amount, same as
|
||||||
|
-- ItemEffects.lua's potion message -- the engine fallback never shows it
|
||||||
Game.stack:push(TextBox.new(Game,
|
Game.stack:push(TextBox.new(Game,
|
||||||
Strings("%s's HP\nwas restored!", target.nickname or def.name)))
|
romText(Game.data, "_PotionText", "%s's HP\nwas restored!",
|
||||||
|
target.nickname or def.name, target.hp - before)))
|
||||||
return true
|
return true
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -2126,7 +2131,8 @@ function OverworldState:tryHiddenObject(fx, fy)
|
|||||||
-- leaves the spot unfound; _CantCarryMoreText is the Toss line (#872)
|
-- leaves the spot unfound; _CantCarryMoreText is the Toss line (#872)
|
||||||
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
|
||||||
Game.stack:push(TextBox.new(Game,
|
Game.stack:push(TextBox.new(Game,
|
||||||
Strings("%s found\n%s!", save.player.name, name) .. "\f"
|
romText(Game.data, "_FoundHiddenItemText", "%s found\n%s!",
|
||||||
|
save.player.name, name) .. "\f"
|
||||||
.. romText(Game.data, "_HiddenItemBagFullText",
|
.. romText(Game.data, "_HiddenItemBagFullText",
|
||||||
"But, {PLAYER} has\nno more room for\vother items!")))
|
"But, {PLAYER} has\nno more room for\vother items!")))
|
||||||
return true
|
return true
|
||||||
@@ -2137,7 +2143,8 @@ function OverworldState:tryHiddenObject(fx, fy)
|
|||||||
-- text_asm tail runs it as PlaySoundWaitForCurrent +
|
-- text_asm tail runs it as PlaySoundWaitForCurrent +
|
||||||
-- WaitForSoundToFinish once the box has printed (hidden_items.asm)
|
-- 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),
|
romText(Game.data, "_FoundHiddenItemText", "%s found\n%s!",
|
||||||
|
save.player.name, name),
|
||||||
nil, TextBox.soundOpts(Game, "Get_Item2")))
|
nil, TextBox.soundOpts(Game, "Get_Item2")))
|
||||||
return true
|
return true
|
||||||
end
|
end
|
||||||
@@ -2795,8 +2802,8 @@ function OverworldState:talkTo(npc)
|
|||||||
"No more room for\nitems!")
|
"No more room for\nitems!")
|
||||||
if GameVersion.isYellow() then
|
if GameVersion.isYellow() then
|
||||||
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
|
||||||
noRoom = Strings("%s found\n%s!", Game.save.player.name, name)
|
noRoom = romText(Game.data, "_FoundItemText", "%s found\n%s!",
|
||||||
.. "\f" .. noRoom
|
Game.save.player.name, name) .. "\f" .. noRoom
|
||||||
end
|
end
|
||||||
Game.stack:push(TextBox.new(Game, noRoom))
|
Game.stack:push(TextBox.new(Game, noRoom))
|
||||||
return
|
return
|
||||||
@@ -2813,7 +2820,8 @@ function OverworldState:talkTo(npc)
|
|||||||
local ddef = Game.data.items[d.item]
|
local ddef = Game.data.items[d.item]
|
||||||
-- FoundItemText: text_far, sound_get_item_1, text_end (pick_up_item.asm)
|
-- FoundItemText: text_far, sound_get_item_1, text_end (pick_up_item.asm)
|
||||||
Game.stack:push(TextBox.new(Game,
|
Game.stack:push(TextBox.new(Game,
|
||||||
Strings("%s found\n%s!", Game.save.player.name, name), nil,
|
romText(Game.data, "_FoundItemText", "%s found\n%s!",
|
||||||
|
Game.save.player.name, name), nil,
|
||||||
TextBox.soundOpts(Game,
|
TextBox.soundOpts(Game,
|
||||||
(ddef and ddef.keyItem) and "Get_Key_Item" or "Get_Item1")))
|
(ddef and ddef.keyItem) and "Get_Key_Item" or "Get_Item1")))
|
||||||
return
|
return
|
||||||
@@ -2959,6 +2967,24 @@ function OverworldState:openPC(onDone)
|
|||||||
self:openOaksPC(done)
|
self:openOaksPC(done)
|
||||||
end,
|
end,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
-- engine/pokemon/bills_pc.asm:48-60 PKMN LEAGUE row (#1566)
|
||||||
|
if #(Game.save.hallOfFame or {}) > 0 then
|
||||||
|
table.insert(items, {
|
||||||
|
label = Strings("<PK><MN>LEAGUE"),
|
||||||
|
keepOpen = true,
|
||||||
|
onSelect = function()
|
||||||
|
-- pc.asm PKMNLeague plays SFX_ENTER_PC, then PKMNLeaguePC prints
|
||||||
|
-- AccessedHoFPCText (engine/menus/pc.asm:67, league_pc.asm:2)
|
||||||
|
require("src.core.Sound").play(Game.data, "Enter_PC")
|
||||||
|
Game.stack:push(TextBox.new(Game,
|
||||||
|
romText(Game.data, "_AccessedHoFPCText",
|
||||||
|
"Accessed POKéMON\nLEAGUE's site.\fAccessed the HALL\nOF FAME List."),
|
||||||
|
function() Screens.push(Game, "LeaguePC") end))
|
||||||
|
done()
|
||||||
|
end,
|
||||||
|
})
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
local hooked = Runtime.call("ui.pc.items", sameItems, Game, items)
|
local hooked = Runtime.call("ui.pc.items", sameItems, Game, items)
|
||||||
@@ -3293,7 +3319,8 @@ end
|
|||||||
-- the approach walk and then EngageMapTrainer with no further text: the
|
-- the approach walk and then EngageMapTrainer with no further text: the
|
||||||
-- caller already showed the box, so the battle starts without a second
|
-- caller already showed the box, so the battle starts without a second
|
||||||
-- one (#869).
|
-- one (#869).
|
||||||
function OverworldState:engageTrainer(npc, onDone, endBattleText, skipBattleText)
|
function OverworldState:engageTrainer(npc, onDone, endBattleText, skipBattleText,
|
||||||
|
endBattleSound, endBattleIsReward)
|
||||||
local d = npc.def
|
local d = npc.def
|
||||||
Runtime.emit("world.trainer_engaged", { npc = npc, trainerClass = d.trainerClass,
|
Runtime.emit("world.trainer_engaged", { npc = npc, trainerClass = d.trainerClass,
|
||||||
partyIndex = d.trainerParty })
|
partyIndex = d.trainerParty })
|
||||||
@@ -3349,6 +3376,13 @@ function OverworldState:engageTrainer(npc, onDone, endBattleText, skipBattleText
|
|||||||
-- cuts (#282). Substituted here because BattleState:say takes finished
|
-- cuts (#282). Substituted here because BattleState:say takes finished
|
||||||
-- text, while TextBox expanded the {PLAYER}/{RIVAL} tokens itself.
|
-- text, while TextBox expanded the {PLAYER}/{RIVAL} tokens itself.
|
||||||
battle.endBattleText = wonText and TextBox.substitute(Game, wonText) or nil
|
battle.endBattleText = wonText and TextBox.substitute(Game, wonText) or nil
|
||||||
|
-- the badge jingle rides the armed line's first page on the battle
|
||||||
|
-- screen (sound_get_item_1 in _TX_PRE dialogue; see gyms.lua) (#1606)
|
||||||
|
battle.endBattleSound = endBattleText ~= nil and endBattleSound or nil
|
||||||
|
-- one truth for both checkVictoryRewards call sites; endBattleIsReward
|
||||||
|
-- = false marks an armed line that is NOT the victories dialogue (#1606)
|
||||||
|
battle.rewardDialogueShown = endBattleText ~= nil
|
||||||
|
and endBattleIsReward ~= false
|
||||||
battle.onFinish = function(result)
|
battle.onFinish = function(result)
|
||||||
if result == "win" then
|
if result == "win" then
|
||||||
Game.save.defeatedTrainers[npc.id] = true
|
Game.save.defeatedTrainers[npc.id] = true
|
||||||
@@ -3358,7 +3392,8 @@ function OverworldState:engageTrainer(npc, onDone, endBattleText, skipBattleText
|
|||||||
-- checkVictoryRewards pushes the badge/prize box and starts the map's
|
-- checkVictoryRewards pushes the badge/prize box and starts the map's
|
||||||
-- onVictory script UNDER whatever runs next, so the player still sees
|
-- onVictory script UNDER whatever runs next, so the player still sees
|
||||||
-- EndBattle (now inside the battle), then the reward, then AfterBattle
|
-- EndBattle (now inside the battle), then the reward, then AfterBattle
|
||||||
self:checkVictoryRewards(d.trainerClass, d.trainerParty)
|
self:checkVictoryRewards(d.trainerClass, d.trainerParty,
|
||||||
|
battle.rewardDialogueShown)
|
||||||
self:afterBattle(result, battle)
|
self:afterBattle(result, battle)
|
||||||
if onDone then onDone() end
|
if onDone then onDone() end
|
||||||
else
|
else
|
||||||
@@ -3469,7 +3504,10 @@ end
|
|||||||
-- SetEvent / SetEventRange do after the leader victory.
|
-- SetEvent / SetEventRange do after the leader victory.
|
||||||
-- `hide` is { { mapId, objName }, ... } -- HideObject on those toggles
|
-- `hide` is { { mapId, objName }, ... } -- HideObject on those toggles
|
||||||
-- (e.g. Brock victory clears PEWTERCITY_YOUNGSTER / ROUTE22_RIVAL1).
|
-- (e.g. Brock victory clears PEWTERCITY_YOUNGSTER / ROUTE22_RIVAL1).
|
||||||
function OverworldState:checkVictoryRewards(trainerClass, partyIndex)
|
-- `shownOnBattleScreen`: `dialogue` already rode the battle screen as the
|
||||||
|
-- armed end-battle line (scripts/CeruleanGym.asm:113)
|
||||||
|
function OverworldState:checkVictoryRewards(trainerClass, partyIndex,
|
||||||
|
shownOnBattleScreen)
|
||||||
local victories = require("data.scripts.victories")
|
local victories = require("data.scripts.victories")
|
||||||
local reward = victories[trainerClass .. "#" .. tostring(partyIndex or 1)]
|
local reward = victories[trainerClass .. "#" .. tostring(partyIndex or 1)]
|
||||||
if not reward then return self:runVictoryHook() end
|
if not reward then return self:runVictoryHook() end
|
||||||
@@ -3502,7 +3540,9 @@ function OverworldState:checkVictoryRewards(trainerClass, partyIndex)
|
|||||||
end
|
end
|
||||||
local chain = rewardChain()
|
local chain = rewardChain()
|
||||||
if reward.dialogue then
|
if reward.dialogue then
|
||||||
|
if not shownOnBattleScreen then
|
||||||
chain.add(reward.dialogue, reward.badgeSound)
|
chain.add(reward.dialogue, reward.badgeSound)
|
||||||
|
end
|
||||||
if reward.item then
|
if reward.item then
|
||||||
chain.add(reward.tmPre)
|
chain.add(reward.tmPre)
|
||||||
if tmGiven then
|
if tmGiven then
|
||||||
@@ -3745,7 +3785,7 @@ function OverworldState:applyFieldPoison()
|
|||||||
local queue = {}
|
local queue = {}
|
||||||
for _, mon in ipairs(fainted) do
|
for _, mon in ipairs(fainted) do
|
||||||
local name = mon.nickname or Game.data.pokemon[mon.species].name
|
local name = mon.nickname or Game.data.pokemon[mon.species].name
|
||||||
table.insert(queue, Strings("%s\nfainted!", name))
|
table.insert(queue, romText(Game.data, "_PokemonFaintedText", "%s\nfainted!", name))
|
||||||
end
|
end
|
||||||
local alive = false
|
local alive = false
|
||||||
for _, mon in ipairs(save.party) do
|
for _, mon in ipairs(save.party) do
|
||||||
@@ -4415,7 +4455,8 @@ function OverworldState:restoreBattleContinuation(battle, origin)
|
|||||||
if result == "win" then
|
if result == "win" then
|
||||||
game.save.defeatedTrainers[origin.npcId] = true
|
game.save.defeatedTrainers[origin.npcId] = true
|
||||||
if origin.event then game.save.flags[origin.event] = true end
|
if origin.event then game.save.flags[origin.event] = true end
|
||||||
self:checkVictoryRewards(battle.oppClass, battle.partyIndex)
|
self:checkVictoryRewards(battle.oppClass, battle.partyIndex,
|
||||||
|
battle.rewardDialogueShown)
|
||||||
end
|
end
|
||||||
self:afterBattle(result, battle)
|
self:afterBattle(result, battle)
|
||||||
self.engaging = false
|
self.engaging = false
|
||||||
|
|||||||
@@ -391,15 +391,19 @@ end
|
|||||||
-- Gen 2 moveset lives in `levelMoves` (EvosAttacks). So every scripted gift,
|
-- Gen 2 moveset lives in `levelMoves` (EvosAttacks). So every scripted gift,
|
||||||
-- the STARTER included, arrived knowing nothing: FIGHT listed no moves and the
|
-- the STARTER included, arrived knowing nothing: FIGHT listed no moves and the
|
||||||
-- battle had no legal action left in it.
|
-- battle had no legal action left in it.
|
||||||
local function givePokeMon(data, speciesIndex, level, itemIndex)
|
local function givePokeMon(data, speciesIndex, level, itemIndex, opts)
|
||||||
local id = speciesByIndex(data.pokemon, speciesIndex)
|
local id = speciesByIndex(data.pokemon, speciesIndex)
|
||||||
if not id then return nil end
|
if not id then return nil end
|
||||||
return Mon.new(data, id, level or 5, {
|
return Mon.new(data, id, level or 5, {
|
||||||
item = itemIndex and itemIndex ~= 0
|
item = itemIndex and itemIndex ~= 0
|
||||||
and itemByIndex(data.items, itemIndex) or nil,
|
and itemByIndex(data.items, itemIndex) or nil,
|
||||||
|
nickname = opts and opts.nickname or nil,
|
||||||
})
|
})
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- GivePoke's trainer arm (engine/pokemon/move_mon.asm:1698-1736)
|
||||||
|
local RANDY_OT_ID = 1001
|
||||||
|
|
||||||
local function loadGenerated(path)
|
local function loadGenerated(path)
|
||||||
-- Same NX gold/ fallback Game2 uses. World:load is what surfaces
|
-- Same NX gold/ fallback Game2 uses. World:load is what surfaces
|
||||||
-- "Gold cache incomplete" when maps.lua is invisible at the unprefixed path.
|
-- "Gold cache incomplete" when maps.lua is invisible at the unprefixed path.
|
||||||
@@ -997,13 +1001,18 @@ function World:load()
|
|||||||
setStringBuffer = function(value)
|
setStringBuffer = function(value)
|
||||||
if self.game then self.game.stringBuffer = value end
|
if self.game then self.game.stringBuffer = value end
|
||||||
end,
|
end,
|
||||||
givePoke = function(speciesIndex, level, item)
|
givePoke = function(speciesIndex, level, item, opts)
|
||||||
local data = self.game and self.game.data
|
local data = self.game and self.game.data
|
||||||
local save = self.game and self.game.save
|
local save = self.game and self.game.save
|
||||||
if not (data and save) then return end
|
if not (data and save) then return end
|
||||||
save.party = save.party or {}
|
save.party = save.party or {}
|
||||||
local mon = givePokeMon(data, speciesIndex, level, item)
|
local mon = givePokeMon(data, speciesIndex, level, item, opts)
|
||||||
if mon then
|
if mon then
|
||||||
|
if opts and opts.otName then
|
||||||
|
mon.ot = opts.otName
|
||||||
|
mon.otName = opts.otName
|
||||||
|
mon.otId = RANDY_OT_ID
|
||||||
|
end
|
||||||
-- GivePoke -> TryAddMonToParty -> AddPartyMon (move_mon.asm:44-56, :143-149).
|
-- GivePoke -> TryAddMonToParty -> AddPartyMon (move_mon.asm:44-56, :143-149).
|
||||||
Mon.stampOT(save, mon)
|
Mon.stampOT(save, mon)
|
||||||
Party.add(save.party, mon)
|
Party.add(save.party, mon)
|
||||||
@@ -1458,13 +1467,11 @@ function World:mapSceneOf(group, mapNum)
|
|||||||
end
|
end
|
||||||
|
|
||||||
-- wTimeOfDay (constants/ram_constants.asm): MORN_F 0, DAY_F 1, NITE_F 2,
|
-- wTimeOfDay (constants/ram_constants.asm): MORN_F 0, DAY_F 1, NITE_F 2,
|
||||||
-- DARKNESS_F 3. Palettes.daytimeFor has already resolved the clock and the
|
-- DARKNESS_F 3, off the RTC hour (engine/tilesets/timeofday_pals.asm:5-11)
|
||||||
-- map's own PALETTE_* override into one of four names, so this is a rename
|
|
||||||
-- rather than a second clock.
|
|
||||||
local TIME_OF_DAY_ID = { MORN = 0, DAY = 1, NITE = 2, DARK = 3 }
|
local TIME_OF_DAY_ID = { MORN = 0, DAY = 1, NITE = 2, DARK = 3 }
|
||||||
|
|
||||||
function World:timeOfDayId()
|
function World:timeOfDayId()
|
||||||
return TIME_OF_DAY_ID[self.daytime or "DAY"] or 1
|
return TIME_OF_DAY_ID[self.tod or self.daytime or "DAY"] or 1
|
||||||
end
|
end
|
||||||
|
|
||||||
-- GetWeekday -> wCurDay, which the RTC counts SUNDAY 0 .. SATURDAY 6 -- the
|
-- GetWeekday -> wCurDay, which the RTC counts SUNDAY 0 .. SATURDAY 6 -- the
|
||||||
@@ -4369,6 +4376,8 @@ function World:useFieldItem(itemId)
|
|||||||
if itemId == "SACRED_ASH" then return self:useSacredAsh() end
|
if itemId == "SACRED_ASH" then return self:useSacredAsh() end
|
||||||
if itemId == "ESCAPE_ROPE" then return self:useEscapeRope(itemId) end
|
if itemId == "ESCAPE_ROPE" then return self:useEscapeRope(itemId) end
|
||||||
if itemId == "SQUIRTBOTTLE" then return self:useSquirtbottle() end
|
if itemId == "SQUIRTBOTTLE" then return self:useSquirtbottle() end
|
||||||
|
-- CoinCaseEffect (engine/items/item_effects.asm:2243).
|
||||||
|
if itemId == "COIN_CASE" then return "coin_case", self:coins() end
|
||||||
if REPEL_STEPS[itemId] then return self:useRepel(itemId) end
|
if REPEL_STEPS[itemId] then return self:useRepel(itemId) end
|
||||||
if TROPHY_BOXES[itemId] then return self:openTrophyBox(itemId) end
|
if TROPHY_BOXES[itemId] then return self:openTrophyBox(itemId) end
|
||||||
if not World.isRod(itemId, items) then return nil end
|
if not World.isRod(itemId, items) then return nil end
|
||||||
@@ -5802,7 +5811,9 @@ function World:battleMusicContext(opts)
|
|||||||
members = trainer and trainer.classId and members
|
members = trainer and trainer.classId and members
|
||||||
and members[trainer.classId] or nil,
|
and members[trainer.classId] or nil,
|
||||||
landmark = self.map and self.map.def and self.map.def.landmark,
|
landmark = self.map and self.map.def and self.map.def.landmark,
|
||||||
daytime = self.daytime,
|
-- PlayBattleMusic reads wTimeOfDay (engine/battle/start_battle.asm:24),
|
||||||
|
-- not the map's pinned palette set.
|
||||||
|
daytime = self.tod,
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -5999,6 +6010,24 @@ function World:startBattle(opts, onDone)
|
|||||||
return true
|
return true
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- wWinTextPointer / wLossTextPointer (home/trainers.asm:120), overwritten by
|
||||||
|
-- `winlosstext` (engine/overworld/scripting.asm:651)
|
||||||
|
function World:trainerWinLossText()
|
||||||
|
local vm = self.vm
|
||||||
|
if not vm then return nil, nil end
|
||||||
|
local obj = vm.trainerObject or {}
|
||||||
|
local text = self.text or {}
|
||||||
|
-- `winlosstext` writes BOTH pointers; a 0 argument destroys the struct
|
||||||
|
-- value rather than falling back to it (engine/overworld/scripting.asm:651)
|
||||||
|
local winKey, lossKey
|
||||||
|
if vm.winLossArmed then
|
||||||
|
winKey, lossKey = vm.winTextOverride, vm.lossTextOverride
|
||||||
|
else
|
||||||
|
winKey, lossKey = obj.winText, obj.lossText
|
||||||
|
end
|
||||||
|
return winKey and text[winKey] or nil, lossKey and text[lossKey] or nil
|
||||||
|
end
|
||||||
|
|
||||||
-- `startbattle` from a script: a trainer record (class + member) or a
|
-- `startbattle` from a script: a trainer record (class + member) or a
|
||||||
-- loadwildmon pair. The VM is parked on the yield until onDone fires, so the
|
-- loadwildmon pair. The VM is parked on the yield until onDone fires, so the
|
||||||
-- rest of the trainer script (flag set, after-battle text) runs on return.
|
-- rest of the trainer script (flag set, after-battle text) runs on return.
|
||||||
@@ -6057,6 +6086,9 @@ function World:startScriptedBattle(record, wild, onDone)
|
|||||||
attributes = record.attributes,
|
attributes = record.attributes,
|
||||||
items = record.items,
|
items = record.items,
|
||||||
}
|
}
|
||||||
|
-- wWinTextPointer / wLossTextPointer, read by PrintWinLossText on the
|
||||||
|
-- battle screen (home/trainers.asm:230) (#1512)
|
||||||
|
opts.trainer.winText, opts.trainer.lossText = self:trainerWinLossText()
|
||||||
elseif wild and wild.species then
|
elseif wild and wild.species then
|
||||||
local id, def = speciesByIndex(data and data.pokemon, wild.species)
|
local id, def = speciesByIndex(data and data.pokemon, wild.species)
|
||||||
-- InitEnemyMon `.NotRoaming` / BATTLETYPE.FORCESHINY: the DV pair is
|
-- InitEnemyMon `.NotRoaming` / BATTLETYPE.FORCESHINY: the DV pair is
|
||||||
@@ -9194,8 +9226,10 @@ function World:stepContext()
|
|||||||
local def = self.map and self.map.def
|
local def = self.map and self.map.def
|
||||||
return {
|
return {
|
||||||
data = self.game and self.game.data,
|
data = self.game and self.game.data,
|
||||||
|
-- CheckTime reads wTimeOfDay (engine/events/checktime.asm:2), so the
|
||||||
|
-- caller windows follow the clock even inside a pinned-palette room.
|
||||||
phone = {
|
phone = {
|
||||||
map = def, maps = self.maps, daytime = self.daytime,
|
map = def, maps = self.maps, daytime = self.tod,
|
||||||
clock = self.game and self.game.clock,
|
clock = self.game and self.game.clock,
|
||||||
},
|
},
|
||||||
-- GetMapPhoneService: zero means the map HAS service, which maps.lua has
|
-- GetMapPhoneService: zero means the map HAS service, which maps.lua has
|
||||||
|
|||||||
@@ -23,8 +23,13 @@ return function(game)
|
|||||||
game.save.party = { mon }
|
game.save.party = { mon }
|
||||||
-- engine/movie/evolution.asm:103
|
-- engine/movie/evolution.asm:103
|
||||||
Evolution.evolve(game, mon, "RAICHU", nil, "ITEM")
|
Evolution.evolve(game, mon, "RAICHU", nil, "ITEM")
|
||||||
U.wait(12)
|
-- the IsEvolvingText box now holds 50+ frames before the movie (#1596)
|
||||||
local top = game.stack:top()
|
local top
|
||||||
|
for _ = 1, 300 do
|
||||||
|
top = game.stack:top()
|
||||||
|
if top and top.screenId == "EvolutionState" then break end
|
||||||
|
U.wait(1)
|
||||||
|
end
|
||||||
check("the evolution screen opened",
|
check("the evolution screen opened",
|
||||||
top and top.screenId == "EvolutionState")
|
top and top.screenId == "EvolutionState")
|
||||||
U.shot(game, DIR .. "/bug1412_evo_old_pikachu.png")
|
U.shot(game, DIR .. "/bug1412_evo_old_pikachu.png")
|
||||||
|
|||||||
@@ -39,9 +39,13 @@ return function(game)
|
|||||||
local mon = Pokemon.new(game.data, "PIKACHU", 20)
|
local mon = Pokemon.new(game.data, "PIKACHU", 20)
|
||||||
game.save.party = { mon }
|
game.save.party = { mon }
|
||||||
Evolution.evolve(game, mon, "RAICHU")
|
Evolution.evolve(game, mon, "RAICHU")
|
||||||
U.wait(12)
|
-- the IsEvolvingText box now holds 50+ frames before the movie (#1596)
|
||||||
|
local top
|
||||||
local top = game.stack:top()
|
for _ = 1, 300 do
|
||||||
|
top = game.stack:top()
|
||||||
|
if top and top.screenId == "EvolutionState" then break end
|
||||||
|
U.wait(1)
|
||||||
|
end
|
||||||
check("the evolution screen opened", top and top.screenId == "EvolutionState")
|
check("the evolution screen opened", top and top.screenId == "EvolutionState")
|
||||||
U.log("Issue #494: true-color sprites during evolutions and trades")
|
U.log("Issue #494: true-color sprites during evolutions and trades")
|
||||||
U.log("Watch this PIKACHU evolve into RAICHU in ADVANCED colors.")
|
U.log("Watch this PIKACHU evolve into RAICHU in ADVANCED colors.")
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
-- #1479: TILESET_KANTO roof probe (LoadMapGroupRoof, home/map.asm:1738-1749)
|
||||||
|
-- POKEPORT_GAME=gold POKEPORT_DRIVER=tests/drivers/gold_bug1479_probe.lua love .
|
||||||
|
|
||||||
|
local U = require("tests.drivers.util")
|
||||||
|
|
||||||
|
local SPOTS = {
|
||||||
|
{ "ROUTE_28", 6, 6 },
|
||||||
|
{ "SILVER_CAVE_OUTSIDE", 10, 20 },
|
||||||
|
{ "SILVER_CAVE_OUTSIDE", 10, 8 },
|
||||||
|
{ "ROUTE_22", 10, 8 },
|
||||||
|
{ "VIRIDIAN_CITY", 10, 10 },
|
||||||
|
}
|
||||||
|
|
||||||
|
return function(game)
|
||||||
|
local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-kanto"
|
||||||
|
U.wait(45)
|
||||||
|
local world = game.world
|
||||||
|
assert(world and world.map, "gold world did not boot")
|
||||||
|
|
||||||
|
local failed = false
|
||||||
|
for i, spot in ipairs(SPOTS) do
|
||||||
|
local def = world.maps[spot[1]]
|
||||||
|
if not def then
|
||||||
|
U.log("FAIL no map", spot[1])
|
||||||
|
failed = true
|
||||||
|
else
|
||||||
|
world:setMap(spot[1], spot[2], spot[3], "down")
|
||||||
|
U.wait(10)
|
||||||
|
U.shot(game, ("%s/%02d-%s.png"):format(out, i, spot[1]:lower()))
|
||||||
|
-- home/map.asm:1738-1749: a Kanto-tileset map takes no map-group roof,
|
||||||
|
-- so its atlas is cached under the bare tileset name.
|
||||||
|
if def.tileset == "TILESET_KANTO" then
|
||||||
|
for key in pairs(world.atlasCache or {}) do
|
||||||
|
if key:find("TILESET_KANTO|", 1, true) then
|
||||||
|
U.log("FAIL Kanto atlas took a roof:", key)
|
||||||
|
failed = true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
U.log("shot", spot[1], def.tileset)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
U.log(failed and "RESULT FAIL" or "RESULT PASS", "shots in", out)
|
||||||
|
love.event.quit(failed and 1 or 0)
|
||||||
|
end
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
-- #1569 givepoke names (scripting.asm:1817, move_mon.asm:1698-1736)
|
||||||
|
-- POKEPORT_GAME=gold POKEPORT_DRIVER=tests/drivers/gold_bug1569_test.lua love .
|
||||||
|
|
||||||
|
local U = require("tests.drivers.util")
|
||||||
|
|
||||||
|
local function findGivepoke(scripts)
|
||||||
|
for key, rows in pairs(scripts) do
|
||||||
|
if type(rows) == "table" then
|
||||||
|
for _, row in ipairs(rows) do
|
||||||
|
if row.op == "givepoke" and (row.trainer or 0) ~= 0 then
|
||||||
|
return key, row
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
return function(game)
|
||||||
|
U.wait(45)
|
||||||
|
local world = game.world
|
||||||
|
assert(world and world.vm, "gold world did not boot")
|
||||||
|
|
||||||
|
local failed = false
|
||||||
|
local key, row = findGivepoke(world.scripts or {})
|
||||||
|
if not row then
|
||||||
|
U.log("FAIL no trainer-form givepoke in the extracted scripts")
|
||||||
|
failed = true
|
||||||
|
else
|
||||||
|
U.log("givepoke row from", tostring(key))
|
||||||
|
if row.name ~= "KENYA" then
|
||||||
|
U.log("FAIL nickname is", tostring(row.name), "want KENYA")
|
||||||
|
failed = true
|
||||||
|
end
|
||||||
|
if row.otName ~= "RANDY" then
|
||||||
|
U.log("FAIL OT name is", tostring(row.otName), "want RANDY")
|
||||||
|
failed = true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
game.save.party = {}
|
||||||
|
local give = world.vm.givePokeFn
|
||||||
|
assert(give, "the VM has no givePoke hook")
|
||||||
|
local mon = give(row and row.species or 21, row and row.level or 10, 0,
|
||||||
|
{ nickname = "KENYA", otName = "RANDY" })
|
||||||
|
if not mon then
|
||||||
|
U.log("FAIL givePoke made no mon")
|
||||||
|
failed = true
|
||||||
|
else
|
||||||
|
if mon.nickname ~= "KENYA" then
|
||||||
|
U.log("FAIL mon nickname is", tostring(mon.nickname))
|
||||||
|
failed = true
|
||||||
|
end
|
||||||
|
if mon.otName ~= "RANDY" or mon.ot ~= "RANDY" then
|
||||||
|
U.log("FAIL mon OT is", tostring(mon.ot), tostring(mon.otName))
|
||||||
|
failed = true
|
||||||
|
end
|
||||||
|
if mon.otId ~= 1001 then
|
||||||
|
U.log("FAIL mon OT id is", tostring(mon.otId), "want RANDY_OT_ID 1001")
|
||||||
|
failed = true
|
||||||
|
end
|
||||||
|
if mon.species ~= "SPEAROW" then
|
||||||
|
U.log("FAIL species is", tostring(mon.species))
|
||||||
|
failed = true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
U.log(failed and "RESULT FAIL" or "RESULT PASS")
|
||||||
|
love.event.quit(failed and 1 or 0)
|
||||||
|
end
|
||||||
@@ -50,7 +50,8 @@ return function(game)
|
|||||||
U.wait(5)
|
U.wait(5)
|
||||||
local ow = game.stack:top()
|
local ow = game.stack:top()
|
||||||
U.shot(game, DIR .. "/" .. shots.prefix .. "_0_gym.png")
|
U.shot(game, DIR .. "/" .. shots.prefix .. "_0_gym.png")
|
||||||
ow:checkVictoryRewards(class, party)
|
-- true: the badge line rode the battle screen on the real path (#1606)
|
||||||
|
ow:checkVictoryRewards(class, party, true)
|
||||||
U.wait(10)
|
U.wait(10)
|
||||||
for _, s in ipairs(shots.pages) do
|
for _, s in ipairs(shots.pages) do
|
||||||
advancePages(s.want, shots.prefix .. "_" .. s.name)
|
advancePages(s.want, shots.prefix .. "_" .. s.name)
|
||||||
@@ -63,26 +64,64 @@ return function(game)
|
|||||||
|
|
||||||
game.save.player.name = game.save.player.name or "RED"
|
game.save.player.name = game.save.player.name or "RED"
|
||||||
|
|
||||||
runLeader("PEWTER_GYM", 4, 3, "OPP_BROCK", 1, {
|
-- The REAL gym path (#1606): the badge line and jingle must ride the
|
||||||
prefix = "brock",
|
-- battle it pushes (scripts/PewterGym.asm:117-119).
|
||||||
pages = {
|
while game.stack:top() do game.stack:pop() end
|
||||||
{ want = "BOULDERBADGE", name = "1_badge" },
|
local Pokemon = require("src.pokemon.Pokemon")
|
||||||
{ want = "FLASH", name = "2_flash" },
|
game.save.party = { Pokemon.new(game.data, "CHARIZARD", 50) }
|
||||||
{ want = "Wait!", name = "3_wait" },
|
game.save.flags = game.save.flags or {}
|
||||||
{ want = "TM34", name = "4_tm34" },
|
game.save.inventory = game.save.inventory or {}
|
||||||
{ want = "BIDE", name = "5_bide" },
|
game.save.defeatedTrainers = game.save.defeatedTrainers or {}
|
||||||
},
|
game.stack:push(OW, "PEWTER_GYM", 4, 3, "up")
|
||||||
})
|
U.wait(5)
|
||||||
|
local realOw = game.stack:top()
|
||||||
|
local brock
|
||||||
|
for _, npc in ipairs(realOw.npcs or {}) do
|
||||||
|
if npc.def and npc.def.trainerClass == "OPP_BROCK" then brock = npc end
|
||||||
|
end
|
||||||
|
assert(brock, "Brock stands in PEWTER_GYM")
|
||||||
|
require("data.scripts.gyms").PEWTER_GYM.talk.TEXT_PEWTERGYM_BROCK(
|
||||||
|
game, realOw, brock, function() end)
|
||||||
|
local battle
|
||||||
|
for _ = 1, 600 do
|
||||||
|
local top = game.stack:top()
|
||||||
|
if top and top.oppClass == "OPP_BROCK" and top.onFinish then
|
||||||
|
battle = top
|
||||||
|
break
|
||||||
|
end
|
||||||
|
U.tap(game, "a")
|
||||||
|
U.wait(2)
|
||||||
|
end
|
||||||
|
assert(battle, "the real gym path reaches Brock's BattleState")
|
||||||
|
assert(type(battle.endBattleText) == "string" and #battle.endBattleText > 0,
|
||||||
|
"the battle carries the armed badge line (#1606)")
|
||||||
|
assert(battle.endBattleSound == "Get_Item1",
|
||||||
|
"and the badge jingle beside it (sound_level_up, PewterGym.asm)")
|
||||||
|
U.shot(game, DIR .. "/brock_real_battle.png")
|
||||||
|
U.log("force-finishing Brock's battle to run the reward chain")
|
||||||
|
battle.onFinish("win")
|
||||||
|
if game.stack:top() == battle then game.stack:pop() end
|
||||||
|
U.wait(10)
|
||||||
|
-- rewardDialogueShown: the badge line rode the battle screen, so the map
|
||||||
|
-- chain opens on the TM prelude, not on a reprint of the badge line
|
||||||
|
for _, s in ipairs({
|
||||||
|
{ want = "Wait!", name = "1_wait" },
|
||||||
|
{ want = "TM34", name = "2_tm34" },
|
||||||
|
{ want = "BIDE", name = "3_bide" },
|
||||||
|
}) do
|
||||||
|
advancePages(s.want, "brock_" .. s.name)
|
||||||
|
end
|
||||||
assert(game.save.flags.EVENT_BEAT_BROCK, "EVENT_BEAT_BROCK")
|
assert(game.save.flags.EVENT_BEAT_BROCK, "EVENT_BEAT_BROCK")
|
||||||
assert(game.save.inventory.BOULDERBADGE, "BOULDERBADGE")
|
assert(game.save.inventory.BOULDERBADGE, "BOULDERBADGE")
|
||||||
assert((game.save.inventory.TM_BIDE or 0) >= 1, "TM_BIDE")
|
assert((game.save.inventory.TM_BIDE or 0) >= 1, "TM_BIDE")
|
||||||
|
|
||||||
|
-- checkVictoryRewards direct drive, as after a battle whose badge line
|
||||||
|
-- rode the battle screen (shownOnBattleScreen = true)
|
||||||
runLeader("CERULEAN_GYM", 5, 5, "OPP_MISTY", 1, {
|
runLeader("CERULEAN_GYM", 5, 5, "OPP_MISTY", 1, {
|
||||||
prefix = "misty",
|
prefix = "misty",
|
||||||
pages = {
|
pages = {
|
||||||
{ want = "CASCADEBADGE", name = "1_badge" },
|
{ want = "CUT", name = "1_cut" },
|
||||||
{ want = "CUT", name = "2_cut" },
|
{ want = "TM11", name = "2_tm11" },
|
||||||
{ want = "TM11", name = "3_tm11" },
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
assert(game.save.flags.EVENT_BEAT_MISTY, "EVENT_BEAT_MISTY")
|
assert(game.save.flags.EVENT_BEAT_MISTY, "EVENT_BEAT_MISTY")
|
||||||
|
|||||||
@@ -58,6 +58,38 @@ check(position("if (secondaryEnabled) registerSecondaryDisplayListener();") <
|
|||||||
"secondary display monitoring starts before initial discovery")
|
"secondary display monitoring starts before initial discovery")
|
||||||
check(source:find("!monitor.hasDisplay(display.getDisplayId())", 1, true),
|
check(source:find("!monitor.hasDisplay(display.getDisplayId())", 1, true),
|
||||||
"a disconnected active display is rebound without replacing a live one")
|
"a disconnected active display is rebound without replacing a live one")
|
||||||
|
check(source:find("private static volatile boolean secondaryHostResumed = false;",
|
||||||
|
1, true), "secondary output tracks the primary activity lifecycle")
|
||||||
|
check(source:find("if (on && secondaryHostResumed)", 1, true)
|
||||||
|
and source:find("self == null || !secondaryHostResumed || !secondaryEnabled",
|
||||||
|
1, true),
|
||||||
|
"paused hosts cannot reopen secondary output from a late mod frame")
|
||||||
|
|
||||||
|
local pause = position("protected void onPause()")
|
||||||
|
local paused = assert(source:find("secondaryHostResumed = false;", pause, true))
|
||||||
|
local teardown = assert(source:find("teardownSecondaryDisplay();", pause, true))
|
||||||
|
local pauseSuper = assert(source:find("super.onPause();", pause, true))
|
||||||
|
check(pause < paused and paused < teardown and teardown < pauseSuper,
|
||||||
|
"pause blocks secondary setup before dismissing its output")
|
||||||
|
|
||||||
|
local resume = position("public void onResume()")
|
||||||
|
local resumeSuper = assert(source:find("super.onResume();", resume, true))
|
||||||
|
local resumed = assert(source:find("secondaryHostResumed = true;", resume, true))
|
||||||
|
local resumeSetup = assert(source:find("setupSecondaryDisplay();", resume, true))
|
||||||
|
check(resume < resumeSuper and resumeSuper < resumed and resumed < resumeSetup,
|
||||||
|
"resume permits secondary setup only after the primary activity resumes")
|
||||||
|
|
||||||
|
local destroy = position("protected void onDestroy()")
|
||||||
|
local destroyTeardown = assert(source:find("teardownSecondaryDisplay();", destroy, true))
|
||||||
|
local destroySuper = assert(source:find("super.onDestroy();", destroy, true))
|
||||||
|
check(destroy < destroyTeardown and destroyTeardown < destroySuper,
|
||||||
|
"destroy always dismisses secondary output before SDL destruction")
|
||||||
|
|
||||||
|
local mainFile = assert(io.open("main.lua", "rb"))
|
||||||
|
local main = mainFile:read("*a")
|
||||||
|
mainFile:close()
|
||||||
|
check(main:find('require("src.render.SecondScreen").setEnabled(false)', 1, true),
|
||||||
|
"returning from a game disables mod-owned secondary output")
|
||||||
|
|
||||||
check(not source:lower():find("openxr", 1, true),
|
check(not source:lower():find("openxr", 1, true),
|
||||||
"generic Android activity must not require OpenXR")
|
"generic Android activity must not require OpenXR")
|
||||||
|
|||||||
@@ -0,0 +1,121 @@
|
|||||||
|
-- The bag's item list is LIST_MENU_BOX (#1521): home/list_menu.asm:29-31,
|
||||||
|
-- :51-52, :364-365, :471-479, :518-521, data/text_boxes.asm:13
|
||||||
|
|
||||||
|
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||||
|
|
||||||
|
local T = require("tests.harness")
|
||||||
|
local check, eq = T.check, T.eq
|
||||||
|
love = love or require("tests.love_stub")
|
||||||
|
|
||||||
|
-- Font wants a real atlas; the geometry is what this suite is about, so it
|
||||||
|
-- records the calls instead. ListMenu and Theme bind Font at require time.
|
||||||
|
local realFont = package.loaded["src.render.Font"]
|
||||||
|
local calls = {}
|
||||||
|
package.loaded["src.render.Font"] = {
|
||||||
|
BORDER = { tl = 1, tr = 2, bl = 3, br = 4, h = 5, v = 6 },
|
||||||
|
draw = function(text, x, y) calls[#calls + 1] = { "draw", text, x, y } end,
|
||||||
|
drawCode = function(code, x, y) calls[#calls + 1] = { "code", code, x, y } end,
|
||||||
|
drawBox = function(tx, ty, tw, th) calls[#calls + 1] = { "box", tx, ty, tw, th } end,
|
||||||
|
width = function(text) return #tostring(text) * 8 end,
|
||||||
|
}
|
||||||
|
package.loaded["src.ui.ListMenu"] = nil
|
||||||
|
package.loaded["src.ui.Theme"] = nil
|
||||||
|
local ListMenu = require("src.ui.ListMenu")
|
||||||
|
local Theme = require("src.ui.Theme")
|
||||||
|
|
||||||
|
local function found(kind, pred)
|
||||||
|
for _, c in ipairs(calls) do
|
||||||
|
if c[1] == kind and pred(c) then return c end
|
||||||
|
end
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
local function newList(count)
|
||||||
|
local items = {}
|
||||||
|
for i = 1, count do
|
||||||
|
items[i] = { value = "ITEM_" .. i, label = "ITEM " .. i, right = "x" .. i }
|
||||||
|
end
|
||||||
|
return ListMenu.new({}, "ITEMS", items, { kind = "bag", itemBox = true })
|
||||||
|
end
|
||||||
|
|
||||||
|
do
|
||||||
|
local list = newList(6)
|
||||||
|
eq(list.rows, 4, "PrintListMenuEntries prints 4 names, not 7")
|
||||||
|
eq(list.isOpaque, false,
|
||||||
|
"the box is partial, so the map keeps drawing behind it")
|
||||||
|
|
||||||
|
calls = {}
|
||||||
|
list:draw()
|
||||||
|
|
||||||
|
local box = found("box", function(c) return true end)
|
||||||
|
if check(box ~= nil, "the list draws LIST_MENU_BOX") then
|
||||||
|
eq(box[2], 4, "upper-left X 4")
|
||||||
|
eq(box[3], 2, "upper-left Y 2")
|
||||||
|
eq(box[4], 16, "through lower-right X 19")
|
||||||
|
eq(box[5], 11, "through lower-right Y 12")
|
||||||
|
end
|
||||||
|
|
||||||
|
-- names at hlcoord 6, 4 and every two rows after it
|
||||||
|
for row = 1, 4 do
|
||||||
|
local y = 32 + (row - 1) * 16
|
||||||
|
check(found("draw", function(c)
|
||||||
|
return c[2] == "ITEM " .. row and c[3] == 48 and c[4] == y
|
||||||
|
end) ~= nil, "name " .. row .. " sits at (48, " .. y .. ")")
|
||||||
|
end
|
||||||
|
check(found("draw", function(c) return c[2] == "ITEM 5" end) == nil,
|
||||||
|
"the fifth name is scrolled out, not printed below the box")
|
||||||
|
|
||||||
|
-- the quantity: '×' at column 14, the count right-aligned after it
|
||||||
|
check(found("draw", function(c)
|
||||||
|
return c[2] == "x" and c[3] == 112 and c[4] == 40
|
||||||
|
end) ~= nil, "the first quantity's '×' is a row down at column 14")
|
||||||
|
check(found("draw", function(c)
|
||||||
|
return c[2] == "1" and c[3] == 128 and c[4] == 40
|
||||||
|
end) ~= nil, "with the count right-aligned in the two columns after it")
|
||||||
|
|
||||||
|
check(found("code", function(c)
|
||||||
|
return c[2] == Theme.cursor and c[3] == 40 and c[4] == 32
|
||||||
|
end) ~= nil, "the cursor is in column 5 (wTopMenuItemX)")
|
||||||
|
check(found("code", function(c)
|
||||||
|
return c[2] == Theme.moreArrow and c[3] == 144 and c[4] == 88
|
||||||
|
end) ~= nil, "a full page ends with the '▼'")
|
||||||
|
|
||||||
|
-- nothing else: no title, no money footer (wPrintItemPrices = 0)
|
||||||
|
check(found("draw", function(c) return tostring(c[2]):find("¥") end) == nil,
|
||||||
|
"the everyday bag has no money box (that is the mart's screen)")
|
||||||
|
check(found("draw", function(c) return c[2] == "ITEMS" end) == nil,
|
||||||
|
"and no title row: the box carries no header text")
|
||||||
|
end
|
||||||
|
|
||||||
|
-- the box keeps the palette beneath and caps the cursor at wMaxMenuItem
|
||||||
|
do
|
||||||
|
local list = newList(6)
|
||||||
|
eq(list.sgbPalettes, false,
|
||||||
|
"no SET_PAL_GENERIC: ItemMenuLoop keeps RunDefaultPaletteCommand's "
|
||||||
|
.. "palette (start_sub_menus.asm:300)")
|
||||||
|
eq(list.cursorRows, 3,
|
||||||
|
"wMaxMenuItem 2: three cursor rows (home/list_menu.asm:46-48)")
|
||||||
|
list.game = { input = { wasPressed = function(_, b) return b == "down" end,
|
||||||
|
isDown = function() return false end } }
|
||||||
|
for _ = 1, 3 do list:update(1 / 60) end
|
||||||
|
eq(list.index, 4, "three downs reach the fourth item")
|
||||||
|
eq(list.index - list.scroll, 3,
|
||||||
|
"scrolling instead of dropping the cursor onto the look-ahead row")
|
||||||
|
end
|
||||||
|
|
||||||
|
-- a short list stops at its last name, and the terminator's CANCEL row is
|
||||||
|
-- what would follow -- never the '▼'
|
||||||
|
do
|
||||||
|
local list = newList(2)
|
||||||
|
calls = {}
|
||||||
|
list:draw()
|
||||||
|
check(found("code", function(c) return c[2] == Theme.moreArrow end) == nil,
|
||||||
|
"a page that runs out of names prints no '▼' (:372)")
|
||||||
|
end
|
||||||
|
|
||||||
|
package.loaded["src.render.Font"] = realFont
|
||||||
|
package.loaded["src.ui.ListMenu"] = nil
|
||||||
|
package.loaded["src.ui.Theme"] = nil
|
||||||
|
require("src.ui.Screens").invalidate()
|
||||||
|
|
||||||
|
T.finish()
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
-- BattleState:throwBall()'s can't-be-caught path used to queue two
|
||||||
|
-- separate Strings() literals ("It dodged the\nthrown BALL!" then "This
|
||||||
|
-- POKéMON\ncan't be caught!"). The real ROM label _ItemUseBallText00
|
||||||
|
-- combines both as one \f-paged string. TextBox.new() would split \f
|
||||||
|
-- itself, but throwBall() queues through self:sayNext(), which goes
|
||||||
|
-- through the battle queue's own BattleState:startMessage() -- and that
|
||||||
|
-- one only splits on \n/\v, not \f (confirmed live: the \f landed
|
||||||
|
-- mid-line and the second sentence overflowed off the box instead of
|
||||||
|
-- starting a fresh page). The fix resolves the label once, then splits
|
||||||
|
-- it the same way TextBox.lua does and queues one sayNext per page. This
|
||||||
|
-- test fakes the label and checks the two pages reach the queue as two
|
||||||
|
-- separate messages, in order, not merged into one with a raw \f still
|
||||||
|
-- inside it.
|
||||||
|
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||||
|
|
||||||
|
local T = require("tests.modkit")
|
||||||
|
local Data = T.fixtures.fresh()
|
||||||
|
require("src.render.Font").load(Data)
|
||||||
|
|
||||||
|
local BattleState = require("src.battle.BattleState")
|
||||||
|
local Pokemon = require("src.pokemon.Pokemon")
|
||||||
|
local SaveData = require("src.core.SaveData")
|
||||||
|
|
||||||
|
local function mkbattle()
|
||||||
|
local save = SaveData.newGame()
|
||||||
|
save.party = { Pokemon.new(Data, "FIXMON_A", 10) }
|
||||||
|
local game = { data = Data, save = save,
|
||||||
|
stack = { top = function() return nil end, push = function() end } }
|
||||||
|
local battle = BattleState.newWild(game, "FIXMON_C", 8)
|
||||||
|
battle.ghost = true -- forces the can't-be-caught path
|
||||||
|
return battle
|
||||||
|
end
|
||||||
|
|
||||||
|
-- throwBall defers its message-queuing work into self.queue via one
|
||||||
|
-- top-level self:act(fn); run only that one function to reach the
|
||||||
|
-- say() calls the fix touches. It's the last entry throwBall itself
|
||||||
|
-- appends (after the immediate sayAuto), and it further queues its own
|
||||||
|
-- self:act(function() self:executeAction(...) end) for the actual enemy
|
||||||
|
-- turn -- deliberately NOT run here (out of scope, and running the queue
|
||||||
|
-- generically after mutation risks looping into a real turn simulation)
|
||||||
|
local function runThrowBallAct(battle)
|
||||||
|
for i = #battle.queue, 1, -1 do
|
||||||
|
if battle.queue[i].fn then
|
||||||
|
battle.queue[i].fn()
|
||||||
|
return
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local function textEntries(battle)
|
||||||
|
local out = {}
|
||||||
|
for _, entry in ipairs(battle.queue) do
|
||||||
|
if entry.text then out[#out + 1] = entry.text end
|
||||||
|
end
|
||||||
|
return out
|
||||||
|
end
|
||||||
|
|
||||||
|
-- translated: the faked label's two \f-separated pages reach the queue
|
||||||
|
-- as two separate messages, in order, and neither one still contains a
|
||||||
|
-- raw \f (which would mean the battle queue's own renderer has to deal
|
||||||
|
-- with it, and it can't)
|
||||||
|
do
|
||||||
|
local battle = mkbattle()
|
||||||
|
Data.text._ItemUseBallText00 = "FAKE-DODGE!\fFAKE-CANTCATCH!"
|
||||||
|
battle:throwBall("FIX_BALL")
|
||||||
|
runThrowBallAct(battle)
|
||||||
|
local texts = textEntries(battle)
|
||||||
|
T.eq(texts[1], "FAKE-DODGE!", "page 1 reaches the queue on its own")
|
||||||
|
T.eq(texts[2], "FAKE-CANTCATCH!", "page 2 follows right after, still in order")
|
||||||
|
for _, t in ipairs(texts) do
|
||||||
|
T.check(not t:find("\f", 1, true), "no queued message still carries a raw \\f")
|
||||||
|
end
|
||||||
|
Data.text._ItemUseBallText00 = nil
|
||||||
|
end
|
||||||
|
|
||||||
|
-- vanilla: no catalog entry still falls back to the two English pages,
|
||||||
|
-- split the same way
|
||||||
|
do
|
||||||
|
local battle = mkbattle()
|
||||||
|
battle:throwBall("FIX_BALL")
|
||||||
|
runThrowBallAct(battle)
|
||||||
|
local texts = textEntries(battle)
|
||||||
|
T.eq(texts[1], "It dodged the\nthrown BALL!", "vanilla page 1")
|
||||||
|
T.eq(texts[2], "This POKéMON\ncan't be caught!", "vanilla page 2")
|
||||||
|
end
|
||||||
|
|
||||||
|
T.finish("battle_ball_dodge_romtext")
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
-- BattleState:storeCaughtMon() queues up to two plain-Lua-literal
|
||||||
|
-- messages: the new-Pokedex-data line (_ItemUseBallText06) and, when the
|
||||||
|
-- party is full, the box-transfer line (_ItemUseBallText07/08, keyed on
|
||||||
|
-- EVENT_MET_BILL -- two full, independently-translated ROM strings, not
|
||||||
|
-- one template with a substituted PC name). This test fakes all three
|
||||||
|
-- labels and checks the queued messages use them, not the English
|
||||||
|
-- literals.
|
||||||
|
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||||
|
|
||||||
|
local T = require("tests.modkit")
|
||||||
|
local Data = T.fixtures.fresh()
|
||||||
|
require("src.render.Font").load(Data)
|
||||||
|
|
||||||
|
local BattleState = require("src.battle.BattleState")
|
||||||
|
local Pokemon = require("src.pokemon.Pokemon")
|
||||||
|
local SaveData = require("src.core.SaveData")
|
||||||
|
|
||||||
|
local function findText(battle, needle)
|
||||||
|
for _, entry in ipairs(battle.queue) do
|
||||||
|
if entry.text and entry.text:find(needle, 1, true) then return entry.text end
|
||||||
|
end
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
local function mkbattle(partySize, metBill)
|
||||||
|
local save = SaveData.newGame()
|
||||||
|
save.party = {}
|
||||||
|
for i = 1, partySize do
|
||||||
|
save.party[i] = Pokemon.new(Data, "FIXMON_A", 5)
|
||||||
|
end
|
||||||
|
save.flags = save.flags or {}
|
||||||
|
save.flags.EVENT_MET_BILL = metBill
|
||||||
|
local game = { data = Data, save = save,
|
||||||
|
stack = { top = function() return nil end, push = function() end } }
|
||||||
|
return BattleState.newWild(game, "FIXMON_C", 8)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- empty party: Party.add succeeds, only the new-Pokedex-data message fires
|
||||||
|
do
|
||||||
|
local battle = mkbattle(0, false)
|
||||||
|
Data.text._ItemUseBallText06 = "FAKE-DEX {RAM:wEnemyMonNick} FAKE!"
|
||||||
|
battle:storeCaughtMon()
|
||||||
|
T.eq(findText(battle, "FAKE-DEX"), "FAKE-DEX " .. battle.enemy.name .. " FAKE!",
|
||||||
|
"a translated _ItemUseBallText06 reaches the new-Pokedex-data message")
|
||||||
|
Data.text._ItemUseBallText06 = nil
|
||||||
|
end
|
||||||
|
|
||||||
|
-- full party, EVENT_MET_BILL true: box transfer via _ItemUseBallText07
|
||||||
|
do
|
||||||
|
local battle = mkbattle(6, true)
|
||||||
|
Data.text._ItemUseBallText07 = "FAKE-BILL {RAM:wBoxMonNicks} FAKE!"
|
||||||
|
battle:storeCaughtMon()
|
||||||
|
T.eq(findText(battle, "FAKE-BILL"), "FAKE-BILL " .. battle.enemy.name .. " FAKE!",
|
||||||
|
"EVENT_MET_BILL true routes through the translated _ItemUseBallText07")
|
||||||
|
Data.text._ItemUseBallText07 = nil
|
||||||
|
end
|
||||||
|
|
||||||
|
-- full party, EVENT_MET_BILL false: box transfer via _ItemUseBallText08
|
||||||
|
do
|
||||||
|
local battle = mkbattle(6, false)
|
||||||
|
Data.text._ItemUseBallText08 = "FAKE-SOMEONE {RAM:wBoxMonNicks} FAKE!"
|
||||||
|
battle:storeCaughtMon()
|
||||||
|
T.eq(findText(battle, "FAKE-SOMEONE"), "FAKE-SOMEONE " .. battle.enemy.name .. " FAKE!",
|
||||||
|
"EVENT_MET_BILL false routes through the translated _ItemUseBallText08")
|
||||||
|
Data.text._ItemUseBallText08 = nil
|
||||||
|
end
|
||||||
|
|
||||||
|
-- vanilla, full party, EVENT_MET_BILL true: English literal, BILL's PC
|
||||||
|
do
|
||||||
|
local battle = mkbattle(6, true)
|
||||||
|
battle:storeCaughtMon()
|
||||||
|
T.eq(findText(battle, "transferred"),
|
||||||
|
battle.enemy.name .. " was\ntransferred to\nBILL's PC!",
|
||||||
|
"no catalog entry falls back to the English BILL's-PC literal")
|
||||||
|
end
|
||||||
|
|
||||||
|
T.finish("battle_catch_messages_romtext")
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
-- BattleState:onFaint's "%s\nfainted!" collapsed two distinct ROM
|
||||||
|
-- strings (_EnemyMonFaintedText already carries its own "Enemy" wording;
|
||||||
|
-- _PlayerMonFaintedText does not) into one literal, substituted with
|
||||||
|
-- displayName(battler) -- which itself runs the enemy name through a
|
||||||
|
-- SEPARATE Strings("Enemy %s", ...) call. The fix passes the raw
|
||||||
|
-- battler.name and lets each label supply its own wording. This test
|
||||||
|
-- fakes both labels and checks the raw name reaches the right one, with
|
||||||
|
-- no "Enemy" ever duplicated.
|
||||||
|
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||||
|
|
||||||
|
local T = require("tests.modkit")
|
||||||
|
local Data = T.fixtures.fresh()
|
||||||
|
require("src.render.Font").load(Data)
|
||||||
|
|
||||||
|
local BattleState = require("src.battle.BattleState")
|
||||||
|
local Pokemon = require("src.pokemon.Pokemon")
|
||||||
|
local SaveData = require("src.core.SaveData")
|
||||||
|
|
||||||
|
local function mkbattle()
|
||||||
|
local save = SaveData.newGame()
|
||||||
|
save.party = { Pokemon.new(Data, "FIXMON_A", 10) }
|
||||||
|
local game = { data = Data, save = save,
|
||||||
|
stack = { top = function() return nil end, push = function() end } }
|
||||||
|
return BattleState.newWild(game, "FIXMON_C", 8)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- finds the queued say-message text (onFaint queues several entries: a
|
||||||
|
-- wait, then the say)
|
||||||
|
local function findText(battle)
|
||||||
|
for _, entry in ipairs(battle.queue) do
|
||||||
|
if entry.text then return entry.text end
|
||||||
|
end
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
-- enemy: translated _EnemyMonFaintedText reaches onFaint, raw name only
|
||||||
|
do
|
||||||
|
local battle = mkbattle()
|
||||||
|
Data.text._EnemyMonFaintedText = "FAKE-ENEMY {RAM:wEnemyMonNick} FAKE!"
|
||||||
|
battle:onFaint(battle.enemy)
|
||||||
|
T.eq(findText(battle), "FAKE-ENEMY " .. battle.enemy.name .. " FAKE!",
|
||||||
|
"a translated _EnemyMonFaintedText reaches the enemy faint message")
|
||||||
|
Data.text._EnemyMonFaintedText = nil
|
||||||
|
end
|
||||||
|
|
||||||
|
-- enemy vanilla: the English literal already carries "Enemy " itself
|
||||||
|
do
|
||||||
|
local battle = mkbattle()
|
||||||
|
battle:onFaint(battle.enemy)
|
||||||
|
T.eq(findText(battle), "Enemy " .. battle.enemy.name .. "\nfainted!",
|
||||||
|
"no catalog entry falls back to the English literal, Enemy included")
|
||||||
|
end
|
||||||
|
|
||||||
|
-- player: translated _PlayerMonFaintedText reaches onFaint
|
||||||
|
do
|
||||||
|
local battle = mkbattle()
|
||||||
|
Data.text._PlayerMonFaintedText = "FAKE-PLAYER {RAM:wBattleMonNick} FAKE!"
|
||||||
|
battle:onFaint(battle.player)
|
||||||
|
T.eq(findText(battle), "FAKE-PLAYER " .. battle.player.name .. " FAKE!",
|
||||||
|
"a translated _PlayerMonFaintedText reaches the player faint message")
|
||||||
|
Data.text._PlayerMonFaintedText = nil
|
||||||
|
end
|
||||||
|
|
||||||
|
T.finish("battle_fainted_message_romtext")
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
-- SwitchPlayerMon (core.asm:2419-2423), AnimateRetreatingPlayerMon (:1769-1796)
|
||||||
|
-- (#1563); pokeyellow core.asm:1862-1866 (#1545); core.asm:1471-1488 (#1608)
|
||||||
|
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||||
|
|
||||||
|
local T = require("tests.modkit")
|
||||||
|
local Data = T.fixtures.fresh()
|
||||||
|
require("src.render.Font").load(Data)
|
||||||
|
local BattleState = require("src.battle.BattleState")
|
||||||
|
local Pokemon = require("src.pokemon.Pokemon")
|
||||||
|
local SaveData = require("src.core.SaveData")
|
||||||
|
local TypeChart = require("src.battle.TypeChart")
|
||||||
|
TypeChart.load(Data)
|
||||||
|
|
||||||
|
local function newBattle()
|
||||||
|
local save = SaveData.newGame()
|
||||||
|
save.party = { Pokemon.new(Data, "FIXMON_A", 40), Pokemon.new(Data, "FIXMON_A", 40) }
|
||||||
|
local game = { data = Data, save = save,
|
||||||
|
stack = { top = function() return nil end, push = function() end } }
|
||||||
|
local battle = BattleState.newWild(game, "FIXMON_C", 40)
|
||||||
|
battle.rng = function(a) if a then return a end return 0 end
|
||||||
|
return battle
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------
|
||||||
|
-- the shrink stages and their frame budget
|
||||||
|
-- ---------------------------------------------------------------------
|
||||||
|
do
|
||||||
|
local battle = newBattle()
|
||||||
|
battle.queue, battle.nextInsert = {}, 0
|
||||||
|
battle:queueRetreatAnim()
|
||||||
|
T.eq(#battle.queue, 2, "the retreat queues its start act plus the hold")
|
||||||
|
T.eq(battle.queue[2].wait, 7, "4 frames at 5x5 then Delay3 at 3x3")
|
||||||
|
T.check(battle:shrinkOutScale(battle.player) == nil, "nothing shrinks yet")
|
||||||
|
battle.queue[1].fn()
|
||||||
|
T.eq(battle.shrinkOut.battler, battle.player, "the outgoing pic is the one drawn")
|
||||||
|
T.eq(battle:shrinkOutScale(battle.player), 5 / 7, "wDownscaledMonSize 0 -> 5x5")
|
||||||
|
battle.shrinkOut.frame = 3
|
||||||
|
T.eq(battle:shrinkOutScale(battle.player), 5 / 7, "for four frames")
|
||||||
|
battle.shrinkOut.frame = 4
|
||||||
|
T.eq(battle:shrinkOutScale(battle.player), 3 / 7, "wDownscaledMonSize 1 -> 3x3")
|
||||||
|
battle.shrinkOut.frame = 6
|
||||||
|
T.eq(battle:shrinkOutScale(battle.player), 3 / 7, "through Delay3")
|
||||||
|
battle.shrinkOut.frame = 7
|
||||||
|
T.eq(battle:shrinkOutScale(battle.player), 0,
|
||||||
|
"then the 7x7 area holds cleared: no full-size flash before the swap")
|
||||||
|
T.check(battle:shrinkOutScale({}) == nil,
|
||||||
|
"the swapped-in battler draws normally")
|
||||||
|
T.check(battle:shrinkOutScale(battle.enemy) == nil,
|
||||||
|
"AnimateRetreatingPlayerMon is player-side only")
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------
|
||||||
|
-- the Yellow starter slides off instead of shrinking
|
||||||
|
-- ---------------------------------------------------------------------
|
||||||
|
do
|
||||||
|
local battle = newBattle()
|
||||||
|
battle.starterPikachuSendOut = function() return true end
|
||||||
|
battle.queue, battle.nextInsert = {}, 0
|
||||||
|
battle:queueRetreatAnim()
|
||||||
|
T.eq(#battle.queue, 3, "start act, the slide's hold, then the slot clear")
|
||||||
|
battle.queue[1].fn()
|
||||||
|
local slide = battle.picOff and battle.picOff.playerMon
|
||||||
|
T.check(slide ~= nil, "the back pic slot is sliding")
|
||||||
|
T.eq(slide.to, -64, "8 tiles off the left edge")
|
||||||
|
T.eq(slide.hold, 3, "wSlideMonDelay 3 V-blanks per tile")
|
||||||
|
T.eq(battle.queue[2].wait, 24, "8 tiles x 3 frames")
|
||||||
|
T.check(battle:shrinkOutScale(battle.player) == nil, "and no downscale stage")
|
||||||
|
battle.queue[3].fn()
|
||||||
|
T.check((battle.picOff or {}).playerMon == nil, "the slot clears afterwards")
|
||||||
|
T.eq(battle.sendingOut, true,
|
||||||
|
"and stays hidden until the swap's send-out (#1545)")
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------
|
||||||
|
-- resolveSwitch runs the retreat between the withdraw text and the swap
|
||||||
|
-- ---------------------------------------------------------------------
|
||||||
|
do
|
||||||
|
local battle = newBattle()
|
||||||
|
battle.queue, battle.nextInsert = {}, 0
|
||||||
|
local outgoing = battle.player.mon
|
||||||
|
battle:resolveSwitch(battle.game.save.party[2])
|
||||||
|
local withdraw = table.remove(battle.queue, 1)
|
||||||
|
battle.nextInsert = 0
|
||||||
|
withdraw.fn()
|
||||||
|
T.check(battle.queue[1] and battle.queue[1].text ~= nil, "RetreatMon text first")
|
||||||
|
T.check(battle.queue[2] and battle.queue[2].fn ~= nil, "then the retreat start")
|
||||||
|
T.eq(battle.queue[3] and battle.queue[3].wait, 7, "then its hold")
|
||||||
|
T.eq(battle.player.mon, outgoing, "the party slot has not been swapped yet")
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------
|
||||||
|
-- a fainted pick reopens the party list (#1608)
|
||||||
|
-- ---------------------------------------------------------------------
|
||||||
|
do
|
||||||
|
local battle = newBattle()
|
||||||
|
battle.queue, battle.nextInsert = {}, 0
|
||||||
|
battle.buildScreen = function(_, _, opts) return opts end
|
||||||
|
battle:openParty()
|
||||||
|
local opts = battle.queue[1].ui()
|
||||||
|
battle.queue, battle.nextInsert = {}, 0
|
||||||
|
battle.game.save.party[2].hp = 0
|
||||||
|
opts.onSwitch(battle.game.save.party[2])
|
||||||
|
T.check(battle.queue[1] and battle.queue[1].text
|
||||||
|
and battle.queue[1].text:find("no will", 1, true) ~= nil,
|
||||||
|
"HasMonFainted prints NoWillText")
|
||||||
|
T.check(battle.queue[2] and battle.queue[2].fn ~= nil, "and queues the reprompt")
|
||||||
|
battle.queue[2].fn()
|
||||||
|
T.check(battle.queue[3] and battle.queue[3].ui ~= nil,
|
||||||
|
"GoBackToPartyMenu puts the list back up")
|
||||||
|
|
||||||
|
battle.queue, battle.nextInsert = {}, 0
|
||||||
|
opts.onSwitch(battle.player.mon)
|
||||||
|
T.check(battle.queue[1] and battle.queue[1].text
|
||||||
|
and battle.queue[1].text:find("already out", 1, true) ~= nil,
|
||||||
|
"AlreadyOutText for the mon that is already out")
|
||||||
|
T.check(battle.queue[2] and battle.queue[2].fn ~= nil, "which also reprompts")
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------
|
||||||
|
-- the SHIFT prompt's picker reprompts on a dead or already-out pick too
|
||||||
|
-- (HasMonFainted's NoWillText, core.asm:1473-1488) (#1608)
|
||||||
|
-- ---------------------------------------------------------------------
|
||||||
|
do
|
||||||
|
local save = SaveData.newGame()
|
||||||
|
save.player.name = "RED"
|
||||||
|
save.party = { Pokemon.new(Data, "FIXMON_A", 30),
|
||||||
|
Pokemon.new(Data, "FIXMON_B", 30) }
|
||||||
|
save.options = { battleStyle = "shift" }
|
||||||
|
local game = { data = Data, save = save,
|
||||||
|
stack = { top = function() return nil end,
|
||||||
|
push = function() end, pop = function() end } }
|
||||||
|
local battle = BattleState.newTrainer(game, "OPP_FIX_YOUNGSTER", 1)
|
||||||
|
battle.participants = {}
|
||||||
|
battle.buildScreen = function(_, _, opts) return opts end
|
||||||
|
-- capture the picker opts the SHIFT branch pushes
|
||||||
|
local captured
|
||||||
|
Data.screens = Data.screens or {}
|
||||||
|
Data.screens.PartyMenu = function(_, opts) captured = opts; return {} end
|
||||||
|
require("src.ui.Screens").invalidate()
|
||||||
|
battle.enemyParty[1].hp = 0
|
||||||
|
battle.enemy.mon = battle.enemyParty[1]
|
||||||
|
battle:enemyMonFainted()
|
||||||
|
local choiceRow
|
||||||
|
for _, row in ipairs(battle.queue) do
|
||||||
|
if row.choice then choiceRow = row end
|
||||||
|
end
|
||||||
|
T.check(choiceRow ~= nil, "SHIFT queues the change-POKeMON choice")
|
||||||
|
choiceRow.choice(true)
|
||||||
|
T.check(captured ~= nil and captured.forceSwitch == true,
|
||||||
|
"YES opens the forced party picker")
|
||||||
|
|
||||||
|
battle.queue, battle.nextInsert = {}, 0
|
||||||
|
save.party[2].hp = 0
|
||||||
|
captured.onSwitch(save.party[2])
|
||||||
|
T.check(battle.queue[1] and battle.queue[1].text
|
||||||
|
and battle.queue[1].text:find("no will", 1, true) ~= nil,
|
||||||
|
"a fainted SHIFT pick prints NoWillText first")
|
||||||
|
T.check(battle.queue[2] and battle.queue[2].ui ~= nil,
|
||||||
|
"then the picker goes straight back up, ahead of the send-out")
|
||||||
|
T.eq(battle.queue[2].ui(), captured, "with the same forced opts")
|
||||||
|
|
||||||
|
battle.queue, battle.nextInsert = {}, 0
|
||||||
|
captured.onSwitch(battle.player.mon)
|
||||||
|
T.check(battle.queue[1] and battle.queue[1].text
|
||||||
|
and battle.queue[1].text:find("already out", 1, true) ~= nil,
|
||||||
|
"an already-out SHIFT pick prints AlreadyOutText")
|
||||||
|
T.check(battle.queue[2] and battle.queue[2].ui ~= nil, "and reprompts too")
|
||||||
|
|
||||||
|
battle.queue, battle.nextInsert = {}, 0
|
||||||
|
save.party[2].hp = 10
|
||||||
|
captured.onSwitch(save.party[2])
|
||||||
|
T.eq(#battle.queue, 0, "a healthy pick queues no reprompt rows")
|
||||||
|
Data.screens.PartyMenu = nil
|
||||||
|
require("src.ui.Screens").invalidate()
|
||||||
|
end
|
||||||
|
|
||||||
|
T.finish("retreat animation and switch reprompt (#1563, #1545, #1608)")
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
-- BoxMenu's RELEASE confirmation ("Once released,\n%s is\ngone forever.
|
||||||
|
-- OK?") used to be a bare Lua literal. tests/engine/pc_release.lua only
|
||||||
|
-- ever runs with an empty Data.text, so it can't tell a properly-wired
|
||||||
|
-- t._OnceReleasedText or "..." fallback apart from a literal that never
|
||||||
|
-- looked at t at all -- every assertion there passes either way. This
|
||||||
|
-- test drives the same interactive release flow with a faked
|
||||||
|
-- Data.text._OnceReleasedText and checks the pushed TextBox's raw text
|
||||||
|
-- (captured via a TextBox.new spy, so real pagination/choice behavior is
|
||||||
|
-- untouched) uses the translated value, not the English literal.
|
||||||
|
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||||
|
|
||||||
|
local T = require("tests.modkit")
|
||||||
|
local Data = T.fixtures.fresh()
|
||||||
|
local ids = T.fixtures.ids
|
||||||
|
require("src.render.Font").load(Data)
|
||||||
|
|
||||||
|
local Pokemon = require("src.pokemon.Pokemon")
|
||||||
|
local Boxes = require("src.pokemon.Boxes")
|
||||||
|
local TextBox = require("src.render.TextBox")
|
||||||
|
local BoxMenu = require("src.ui.BoxMenu")
|
||||||
|
local ListMenu = require("src.ui.ListMenu")
|
||||||
|
local ChoiceBox = require("src.ui.ChoiceBox")
|
||||||
|
local SaveData = require("src.core.SaveData")
|
||||||
|
local Sound = require("src.core.Sound")
|
||||||
|
|
||||||
|
local realCry, realPlay = Sound.playCry, Sound.play
|
||||||
|
Sound.playCry = function() end
|
||||||
|
Sound.play = function() end
|
||||||
|
|
||||||
|
-- spy: records every raw text TextBox.new receives, without disturbing
|
||||||
|
-- pagination/choice re-push, so the interactive flow behaves exactly like
|
||||||
|
-- pc_release.lua's
|
||||||
|
local captured
|
||||||
|
local realNew = TextBox.new
|
||||||
|
TextBox.new = function(game, text, onDone, opts)
|
||||||
|
captured[#captured + 1] = text
|
||||||
|
return realNew(game, text, onDone, opts)
|
||||||
|
end
|
||||||
|
|
||||||
|
local stack = { states = {} }
|
||||||
|
function stack:push(s) self.states[#self.states + 1] = s end
|
||||||
|
function stack:pop()
|
||||||
|
local t = self.states[#self.states]
|
||||||
|
self.states[#self.states] = nil
|
||||||
|
return t
|
||||||
|
end
|
||||||
|
function stack:top() return self.states[#self.states] end
|
||||||
|
function stack:update(dt)
|
||||||
|
local t = self:top()
|
||||||
|
if t and t.update then t:update(dt) end
|
||||||
|
end
|
||||||
|
|
||||||
|
local pressed = {}
|
||||||
|
local function press(btn)
|
||||||
|
pressed = { [btn] = true }
|
||||||
|
stack:update(1 / 60)
|
||||||
|
pressed = {}
|
||||||
|
end
|
||||||
|
|
||||||
|
local function topMt() return getmetatable(stack:top()) end
|
||||||
|
local function mash(btn, cond, n)
|
||||||
|
for _ = 1, (n or 400) do
|
||||||
|
if cond() then return true end
|
||||||
|
press(btn)
|
||||||
|
end
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
|
local function mkGame()
|
||||||
|
stack.states = {}
|
||||||
|
captured = {}
|
||||||
|
local game = {
|
||||||
|
data = Data,
|
||||||
|
save = SaveData.newGame(),
|
||||||
|
stack = stack,
|
||||||
|
input = {
|
||||||
|
wasPressed = function(_, key) return pressed[key] or false end,
|
||||||
|
isDown = function() return false end,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
game.save.options = game.save.options or {}
|
||||||
|
game.save.options.textSpeed = 1
|
||||||
|
local box = Boxes.active(game.save)
|
||||||
|
box[1] = Pokemon.new(Data, ids.species[1], 5)
|
||||||
|
return game, box
|
||||||
|
end
|
||||||
|
|
||||||
|
local function releaseFirstMon(game)
|
||||||
|
stack:push(BoxMenu.new(game))
|
||||||
|
press("down"); press("down"); press("a") -- open RELEASE list
|
||||||
|
T.check(topMt() == ListMenu, "RELEASE opens the box list")
|
||||||
|
press("a") -- choose the first (only) mon
|
||||||
|
T.check(mash("a", function() return topMt() == ChoiceBox end),
|
||||||
|
"confirm choice opens")
|
||||||
|
-- the confirmation TextBox is captured[1] the moment it was pushed,
|
||||||
|
-- before this mash even ran
|
||||||
|
return captured[1]
|
||||||
|
end
|
||||||
|
|
||||||
|
-- monName (BoxMenu.lua): mon.nickname or def.name
|
||||||
|
local function monName(box)
|
||||||
|
local mon = box[1]
|
||||||
|
local def = Data.pokemon[mon.species]
|
||||||
|
return mon.nickname or def.name
|
||||||
|
end
|
||||||
|
|
||||||
|
-- translated: the fake value must reach the pushed TextBox
|
||||||
|
do
|
||||||
|
local game, box = mkGame()
|
||||||
|
local name = monName(box)
|
||||||
|
Data.text._OnceReleasedText = "FAKE {RAM:wStringBuffer} released!"
|
||||||
|
local text = releaseFirstMon(game)
|
||||||
|
T.eq(text, "FAKE " .. name .. " released!",
|
||||||
|
"a translated _OnceReleasedText reaches the release confirmation")
|
||||||
|
Data.text._OnceReleasedText = nil
|
||||||
|
end
|
||||||
|
|
||||||
|
-- vanilla: with no catalog entry, the English literal still substitutes
|
||||||
|
do
|
||||||
|
local game, box = mkGame()
|
||||||
|
local name = monName(box)
|
||||||
|
local text = releaseFirstMon(game)
|
||||||
|
T.eq(text, "Once released,\n" .. name .. " is\ngone forever. OK?",
|
||||||
|
"no catalog entry still falls back to the English literal")
|
||||||
|
end
|
||||||
|
|
||||||
|
TextBox.new = realNew
|
||||||
|
Sound.playCry, Sound.play = realCry, realPlay
|
||||||
|
T.finish("box_release_confirmation_romtext")
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
-- HandleSelfConfusionDamage (engine/battle/core.asm:3672-3714, enemy side
|
||||||
|
-- :5806-5811) (#1578)
|
||||||
|
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||||
|
|
||||||
|
local T = require("tests.modkit")
|
||||||
|
local Data = T.fixtures.fresh()
|
||||||
|
require("src.render.Font").load(Data)
|
||||||
|
local BattleState = require("src.battle.BattleState")
|
||||||
|
local Pokemon = require("src.pokemon.Pokemon")
|
||||||
|
local SaveData = require("src.core.SaveData")
|
||||||
|
local TypeChart = require("src.battle.TypeChart")
|
||||||
|
TypeChart.load(Data)
|
||||||
|
|
||||||
|
local function newBattle()
|
||||||
|
local save = SaveData.newGame()
|
||||||
|
save.party = { Pokemon.new(Data, "FIXMON_A", 40) }
|
||||||
|
local game = { data = Data, save = save,
|
||||||
|
stack = { top = function() return nil end, push = function() end } }
|
||||||
|
local battle = BattleState.newWild(game, "FIXMON_C", 40)
|
||||||
|
-- cp 50 percent + 1: rand < 128 hurts the user
|
||||||
|
battle.rng = function() return 0 end
|
||||||
|
return battle
|
||||||
|
end
|
||||||
|
|
||||||
|
local function rowsOf(battle)
|
||||||
|
local out = {}
|
||||||
|
for _, item in ipairs(battle.queue) do
|
||||||
|
if item.anim then out[#out + 1] = item end
|
||||||
|
end
|
||||||
|
return out
|
||||||
|
end
|
||||||
|
|
||||||
|
do
|
||||||
|
local battle = newBattle()
|
||||||
|
battle.queue, battle.nextInsert = {}, 0
|
||||||
|
battle.player.confusedTurns = 3
|
||||||
|
T.eq(battle:statusInterrupt(battle.player, battle.enemy, nil), true,
|
||||||
|
"the self-hit interrupts the player's action")
|
||||||
|
local anims = rowsOf(battle)
|
||||||
|
T.eq(#anims, 2, "the IsConfusedText onomatopoeia, then the self-hit's own")
|
||||||
|
T.eq(anims[1].anim, "CONF_PLAYER_ANIM", "CONF_PLAYER_ANIM rides IsConfusedText")
|
||||||
|
T.eq(anims[2].anim, "POUND", "wAnimationID 1 is POUND")
|
||||||
|
T.eq(anims[2].attackerIsPlayer, false,
|
||||||
|
"hWhoseTurn is flipped to the opponent, so it plays against the player")
|
||||||
|
T.check(anims[2].hit == nil, "wAnimationType 0 adds no shake or blink layer")
|
||||||
|
local text, anim
|
||||||
|
for i, item in ipairs(battle.queue) do
|
||||||
|
if item.text and item.text:find("confusion", 1, true) then text = text or i end
|
||||||
|
if item.anim == "POUND" then anim = i end
|
||||||
|
end
|
||||||
|
T.check(text and anim and text < anim, "HurtItselfText prints before the animation")
|
||||||
|
end
|
||||||
|
|
||||||
|
do
|
||||||
|
local battle = newBattle()
|
||||||
|
battle.queue, battle.nextInsert = {}, 0
|
||||||
|
battle.enemy.confusedTurns = 3
|
||||||
|
battle:statusInterrupt(battle.enemy, battle.player, nil)
|
||||||
|
local anims = rowsOf(battle)
|
||||||
|
T.eq(anims[#anims].anim, "POUND", "the enemy side animates too")
|
||||||
|
T.eq(anims[#anims].attackerIsPlayer, true, "from the player's side of hWhoseTurn")
|
||||||
|
end
|
||||||
|
|
||||||
|
T.finish("confusion self-hit animation (#1578)")
|
||||||
@@ -30,7 +30,9 @@ package.loaded["src.core.Sound"] = {
|
|||||||
playCry = function() end,
|
playCry = function() end,
|
||||||
}
|
}
|
||||||
package.loaded["src.render.TextBox"] = {
|
package.loaded["src.render.TextBox"] = {
|
||||||
new = function(_, text, done) return { textBox = true, text = text, done = done } end,
|
new = function(_, text, done, opts)
|
||||||
|
return { textBox = true, text = text, done = done, opts = opts }
|
||||||
|
end,
|
||||||
}
|
}
|
||||||
-- BagMenu and PartyMenu bind TextBox at require time, so they load against the
|
-- BagMenu and PartyMenu bind TextBox at require time, so they load against the
|
||||||
-- stub; Screens caches its factory per id and must be told to forget.
|
-- stub; Screens caches its factory per id and must be told to forget.
|
||||||
@@ -126,6 +128,21 @@ local function useStone(game)
|
|||||||
game.input.pressed = "a"
|
game.input.pressed = "a"
|
||||||
picker:update(1 / 60)
|
picker:update(1 / 60)
|
||||||
game.input.pressed = nil
|
game.input.pressed = nil
|
||||||
|
-- IsEvolvingText STAYS up; its onShown starts the DelayFrames 50 hold,
|
||||||
|
-- which then pushes the movie over it (evos_moves.asm:120-134) (#1596)
|
||||||
|
local intro = game.stack:top()
|
||||||
|
if not (intro and intro.textBox and intro.opts and intro.opts.stay) then
|
||||||
|
return nil, "the \"is evolving!\" box never opened"
|
||||||
|
end
|
||||||
|
if not tostring(intro.text):find("evolving") then
|
||||||
|
return nil, "the box before the movie is not _IsEvolvingText"
|
||||||
|
end
|
||||||
|
intro.opts.stay.onShown()
|
||||||
|
local hold = game.stack:top()
|
||||||
|
for _ = 1, 60 do
|
||||||
|
if pushed then break end
|
||||||
|
if hold.update then hold.update() end
|
||||||
|
end
|
||||||
return list
|
return list
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,133 @@
|
|||||||
|
-- The evolution dialogue is the cart's, in the cart's order (#1596):
|
||||||
|
-- engine/pokemon/evos_moves.asm:120-134 (the clear is rows 0-11 only),
|
||||||
|
-- :136-150, :151-153
|
||||||
|
|
||||||
|
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||||
|
|
||||||
|
local T = require("tests.harness")
|
||||||
|
local check, eq = T.check, T.eq
|
||||||
|
love = love or require("tests.love_stub")
|
||||||
|
|
||||||
|
local played = {}
|
||||||
|
package.loaded["src.core.Sound"] = {
|
||||||
|
play = function(_, id) played[#played + 1] = id end,
|
||||||
|
playCry = function() end,
|
||||||
|
}
|
||||||
|
|
||||||
|
local Fixtures = require("tests.modkit.fixtures")
|
||||||
|
local Evolution = require("src.pokemon.Evolution")
|
||||||
|
local EvolutionState = require("src.ui.EvolutionState")
|
||||||
|
local Input = require("src.core.Input")
|
||||||
|
local Pokemon = require("src.pokemon.Pokemon")
|
||||||
|
local StateStack = require("src.core.StateStack")
|
||||||
|
local TextBox = require("src.render.TextBox")
|
||||||
|
|
||||||
|
local Data = Fixtures.fresh()
|
||||||
|
require("src.render.Font").load(Data)
|
||||||
|
|
||||||
|
local EVO_LEVEL = 16
|
||||||
|
|
||||||
|
local function newGame()
|
||||||
|
local game = { data = Data }
|
||||||
|
local mon = Pokemon.new(Data, "FIXMON_A", EVO_LEVEL)
|
||||||
|
game.save = {
|
||||||
|
party = { mon },
|
||||||
|
player = { name = "RED", id = 1 },
|
||||||
|
options = { textSpeed = 5 },
|
||||||
|
flags = {},
|
||||||
|
pokedex = { seen = {}, owned = {} },
|
||||||
|
}
|
||||||
|
game.stack = setmetatable({}, { __index = StateStack })
|
||||||
|
game.stack:init()
|
||||||
|
game.input = Input
|
||||||
|
Input:init()
|
||||||
|
return game, mon
|
||||||
|
end
|
||||||
|
|
||||||
|
local function step(game)
|
||||||
|
game.input:step()
|
||||||
|
game.stack:update(1 / 60)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function textOf(box)
|
||||||
|
local out = {}
|
||||||
|
for _, page in ipairs(box.pages) do
|
||||||
|
for _, line in ipairs(page) do out[#out + 1] = line end
|
||||||
|
end
|
||||||
|
return table.concat(out, " ")
|
||||||
|
end
|
||||||
|
|
||||||
|
-- The box that goes up before the movie, and the frames it holds for.
|
||||||
|
do
|
||||||
|
local game, mon = newGame()
|
||||||
|
Evolution.evolve(game, mon, "FIXMON_B", nil, "LEVEL")
|
||||||
|
local intro = game.stack:top()
|
||||||
|
if check(getmetatable(intro) == TextBox,
|
||||||
|
"_IsEvolvingText goes up in a real bordered text box first") then
|
||||||
|
check(textOf(intro):find("is evolving"),
|
||||||
|
"and it is the cart's line: " .. textOf(intro))
|
||||||
|
check(intro.stay ~= nil and not intro.stay.prompt,
|
||||||
|
"which waits for no button (IsEvolvingText ends in `done`) "
|
||||||
|
.. "and stays up under whatever follows")
|
||||||
|
end
|
||||||
|
-- it hands off to the movie on its own, with no input at all
|
||||||
|
local top
|
||||||
|
for _ = 1, 900 do
|
||||||
|
top = game.stack:top()
|
||||||
|
if getmetatable(top) == EvolutionState then break end
|
||||||
|
step(game)
|
||||||
|
end
|
||||||
|
check(getmetatable(top) == EvolutionState,
|
||||||
|
"the flash movie opens once the DelayFrames 50 hold has passed")
|
||||||
|
local underneath = false
|
||||||
|
for _, s in ipairs(game.stack.states or {}) do
|
||||||
|
if s == intro then underneath = true end
|
||||||
|
end
|
||||||
|
check(underneath, "the 'is evolving!' box is still on the stack under the "
|
||||||
|
.. "flash (ClearScreenArea wipes rows 0-11 only, evos_moves.asm:126-128)")
|
||||||
|
check(not EvolutionState.isOpaque,
|
||||||
|
"and the flash screen is not opaque, so the box beneath draws")
|
||||||
|
eq(mon.species, "FIXMON_A",
|
||||||
|
"and nothing has evolved yet while the box was up")
|
||||||
|
end
|
||||||
|
|
||||||
|
-- What closes the movie: EvolvedText + IntoText, and the jingle.
|
||||||
|
do
|
||||||
|
local game, mon = newGame()
|
||||||
|
Evolution.evolve(game, mon, "FIXMON_B", nil, "LEVEL")
|
||||||
|
local evo
|
||||||
|
for _ = 1, 900 do
|
||||||
|
evo = game.stack:top()
|
||||||
|
if getmetatable(evo) == EvolutionState then break end
|
||||||
|
step(game)
|
||||||
|
end
|
||||||
|
assert(getmetatable(evo) == EvolutionState, "the movie never opened")
|
||||||
|
played = {}
|
||||||
|
for _ = 1, 600 do
|
||||||
|
if evo.done then break end
|
||||||
|
step(game)
|
||||||
|
end
|
||||||
|
eq(mon.species, "FIXMON_B", "the mon evolved")
|
||||||
|
local box = game.stack:top()
|
||||||
|
if check(getmetatable(box) == TextBox, "and the result text is a text box") then
|
||||||
|
local said = textOf(box)
|
||||||
|
check(said:find("evolved") and said:find("into"),
|
||||||
|
"_EvolvedText + _IntoText print together: " .. said)
|
||||||
|
check(not said:find("Congratulations"),
|
||||||
|
"no fabricated \"Congratulations!\" line (it is in no ROM)")
|
||||||
|
check(box.auto ~= nil and box.auto.sound ~= nil,
|
||||||
|
"and the box carries a jingle the way sound_get_item_1 boxes do")
|
||||||
|
-- type it out; auto.sound fires once the last page has landed
|
||||||
|
for _ = 1, 900 do
|
||||||
|
if box.autoStarted then break end
|
||||||
|
step(game)
|
||||||
|
end
|
||||||
|
local heard = false
|
||||||
|
for _, id in ipairs(played) do
|
||||||
|
if id == "Get_Item2" then heard = true end
|
||||||
|
end
|
||||||
|
check(heard, "SFX_GET_ITEM_2 plays on that box (evos_moves.asm:151)")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
T.finish()
|
||||||
@@ -52,6 +52,14 @@ local function step(game)
|
|||||||
game.stack:update(1 / 60)
|
game.stack:update(1 / 60)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
local function textOf(box)
|
||||||
|
local out = {}
|
||||||
|
for _, page in ipairs(box.pages) do
|
||||||
|
for _, line in ipairs(page) do out[#out + 1] = line end
|
||||||
|
end
|
||||||
|
return table.concat(out, " ")
|
||||||
|
end
|
||||||
|
|
||||||
-- the post-battle sequence: grew-to-level box, then Evolution.checkParty
|
-- the post-battle sequence: grew-to-level box, then Evolution.checkParty
|
||||||
local function levelUpBox(game, mon)
|
local function levelUpBox(game, mon)
|
||||||
game.stack:push(TextBox.new(game, "FIXMON A grew\nto level 16!",
|
game.stack:push(TextBox.new(game, "FIXMON A grew\nto level 16!",
|
||||||
@@ -69,21 +77,27 @@ local function dismissWithB(game, mon)
|
|||||||
if not box.done then return nil, "the level-up text never finished typing" end
|
if not box.done then return nil, "the level-up text never finished typing" end
|
||||||
Input:keypressed(B_KEY)
|
Input:keypressed(B_KEY)
|
||||||
step(game)
|
step(game)
|
||||||
local top = game.stack:top()
|
-- IsEvolvingText holds its own box for DelayFrames 50 before EvolveMon
|
||||||
|
-- runs (engine/pokemon/evos_moves.asm:120-134)
|
||||||
|
local intro = game.stack:top()
|
||||||
|
if getmetatable(intro) ~= TextBox then
|
||||||
|
return nil, "the \"is evolving!\" box never opened"
|
||||||
|
end
|
||||||
|
if not textOf(intro):find("is evolving") then
|
||||||
|
return nil, "the box before the movie is not _IsEvolvingText"
|
||||||
|
end
|
||||||
|
local top
|
||||||
|
for _ = 1, 900 do
|
||||||
|
top = game.stack:top()
|
||||||
|
if getmetatable(top) == EvolutionState then break end
|
||||||
|
step(game)
|
||||||
|
end
|
||||||
if getmetatable(top) ~= EvolutionState then
|
if getmetatable(top) ~= EvolutionState then
|
||||||
return nil, "the evolution screen never opened"
|
return nil, "the evolution screen never opened"
|
||||||
end
|
end
|
||||||
return top
|
return top
|
||||||
end
|
end
|
||||||
|
|
||||||
local function textOf(box)
|
|
||||||
local out = {}
|
|
||||||
for _, page in ipairs(box.pages) do
|
|
||||||
for _, line in ipairs(page) do out[#out + 1] = line end
|
|
||||||
end
|
|
||||||
return table.concat(out, " ")
|
|
||||||
end
|
|
||||||
|
|
||||||
-- B held out of the text box: the movie must run to the end and evolve.
|
-- B held out of the text box: the movie must run to the end and evolve.
|
||||||
do
|
do
|
||||||
local game, mon = newGame()
|
local game, mon = newGame()
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
-- wLastDexMode (engine/pokedex/pokedex.asm:60, :97) (#1474)
|
||||||
|
-- luajit tests/engine/gen2_dex_mode_persist_bug1474.lua
|
||||||
|
|
||||||
|
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||||
|
|
||||||
|
local T = require("tests.harness")
|
||||||
|
local check, eq = T.check, T.eq
|
||||||
|
love = love or require("tests.love_stub")
|
||||||
|
|
||||||
|
local PokedexMenu = require("src.ui.gen2.PokedexMenu")
|
||||||
|
|
||||||
|
local save = { position = { map = "ROUTE_30" } }
|
||||||
|
local game = { data = {}, save = save }
|
||||||
|
|
||||||
|
local first = PokedexMenu.new(game, {})
|
||||||
|
eq(first.modeIndex, 1, "a save with no remembered mode opens in NEW")
|
||||||
|
|
||||||
|
-- what the OPTION screen's .ChangeMode leaves behind
|
||||||
|
first.modeIndex = 3
|
||||||
|
first:close()
|
||||||
|
eq(save.lastDexMode, "A-Z", "closing the dex writes the live mode into the save")
|
||||||
|
|
||||||
|
local second = PokedexMenu.new(game, {})
|
||||||
|
eq(second.modeIndex, 3, "reopening the dex restores the remembered mode")
|
||||||
|
|
||||||
|
second.modeIndex = 2
|
||||||
|
second:close()
|
||||||
|
eq(PokedexMenu.new(game, {}).modeIndex, 2, "OLD survives the same way")
|
||||||
|
|
||||||
|
local fresh = PokedexMenu.new({ data = {}, save = {} }, {})
|
||||||
|
eq(fresh.modeIndex, 1, "a fresh save still starts on NEW")
|
||||||
|
|
||||||
|
local closed = false
|
||||||
|
local menu = PokedexMenu.new(game, { onClose = function() closed = true end })
|
||||||
|
menu:close()
|
||||||
|
check(closed, "close still runs the caller's onClose")
|
||||||
|
|
||||||
|
-- Save.newGame seeds the key and Save.validate clamps a hand-edited value
|
||||||
|
local Save = require("src.core.gen2.Save")
|
||||||
|
eq(Save.newGame().lastDexMode, "NEW",
|
||||||
|
"a brand-new save carries the key from the start (wLastDexMode's zero)")
|
||||||
|
local edited = Save.newGame()
|
||||||
|
edited.lastDexMode = "SPICY"
|
||||||
|
Save.validate(edited)
|
||||||
|
eq(edited.lastDexMode, "NEW", "validate clamps an out-of-range mode to NEW")
|
||||||
|
local kept = Save.newGame()
|
||||||
|
kept.lastDexMode = "A-Z"
|
||||||
|
Save.validate(kept)
|
||||||
|
eq(kept.lastDexMode, "A-Z", "and keeps a legal one")
|
||||||
|
|
||||||
|
T.finish("gen2 pokedex mode persistence bug 1474")
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
-- The Fly map draws no Pokegear mode arrow (engine/pokegear/pokegear.asm:1999) (#1477)
|
||||||
|
-- luajit tests/engine/gen2_fly_map_arrow_bug1477.lua
|
||||||
|
|
||||||
|
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||||
|
|
||||||
|
local T = require("tests.harness")
|
||||||
|
local check = T.check
|
||||||
|
love = love or require("tests.love_stub")
|
||||||
|
|
||||||
|
local Pokegear = require("src.ui.gen2.Pokegear")
|
||||||
|
|
||||||
|
local function drawnArrow(fly)
|
||||||
|
local drew = false
|
||||||
|
local self = setmetatable({
|
||||||
|
fly = fly,
|
||||||
|
styled = function() return true end,
|
||||||
|
groundColor = function() return { 0, 0, 0 } end,
|
||||||
|
card = function() return { id = "map", label = fly and "FLY" or "MAP" } end,
|
||||||
|
drawMap = function() end,
|
||||||
|
drawModeArrow = function() drew = true end,
|
||||||
|
}, { __index = Pokegear })
|
||||||
|
local realRect = love.graphics.rectangle
|
||||||
|
love.graphics.rectangle = function() end
|
||||||
|
self:drawPanel()
|
||||||
|
love.graphics.rectangle = realRect
|
||||||
|
return drew
|
||||||
|
end
|
||||||
|
|
||||||
|
check(drawnArrow(false), "the Pokegear MAP card still animates the arrow")
|
||||||
|
check(not drawnArrow(true), "_FlyMap draws no mode-indicator arrow")
|
||||||
|
|
||||||
|
T.finish("gen2 fly map arrow bug 1477")
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
-- #1479 / #1449: TILESET_KANTO takes no map-group roof (home/map.asm:1738-1749)
|
||||||
|
-- luajit tests/engine/gen2_kanto_no_roof_bug1479.lua
|
||||||
|
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||||
|
|
||||||
|
local T = require("tests.modkit")
|
||||||
|
|
||||||
|
local MapPreview = require("src.world.gen2.MapPreview")
|
||||||
|
|
||||||
|
local baker = {
|
||||||
|
tilesets = {
|
||||||
|
TILESET_KANTO = { image = "assets/generated/tilesets/kanto.png" },
|
||||||
|
TILESET_JOHTO = { image = "assets/generated/tilesets/johto.png" },
|
||||||
|
},
|
||||||
|
roofs = {
|
||||||
|
mapGroupRoofs = { [19] = "ROOF_SILVER" },
|
||||||
|
roofs = { ROOF_SILVER = {} },
|
||||||
|
},
|
||||||
|
atlasCache = {},
|
||||||
|
mapImages = {},
|
||||||
|
}
|
||||||
|
|
||||||
|
-- data/maps/maps.asm:396: SilverCaveOutside and Route28 are both group 19
|
||||||
|
-- (MapGroup_Silver) and both TILESET_KANTO.
|
||||||
|
MapPreview.atlasFor(baker, { tileset = "TILESET_KANTO", group = 19 })
|
||||||
|
MapPreview.atlasFor(baker, { tileset = "TILESET_JOHTO", group = 19 })
|
||||||
|
|
||||||
|
local keys = {}
|
||||||
|
for key in pairs(baker.atlasCache) do keys[key] = true end
|
||||||
|
|
||||||
|
T.check(keys["TILESET_KANTO"],
|
||||||
|
"the Kanto atlas is cached under the bare tileset, with no roof")
|
||||||
|
T.check(not keys["TILESET_KANTO|ROOF_SILVER"],
|
||||||
|
"and never under a map-group roof")
|
||||||
|
T.check(keys["TILESET_JOHTO|ROOF_SILVER"],
|
||||||
|
"while TILESET_JOHTO still takes its group's roof")
|
||||||
|
|
||||||
|
-- World:atlasFor is the copy the bug was filed against; it has its own
|
||||||
|
-- ROOF_TILESETS gate, so pin it separately from MapPreview's
|
||||||
|
local realAssets = package.loaded["src.render.Assets"]
|
||||||
|
package.loaded["src.render.Assets"] = setmetatable({
|
||||||
|
image = function() return { setFilter = function() end } end,
|
||||||
|
resolve = function(path) return path end,
|
||||||
|
}, { __index = realAssets or { register = function() end } })
|
||||||
|
package.loaded["src.world.gen2.World"] = nil
|
||||||
|
local World = require("src.world.gen2.World")
|
||||||
|
local world = setmetatable({
|
||||||
|
tilesets = baker.tilesets,
|
||||||
|
roofs = baker.roofs,
|
||||||
|
atlasCache = {},
|
||||||
|
}, { __index = World })
|
||||||
|
world:atlasFor({ tileset = "TILESET_KANTO", group = 19 })
|
||||||
|
world:atlasFor({ tileset = "TILESET_JOHTO", group = 19 })
|
||||||
|
T.check(world.atlasCache["TILESET_KANTO"] ~= nil
|
||||||
|
and world.atlasCache["TILESET_KANTO|ROOF_SILVER"] == nil,
|
||||||
|
"World:atlasFor bakes Kanto with no map-group roof (home/map.asm:1738-1749)")
|
||||||
|
T.check(world.atlasCache["TILESET_JOHTO|ROOF_SILVER"] ~= nil,
|
||||||
|
"and still roofs TILESET_JOHTO by group")
|
||||||
|
package.loaded["src.render.Assets"] = realAssets
|
||||||
|
package.loaded["src.world.gen2.World"] = nil
|
||||||
|
|
||||||
|
T.finish("gen2 Kanto roof gate (#1479)")
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
-- #1558: menu_gfx.stats -> $31..$41 quads (engine/gfx/load_font.asm:90-95);
|
||||||
|
-- PAGE_PALETTES is gfx/stats/pages.pal (cgb_layouts.asm:199-212)
|
||||||
|
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||||
|
|
||||||
|
local T = require("tests.modkit")
|
||||||
|
local SummaryMenu = require("src.ui.gen2.SummaryMenu")
|
||||||
|
|
||||||
|
-- gfx/stats/pages.pal, RGB 5-bit rows in ROM order: pink, green, blue
|
||||||
|
local ROM_PALS = {
|
||||||
|
{ { 31, 31, 31 }, { 31, 19, 31 }, { 31, 15, 31 }, { 0, 0, 0 } },
|
||||||
|
{ { 31, 31, 31 }, { 21, 31, 14 }, { 17, 31, 0 }, { 0, 0, 0 } },
|
||||||
|
{ { 31, 31, 31 }, { 17, 31, 31 }, { 17, 31, 31 }, { 0, 0, 0 } },
|
||||||
|
}
|
||||||
|
local function up(v) return math.floor(v * 255 / 31 + 0.5) end
|
||||||
|
for p = 1, 3 do
|
||||||
|
for c = 1, 4 do
|
||||||
|
for ch = 1, 3 do
|
||||||
|
T.eq(SummaryMenu.PAGE_PALETTES[p][c][ch], up(ROM_PALS[p][c][ch]),
|
||||||
|
("pages.pal palette %d color %d channel %d"):format(p, c, ch))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- the quads: 17 tiles from $31, one 8x8 cell each off the 136x8 sheet
|
||||||
|
love.graphics = love.graphics or {}
|
||||||
|
local realNewQuad = love.graphics.newQuad
|
||||||
|
love.graphics.newQuad = function(x, y, w, h)
|
||||||
|
return { x = x, y = y, w = w, h = h }
|
||||||
|
end
|
||||||
|
local menu = setmetatable({
|
||||||
|
menuGfx = { stats = { sheet = "stats", tiles = 17, firstTile = 0x31 } },
|
||||||
|
picImage = function()
|
||||||
|
return { getDimensions = function() return 136, 8 end }
|
||||||
|
end,
|
||||||
|
}, { __index = SummaryMenu })
|
||||||
|
local sheet = menu:statsTiles()
|
||||||
|
if T.check(sheet ~= nil, "menu_gfx.stats builds the tile sheet") then
|
||||||
|
for id = 0x31, 0x41 do
|
||||||
|
local q = sheet.quads[id]
|
||||||
|
T.check(q ~= nil and q.x == (id - 0x31) * 8 and q.w == 8 and q.h == 8,
|
||||||
|
("tile $%02x maps to sheet cell %d"):format(id, id - 0x31))
|
||||||
|
end
|
||||||
|
T.check(sheet.quads[0x42] == nil, "and exactly 17 tiles, no more")
|
||||||
|
end
|
||||||
|
|
||||||
|
-- a cache built before menu_gfx.stats existed keeps the hand-drawn fallback
|
||||||
|
local old = setmetatable({ menuGfx = {} }, { __index = SummaryMenu })
|
||||||
|
T.check(old:statsTiles() == nil, "no menu_gfx.stats falls back, not crashes")
|
||||||
|
love.graphics.newQuad = realNewQuad
|
||||||
|
|
||||||
|
T.finish("gen2 stats tiles and page palettes (#1558)")
|
||||||
@@ -0,0 +1,210 @@
|
|||||||
|
-- engine/battle/core.asm:2310-2323 (WinTrainerBattle), :2763-2782 (LostBattle),
|
||||||
|
-- home/trainers.asm:120 and :230 (PrintWinLossText)
|
||||||
|
|
||||||
|
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||||
|
|
||||||
|
love = require("tests.love_stub")
|
||||||
|
|
||||||
|
local T = require("tests.harness")
|
||||||
|
local Battle = require("src.battle.gen2.Battle")
|
||||||
|
local Mon = require("src.battle.gen2.Mon")
|
||||||
|
local World = require("src.world.gen2.World")
|
||||||
|
|
||||||
|
local LEVEL = 10
|
||||||
|
|
||||||
|
local MOVES = {
|
||||||
|
TACKLE = { id = "TACKLE", name = "TACKLE", power = 35, type = "NORMAL",
|
||||||
|
accuracy = 100, pp = 35, effect = "EFFECT_NORMAL_HIT" },
|
||||||
|
}
|
||||||
|
|
||||||
|
local POKEMON = {
|
||||||
|
growthRates = {
|
||||||
|
GROWTH_MEDIUM_FAST = { numerator = 1, denominator = 1, squared = 0,
|
||||||
|
linear = 0, constant = 0 },
|
||||||
|
},
|
||||||
|
RATTATA = {
|
||||||
|
id = "RATTATA", index = 19, name = "RATTATA",
|
||||||
|
baseStats = { hp = 30, attack = 56, defense = 35, speed = 72,
|
||||||
|
specialAttack = 25, specialDefense = 35 },
|
||||||
|
types = { "NORMAL", "NORMAL" }, catchRate = 255, baseExp = 51,
|
||||||
|
growthRate = "GROWTH_MEDIUM_FAST", genderRatio = 127,
|
||||||
|
levelMoves = { { level = 1, move = "TACKLE" } }, evolutions = {},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
local DATA = {
|
||||||
|
pokemon = POKEMON,
|
||||||
|
moves = MOVES,
|
||||||
|
type_chart = { types = { NORMAL = { id = "NORMAL", index = 0,
|
||||||
|
category = "physical" } }, matchups = {} },
|
||||||
|
items = {},
|
||||||
|
}
|
||||||
|
|
||||||
|
local perfect = { attack = 15, defense = 15, speed = 15, special = 15 }
|
||||||
|
perfect.hp = Mon.hpDV(perfect)
|
||||||
|
|
||||||
|
local function mon()
|
||||||
|
local m = Mon.new(DATA, "RATTATA", LEVEL, { dvs = perfect })
|
||||||
|
m.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } }
|
||||||
|
return m
|
||||||
|
end
|
||||||
|
|
||||||
|
local function newSave()
|
||||||
|
return { player = { name = "GOLD", money = 1000, id = 4242 },
|
||||||
|
mom = { savedMoney = 0 }, party = {} }
|
||||||
|
end
|
||||||
|
|
||||||
|
local function newBattle(trainer, battleType)
|
||||||
|
local save = newSave()
|
||||||
|
local battle = Battle.new({ data = DATA, party = { mon() },
|
||||||
|
trainer = trainer, battleType = battleType, save = save,
|
||||||
|
random = function(n) return 99 % math.max(1, n or 1) end })
|
||||||
|
return battle, save
|
||||||
|
end
|
||||||
|
|
||||||
|
local function trainerRecord(extra)
|
||||||
|
local record = { name = "SAGE CHOW", baseMoney = 3, party = { mon() } }
|
||||||
|
for key, value in pairs(extra or {}) do record[key] = value end
|
||||||
|
return record
|
||||||
|
end
|
||||||
|
|
||||||
|
local function kinds(events)
|
||||||
|
local out = {}
|
||||||
|
for i, event in ipairs(events or {}) do out[i] = event.kind end
|
||||||
|
return table.concat(out, ",")
|
||||||
|
end
|
||||||
|
|
||||||
|
local function indexOf(events, kind)
|
||||||
|
for i, event in ipairs(events or {}) do
|
||||||
|
if event.kind == kind then return i, event end
|
||||||
|
end
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
-- The win arm: defeated line, frontpic slide-in, the trainer's own line, money.
|
||||||
|
do
|
||||||
|
local battle = newBattle(trainerRecord({ winText = "Th-Thank you!" }))
|
||||||
|
battle.enemy.hp = 0
|
||||||
|
T.check(battle:resolveFaints(), "the last enemy mon ends the battle")
|
||||||
|
local events = battle:takeEvents()
|
||||||
|
local defeated = indexOf(events, "faint")
|
||||||
|
local ret = indexOf(events, "trainer-return")
|
||||||
|
local win, winEvent = indexOf(events, "win-text")
|
||||||
|
local money = indexOf(events, "money")
|
||||||
|
T.check(ret and win and money, "all three win rows are queued: " ..
|
||||||
|
kinds(events))
|
||||||
|
T.check(defeated < ret, "the last faint event precedes the pic's return")
|
||||||
|
T.check(ret < win, "the frontpic slides back in before the line")
|
||||||
|
T.check(win < money, "and PrintWinLossText runs before the payout")
|
||||||
|
T.eq(winEvent.text, "Th-Thank you!", "the struct's win text is printed")
|
||||||
|
end
|
||||||
|
|
||||||
|
-- The pic comes back whether or not there is a line: the DEBUG_BATTLE_F skip
|
||||||
|
-- sits in front of PrintWinLossText alone.
|
||||||
|
do
|
||||||
|
local battle = newBattle(trainerRecord())
|
||||||
|
battle.enemy.hp = 0
|
||||||
|
battle:resolveFaints()
|
||||||
|
local events = battle:takeEvents()
|
||||||
|
T.check(indexOf(events, "trainer-return"), "the slide-in is unconditional")
|
||||||
|
T.eq(indexOf(events, "win-text"), nil, "with no line to print")
|
||||||
|
end
|
||||||
|
|
||||||
|
-- A wild battle has no trainer and no line.
|
||||||
|
do
|
||||||
|
local save = newSave()
|
||||||
|
local wild = mon()
|
||||||
|
local battle = Battle.new({ data = DATA, party = { mon() }, wild = wild,
|
||||||
|
save = save, random = function(n) return 99 % math.max(1, n or 1) end })
|
||||||
|
battle.enemy.hp = 0
|
||||||
|
battle:resolveFaints()
|
||||||
|
local events = battle:takeEvents()
|
||||||
|
T.eq(indexOf(events, "trainer-return"), nil, "no frontpic to slide back in")
|
||||||
|
T.eq(indexOf(events, "win-text"), nil, "and nothing to print")
|
||||||
|
end
|
||||||
|
|
||||||
|
-- The loss arm: only BATTLETYPE_CANLOSE reaches PrintWinLossText.
|
||||||
|
do
|
||||||
|
local battle = newBattle(trainerRecord({ winText = "Th-Thank you!",
|
||||||
|
lossText = "...Too weak..." }), Battle.BATTLETYPE_CANLOSE)
|
||||||
|
battle.player.hp = 0
|
||||||
|
T.check(battle:resolveFaints(), "the whiteout ends the battle")
|
||||||
|
local events = battle:takeEvents()
|
||||||
|
local _, lossEvent = indexOf(events, "win-text")
|
||||||
|
T.check(lossEvent, "the loss line is printed: " .. kinds(events))
|
||||||
|
T.eq(lossEvent.text, "...Too weak...", "wLossTextPointer, not the win one")
|
||||||
|
end
|
||||||
|
|
||||||
|
do
|
||||||
|
local battle = newBattle(trainerRecord({ lossText = "...Too weak..." }))
|
||||||
|
battle.player.hp = 0
|
||||||
|
battle:resolveFaints()
|
||||||
|
local events = battle:takeEvents()
|
||||||
|
T.eq(indexOf(events, "win-text"), nil, "an ordinary loss whites out instead")
|
||||||
|
end
|
||||||
|
|
||||||
|
-- wWinTextPointer / wLossTextPointer: the map object's struct, or whatever
|
||||||
|
-- `winlosstext` overwrote the pair with.
|
||||||
|
do
|
||||||
|
local world = { text = { ["3:4000"] = "Th-Thank you!",
|
||||||
|
["3:4100"] = "...Too weak...", ["3:4200"] = "Scripted win." } }
|
||||||
|
world.vm = { trainerObject = { winText = "3:4000", lossText = "3:4100" } }
|
||||||
|
local win, loss = World.trainerWinLossText(world)
|
||||||
|
T.eq(win, "Th-Thank you!", "the struct's win text is decoded")
|
||||||
|
T.eq(loss, "...Too weak...", "and its loss text with it")
|
||||||
|
|
||||||
|
world.vm.winLossArmed = true
|
||||||
|
world.vm.winTextOverride = "3:4200"
|
||||||
|
win, loss = World.trainerWinLossText(world)
|
||||||
|
T.eq(win, "Scripted win.", "`winlosstext` overwrites the pointer")
|
||||||
|
-- winlosstext writes BOTH pointers; its 0 loss argument destroyed the
|
||||||
|
-- struct's value (engine/overworld/scripting.asm:651)
|
||||||
|
T.eq(loss, nil, "and a 0 loss argument zeroes the loss pointer with it")
|
||||||
|
|
||||||
|
world.vm.trainerObject = nil
|
||||||
|
world.vm.winTextOverride = nil
|
||||||
|
world.vm.winLossArmed = nil
|
||||||
|
win, loss = World.trainerWinLossText(world)
|
||||||
|
T.eq(win, nil, "a battle with no trainer object has no line")
|
||||||
|
T.eq(loss, nil, "on either side")
|
||||||
|
|
||||||
|
world.vm = nil
|
||||||
|
T.eq(World.trainerWinLossText(world), nil, "and neither has one with no VM")
|
||||||
|
end
|
||||||
|
|
||||||
|
-- The screen side: the slide-in owns the frames the way SlideBattlePicOut
|
||||||
|
-- does, and the line pages like the map text it is.
|
||||||
|
local BattleState = require("src.ui.gen2.BattleState")
|
||||||
|
|
||||||
|
local function newScreen(battle, queue, image)
|
||||||
|
return setmetatable({
|
||||||
|
battle = battle, queue = queue, picHidden = {}, evolvable = {},
|
||||||
|
phase = "resolving", messageTimer = 0, enemyTrainerImage = image,
|
||||||
|
}, { __index = BattleState })
|
||||||
|
end
|
||||||
|
|
||||||
|
do
|
||||||
|
local battle = newBattle(trainerRecord({ winText = "Th-Thank you!" }))
|
||||||
|
local screen = newScreen(battle, { { kind = "trainer-return" },
|
||||||
|
{ kind = "win-text", text = "Th-Thank you!" } }, {})
|
||||||
|
screen:advanceQueue()
|
||||||
|
T.eq(screen.winSlide, 0, "the slide starts on the frame the event runs")
|
||||||
|
T.check(screen.winSliding, "and owns the screen while it runs")
|
||||||
|
T.check(screen.showEnemyTrainer, "the beaten trainer is back on the field")
|
||||||
|
T.eq(screen.picHidden.enemy, false, "in the box the fainted mon left empty")
|
||||||
|
T.eq(#screen.queue, 1, "the line is still waiting behind it")
|
||||||
|
end
|
||||||
|
|
||||||
|
do
|
||||||
|
local battle = newBattle(trainerRecord())
|
||||||
|
local screen = newScreen(battle, { { kind = "trainer-return" },
|
||||||
|
{ kind = "win-text", text = "Th-Thank you!\fReally." } }, nil)
|
||||||
|
screen:advanceQueue()
|
||||||
|
T.eq(screen.winSlide, nil, "no cached pic, no slide")
|
||||||
|
T.eq(screen.message, "Th-Thank you!", "the line runs straight away")
|
||||||
|
T.check(screen.messagePages, "with its `para` page held back")
|
||||||
|
T.check(screen:nextPage(), "which the queue waits for")
|
||||||
|
T.eq(screen.message, "Really.", "before the second page shows")
|
||||||
|
end
|
||||||
|
|
||||||
|
T.finish("gen2 win loss text bug 1512")
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
-- A gym leader's badge line prints from TrainerBattleVictory (#1606):
|
||||||
|
-- scripts/CeruleanGym.asm:111, PewterGym.asm:117, home/trainers.asm:341
|
||||||
|
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||||
|
|
||||||
|
local T = require("tests.modkit")
|
||||||
|
local gyms = require("data.scripts.gyms")
|
||||||
|
local victories = require("data.scripts.victories")
|
||||||
|
local OW = require("src.world.OverworldController")
|
||||||
|
|
||||||
|
local function setUpvalue(fn, name, val)
|
||||||
|
local i = 1
|
||||||
|
while true do
|
||||||
|
local n = debug.getupvalue(fn, i)
|
||||||
|
if not n then return false end
|
||||||
|
if n == name then debug.setupvalue(fn, i, val); return true end
|
||||||
|
i = i + 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- one page of text per label, so a joined chain is readable in a failure
|
||||||
|
local text = {}
|
||||||
|
for _, key in ipairs({ "OPP_BROCK#1", "OPP_MISTY#1", "OPP_LT_SURGE#1",
|
||||||
|
"OPP_ERIKA#1", "OPP_KOGA#1", "OPP_SABRINA#1",
|
||||||
|
"OPP_BLAINE#1", "OPP_GIOVANNI#3" }) do
|
||||||
|
for _, label in ipairs(victories[key].dialogue or {}) do
|
||||||
|
text[label] = "text:" .. label
|
||||||
|
end
|
||||||
|
for _, label in ipairs(victories[key].tmPre or {}) do
|
||||||
|
text[label] = "text:" .. label
|
||||||
|
end
|
||||||
|
for _, label in ipairs(victories[key].tmDialogue or {}) do
|
||||||
|
text[label] = "text:" .. label
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local fakeGame = { data = { text = text }, save = { flags = {} } }
|
||||||
|
|
||||||
|
local armed, armedSound
|
||||||
|
local fakeOw = {
|
||||||
|
engageTrainer = function(_, _, _, endBattleText, _, endBattleSound)
|
||||||
|
armed, armedSound = endBattleText, endBattleSound
|
||||||
|
end,
|
||||||
|
}
|
||||||
|
|
||||||
|
local function armedFor(mapId, textId, victoryKey)
|
||||||
|
armed, armedSound = nil, nil
|
||||||
|
gyms[mapId].talk[textId](fakeGame, fakeOw, { id = "npc#1" }, function() end)
|
||||||
|
local labels = victories[victoryKey].dialogue
|
||||||
|
local want = {}
|
||||||
|
for i, label in ipairs(labels) do want[i] = text[label] end
|
||||||
|
return armed, table.concat(want, "\f")
|
||||||
|
end
|
||||||
|
|
||||||
|
-- every leader, in badge order
|
||||||
|
local leaders = {
|
||||||
|
{ "PEWTER_GYM", "TEXT_PEWTERGYM_BROCK", "OPP_BROCK#1" },
|
||||||
|
{ "CERULEAN_GYM", "TEXT_CERULEANGYM_MISTY", "OPP_MISTY#1" },
|
||||||
|
{ "VERMILION_GYM", "TEXT_VERMILIONGYM_LT_SURGE", "OPP_LT_SURGE#1" },
|
||||||
|
{ "CELADON_GYM", "TEXT_CELADONGYM_ERIKA", "OPP_ERIKA#1" },
|
||||||
|
{ "FUCHSIA_GYM", "TEXT_FUCHSIAGYM_KOGA", "OPP_KOGA#1" },
|
||||||
|
{ "SAFFRON_GYM", "TEXT_SAFFRONGYM_SABRINA", "OPP_SABRINA#1" },
|
||||||
|
{ "CINNABAR_GYM", "TEXT_CINNABARGYM_BLAINE", "OPP_BLAINE#1" },
|
||||||
|
{ "VIRIDIAN_GYM", "TEXT_VIRIDIANGYM_GIOVANNI", "OPP_GIOVANNI#3" },
|
||||||
|
}
|
||||||
|
for _, entry in ipairs(leaders) do
|
||||||
|
local got, want = armedFor(entry[1], entry[2], entry[3])
|
||||||
|
T.eq(got, want, entry[3] .. " arms its badge line for the battle screen")
|
||||||
|
-- the dialogue's sound command rides the armed line onto the battle
|
||||||
|
-- screen too (sound_get_item_1 / sound_get_key_item) (#1606)
|
||||||
|
T.eq(armedSound, victories[entry[3]].badgeSound,
|
||||||
|
entry[3] .. " arms its badge jingle beside the line")
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Brock's armed label is one text chain of two text_far pages
|
||||||
|
-- (PewterGymBrockReceivedBoulderBadgeText), so both ride the battle screen
|
||||||
|
local brock = select(1, armedFor("PEWTER_GYM", "TEXT_PEWTERGYM_BROCK",
|
||||||
|
"OPP_BROCK#1"))
|
||||||
|
T.check(brock:find("\f", 1, true) ~= nil,
|
||||||
|
"Brock's badge line keeps its BoulderBadgeInfo page")
|
||||||
|
|
||||||
|
-- the beaten branch still talks instead of re-engaging
|
||||||
|
armed = nil
|
||||||
|
fakeGame.save.flags.EVENT_BEAT_MISTY = true
|
||||||
|
fakeGame.save.flags.EVENT_GOT_TM11 = true
|
||||||
|
local realStack = { push = function() end }
|
||||||
|
gyms.CERULEAN_GYM.talk.TEXT_CERULEANGYM_MISTY(
|
||||||
|
{ data = { text = text }, save = fakeGame.save, stack = realStack },
|
||||||
|
fakeOw, { id = "npc#1" }, function() end)
|
||||||
|
T.eq(armed, nil, "a beaten leader does not re-arm the badge line")
|
||||||
|
fakeGame.save.flags.EVENT_BEAT_MISTY = nil
|
||||||
|
fakeGame.save.flags.EVENT_GOT_TM11 = nil
|
||||||
|
|
||||||
|
-- checkVictoryRewards must not reprint what the battle screen showed
|
||||||
|
local boxes
|
||||||
|
local textBoxStub = {
|
||||||
|
new = function(_, str, onDone)
|
||||||
|
boxes[#boxes + 1] = str
|
||||||
|
return { onDone = onDone }
|
||||||
|
end,
|
||||||
|
soundOpts = function() return nil end,
|
||||||
|
}
|
||||||
|
local pushed
|
||||||
|
local rewardGame = {
|
||||||
|
data = { text = text, items = { TM_BUBBLEBEAM = { name = "TM11" } } },
|
||||||
|
save = { flags = {}, inventory = {}, player = { name = "RED" } },
|
||||||
|
stack = { push = function(_, box) pushed[#pushed + 1] = box end },
|
||||||
|
}
|
||||||
|
T.check(setUpvalue(OW.checkVictoryRewards, "Game", rewardGame),
|
||||||
|
"Game upvalue on checkVictoryRewards")
|
||||||
|
-- TextBox is only named inside the rewardChain closure; the chunk-level
|
||||||
|
-- upvalue cell is shared, so any closure that names it will do
|
||||||
|
T.check(setUpvalue(OW.engageTrainer, "TextBox", textBoxStub),
|
||||||
|
"TextBox upvalue on the reward chain")
|
||||||
|
|
||||||
|
local fakeSelf = setmetatable({
|
||||||
|
map = { id = "CERULEAN_GYM", def = { label = "CeruleanGym" } },
|
||||||
|
runVictoryHook = function() end,
|
||||||
|
}, { __index = OW })
|
||||||
|
|
||||||
|
local function rewardPages(shownOnBattleScreen)
|
||||||
|
boxes, pushed = {}, {}
|
||||||
|
rewardGame.save.flags = {}
|
||||||
|
rewardGame.save.inventory = {}
|
||||||
|
fakeSelf:checkVictoryRewards("OPP_MISTY", 1, shownOnBattleScreen)
|
||||||
|
-- the chain pushes one box at a time; walk it to the end
|
||||||
|
local i = 1
|
||||||
|
while pushed[i] do
|
||||||
|
local box = pushed[i]
|
||||||
|
i = i + 1
|
||||||
|
if box.onDone then box.onDone() end
|
||||||
|
end
|
||||||
|
return table.concat(boxes, "\f")
|
||||||
|
end
|
||||||
|
|
||||||
|
local badge = text["_CeruleanGymMistyReceivedCascadeBadgeText"]
|
||||||
|
local onMap = rewardPages(false)
|
||||||
|
T.check(onMap:find(badge, 1, true) ~= nil,
|
||||||
|
"without the battle-screen line the reward chain still shows the badge text")
|
||||||
|
local afterBattleScreen = rewardPages(true)
|
||||||
|
T.eq(afterBattleScreen:find(badge, 1, true), nil,
|
||||||
|
"the badge line is not reprinted on the map once the battle screen showed it")
|
||||||
|
T.check(afterBattleScreen:find(text["_CeruleanGymMistyCascadeBadgeInfoText"],
|
||||||
|
1, true) ~= nil,
|
||||||
|
"the TM hand-over still runs on the map")
|
||||||
|
T.check(rewardGame.save.inventory.CASCADEBADGE == 1, "the badge is still given")
|
||||||
|
|
||||||
|
T.finish("gym end battle text (#1606)")
|
||||||
@@ -0,0 +1,193 @@
|
|||||||
|
-- Title -> main menu handoff (#1510) and the Oak-intro naming layout (#1511):
|
||||||
|
-- title.asm .finishedWaiting, oak_speech2.asm ChoosePlayerName
|
||||||
|
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||||
|
|
||||||
|
local T = require("tests.modkit")
|
||||||
|
local Data = T.fixtures.load()
|
||||||
|
|
||||||
|
local SaveData = require("src.core.SaveData")
|
||||||
|
local Menu = require("src.ui.Menu")
|
||||||
|
local NamingScreen = require("src.ui.NamingScreen")
|
||||||
|
local OakSpeech = require("src.ui.OakSpeech")
|
||||||
|
local TitleState = require("src.ui.TitleState")
|
||||||
|
|
||||||
|
local function newStack()
|
||||||
|
local stack = { states = {} }
|
||||||
|
function stack:push(state, ...)
|
||||||
|
table.insert(self.states, state)
|
||||||
|
if type(state.enter) == "function" then state:enter(...) end
|
||||||
|
end
|
||||||
|
function stack:pop() return table.remove(self.states) end
|
||||||
|
function stack:top() return self.states[#self.states] end
|
||||||
|
return stack
|
||||||
|
end
|
||||||
|
|
||||||
|
local function newGame()
|
||||||
|
return {
|
||||||
|
data = Data,
|
||||||
|
save = SaveData.newGame(),
|
||||||
|
stack = newStack(),
|
||||||
|
input = { wasPressed = function() return false end,
|
||||||
|
isDown = function() return false end },
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
local function run(state, frames)
|
||||||
|
for _ = 1, frames do
|
||||||
|
if state.game.stack:top() ~= state then return end
|
||||||
|
state:update(1 / 60)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ------------------------------------------------- #1510: title -> menu
|
||||||
|
|
||||||
|
local game = newGame()
|
||||||
|
local title = TitleState.new(game, {})
|
||||||
|
game.stack:push(title)
|
||||||
|
|
||||||
|
title:toMenu()
|
||||||
|
local flash = game.stack:top()
|
||||||
|
T.check(flash ~= title, "START pushes a state before the menu")
|
||||||
|
T.eq(flash.isOpaque, true, "GBPalWhiteOutWithDelay3 covers the title art")
|
||||||
|
T.check(not title.menuOpen, "the title is still drawing itself mid-blink")
|
||||||
|
|
||||||
|
run(flash, 60)
|
||||||
|
local menu = game.stack:top()
|
||||||
|
T.check(getmetatable(menu) == Menu, "the blink hands off to the main menu")
|
||||||
|
T.eq(title.menuOpen, true, "MainMenu's ClearScreen takes the title art down")
|
||||||
|
T.eq(menu.tx, 0, "the CONTINUE / NEW GAME box is still at hlcoord 0,0")
|
||||||
|
|
||||||
|
-- OPTION returns to .mainMenuLoop, so its row must not close the box
|
||||||
|
local option
|
||||||
|
for _, item in ipairs(menu.items) do
|
||||||
|
if item.label == "OPTION" then option = item end
|
||||||
|
end
|
||||||
|
T.check(option ~= nil and option.keepOpen == true,
|
||||||
|
"OPTION keeps the main menu on the stack")
|
||||||
|
|
||||||
|
-- B: DisplayTitleScreen opens with GBPalWhiteOut
|
||||||
|
T.check(type(menu.onCancel) == "function", "the main menu cancels back out")
|
||||||
|
game.stack:pop()
|
||||||
|
menu.onCancel()
|
||||||
|
local back = game.stack:top()
|
||||||
|
T.eq(back.isOpaque, true, "backing out blinks white too")
|
||||||
|
T.eq(title.menuOpen, true, "the title art stays down until the blink ends")
|
||||||
|
run(back, 60)
|
||||||
|
T.eq(title.menuOpen, false, "and comes back once the blink is over")
|
||||||
|
T.eq(game.stack:top(), title, "leaving the menu lands back on the title")
|
||||||
|
-- main_menu.asm:70 jumps back to DisplayTitleScreen: the whole cinematic reruns
|
||||||
|
T.eq(title.phase, "drop", "cancel reruns the boot cinematic from the logo drop")
|
||||||
|
|
||||||
|
-- a stranded menuOpen (an onSelect that handed control straight back) may
|
||||||
|
-- not leave a blank white title behind
|
||||||
|
title.menuOpen = true
|
||||||
|
title:update(1 / 60)
|
||||||
|
T.eq(title.menuOpen, false, "a stranded menuOpen clears on the next update")
|
||||||
|
|
||||||
|
-- .finishedWaiting: PlayCry then WaitForSoundToFinish before the white-out
|
||||||
|
-- (engine/movie/title.asm:241-243)
|
||||||
|
while title.phase ~= "loop" do title:updateSequence() end
|
||||||
|
game.input.wasPressed = function(_, b) return b == "start" end
|
||||||
|
title:update(1 / 60)
|
||||||
|
game.input.wasPressed = function() return false end
|
||||||
|
T.eq(title.phase, "exitCry", "START waits out the cry before the white-out")
|
||||||
|
T.eq(game.stack:top(), title, "no flash is pushed on the cry frame")
|
||||||
|
run(title, 10)
|
||||||
|
T.check(game.stack:top() ~= title, "the flash follows once the cry is done")
|
||||||
|
|
||||||
|
-- ------------------------------------------- #1511: the intro NAME box
|
||||||
|
|
||||||
|
local ngame = newGame()
|
||||||
|
local naming = NamingScreen.new(ngame, {
|
||||||
|
presets = { "RED", "ASH", "JACK" }, introBox = true,
|
||||||
|
})
|
||||||
|
ngame.stack:push(naming)
|
||||||
|
local box = ngame.stack:top()
|
||||||
|
T.check(getmetatable(box) == Menu, "the preset list is a bordered menu")
|
||||||
|
-- DisplayIntroNameTextBox: TextBoxBorder at hlcoord 0,0 with b=$a, c=$9
|
||||||
|
T.eq(box.tx, 0, "the name box starts at column 0")
|
||||||
|
T.eq(box.ty, 0, "the name box starts at row 0")
|
||||||
|
T.eq(box.tw, 11, "c=$9 plus both border columns is 11 tiles wide")
|
||||||
|
T.eq(box.th, 12, "b=$a plus both border rows is 12 tiles tall")
|
||||||
|
T.eq(box.title, "NAME", "the NAME label rides the box's top border")
|
||||||
|
T.eq(box.itemY, 2, "wTopMenuItemY 2: the list is anchored down from the top")
|
||||||
|
T.eq(box.cancelable, false, "there is no way out of the naming choice")
|
||||||
|
|
||||||
|
-- every other NamingScreen caller keeps the old preset box
|
||||||
|
local plain = NamingScreen.new(newGame(), { presets = { "RED" } })
|
||||||
|
local pgame = plain.game
|
||||||
|
pgame.stack:push(plain)
|
||||||
|
T.eq(pgame.stack:top().tx, 4, "a non-intro preset list is unchanged")
|
||||||
|
T.eq(pgame.stack:top().title, nil, "and carries no header")
|
||||||
|
|
||||||
|
-- ------------------------------------------ #1511: the pic slide + box
|
||||||
|
|
||||||
|
local steps = OakSpeech.defaultSteps({})
|
||||||
|
local byId = {}
|
||||||
|
for _, step in ipairs(steps) do byId[step.id] = step end
|
||||||
|
-- oak_speech.asm:86-91 MovePicLeft, then a text_end box that stays up
|
||||||
|
T.eq(byId.ask_player_name.reveal, "wipe", "the player pic wipes in")
|
||||||
|
T.eq(byId.ask_player_name.stay, true, "and its question box stays on screen")
|
||||||
|
T.eq(byId.ask_rival_name.reveal, "fade", "the rival pic fades in")
|
||||||
|
T.eq(byId.ask_rival_name.stay, true, "and its box stays on screen too")
|
||||||
|
|
||||||
|
local sgame = newGame()
|
||||||
|
local speech = OakSpeech.new(sgame, function() end)
|
||||||
|
sgame.stack:push(speech)
|
||||||
|
speech.steps = steps
|
||||||
|
speech.step = 0
|
||||||
|
speech:runStep(byId.name_player)
|
||||||
|
local slide = sgame.stack:top()
|
||||||
|
T.check(slide ~= speech, "the name beat slides the pic before the box opens")
|
||||||
|
T.eq(speech.picSlide, 0, "the slide starts where the pic already sat")
|
||||||
|
-- six tiles, one per Delay3
|
||||||
|
run(slide, 6 * 3)
|
||||||
|
T.eq(speech.picSlide, 48, "OakSpeechSlidePicRight ends six tiles across")
|
||||||
|
T.check(sgame.stack:top() ~= slide, "the slide pops itself when it lands")
|
||||||
|
|
||||||
|
-- ------------------------------------- #1511: the question box stays up
|
||||||
|
|
||||||
|
local fgame = newGame()
|
||||||
|
local flow = OakSpeech.new(fgame, function() end)
|
||||||
|
fgame.stack:push(flow)
|
||||||
|
flow.steps = OakSpeech.defaultSteps(flow)
|
||||||
|
for i, step in ipairs(flow.steps) do
|
||||||
|
if step.id == "ask_player_name" then flow.step = i - 1 end
|
||||||
|
end
|
||||||
|
flow:advance()
|
||||||
|
local function pump(frames)
|
||||||
|
for _ = 1, frames do
|
||||||
|
local top = fgame.stack:top()
|
||||||
|
if top.update then top:update(1 / 60) end
|
||||||
|
if getmetatable(fgame.stack:top()) == Menu then return end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
pump(400)
|
||||||
|
-- _IntroducePlayerText ends in `prompt` (text_2.asm:1730): arrowed A wait
|
||||||
|
T.check(getmetatable(fgame.stack:top()) ~= Menu,
|
||||||
|
"the question box waits for A before the name list")
|
||||||
|
T.check(fgame.stack:top() == flow.holdBox and flow.holdBox.done,
|
||||||
|
"the typed-out question box is on top, waiting for the press")
|
||||||
|
fgame.input.wasPressed = function(_, b) return b == "a" end
|
||||||
|
fgame.stack:top():update(1 / 60)
|
||||||
|
fgame.input.wasPressed = function() return false end
|
||||||
|
pump(400)
|
||||||
|
T.check(getmetatable(fgame.stack:top()) == Menu, "A opens the preset list")
|
||||||
|
T.eq(fgame.stack.states[2], flow.holdBox,
|
||||||
|
"IntroducePlayerText's box is still on the stack under the name list")
|
||||||
|
T.check(flow.holdBox.stayShown == true,
|
||||||
|
"prompt-then-hold: the box stays up after the press (home/text.asm:434)")
|
||||||
|
|
||||||
|
local preset = fgame.stack:top()
|
||||||
|
preset.index = 2
|
||||||
|
fgame.input.wasPressed = function(_, b) return b == "a" end
|
||||||
|
preset:update(1 / 60)
|
||||||
|
fgame.input.wasPressed = function() return false end
|
||||||
|
T.eq(fgame.save.player.name, "RED", "picking a preset names the player")
|
||||||
|
T.eq(flow.holdBox, nil, "and takes the question box down with the list")
|
||||||
|
-- 13-frame ClearScreenArea / DelayFrames beat, then six tiles of slide
|
||||||
|
-- (oak_speech2.asm:69-78)
|
||||||
|
run(fgame.stack:top(), 13 + 6 * 3)
|
||||||
|
T.eq(flow.picSlide, 0, "OakSpeechSlidePicLeft puts the pic back")
|
||||||
|
|
||||||
|
T.finish("intro_title_naming_bug1510_1511")
|
||||||
@@ -300,6 +300,88 @@ LauncherView.draw(edgeImp)
|
|||||||
check((edgeImp._tabScroll.skins or 0) > 0,
|
check((edgeImp._tabScroll.skins or 0) > 0,
|
||||||
"which reaches the tab region even though the cursor is below it")
|
"which reaches the tab region even though the cursor is below it")
|
||||||
|
|
||||||
|
Kit.blockClicks = false
|
||||||
|
Kit._clipRect = nil
|
||||||
|
Kit.wheelY = 0
|
||||||
|
Kit.mouseX, Kit.mouseY = 400, 400
|
||||||
|
Kit.dragBegin(50, 50)
|
||||||
|
Kit.dragAdd(120)
|
||||||
|
local pg = Kit.wheelPage(0, 0, 100, 100, 1, 100, 10)
|
||||||
|
eq(pg, 3, "a drag that crossed two half-heights turns two pages")
|
||||||
|
eq(Kit.dragAccum, 20, "and keeps the remainder for the next flip")
|
||||||
|
Kit.dragAdd(-140)
|
||||||
|
pg = Kit.wheelPage(0, 0, 100, 100, pg, 100, 10)
|
||||||
|
eq(pg, 1, "dragging back down returns those pages")
|
||||||
|
eq(Kit.dragAccum, -20, "with the remainder's sign preserved")
|
||||||
|
Kit.dragAdd(-80)
|
||||||
|
pg = Kit.wheelPage(0, 0, 100, 100, pg, 100, 10)
|
||||||
|
eq(pg, 1, "a drag past the first page clamps to it")
|
||||||
|
eq(Kit.dragAccum, 0, "and drops the pile-up so reversing is instant")
|
||||||
|
Kit.dragAdd(200)
|
||||||
|
pg = Kit.wheelPage(200, 200, 100, 100, pg, 100, 10)
|
||||||
|
eq(pg, 1, "a drag that began outside the list is not the list's")
|
||||||
|
eq(Kit.dragAccum, 200, "and its travel stays queued")
|
||||||
|
Kit.dragEnd()
|
||||||
|
eq(Kit.dragAccum, 0, "releasing the button retires the gesture")
|
||||||
|
|
||||||
|
window(360, 780)
|
||||||
|
local mouseImp = skinLauncher(12)
|
||||||
|
LauncherView.draw(mouseImp)
|
||||||
|
LauncherView.draw(mouseImp)
|
||||||
|
local mreg = mouseImp._tabRegionRect
|
||||||
|
local mmax = mouseImp._tabScrollMax.skins
|
||||||
|
check(mmax > 0, "the mouse-dragged panel has travel")
|
||||||
|
local mdown = false
|
||||||
|
love.mouse.isDown = function() return mdown end
|
||||||
|
|
||||||
|
pointer(mreg.x + 20, mreg.y + 40)
|
||||||
|
mdown = true
|
||||||
|
LauncherView.update(mouseImp, 0.016)
|
||||||
|
check(mouseImp._clickPt == nil,
|
||||||
|
"a press over a scrollable region mints no click")
|
||||||
|
check(mouseImp._mouseAt ~= nil, "it arms a drag instead")
|
||||||
|
pointer(mreg.x + 20, mreg.y + 40 - 200)
|
||||||
|
LauncherView.update(mouseImp, 0.016)
|
||||||
|
eq(mouseImp._tabScroll.skins, math.min(200, mmax),
|
||||||
|
"dragging the held mouse scrolls the panel by its travel")
|
||||||
|
eq(mouseImp._pageScroll or 0, 0,
|
||||||
|
"while the panel still has travel, the page waits")
|
||||||
|
pointer(mreg.x + 20, mreg.y + 40 - 200 - mmax * 2)
|
||||||
|
LauncherView.update(mouseImp, 0.016)
|
||||||
|
eq(mouseImp._tabScroll.skins, mmax,
|
||||||
|
"a longer mouse drag reaches the panel's bottom")
|
||||||
|
check((mouseImp._pageScroll or 0) > 0, "and spills into the page from there")
|
||||||
|
mdown = false
|
||||||
|
LauncherView.update(mouseImp, 0.016)
|
||||||
|
check(mouseImp._clickPt == nil, "a released drag is not a click")
|
||||||
|
check(mouseImp._mouseAt == nil, "and the gesture is retired")
|
||||||
|
|
||||||
|
pointer(mreg.x + 20, mreg.y + 40)
|
||||||
|
mdown = true
|
||||||
|
LauncherView.update(mouseImp, 0.016)
|
||||||
|
check(mouseImp._clickPt == nil, "a fresh press still holds its click back")
|
||||||
|
mdown = false
|
||||||
|
LauncherView.update(mouseImp, 0.016)
|
||||||
|
check(mouseImp._clickPt ~= nil,
|
||||||
|
"press and release without travel is still a tap, on release")
|
||||||
|
mouseImp._clickPt = nil
|
||||||
|
|
||||||
|
LauncherView.draw(gameImp)
|
||||||
|
LauncherView.draw(gameImp)
|
||||||
|
check((gameImp._noDragN or 0) > 0, "the game tab publishes its cartridge rect")
|
||||||
|
local cart = gameImp._noDragRects[1]
|
||||||
|
pointer(cart.x + cart.w / 2, cart.y + cart.h / 2)
|
||||||
|
mdown = true
|
||||||
|
LauncherView.update(gameImp, 0.016)
|
||||||
|
check(gameImp._clickPt ~= nil,
|
||||||
|
"a press on the cartridge clicks at once so its own spin-drag still owns "
|
||||||
|
.. "the gesture")
|
||||||
|
check(gameImp._mouseAt == nil, "and never arms the scroll drag")
|
||||||
|
gameImp._clickPt = nil
|
||||||
|
mdown = false
|
||||||
|
LauncherView.update(gameImp, 0.016)
|
||||||
|
love.mouse.isDown = nil
|
||||||
|
|
||||||
local function read(path)
|
local function read(path)
|
||||||
local f = assert(io.open(path, "r"))
|
local f = assert(io.open(path, "r"))
|
||||||
local src = f:read("*a")
|
local src = f:read("*a")
|
||||||
|
|||||||
@@ -0,0 +1,139 @@
|
|||||||
|
-- Two OverworldController.lua messages used to be bare Lua literals:
|
||||||
|
-- applyFieldPoison()'s "%s\nfainted!" (the third of three collapsed
|
||||||
|
-- fainted-message ROM strings, _PokemonFaintedText) and
|
||||||
|
-- useSoftboiledFieldMove()'s "It won't have\nany effect."/"%s's HP\nwas
|
||||||
|
-- restored!" (the same _ItemUseNoEffectText/_PotionText labels
|
||||||
|
-- ItemEffects.lua's real potion message already uses -- _PotionText's
|
||||||
|
-- second slot is the actual amount healed, which the old literal never
|
||||||
|
-- showed at all).
|
||||||
|
--
|
||||||
|
-- Uses the debug.setupvalue technique already established in
|
||||||
|
-- oaks_pc_flow.lua to fake the module-level Game/TextBox upvalues
|
||||||
|
-- ROM-free, without going through the heavy OverworldState:enter().
|
||||||
|
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||||
|
|
||||||
|
local T = require("tests.modkit")
|
||||||
|
local Data = T.fixtures.fresh()
|
||||||
|
|
||||||
|
local SaveData = require("src.core.SaveData")
|
||||||
|
local Pokemon = require("src.pokemon.Pokemon")
|
||||||
|
local OW = require("src.world.OverworldController")
|
||||||
|
|
||||||
|
local function setUpvalue(fn, name, val)
|
||||||
|
local i = 1
|
||||||
|
while true do
|
||||||
|
local n = debug.getupvalue(fn, i)
|
||||||
|
if not n then return false end
|
||||||
|
if n == name then debug.setupvalue(fn, i, val); return true end
|
||||||
|
i = i + 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local pushed = {}
|
||||||
|
local textBoxStub = {
|
||||||
|
new = function(_, text, onDone, opts)
|
||||||
|
return { text = text, onDone = onDone, opts = opts }
|
||||||
|
end,
|
||||||
|
}
|
||||||
|
local realSound = package.loaded["src.core.Sound"]
|
||||||
|
package.loaded["src.core.Sound"] = { play = function() end, playCry = function() end }
|
||||||
|
|
||||||
|
local function mkGame()
|
||||||
|
local save = SaveData.newGame()
|
||||||
|
save.party = { Pokemon.new(Data, "FIXMON_A", 20) }
|
||||||
|
pushed = {}
|
||||||
|
return {
|
||||||
|
data = Data, save = save,
|
||||||
|
stack = { push = function(_, item) pushed[#pushed + 1] = item end },
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
for _, name in ipairs({ "applyFieldPoison", "useSoftboiledFieldMove" }) do
|
||||||
|
T.check(setUpvalue(OW[name], "Game", mkGame()), ("Game upvalue on %s"):format(name))
|
||||||
|
T.check(setUpvalue(OW[name], "TextBox", textBoxStub), ("TextBox upvalue on %s"):format(name))
|
||||||
|
end
|
||||||
|
|
||||||
|
local fakeSelf = setmetatable({}, { __index = OW })
|
||||||
|
|
||||||
|
-- ---- applyFieldPoison: _PokemonFaintedText ----
|
||||||
|
do
|
||||||
|
local game = mkGame()
|
||||||
|
setUpvalue(OW.applyFieldPoison, "Game", game)
|
||||||
|
local mon = game.save.party[1]
|
||||||
|
mon.status = "PSN"
|
||||||
|
mon.hp = 1 -- one poison tick (1 dmg by default) faints it
|
||||||
|
game.save.poisonSteps = 3 -- (3+1) % 4 == 0: this step ticks poison
|
||||||
|
|
||||||
|
Data.text._PokemonFaintedText = "FAKE {RAM:wNameBuffer} FAKE!"
|
||||||
|
fakeSelf:applyFieldPoison()
|
||||||
|
T.eq(pushed[1] and pushed[1].text, "FAKE " .. (mon.nickname or "FIXMON A") .. " FAKE!",
|
||||||
|
"a translated _PokemonFaintedText reaches the field-poison faint message")
|
||||||
|
Data.text._PokemonFaintedText = nil
|
||||||
|
end
|
||||||
|
|
||||||
|
-- vanilla: no catalog entry, English literal
|
||||||
|
do
|
||||||
|
local game = mkGame()
|
||||||
|
setUpvalue(OW.applyFieldPoison, "Game", game)
|
||||||
|
local mon = game.save.party[1]
|
||||||
|
mon.status = "PSN"
|
||||||
|
mon.hp = 1
|
||||||
|
game.save.poisonSteps = 3
|
||||||
|
|
||||||
|
fakeSelf:applyFieldPoison()
|
||||||
|
T.eq(pushed[1] and pushed[1].text,
|
||||||
|
(mon.nickname or "FIXMON A") .. "\nfainted!",
|
||||||
|
"no catalog entry falls back to the English fainted literal")
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ---- useSoftboiledFieldMove: _ItemUseNoEffectText / _PotionText ----
|
||||||
|
do
|
||||||
|
local game = mkGame()
|
||||||
|
setUpvalue(OW.useSoftboiledFieldMove, "Game", game)
|
||||||
|
local user = Pokemon.new(Data, "FIXMON_A", 20)
|
||||||
|
local target = Pokemon.new(Data, "FIXMON_B", 20)
|
||||||
|
target.hp = target.stats.hp -- already full: no effect
|
||||||
|
|
||||||
|
Data.text._ItemUseNoEffectText = "FAKE-NOEFFECT!"
|
||||||
|
local ok = fakeSelf:useSoftboiledFieldMove(user, target)
|
||||||
|
T.check(ok == false, "a full-HP target reports no effect")
|
||||||
|
T.eq(pushed[1] and pushed[1].text, "FAKE-NOEFFECT!",
|
||||||
|
"a translated _ItemUseNoEffectText reaches the no-effect message")
|
||||||
|
Data.text._ItemUseNoEffectText = nil
|
||||||
|
end
|
||||||
|
|
||||||
|
do
|
||||||
|
local game = mkGame()
|
||||||
|
setUpvalue(OW.useSoftboiledFieldMove, "Game", game)
|
||||||
|
local user = Pokemon.new(Data, "FIXMON_A", 20)
|
||||||
|
local target = Pokemon.new(Data, "FIXMON_B", 20)
|
||||||
|
target.hp = target.stats.hp - 10 -- missing exactly 10 HP
|
||||||
|
|
||||||
|
Data.text._PotionText = "FAKE {RAM:wNameBuffer} healed {NUM:wHPBarHPDifference, 2, 3}!"
|
||||||
|
local ok = fakeSelf:useSoftboiledFieldMove(user, target)
|
||||||
|
T.check(ok == true, "a damaged target heals successfully")
|
||||||
|
T.eq(pushed[1] and pushed[1].text,
|
||||||
|
"FAKE " .. (target.nickname or "FIXMON B") .. " healed 10!",
|
||||||
|
"a translated _PotionText reaches the heal message, amount included")
|
||||||
|
Data.text._PotionText = nil
|
||||||
|
end
|
||||||
|
|
||||||
|
-- vanilla: no catalog entry, the fallback's single %s slot still fills
|
||||||
|
-- correctly (the amount is silently dropped by design, same as
|
||||||
|
-- ItemEffects.lua's own _PotionText fallback -- not a regression, this
|
||||||
|
-- matches the pre-fix literal's behavior exactly)
|
||||||
|
do
|
||||||
|
local game = mkGame()
|
||||||
|
setUpvalue(OW.useSoftboiledFieldMove, "Game", game)
|
||||||
|
local user = Pokemon.new(Data, "FIXMON_A", 20)
|
||||||
|
local target = Pokemon.new(Data, "FIXMON_B", 20)
|
||||||
|
target.hp = target.stats.hp - 10
|
||||||
|
local ok = fakeSelf:useSoftboiledFieldMove(user, target)
|
||||||
|
T.check(ok == true, "a damaged target heals successfully (vanilla)")
|
||||||
|
T.eq(pushed[1] and pushed[1].text,
|
||||||
|
(target.nickname or "FIXMON B") .. "'s HP\nwas restored!",
|
||||||
|
"no catalog entry falls back to the English literal (no amount shown)")
|
||||||
|
end
|
||||||
|
|
||||||
|
package.loaded["src.core.Sound"] = realSound
|
||||||
|
T.finish("overworld_field_faint_heal_romtext")
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
-- OverworldState:tryHiddenObject()'s "%s found\n%s!" message used to
|
||||||
|
-- substitute both the player name and the item name into one bare Lua
|
||||||
|
-- literal. The real _FoundHiddenItemText label leads with a {PLAYER}
|
||||||
|
-- named token, which romText auto-fills from a 2-arg call in the same
|
||||||
|
-- order the literal already used -- this test checks both slots land
|
||||||
|
-- correctly (an accidental argument swap is the easy mistake this shape
|
||||||
|
-- invites).
|
||||||
|
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||||
|
|
||||||
|
local T = require("tests.modkit")
|
||||||
|
local Data = T.fixtures.fresh()
|
||||||
|
|
||||||
|
local SaveData = require("src.core.SaveData")
|
||||||
|
local OW = require("src.world.OverworldController")
|
||||||
|
|
||||||
|
local function setUpvalue(fn, name, val)
|
||||||
|
local i = 1
|
||||||
|
while true do
|
||||||
|
local n = debug.getupvalue(fn, i)
|
||||||
|
if not n then return false end
|
||||||
|
if n == name then debug.setupvalue(fn, i, val); return true end
|
||||||
|
i = i + 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local pushed = {}
|
||||||
|
local textBoxStub = {
|
||||||
|
new = function(_, text, onDone, opts)
|
||||||
|
return { text = text, onDone = onDone, opts = opts }
|
||||||
|
end,
|
||||||
|
soundOpts = function() return {} end,
|
||||||
|
}
|
||||||
|
|
||||||
|
local MAP_ID = "FIX_TOWN"
|
||||||
|
Data.field.hiddenItems[MAP_ID] = { { x = 3, y = 3, item = "FIX_BALL" } }
|
||||||
|
|
||||||
|
local function mkGame()
|
||||||
|
local save = SaveData.newGame()
|
||||||
|
save.player.name = "FAKEPLAYER"
|
||||||
|
pushed = {}
|
||||||
|
return {
|
||||||
|
data = Data, save = save,
|
||||||
|
stack = { push = function(_, item) pushed[#pushed + 1] = item end },
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
T.check(setUpvalue(OW.tryHiddenObject, "Game", mkGame()), "Game upvalue on tryHiddenObject")
|
||||||
|
T.check(setUpvalue(OW.tryHiddenObject, "TextBox", textBoxStub), "TextBox upvalue on tryHiddenObject")
|
||||||
|
|
||||||
|
local fakeSelf = setmetatable({ map = { id = MAP_ID } }, { __index = OW })
|
||||||
|
|
||||||
|
-- translated: player name and item name both land in the right slots
|
||||||
|
do
|
||||||
|
local game = mkGame()
|
||||||
|
setUpvalue(OW.tryHiddenObject, "Game", game)
|
||||||
|
Data.text._FoundHiddenItemText = "FAKE {PLAYER} found FAKE {RAM:wNameBuffer} FAKE!"
|
||||||
|
local found = fakeSelf:tryHiddenObject(3, 3)
|
||||||
|
T.check(found == true, "the hidden item at (3,3) is found")
|
||||||
|
T.eq(pushed[1] and pushed[1].text,
|
||||||
|
"FAKE FAKEPLAYER found FAKE FIX BALL FAKE!",
|
||||||
|
"a translated _FoundHiddenItemText fills both {PLAYER} and the item name")
|
||||||
|
Data.text._FoundHiddenItemText = nil
|
||||||
|
end
|
||||||
|
|
||||||
|
-- vanilla: no catalog entry, so romText falls back to plain
|
||||||
|
-- Strings(fallback, ...) -- the fallback literal is "%s found\n%s!" (both
|
||||||
|
-- slots plain %s, matching the pre-fix literal's own shape), not the
|
||||||
|
-- {PLAYER} token the real label uses, since Strings() never does
|
||||||
|
-- {TOKEN} substitution on its own. (A {PLAYER}-token fallback would
|
||||||
|
-- still render correctly too, since the real TextBox.new always runs
|
||||||
|
-- TextBox.substitute over whatever text it's given -- but this test
|
||||||
|
-- stubs TextBox without that call, and the fallback shouldn't lean on a
|
||||||
|
-- substitution pass happening downstream regardless.)
|
||||||
|
do
|
||||||
|
local game = mkGame()
|
||||||
|
setUpvalue(OW.tryHiddenObject, "Game", game)
|
||||||
|
game.save.hiddenTaken = {} -- fresh spot
|
||||||
|
local found = fakeSelf:tryHiddenObject(3, 3)
|
||||||
|
T.check(found == true, "the hidden item is found again in a fresh game")
|
||||||
|
T.eq(pushed[1] and pushed[1].text, "FAKEPLAYER found\nFIX BALL!",
|
||||||
|
"with no catalog entry, the fallback still fills both the player "
|
||||||
|
.. "and item name via plain %s substitution")
|
||||||
|
end
|
||||||
|
|
||||||
|
T.finish("overworld_hidden_item_romtext")
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
-- The PC gains a PKMN LEAGUE row after the Hall of Fame (#1566):
|
||||||
|
-- DisplayPCMainMenu (engine/pokemon/bills_pc.asm:5), PKMNLeague (pc.asm:67)
|
||||||
|
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||||
|
|
||||||
|
local T = require("tests.modkit")
|
||||||
|
local OW = require("src.world.OverworldController")
|
||||||
|
|
||||||
|
local function setUpvalue(fn, name, val)
|
||||||
|
local i = 1
|
||||||
|
while true do
|
||||||
|
local n = debug.getupvalue(fn, i)
|
||||||
|
if not n then return false end
|
||||||
|
if n == name then debug.setupvalue(fn, i, val); return true end
|
||||||
|
i = i + 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local pushed = {}
|
||||||
|
local fakeGame = {
|
||||||
|
data = { text = {
|
||||||
|
_TurnedOnPC1Text = "RED turned on\nthe PC.",
|
||||||
|
_AccessedHoFPCText = "Accessed POKéMON\nLEAGUE's site.",
|
||||||
|
} },
|
||||||
|
save = {
|
||||||
|
flags = { EVENT_GOT_POKEDEX = true },
|
||||||
|
player = { name = "RED" },
|
||||||
|
},
|
||||||
|
stack = { push = function(_, item) pushed[#pushed + 1] = item end },
|
||||||
|
}
|
||||||
|
|
||||||
|
local sounds = {}
|
||||||
|
package.loaded["src.core.Sound"] = {
|
||||||
|
play = function(_, name) sounds[#sounds + 1] = name end,
|
||||||
|
}
|
||||||
|
local menuItems
|
||||||
|
package.loaded["src.ui.Menu"] = {
|
||||||
|
new = function(_, items) menuItems = items; return { menu = true } end,
|
||||||
|
}
|
||||||
|
local opened = {}
|
||||||
|
T.check(setUpvalue(OW.openPC, "Game", fakeGame), "Game upvalue on openPC")
|
||||||
|
T.check(setUpvalue(OW.openPC, "Screens", {
|
||||||
|
push = function(_, id) opened[#opened + 1] = id end,
|
||||||
|
}), "Screens upvalue on openPC")
|
||||||
|
T.check(setUpvalue(OW.openPC, "TextBox", {
|
||||||
|
new = function(_, text, onDone) return { text = text, onDone = onDone } end,
|
||||||
|
}), "TextBox upvalue on openPC")
|
||||||
|
|
||||||
|
local fakeSelf = setmetatable({}, { __index = OW })
|
||||||
|
|
||||||
|
local function labels()
|
||||||
|
pushed, menuItems, sounds, opened = {}, nil, {}, {}
|
||||||
|
fakeSelf:openPC(function() end)
|
||||||
|
pushed[1].onDone() -- close TurnedOnPC1Text; the menu goes up behind it
|
||||||
|
local names = {}
|
||||||
|
for i, item in ipairs(menuItems or {}) do names[i] = item.label end
|
||||||
|
return names
|
||||||
|
end
|
||||||
|
|
||||||
|
local function indexOf(list, label)
|
||||||
|
for i, name in ipairs(list) do
|
||||||
|
if name == label then return i end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- wNumHoFTeams == 0: three rows plus LOG OFF (bills_pc.asm .noLeaguePC)
|
||||||
|
local before = labels()
|
||||||
|
T.eq(indexOf(before, "<PK><MN>LEAGUE"), nil,
|
||||||
|
"no PKMN LEAGUE row before the Hall of Fame")
|
||||||
|
T.eq(#before, 4, "BILL's PC, the player's PC, PROF.OAK's PC and LOG OFF")
|
||||||
|
|
||||||
|
-- one recorded team is enough, and it stays for good
|
||||||
|
fakeGame.save.hallOfFame = { { { species = "PIKACHU", level = 80 } } }
|
||||||
|
local after = labels()
|
||||||
|
local iLeague = indexOf(after, "<PK><MN>LEAGUE")
|
||||||
|
T.check(iLeague, "PKMN LEAGUE appears once a team is in the Hall of Fame")
|
||||||
|
T.eq(after[iLeague - 1], "PROF.OAK's PC", "it follows PROF.OAK's PC")
|
||||||
|
T.eq(after[iLeague + 1], "LOG OFF", "and LOG OFF still closes the menu")
|
||||||
|
T.check(menuItems[iLeague].keepOpen,
|
||||||
|
"B returns to the PC menu (ReloadMainMenu), it does not log off")
|
||||||
|
|
||||||
|
-- selecting it: SFX_ENTER_PC, AccessedHoFPCText, then the roster screen
|
||||||
|
menuItems[iLeague].onSelect()
|
||||||
|
T.eq(sounds[#sounds], "Enter_PC", "PKMNLeague plays SFX_ENTER_PC")
|
||||||
|
local box = pushed[#pushed]
|
||||||
|
T.eq(box.text, fakeGame.data.text._AccessedHoFPCText,
|
||||||
|
"PKMNLeaguePC prints AccessedHoFPCText first")
|
||||||
|
box.onDone()
|
||||||
|
T.same(opened, { "LeaguePC" }, "the Hall of Fame roster screen opens")
|
||||||
|
|
||||||
|
-- without the Pokedex, .noOaksPC2 skips Oak's PC and the league row alike
|
||||||
|
-- (bills_pc.asm:48-49, :68-72); only the box height ignores it (:5-7)
|
||||||
|
fakeGame.save.flags.EVENT_GOT_POKEDEX = nil
|
||||||
|
local noDex = labels()
|
||||||
|
T.eq(indexOf(noDex, "<PK><MN>LEAGUE"), nil,
|
||||||
|
"no dex, no PKMN LEAGUE row, HoF teams or not")
|
||||||
|
T.eq(indexOf(noDex, "PROF.OAK's PC"), nil, "and no PROF.OAK's PC either")
|
||||||
|
|
||||||
|
T.finish("pc league row (#1566)")
|
||||||
@@ -136,8 +136,9 @@ do
|
|||||||
local list, why = useFromBag(game, nil, "RARE_CANDY")
|
local list, why = useFromBag(game, nil, "RARE_CANDY")
|
||||||
if check(list ~= nil, "the bag opened and reached the picker: " .. tostring(why)) then
|
if check(list ~= nil, "the bag opened and reached the picker: " .. tostring(why)) then
|
||||||
eq(mon.level, 6, "the candy leveled the mon 5 -> 6")
|
eq(mon.level, 6, "the candy leveled the mon 5 -> 6")
|
||||||
check(not inStack(game.stack, isPicker),
|
-- .useRareCandy over the party list (item_effects.asm:1392-1418) #1594
|
||||||
"the pickOnly picker popped itself before onSwitch")
|
check(inStack(game.stack, isPicker),
|
||||||
|
"the party picker is still up under the level text (#1594)")
|
||||||
check(inStack(game.stack, function(s) return s == list end),
|
check(inStack(game.stack, function(s) return s == list end),
|
||||||
"the bag list is STILL on the stack (.useItem_partyMenu re-enters "
|
"the bag list is STILL on the stack (.useItem_partyMenu re-enters "
|
||||||
.. "StartMenu_Item, it does not CloseStartMenu) (#796)")
|
.. "StartMenu_Item, it does not CloseStartMenu) (#796)")
|
||||||
@@ -158,6 +159,47 @@ do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- .useRareCandy: TryEvolvingMon runs over the party list
|
||||||
|
-- (item_effects.asm:1392-1418) (#1594)
|
||||||
|
do
|
||||||
|
local evolveCalls = {}
|
||||||
|
package.loaded["src.pokemon.Evolution"] = {
|
||||||
|
pendingFor = function() return "FIXMON_B", { method = "LEVEL" } end,
|
||||||
|
evolve = function(_, _, to, onDone, via)
|
||||||
|
evolveCalls[#evolveCalls + 1] = { to = to, onDone = onDone, via = via }
|
||||||
|
end,
|
||||||
|
}
|
||||||
|
package.loaded["src.battle.BattleState"] = {
|
||||||
|
StatBox = { new = function(_, _, cb) return { statBox = true, cb = cb } end },
|
||||||
|
}
|
||||||
|
local game = freshGame(3)
|
||||||
|
local list = useFromBag(game, nil, "RARE_CANDY")
|
||||||
|
if check(list ~= nil, "the bag reached the picker (evolution case)") then
|
||||||
|
local box = game.stack:top()
|
||||||
|
check(isBox(box), "the level line prints first")
|
||||||
|
game.stack:pop() -- a real TextBox pops itself before onDone
|
||||||
|
box.done()
|
||||||
|
local stat = game.stack:top()
|
||||||
|
if check(stat and stat.statBox, "then the stat window") then
|
||||||
|
game.stack:pop() -- as does the stat window before its callback
|
||||||
|
stat.cb()
|
||||||
|
eq(#evolveCalls, 1, "the pending evolution starts")
|
||||||
|
check(inStack(game.stack, isPicker),
|
||||||
|
"with the party picker STILL up: the evolution prints over it, "
|
||||||
|
.. "not over the bag list (#1594)")
|
||||||
|
check(type(evolveCalls[1].onDone) == "function",
|
||||||
|
"closePicker rides the evolution's completion callback")
|
||||||
|
evolveCalls[1].onDone()
|
||||||
|
check(not inStack(game.stack, isPicker),
|
||||||
|
"and the picker comes down once the evolution flow completes")
|
||||||
|
check(inStack(game.stack, function(s) return s == list end),
|
||||||
|
"while the bag list survives (#796)")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
package.loaded["src.pokemon.Evolution"] = nil
|
||||||
|
package.loaded["src.battle.BattleState"] = nil
|
||||||
|
end
|
||||||
|
|
||||||
-- The last candy: the row goes away (RemoveUsedItem empties the slot) and the
|
-- The last candy: the row goes away (RemoveUsedItem empties the slot) and the
|
||||||
-- cursor clamps to a real row -- but the list itself still must not close.
|
-- cursor clamps to a real row -- but the list itself still must not close.
|
||||||
do
|
do
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
-- SAVE confirmation layout (#1522): PrintSaveScreenText's own box and
|
||||||
|
-- SaveTheGame_YesOrNo's TWO_OPTION_MENU at hlcoord 0, 7
|
||||||
|
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||||
|
|
||||||
|
local T = require("tests.modkit")
|
||||||
|
local Data = T.fixtures.load()
|
||||||
|
|
||||||
|
local SaveData = require("src.core.SaveData")
|
||||||
|
local StartMenu = require("src.ui.StartMenu")
|
||||||
|
local ChoiceBox = require("src.ui.ChoiceBox")
|
||||||
|
|
||||||
|
local function newStack()
|
||||||
|
local stack = { states = {} }
|
||||||
|
function stack:push(state) table.insert(self.states, state) end
|
||||||
|
function stack:pop() return table.remove(self.states) end
|
||||||
|
function stack:top() return self.states[#self.states] end
|
||||||
|
return stack
|
||||||
|
end
|
||||||
|
|
||||||
|
local game = {
|
||||||
|
data = Data,
|
||||||
|
save = SaveData.newGame(),
|
||||||
|
stack = newStack(),
|
||||||
|
input = { wasPressed = function() return false end,
|
||||||
|
isDown = function() return false end },
|
||||||
|
}
|
||||||
|
game.save.player.name = "RED"
|
||||||
|
game.save.playTime = 3 * 3600 + 7 * 60
|
||||||
|
|
||||||
|
local menu = StartMenu.new(game)
|
||||||
|
local save
|
||||||
|
for _, item in ipairs(menu.items) do
|
||||||
|
if item.label == "SAVE" then save = item end
|
||||||
|
end
|
||||||
|
T.check(save ~= nil, "the start menu lists SAVE")
|
||||||
|
|
||||||
|
save.onSelect()
|
||||||
|
-- PrintSaveScreenText ends `ld c, 30 / jp DelayFrames`: the bare panel
|
||||||
|
-- holds 30 frames before the prompt (main_menu.asm:404-405)
|
||||||
|
T.eq(#game.stack.states, 1, "the panel shows alone first")
|
||||||
|
for _ = 1, 30 do game.stack:top().update() end
|
||||||
|
T.eq(#game.stack.states, 2, "the panel and the prompt are two separate states")
|
||||||
|
local panel, prompt = game.stack.states[1], game.stack.states[2]
|
||||||
|
T.check(type(panel.draw) == "function" and not panel.isTextBox,
|
||||||
|
"the info panel is its own drawn state, not a TextBox page")
|
||||||
|
T.check(prompt.isTextBox == true, "the prompt is the dialogue box on top")
|
||||||
|
T.eq(#prompt.pages, 1, "the prompt is one page: no \\f-merged info panel")
|
||||||
|
T.check(prompt.pages[1][1]:find("Would you like to"),
|
||||||
|
"the prompt page is WouldYouLikeToSaveText")
|
||||||
|
|
||||||
|
-- save.asm:188 hlcoord 0, 7
|
||||||
|
T.eq(prompt.choiceBox.tx, 0, "the save Yes/No box sits at column 0 (left)")
|
||||||
|
T.eq(prompt.choiceBox.ty, 7, "the save Yes/No box sits at row 7")
|
||||||
|
T.eq(prompt.choiceBox, require("src.ui.Theme").saveBox,
|
||||||
|
"the geometry routes through Theme so field.theme can restyle it")
|
||||||
|
local choice = ChoiceBox.new(game, function() end, { box = prompt.choiceBox })
|
||||||
|
T.eq(choice.tx, 0, "ChoiceBox honours the save-specific left placement")
|
||||||
|
|
||||||
|
-- answering NO takes the panel back down with the prompt
|
||||||
|
prompt.done = true
|
||||||
|
prompt:update(1 / 60)
|
||||||
|
local yesno = game.stack:top()
|
||||||
|
T.check(getmetatable(yesno) == ChoiceBox, "the prompt pushes the Yes/No box")
|
||||||
|
T.eq(yesno.tx, 0, "the pushed Yes/No box is the left-hand one")
|
||||||
|
game.stack:pop()
|
||||||
|
game.stack:pop()
|
||||||
|
prompt.choice(false)
|
||||||
|
T.eq(#game.stack.states, 0, "declining closes the info panel too")
|
||||||
|
|
||||||
|
T.finish("save_confirm_layout_bug1522")
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
-- SlotMachine:resolveWin's "%s lined up!\nScored %d coins!" message used
|
||||||
|
-- to interpolate the symbol id AND the payout into one bare Lua literal.
|
||||||
|
-- The real _LinedUpText label has no slot for the symbol at all (the
|
||||||
|
-- original ROM drew it separately) -- only for the coin count. This test
|
||||||
|
-- fakes _LinedUpText and checks the symbol is concatenated in front of the
|
||||||
|
-- translated suffix, with the payout correctly substituted into it.
|
||||||
|
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||||
|
|
||||||
|
local T = require("tests.modkit")
|
||||||
|
local Data = T.fixtures.fresh()
|
||||||
|
|
||||||
|
local SlotMachine = require("src.ui.SlotMachine")
|
||||||
|
|
||||||
|
local function mkSelf()
|
||||||
|
local game = { data = Data, save = { coins = 0 } }
|
||||||
|
return setmetatable({ game = game, allowMatchesCounter = 0 }, SlotMachine)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- translated: the fake suffix reaches self.message, with the symbol
|
||||||
|
-- concatenated in front and the payout substituted into the fake text
|
||||||
|
do
|
||||||
|
local self = mkSelf()
|
||||||
|
Data.text._LinedUpText = " FAKE-SUFFIX {RAM:wStringBuffer}!"
|
||||||
|
self:resolveWin({ symbol = "CHERRY", payout = 8 })
|
||||||
|
T.eq(self.message, "CHERRY FAKE-SUFFIX 8!",
|
||||||
|
"a translated _LinedUpText reaches the lined-up message")
|
||||||
|
Data.text._LinedUpText = nil
|
||||||
|
end
|
||||||
|
|
||||||
|
-- vanilla: with no catalog entry, the English literal still substitutes,
|
||||||
|
-- with its own leading space (the original had no slot for the symbol)
|
||||||
|
do
|
||||||
|
local self = mkSelf()
|
||||||
|
self:resolveWin({ symbol = "CHERRY", payout = 8 })
|
||||||
|
T.eq(self.message, "CHERRY lined up!\nScored 8 coins!",
|
||||||
|
"no catalog entry still falls back to the English literal")
|
||||||
|
end
|
||||||
|
|
||||||
|
T.finish("slot_machine_lined_up_romtext")
|
||||||
@@ -94,20 +94,26 @@ do
|
|||||||
local eng, transport = engine({
|
local eng, transport = engine({
|
||||||
["POST /sync/create"] = { code = 200, body =
|
["POST /sync/create"] = { code = 200, body =
|
||||||
'{"account":"aa11","code1":"11112222","code2":"33334444","deviceToken":"tok"}' },
|
'{"account":"aa11","code1":"11112222","code2":"33334444","deviceToken":"tok"}' },
|
||||||
}, {}, SyncState.defaults())
|
["GET /sync/state"] = { code = 200, body = '{"saves":{}}' },
|
||||||
|
["PUT /sync/save"] = { code = 200, body = '{"ok":true,"rev":1}' },
|
||||||
|
}, { saveEntry("red", "abc", 500, 400) }, SyncState.defaults())
|
||||||
|
|
||||||
T.eq(eng:linked(), false, "a fresh engine is not linked")
|
T.eq(eng:linked(), false, "a fresh engine is not linked")
|
||||||
T.eq(eng.status, "Not set up", "and says so")
|
T.eq(eng.status, "Not set up", "and says so")
|
||||||
|
|
||||||
eng:createAccount("laptop")
|
eng:createAccount("laptop")
|
||||||
pump(eng, 3)
|
pump(eng)
|
||||||
T.eq(eng:linked(), true, "creating an account links this device")
|
T.eq(eng:linked(), true, "creating an account links this device")
|
||||||
T.eq(eng.state.account, "aa11", "and stores the account id")
|
T.eq(eng.state.account, "aa11", "and stores the account id")
|
||||||
T.eq(eng.codes.code1, "1111-2222", "the first code is shown grouped")
|
T.eq(eng.codes.code1, "1111-2222", "the first code is shown grouped")
|
||||||
T.eq(eng.codes.code2, "3333-4444", "and so is the second")
|
T.eq(eng.codes.code2, "3333-4444", "and so is the second")
|
||||||
T.eq(eng.state.code1, nil, "codes never enter the persisted state")
|
T.eq(eng.state.code1, nil, "codes never enter the persisted state")
|
||||||
|
T.eq(transport.sent[2].url, "http://sync.test/sync/state",
|
||||||
|
"creating the account starts a sync straight away")
|
||||||
|
T.eq(transport.sent[3].method, "PUT",
|
||||||
|
"so the saves that existed before setup are uploaded")
|
||||||
|
T.eq(SyncState.rev(eng.state, "red/abc"), 1, "and the served rev is remembered")
|
||||||
T.eq(eng.phase, "idle", "and the engine settles")
|
T.eq(eng.phase, "idle", "and the engine settles")
|
||||||
T.eq(#transport.sent, 1, "one request was made")
|
|
||||||
end
|
end
|
||||||
|
|
||||||
do
|
do
|
||||||
@@ -293,14 +299,105 @@ do
|
|||||||
T.eq(#transport.sent, 0, "with sync off an in-game save uploads nothing")
|
T.eq(#transport.sent, 0, "with sync off an in-game save uploads nothing")
|
||||||
end
|
end
|
||||||
|
|
||||||
|
do
|
||||||
|
local eng, transport = engine({
|
||||||
|
["GET /sync/state"] = { code = 200, body = '{"saves":{}}' },
|
||||||
|
}, {})
|
||||||
|
eng:update(SyncEngine.AUTO_INTERVAL - 1)
|
||||||
|
T.eq(#transport.sent, 0, "an idle linked engine does not poll early")
|
||||||
|
eng:update(1)
|
||||||
|
T.eq(#transport.sent, 1, "after the auto interval it checks the server")
|
||||||
|
pump(eng)
|
||||||
|
T.eq(eng.phase, "idle", "and settles")
|
||||||
|
eng:update(SyncEngine.AUTO_INTERVAL - 10)
|
||||||
|
T.eq(#transport.sent, 1, "the next poll waits a whole interval again")
|
||||||
|
eng:update(10)
|
||||||
|
T.eq(#transport.sent, 2, "then fires")
|
||||||
|
end
|
||||||
|
|
||||||
|
do
|
||||||
|
local eng, transport = engine({
|
||||||
|
["GET /sync/state"] = { code = 200, body = '{"saves":{}}' },
|
||||||
|
}, {}, SyncState.defaults())
|
||||||
|
eng:update(SyncEngine.AUTO_INTERVAL * 2)
|
||||||
|
T.eq(#transport.sent, 0, "an unlinked engine never polls on its own")
|
||||||
|
end
|
||||||
|
|
||||||
|
do
|
||||||
|
local calls = 0
|
||||||
|
local eng = engine({
|
||||||
|
["GET /sync/state"] = function()
|
||||||
|
calls = calls + 1
|
||||||
|
if calls == 1 then return { code = 500, body = '{"error":"down"}' } end
|
||||||
|
return { code = 200, body = '{"saves":{}}' }
|
||||||
|
end,
|
||||||
|
}, {})
|
||||||
|
eng:syncNow()
|
||||||
|
pump(eng, 3)
|
||||||
|
T.eq(eng.phase, "error", "the first sync fails")
|
||||||
|
eng:update(SyncEngine.AUTO_INTERVAL)
|
||||||
|
pump(eng, 3)
|
||||||
|
T.eq(eng.phase, "idle", "the auto interval retries and recovers")
|
||||||
|
end
|
||||||
|
|
||||||
|
do
|
||||||
|
local eng, transport = conflictEngine()
|
||||||
|
eng:syncNow()
|
||||||
|
pump(eng)
|
||||||
|
local sent = #transport.sent
|
||||||
|
eng:update(SyncEngine.AUTO_INTERVAL * 2)
|
||||||
|
T.eq(#transport.sent, sent, "a waiting conflict is never auto-synced over")
|
||||||
|
T.eq(eng.phase, "conflict", "the player still decides")
|
||||||
|
end
|
||||||
|
|
||||||
|
do
|
||||||
|
local eng, transport = engine({
|
||||||
|
["GET /sync/state"] = { code = 200, body = '{"saves":{}}' },
|
||||||
|
}, {})
|
||||||
|
eng:noteResumed()
|
||||||
|
T.eq(#transport.sent, 1, "regaining the app checks the server")
|
||||||
|
pump(eng)
|
||||||
|
eng:noteResumed()
|
||||||
|
T.eq(#transport.sent, 1, "but not twice in quick succession")
|
||||||
|
end
|
||||||
|
|
||||||
|
do
|
||||||
|
local eng, transport = engine({}, {}, SyncState.defaults())
|
||||||
|
eng:noteResumed()
|
||||||
|
T.eq(#transport.sent, 0, "an unlinked engine ignores a resume")
|
||||||
|
end
|
||||||
|
|
||||||
|
do
|
||||||
|
local state = linkedState()
|
||||||
|
SyncState.setRev(state, "red/abc", 2, 500)
|
||||||
|
local eng, transport, saves = engine({
|
||||||
|
["GET /sync/state"] = { code = 200,
|
||||||
|
body = '{"saves":{"red/abc":{"rev":4,"meta":{"savedAt":900}},' ..
|
||||||
|
'"gold/xyz":{"rev":1,"meta":{"savedAt":900}}}}' },
|
||||||
|
["GET /sync/save"] = { code = 200,
|
||||||
|
body = '{"rev":1,"meta":{"savedAt":900},"blob":"return { player = {} }"}' },
|
||||||
|
}, { saveEntry("red", "abc", 500, 400) }, state)
|
||||||
|
eng:protectPlaythrough("red", "abc")
|
||||||
|
eng:syncNow()
|
||||||
|
pump(eng)
|
||||||
|
T.eq(#saves.writes, 1, "only the save that is not being played downloads")
|
||||||
|
T.eq(saves.writes[1].version, "gold", "the other playthrough still arrives")
|
||||||
|
T.eq(SyncState.rev(eng.state, "red/abc"), 2,
|
||||||
|
"the live playthrough keeps its rev so the launcher can fetch it later")
|
||||||
|
T.eq(eng.phase, "idle", "and the sync settles")
|
||||||
|
eng:protectPlaythrough("red", nil)
|
||||||
|
T.eq(eng.protectedKey, nil, "no live playthrough means no protection")
|
||||||
|
end
|
||||||
|
|
||||||
do
|
do
|
||||||
local eng = engine({
|
local eng = engine({
|
||||||
["POST /sync/create"] = { code = 200, body =
|
["POST /sync/create"] = { code = 200, body =
|
||||||
'{"account":"aa11","code1":"11112222","code2":"33334444",' ..
|
'{"account":"aa11","code1":"11112222","code2":"33334444",' ..
|
||||||
'"deviceToken":"tok","device":"0a1b2c3d"}' },
|
'"deviceToken":"tok","device":"0a1b2c3d"}' },
|
||||||
|
["GET /sync/state"] = { code = 200, body = '{"saves":{}}' },
|
||||||
}, {}, SyncState.defaults())
|
}, {}, SyncState.defaults())
|
||||||
eng:createAccount("laptop")
|
eng:createAccount("laptop")
|
||||||
pump(eng, 3)
|
pump(eng)
|
||||||
T.eq(eng.state.deviceId, "0a1b2c3d",
|
T.eq(eng.state.deviceId, "0a1b2c3d",
|
||||||
"creating an account records the id the server gave this device")
|
"creating an account records the id the server gave this device")
|
||||||
T.eq(SyncState.sanitize(eng.state).deviceId, "0a1b2c3d",
|
T.eq(SyncState.sanitize(eng.state).deviceId, "0a1b2c3d",
|
||||||
|
|||||||
@@ -49,6 +49,13 @@ local function indexOf(rows, name)
|
|||||||
return nil
|
return nil
|
||||||
end
|
end
|
||||||
|
|
||||||
|
local function hasText(battle, needle)
|
||||||
|
for _, item in ipairs(battle.queue) do
|
||||||
|
if item.text and item.text:find(needle, 1, true) then return true end
|
||||||
|
end
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
-- ---------------------------------------------------------------------
|
-- ---------------------------------------------------------------------
|
||||||
-- the player's setup turn: the effect animation precedes the move's own
|
-- the player's setup turn: the effect animation precedes the move's own
|
||||||
-- ---------------------------------------------------------------------
|
-- ---------------------------------------------------------------------
|
||||||
@@ -73,6 +80,17 @@ do
|
|||||||
battle:performMove(battle.player, battle.enemy, slot)
|
battle:performMove(battle.player, battle.enemy, slot)
|
||||||
T.check(indexOf(animRows(battle), "SHRINKING_SQUARE_ANIM") == nil,
|
T.check(indexOf(animRows(battle), "SHRINKING_SQUARE_ANIM") == nil,
|
||||||
"a locked-in Thrash queues no setup animation")
|
"a locked-in Thrash queues no setup animation")
|
||||||
|
-- .ThrashingAboutCheck (core.asm:3534-3535, enemy mirror :5909-5910) (#1577)
|
||||||
|
T.check(indexOf(animRows(battle), "THRASH") ~= nil,
|
||||||
|
"a continuation turn animates THRASH, not the locked move's own id")
|
||||||
|
T.check(indexOf(animRows(battle), "FIX_THRASH") == nil,
|
||||||
|
"so the locked move's own animation does not play")
|
||||||
|
T.check(hasText(battle, "thrashing about"),
|
||||||
|
"ThrashingAboutText prints in place of the used-move line")
|
||||||
|
T.check(not hasText(battle, "used FIX THRASH"),
|
||||||
|
"and the used-move line does not")
|
||||||
|
T.eq(battle.player.thrashTurns, 1,
|
||||||
|
"the continuation turn runs wPlayerNumAttacksLeft down")
|
||||||
end
|
end
|
||||||
|
|
||||||
-- ---------------------------------------------------------------------
|
-- ---------------------------------------------------------------------
|
||||||
@@ -104,6 +122,27 @@ do
|
|||||||
T.check(indexOf(rows, "SHRINKING_SQUARE_ANIM") ~= nil,
|
T.check(indexOf(rows, "SHRINKING_SQUARE_ANIM") ~= nil,
|
||||||
"the setup animation survives a miss")
|
"the setup animation survives a miss")
|
||||||
T.check(indexOf(rows, "FIX_THRASH") == nil, "while the move's own anim is cancelled")
|
T.check(indexOf(rows, "FIX_THRASH") == nil, "while the move's own anim is cancelled")
|
||||||
|
-- ThrashPetalDanceEffect commits before MoveHitTest
|
||||||
|
-- (core.asm:3129-3133, effects.asm:791-808) (#1565)
|
||||||
|
T.eq(battle.player.thrashTurns, 2, "the miss still rolls wPlayerNumAttacksLeft")
|
||||||
|
T.check(battle:menuLockedAction(battle.player) ~= nil,
|
||||||
|
"and the user is locked into Thrash next turn")
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------
|
||||||
|
-- JumpMoveEffect (core.asm:3129-3133) before MoveHitTest INVULNERABLE (:3150) (#1565)
|
||||||
|
do
|
||||||
|
local battle = newBattle()
|
||||||
|
battle.queue, battle.nextInsert = {}, 0
|
||||||
|
battle.enemy.invulnerable = true
|
||||||
|
battle:performMove(battle.player, battle.enemy, { id = "FIX_THRASH", pp = 20 })
|
||||||
|
T.check(indexOf(animRows(battle), "SHRINKING_SQUARE_ANIM") ~= nil,
|
||||||
|
"the setup animation plays against a mid-Fly/Dig target")
|
||||||
|
T.eq(battle.player.thrashTurns, 2,
|
||||||
|
"the 2-3 roll commits against a mid-Fly/Dig target")
|
||||||
|
T.check(battle:menuLockedAction(battle.player) ~= nil,
|
||||||
|
"and the user is locked into Thrash next turn")
|
||||||
|
T.check(hasText(battle, "attack missed"), "while the attack itself misses")
|
||||||
end
|
end
|
||||||
|
|
||||||
T.finish("thrash setup animation (#1532)")
|
T.finish("thrash setup animation (#1532)")
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
-- GainExperience compares MON_OTID against wPlayerID at every award
|
||||||
|
-- (engine/battle/experience.asm:69-88) (#1488)
|
||||||
|
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||||
|
|
||||||
|
local T = require("tests.modkit")
|
||||||
|
local Data = T.fixtures.fresh()
|
||||||
|
require("src.render.Font").load(Data)
|
||||||
|
local BattleState = require("src.battle.BattleState")
|
||||||
|
local Pokemon = require("src.pokemon.Pokemon")
|
||||||
|
local SaveData = require("src.core.SaveData")
|
||||||
|
local TypeChart = require("src.battle.TypeChart")
|
||||||
|
TypeChart.load(Data)
|
||||||
|
|
||||||
|
local function boostedText(mutate)
|
||||||
|
local save = SaveData.newGame()
|
||||||
|
save.player.id = save.player.id or 1234
|
||||||
|
save.party = { Pokemon.new(Data, "FIXMON_A", 20) }
|
||||||
|
mutate(save.party[1], save)
|
||||||
|
local game = { data = Data, save = save,
|
||||||
|
stack = { top = function() return nil end, push = function() end } }
|
||||||
|
local battle = BattleState.newWild(game, "FIXMON_C", 10)
|
||||||
|
battle.participants = { [save.party[1]] = true }
|
||||||
|
battle:enemyMonFainted()
|
||||||
|
for _, item in ipairs(battle.queue) do
|
||||||
|
if item.text and item.text:find("boosted", 1, true) then return true end
|
||||||
|
end
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
|
T.eq(boostedText(function(mon, save) mon.otId = save.player.id end), false,
|
||||||
|
"a mon whose OTID is the player's own earns no boost")
|
||||||
|
T.eq(boostedText(function(mon, save) mon.otId = (save.player.id or 0) + 1 end), true,
|
||||||
|
"a foreign OTID trips BoostExp")
|
||||||
|
T.eq(boostedText(function(mon, save)
|
||||||
|
-- traded away and traded back: the stored OTID is the player's again
|
||||||
|
mon.traded = true
|
||||||
|
mon.otId = save.player.id
|
||||||
|
end), false, "and a mon traded back to its original trainer loses it (#1488)")
|
||||||
|
T.eq(boostedText(function(mon)
|
||||||
|
-- repairTradedOtIds leaves traded mons with otId nil (#1265, #1461)
|
||||||
|
mon.traded = true
|
||||||
|
mon.otId = nil
|
||||||
|
end), true, "a traded mon with no stored OTID keeps the boost (#1488)")
|
||||||
|
|
||||||
|
T.finish("traded exp boost is an OTID comparison (#1488)")
|
||||||
@@ -34,6 +34,20 @@ eq(rel.payload.size, 12345, "payload asset size picked")
|
|||||||
eq(rel.sums.url, "http://x/sums", "sums asset url picked")
|
eq(rel.sums.url, "http://x/sums", "sums asset url picked")
|
||||||
eq(rel.notes, "", "missing release body becomes empty notes")
|
eq(rel.notes, "", "missing release body becomes empty notes")
|
||||||
|
|
||||||
|
-- Native package assets are target-specific. This is intentionally separate
|
||||||
|
-- from the generic .love payload, which a new shell may be unable to host.
|
||||||
|
local android = Check.parseRelease(body, nil, { os = "Android", arch = "arm64" })
|
||||||
|
eq(android.fullName, "gen1recomp-1.4.2-android.apk", "Android package name derived")
|
||||||
|
eq(android.full, nil, "missing Android package is surfaced as nil")
|
||||||
|
eq(Check.fullAssetName("1.4.2", "Android", "arm64"),
|
||||||
|
"gen1recomp-1.4.2-android.apk", "Android full asset mapping")
|
||||||
|
eq(Check.fullAssetName("1.4.2", "Linux", "aarch64"),
|
||||||
|
"gen1recomp-1.4.2-linux-arm64.AppImage", "Linux ARM package mapping")
|
||||||
|
eq(Check.fullAssetName("1.4.2", "iOS", "arm64"),
|
||||||
|
"gen1recomp++-1.4.2-ios.ipa", "iOS package mapping")
|
||||||
|
eq(Check.fullAssetName("not-a-version", "Android", "arm64"), nil,
|
||||||
|
"invalid full-package version rejected")
|
||||||
|
|
||||||
local withNotes = Check.parseRelease(Json.encode({
|
local withNotes = Check.parseRelease(Json.encode({
|
||||||
tag_name = "v1.4.2",
|
tag_name = "v1.4.2",
|
||||||
body = "## Issues closed\n\n- #1 cart padding",
|
body = "## Issues closed\n\n- #1 cart padding",
|
||||||
|
|||||||
@@ -149,9 +149,11 @@ do
|
|||||||
local events = battle:takeTurn({ kind = "move", move = "ROAR" })
|
local events = battle:takeTurn({ kind = "move", move = "ROAR" })
|
||||||
check(saidSomethingLike(events, "fled in fear!"),
|
check(saidSomethingLike(events, "fled in fear!"),
|
||||||
"FledInFearText: the wild mon is blown away")
|
"FledInFearText: the wild mon is blown away")
|
||||||
check(not saidSomethingLike(events, "used TACKLE!"),
|
-- EFFECT_FORCE_SWITCH is priority 0, below BASE_PRIORITY
|
||||||
"and the mon that left never takes its half of the turn")
|
-- (data/moves/effects_priorities.asm:5): Roar goes last (#1475)
|
||||||
eq(player.hp, hpBefore, "so nothing came back the other way")
|
check(saidSomethingLike(events, "used TACKLE!"),
|
||||||
|
"so the wild mon takes its half of the turn first, Speed regardless")
|
||||||
|
check(player.hp < hpBefore, "and its hit landed before the blow-away")
|
||||||
eq(battle.over, true, "the battle is over")
|
eq(battle.over, true, "the battle is over")
|
||||||
eq(battle.outcome, "fled", "as the cart's DRAW")
|
eq(battle.outcome, "fled", "as the cart's DRAW")
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1306,4 +1306,153 @@ do
|
|||||||
eq(lead.moves[4].id, "SURF", "and SURF is still there")
|
eq(lead.moves[4].id, "SURF", "and SURF is still there")
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- ---- GetMovePriority's Vital Throw carve-out (#1475) ----------------------
|
||||||
|
-- engine/battle/core.asm:787-789
|
||||||
|
do
|
||||||
|
local screen = newScreen()
|
||||||
|
check(runToMenu(screen), "reached the menu")
|
||||||
|
local battle = screen.battle
|
||||||
|
local moves = battle.data.moves
|
||||||
|
moves.VITAL_THROW = { id = "VITAL_THROW", name = "VITALTHROW", power = 70,
|
||||||
|
type = "NORMAL", accuracy = 100, pp = 10, effect = "EFFECT_ALWAYS_HIT" }
|
||||||
|
moves.SWIFT = { id = "SWIFT", name = "SWIFT", power = 60, type = "NORMAL",
|
||||||
|
accuracy = 100, pp = 20, effect = "EFFECT_ALWAYS_HIT" }
|
||||||
|
moves.QUICK_ATTACK = { id = "QUICK_ATTACK", name = "QUICKATTACK", power = 40,
|
||||||
|
type = "NORMAL", accuracy = 100, pp = 30, effect = "EFFECT_PRIORITY_HIT" }
|
||||||
|
eq(battle:movePriority("VITAL_THROW"), -1, "VITAL_THROW goes last")
|
||||||
|
eq(battle:movePriority("SWIFT"), 0,
|
||||||
|
"while SWIFT, which shares its effect, keeps BASE_PRIORITY")
|
||||||
|
eq(battle:movePriority("QUICK_ATTACK"), 1, "and the table still reads")
|
||||||
|
moves.ROAR_FIX = { id = "ROAR_FIX", name = "ROAR", power = 0,
|
||||||
|
type = "NORMAL", accuracy = 100, pp = 20, effect = "EFFECT_FORCE_SWITCH" }
|
||||||
|
-- MoveEffectPriorities: EFFECT_FORCE_SWITCH is 0, below BASE_PRIORITY
|
||||||
|
-- (data/moves/effects_priorities.asm:5)
|
||||||
|
eq(battle:movePriority("ROAR_FIX"), -1,
|
||||||
|
"Whirlwind and Roar sit below BASE_PRIORITY")
|
||||||
|
eq(battle:orderOf("VITAL_THROW", "ROAR_FIX"), "player",
|
||||||
|
"VITAL_THROW ties a force-switch move (0 vs 0), so Speed decides")
|
||||||
|
eq(battle:orderOf("TACKLE", "TACKLE"), "player",
|
||||||
|
"the faster mon leads on equal priority")
|
||||||
|
eq(battle:orderOf("VITAL_THROW", "TACKLE"), "enemy",
|
||||||
|
"but VITAL_THROW loses to a normal move whatever the Speed")
|
||||||
|
moves.VITAL_THROW, moves.SWIFT, moves.QUICK_ATTACK = nil, nil, nil
|
||||||
|
moves.ROAR_FIX = nil
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ---- a send-out snapshots HP at send time (#1514) -------------------------
|
||||||
|
-- SendOutPlayerMon's tail (engine/battle/core.asm:3796-3838)
|
||||||
|
do
|
||||||
|
local lead = Mon.new(DATA, "CYNDAQUIL", 10, { dvs = perfect })
|
||||||
|
local bench = Mon.new(DATA, "TOTODILE", 10, { dvs = perfect })
|
||||||
|
local screen, battle = newScreen({ player = lead, party = { lead, bench } })
|
||||||
|
check(runToMenu(screen), "reached the menu")
|
||||||
|
battle:takeEvents()
|
||||||
|
check(battle:switch(2), "the bench mon comes in")
|
||||||
|
local send = battle:takeEvents()[1]
|
||||||
|
eq(send.kind, "send", "the switch emits a send-out")
|
||||||
|
eq(send.hp, bench.hp, "carrying a numeric HP snapshot")
|
||||||
|
bench.hp = bench.hp - 7
|
||||||
|
check(send.hp ~= bench.hp,
|
||||||
|
"which the rest of the turn's damage cannot walk back")
|
||||||
|
screen.shownHp.player = 0
|
||||||
|
screen:push(send)
|
||||||
|
screen:advanceQueue()
|
||||||
|
eq(screen.shownHp.player, send.hp,
|
||||||
|
"and the HUD opens on the snapshot, not on the post-hit value")
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ---- the send-out snapshots level and exp the same way (#1514) ------------
|
||||||
|
-- SendOutPlayerMon reloads wBattleMon* from the party slot (core.asm:3796-3838)
|
||||||
|
do
|
||||||
|
local lead = Mon.new(DATA, "CYNDAQUIL", 10, { dvs = perfect })
|
||||||
|
local bench = Mon.new(DATA, "TOTODILE", 10, { dvs = perfect })
|
||||||
|
local screen, battle = newScreen({ player = lead, party = { lead, bench } })
|
||||||
|
check(runToMenu(screen), "reached the menu")
|
||||||
|
battle:takeEvents()
|
||||||
|
check(battle:switch(2), "the bench mon comes in")
|
||||||
|
local send = battle:takeEvents()[1]
|
||||||
|
eq(send.level, bench.level, "the send carries a level snapshot")
|
||||||
|
eq(send.experience, bench.experience, "and an experience snapshot")
|
||||||
|
-- awardExperience mutates the live table before the UI dequeues the send
|
||||||
|
bench.level = bench.level + 3
|
||||||
|
bench.experience = (bench.experience or 0) + 5000
|
||||||
|
screen:push(send)
|
||||||
|
screen:advanceQueue()
|
||||||
|
eq(screen.shownLevel, send.level,
|
||||||
|
"the HUD opens on the send-time level, not the post-award one")
|
||||||
|
eq(screen.shownExp, screen:expPixels(bench, send.level, send.experience),
|
||||||
|
"and the exp bar fills from the send-time experience")
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ---- LearnMove finishes before the queued send-out (#1516) ----------------
|
||||||
|
-- LearnMove inside GiveExperiencePoints (engine/battle/core.asm:1959-2010)
|
||||||
|
do
|
||||||
|
local screen, lead = learnScreen()
|
||||||
|
screen:push({ kind = "send", side = "enemy", mon = { hp = 1 }, hp = 1,
|
||||||
|
text = "JOE sent out PIDGEY!" })
|
||||||
|
check(runToPhase(screen, "ask-forget"), "the pages reach the question")
|
||||||
|
local tap = tapper(screen)
|
||||||
|
tap("a") -- read the question
|
||||||
|
tap("a") -- YES
|
||||||
|
eq(screen.phase, "choose-forget", "YES opens the picker")
|
||||||
|
tap("a") -- slot 1
|
||||||
|
eq(lead.moves[1].id, "EMBER", "the move is learned")
|
||||||
|
check(screen.message and screen.message:find("forgot", 1, true) ~= nil,
|
||||||
|
"and its line prints ahead of the send-out that was already queued")
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ---- MoveSelectionScreen's two boxes (#1478) ------------------------------
|
||||||
|
-- engine/battle/core.asm:5074-5094, MoveInfoBox :5403-5478
|
||||||
|
do
|
||||||
|
local Chrome = require("src.ui.gen2.Chrome")
|
||||||
|
local lead = Mon.new(DATA, "CYNDAQUIL", 10, { dvs = perfect })
|
||||||
|
lead.moves = { { id = "TACKLE", pp = 30, maxPp = 35 },
|
||||||
|
{ id = "THUNDER_WAVE", pp = 20, maxPp = 20 } }
|
||||||
|
local screen = newScreen({ player = lead, party = { lead } })
|
||||||
|
check(runToMenu(screen), "reached the menu")
|
||||||
|
screen.phase = "moves"
|
||||||
|
screen.moveIndex = 1
|
||||||
|
|
||||||
|
local boxes, prints = {}, {}
|
||||||
|
local saved = { box = Chrome.box, print = Chrome.print,
|
||||||
|
printRight = Chrome.printRight, cursor = Chrome.cursor }
|
||||||
|
Chrome.box = function(x, y, w, h)
|
||||||
|
boxes[#boxes + 1] = ("%d,%d,%d,%d"):format(x, y, w, h)
|
||||||
|
end
|
||||||
|
Chrome.print = function(text, x, y)
|
||||||
|
prints[#prints + 1] = ("%s@%d,%d"):format(tostring(text), x, y)
|
||||||
|
end
|
||||||
|
Chrome.printRight = function(text, x, y)
|
||||||
|
prints[#prints + 1] = ("R:%s@%d,%d"):format(tostring(text), x, y)
|
||||||
|
end
|
||||||
|
Chrome.cursor = function(x, y)
|
||||||
|
prints[#prints + 1] = ("cursor@%d,%d"):format(x, y)
|
||||||
|
end
|
||||||
|
local ok, err = pcall(function() screen:drawPanel() end)
|
||||||
|
Chrome.box, Chrome.print = saved.box, saved.print
|
||||||
|
Chrome.printRight, Chrome.cursor = saved.printRight, saved.cursor
|
||||||
|
check(ok, "the move menu draws: " .. tostring(err))
|
||||||
|
|
||||||
|
local drawn = table.concat(boxes, " ")
|
||||||
|
check(drawn:find("0,8,11,5", 1, true) ~= nil, "the TYPE/PP box is drawn")
|
||||||
|
check(drawn:find("4,12,16,6", 1, true) ~= nil, "over the narrow list box")
|
||||||
|
-- SafeLoadTempTilemapToTilemap keeps the full battle textbox under the
|
||||||
|
-- move list (core.asm:4689); Textbox then MoveInfoBox over it (:5084, :5157).
|
||||||
|
check(drawn:find("0,12,20,6", 1, true) ~= nil,
|
||||||
|
"over the restored full-width message box")
|
||||||
|
local base = drawn:find("0,12,20,6", 1, true)
|
||||||
|
local list = drawn:find("4,12,16,6", 1, true)
|
||||||
|
local info = drawn:find("0,8,11,5", 1, true)
|
||||||
|
check(base < list and list < info,
|
||||||
|
"painted base box, then list box, then info box")
|
||||||
|
local text = table.concat(prints, " ")
|
||||||
|
check(text:find("TACKLE@6,13", 1, true) ~= nil, "names sit at column 6")
|
||||||
|
check(text:find("cursor@5,13", 1, true) ~= nil, "with the cursor at 5")
|
||||||
|
check(text:find("TYPE/@1,9", 1, true) ~= nil, "TYPE/ at (1,9)")
|
||||||
|
check(text:find("NORMAL@2,10", 1, true) ~= nil, "the type name at (2,10)")
|
||||||
|
check(text:find("30/35@5,11", 1, true) ~= nil,
|
||||||
|
"and only the highlighted move's PP, at (5,11)")
|
||||||
|
check(text:match("R:%d+/%d+@19,1%d") == nil, "no PP is printed per row")
|
||||||
|
end
|
||||||
|
|
||||||
S.finish()
|
S.finish()
|
||||||
|
|||||||
@@ -109,6 +109,57 @@ do
|
|||||||
eq(world.daytime, HOST_DAYTIME, "so it is lit by the host clock")
|
eq(world.daytime, HOST_DAYTIME, "so it is lit by the host clock")
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- ---- a pinned palette lights the room, it does not stop the clock (#1557) ---
|
||||||
|
-- timeofday_pals.asm:114 and :5-11, checktime.asm:2, data/maps/maps.asm:427
|
||||||
|
do
|
||||||
|
local save = {}
|
||||||
|
Clock.setTime(save, 21, 0)
|
||||||
|
local world = worldWithSave(save)
|
||||||
|
world.map.def.palette = "PALETTE_DAY"
|
||||||
|
world.map.def.environment = "INDOOR"
|
||||||
|
world:applyPalettes()
|
||||||
|
eq(world.daytime, "DAY", "the pinned room is still lit like day at 21:00")
|
||||||
|
eq(world.tod, "NITE", "but the world clock knows it is night")
|
||||||
|
eq(world:timeOfDayId(), 2, "and wTimeOfDay answers NITE_F")
|
||||||
|
|
||||||
|
-- Script_checktime: CheckTime's bit for wTimeOfDay ANDed with the mask.
|
||||||
|
local function checktime(mask)
|
||||||
|
local scripts = { generation = 2,
|
||||||
|
["s:t"] = { { op = "checktime", args = { mask } } } }
|
||||||
|
local vm = Vm.new(scripts, {}, world.events,
|
||||||
|
{ getTimeOfDay = function() return world:timeOfDayId() end })
|
||||||
|
vm:start("s:t")
|
||||||
|
for _ = 1, 100 do
|
||||||
|
if not vm:running() then break end
|
||||||
|
vm:update()
|
||||||
|
end
|
||||||
|
return vm.scriptVar
|
||||||
|
end
|
||||||
|
eq(checktime(4), 1, "checktime NITE is TRUE inside the PALETTE_DAY room")
|
||||||
|
eq(checktime(2), 0, "and checktime DAY is FALSE there")
|
||||||
|
|
||||||
|
-- the two out-of-World consumers read wTimeOfDay too (#1557)
|
||||||
|
local Pokegear = require("src.ui.gen2.Pokegear")
|
||||||
|
eq(Pokegear.timeOfDayIndex({ game = { world = world } }), 2,
|
||||||
|
"the radio's program pick answers NITE, not the pin (pokegear.asm:1456)")
|
||||||
|
local Specials = require("src.script.gen2.Specials")
|
||||||
|
local buffer
|
||||||
|
local pvm = { curPhoneCaller = 1,
|
||||||
|
setStringBuffer = function(_, name) buffer = name end,
|
||||||
|
specials = { world = {
|
||||||
|
tod = world.tod, daytime = world.daytime,
|
||||||
|
encounters = { grass = { PLAYERS_HOUSE_1F = { slots = {
|
||||||
|
NITE = { { species = "NITEMON" }, { species = "NITEMON" },
|
||||||
|
{ species = "NITEMON" }, { species = "NITEMON" } },
|
||||||
|
DAY = { { species = "DAYMON" }, { species = "DAYMON" },
|
||||||
|
{ species = "DAYMON" }, { species = "DAYMON" } },
|
||||||
|
} } } },
|
||||||
|
} } }
|
||||||
|
Specials.ALL.RandomPhoneWildMon(pvm)
|
||||||
|
eq(buffer, "NITEMON",
|
||||||
|
"RandomPhoneWildMon reads the NITE column (wildmons.asm:861)")
|
||||||
|
end
|
||||||
|
|
||||||
-- ---- the hour-window respawn is not eaten by a busy frame -------------------
|
-- ---- the hour-window respawn is not eaten by a busy frame -------------------
|
||||||
-- UpdateTimePals runs every second; the port rides that poll to redo what a
|
-- UpdateTimePals runs every second; the port rides that poll to redo what a
|
||||||
-- map load would (wObjectMasks). A rollover that lands on a busy frame has to
|
-- map load would (wObjectMasks). A rollover that lands on a busy frame has to
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user