mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-21 13:09:54 +02:00
Compare commits
28 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5e514335c1 | |||
| f5b8b6c85f | |||
| db25c14dfb | |||
| 90163a3ff2 | |||
| f63707c45b | |||
| 7756fdc3cb | |||
| 4bdb9435a4 | |||
| 5cbde96177 | |||
| 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 \
|
||||
|| { 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: |
|
||||
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
|
||||
run: |
|
||||
@@ -498,8 +519,8 @@ jobs:
|
||||
[ -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"
|
||||
chmod +x "$outdir/gen1recomp-${v}-linux-arm64.AppImage"
|
||||
apk="$(find dist/android/debug -name '*.apk' | head -1)"
|
||||
[ -n "$apk" ] || { echo "::error::no Android APK found under dist/android/debug"; exit 1; }
|
||||
apk="$(find dist/android/release -name '*.apk' | head -1)"
|
||||
[ -n "$apk" ] || { echo "::error::no Android APK found under dist/android/release"; exit 1; }
|
||||
cp "$apk" "$outdir/gen1recomp-${v}-android.apk"
|
||||
|
||||
ipa="dist/ios/gen1recomp++.ipa"
|
||||
|
||||
+20
-2
@@ -38,6 +38,22 @@ local function retryTmGive(game, ow, victoryKey, done)
|
||||
return true
|
||||
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
|
||||
-- EVENT_BEAT_BROCK branches his dialogue. Before the badge he prints
|
||||
-- _PewterGymBrockPreBattleText and engages the leader battle
|
||||
@@ -58,7 +74,8 @@ M.PEWTER_GYM.talk = {
|
||||
game.data.text._PewterGymBrockPostBattleAdviceText
|
||||
or "Go to the GYM in\nCERULEAN and test\nyour abilities!", done))
|
||||
else
|
||||
ow:engageTrainer(npc, done)
|
||||
local text, sound = badgeEndBattleText(game, "OPP_BROCK#1")
|
||||
ow:engageTrainer(npc, done, text, nil, sound)
|
||||
end
|
||||
end,
|
||||
}
|
||||
@@ -91,7 +108,8 @@ local function leaderTalk(beatFlag, adviceLabel, fallback, afterAdvice, victoryK
|
||||
game.stack:push(TextBox.new(game,
|
||||
game.data.text[adviceLabel] or fallback, finish))
|
||||
else
|
||||
ow:engageTrainer(npc, done)
|
||||
local text, sound = badgeEndBattleText(game, victoryKey)
|
||||
ow:engageTrainer(npc, done, text, nil, sound)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -837,13 +837,14 @@ M.SILPH_CO_11F = {
|
||||
-- every Silph rocket leaves off-screen (the street rockets are
|
||||
-- handled by M.SAFFRON_CITY.onEnter in story4.lua). Queued, not
|
||||
-- run here: the battle's own callbacks are still unwinding, so
|
||||
-- queueScript starts it on the first idle overworld frame --
|
||||
-- after the end-battle "Arrgh!!" box victories.lua OPP_GIOVANNI#2
|
||||
-- pushes (#722).
|
||||
-- queueScript starts it on the first idle overworld frame (#722).
|
||||
if game.save.flags.EVENT_BEAT_SILPH_CO_GIOVANNI then
|
||||
ow:queueScript(silphAftermathRows())
|
||||
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))
|
||||
return true
|
||||
|
||||
@@ -216,7 +216,10 @@ local function dojoMasterGate(game, ow, x, y)
|
||||
if not master or ow:trainerDefeated(master) then return false end
|
||||
ow.player.facing = "right"
|
||||
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
|
||||
end
|
||||
|
||||
|
||||
+17
-15
@@ -647,27 +647,29 @@ end
|
||||
local rocketRows = {
|
||||
{ "face_player" }, -- 1
|
||||
{ "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
|
||||
{ "jump_if_true", 9 }, -- 5
|
||||
{ "jump_if_true", 10 }, -- 5
|
||||
{ "show_text", "_CeruleanCityRocketText" }, -- 6
|
||||
{ "start_battle", "trainer", "OPP_ROCKET", 5 }, -- 7
|
||||
{ "jump_if_false", "end" }, -- 8
|
||||
{ "show_text", "_CeruleanCityRocketIllReturnTheTMText" }, -- 9
|
||||
{ "set_flag", "EVENT_BEAT_CERULEAN_ROCKET_THIEF" }, -- 10
|
||||
{ "give_item", "TM_DIG", 1, false }, -- 11 (row 13 prints)
|
||||
{ "set_flag", "EVENT_GOT_TM28" }, -- 12
|
||||
{ "show_text", "_CeruleanCityRocketReceivedTM28Text" }, -- 13
|
||||
{ "show_text", "_CeruleanCityRocketIBetterGetMovingText" }, -- 14
|
||||
{ "fade", "out" }, -- 15 GBFadeOutToBlack
|
||||
-- scripts/CeruleanCity.asm:297 SaveEndBattleTextPointers
|
||||
{ "save_end_battle_text", "_CeruleanCityRocketIGiveUpText" }, -- 7
|
||||
{ "start_battle", "trainer", "OPP_ROCKET", 5 }, -- 8
|
||||
{ "jump_if_false", "end" }, -- 9
|
||||
{ "show_text", "_CeruleanCityRocketIllReturnTheTMText" }, -- 10
|
||||
{ "set_flag", "EVENT_BEAT_CERULEAN_ROCKET_THIEF" }, -- 11
|
||||
{ "give_item", "TM_DIG", 1, false }, -- 12 (row 14 prints)
|
||||
{ "set_flag", "EVENT_GOT_TM28" }, -- 13
|
||||
{ "show_text", "_CeruleanCityRocketReceivedTM28Text" }, -- 14
|
||||
{ "show_text", "_CeruleanCityRocketIBetterGetMovingText" }, -- 15
|
||||
{ "fade", "out" }, -- 16 GBFadeOutToBlack
|
||||
-- CeruleanHideRocket while black: GUARD1 (28,12) appears, GUARD2
|
||||
-- (27,12) and the ROCKET go. GUARD2 blocks the trashed-house south
|
||||
-- door neighbour -- the swap reconnects the city (Bill's ticket does
|
||||
-- the same in story.lua; either route is enough).
|
||||
{ "show_object", "CERULEAN_CITY", "CERULEANCITY_GUARD1" }, -- 16
|
||||
{ "hide_object", "CERULEAN_CITY", "CERULEANCITY_GUARD2" }, -- 17
|
||||
{ "hide_object", "CERULEAN_CITY", "CERULEANCITY_ROCKET" }, -- 18
|
||||
{ "fade", "in" }, -- 19 GBFadeInFromBlack
|
||||
{ "show_object", "CERULEAN_CITY", "CERULEANCITY_GUARD1" }, -- 17
|
||||
{ "hide_object", "CERULEAN_CITY", "CERULEANCITY_GUARD2" }, -- 18
|
||||
{ "hide_object", "CERULEAN_CITY", "CERULEANCITY_ROCKET" }, -- 19
|
||||
{ "fade", "in" }, -- 20 GBFadeInFromBlack
|
||||
}
|
||||
|
||||
M.CERULEAN_CITY = {
|
||||
|
||||
@@ -14,6 +14,7 @@ Features intentionally added beyond the original Pokémon Red, Blue, and Yellow
|
||||
* **Screen position setting** (center, upper, top) shared across all games, for clamp-on controllers that cover the lower screen
|
||||
* **Touch skins** in RetroArch overlay format and Delta `.deltaskin` (including PDF-wrapped bezel art), with per-button press states and Super Game Boy borders
|
||||
* **Pokédex diploma and printer image exports**
|
||||
* **Shareable mod lists** over save sync, optionally carrying the options set for those mods, which the receiving device is asked about before anything is changed
|
||||
|
||||
## Gen 2 Specifics
|
||||
|
||||
|
||||
+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
|
||||
(`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-<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.
|
||||
|
||||
A release missing either asset is treated as "no in-place update available":
|
||||
`Check` reports `needs_full` and sends the player to `Check.releaseUrl()`
|
||||
(`https://github.com/bryanthaboi/gen1recomp/releases/latest`).
|
||||
`Check` reports `needs_full`. It also selects the exact current platform asset
|
||||
from the same release and persists the requirement, so it is visible again on
|
||||
every launch, including offline launches.
|
||||
|
||||
## Save-directory layout
|
||||
|
||||
@@ -85,6 +87,7 @@ Under the save directory (identity `pokemon-love2d`):
|
||||
```
|
||||
updates/gen1recomp-<X.Y.Z>.love downloaded payload(s)
|
||||
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.
|
||||
@@ -106,7 +109,8 @@ bundled game, in that case.
|
||||
against the GitHub releases API; safe to call every frame, it is a no-op
|
||||
once a check is in flight or has reached a terminal state. `Check.state()`
|
||||
reports `idle | checking | uptodate | available | downloading | ready |
|
||||
needs_full | error` plus the latest version and download progress.
|
||||
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
|
||||
worker to fetch the payload, polling the growing `.part` file for
|
||||
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
|
||||
player relaunches; the next launch's Boot step (1) is what actually
|
||||
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
|
||||
|
||||
@@ -140,6 +152,13 @@ bundled game, in that case.
|
||||
still need a full reinstall (`minShell` / `payloadHost` gate →
|
||||
`needs_full`). Applying a downloaded payload on Android relaunches via
|
||||
`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
|
||||
`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
|
||||
|
||||
@@ -327,6 +327,7 @@ local function returnToLauncher()
|
||||
if love.audio and love.audio.stop then
|
||||
pcall(love.audio.stop)
|
||||
end
|
||||
pcall(function() require("src.render.SecondScreen").setEnabled(false) end)
|
||||
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
local currentVersion = GameVersion.get()
|
||||
|
||||
+15
-5
@@ -93,8 +93,9 @@ transport, exactly as a missing curl does.
|
||||
love-android 11.5a expects:
|
||||
|
||||
- **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)
|
||||
- **minSdk 19** (Android 4.4), **targetSdk 36** (Android 16)
|
||||
|
||||
Set `ANDROID_SDK_ROOT` (or `ANDROID_HOME`), or let the script write
|
||||
`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.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.version_name` / `app.version_code` | set from `--version X.Y.Z` (code = major*10000 + minor*100 + 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 |
|
||||
| `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; REQUEST_INSTALL_PACKAGES is limited to the user-confirmed full-update installer |
|
||||
|
||||
## Releases
|
||||
|
||||
`.github/workflows/release.yml` builds the APK with `--version` set to the
|
||||
release version and publishes it alongside the macOS/Windows/Linux builds as
|
||||
`PokemonRed-<version>-android.apk`.
|
||||
`gen1recomp-<version>-android.apk`.
|
||||
|
||||
## 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
|
||||
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:
|
||||
|
||||
```
|
||||
|
||||
@@ -10,9 +10,12 @@ android {
|
||||
applicationId project.properties["app.application_id"]
|
||||
versionCode project.properties["app.version_code"].toInteger()
|
||||
versionName project.properties["app.version_name"]
|
||||
minSdk 16
|
||||
compileSdk 34
|
||||
targetSdk 34
|
||||
// NDK r25 no longer supports API 16; API 19 is Android 4.4 and keeps
|
||||
// the native toolchain and package-installer bridge on a supported ABI.
|
||||
minSdk 19
|
||||
// Android 16 / API 36: current Android distribution target.
|
||||
compileSdk 36
|
||||
targetSdk 36
|
||||
|
||||
def getAppName = {
|
||||
def nameArray = project.properties["app.name_byte_array"]
|
||||
@@ -38,10 +41,31 @@ android {
|
||||
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 {
|
||||
release {
|
||||
minifyEnabled true
|
||||
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
|
||||
if (hasReleaseSigning) signingConfig signingConfigs.release
|
||||
}
|
||||
}
|
||||
flavorDimensions = ['mode', 'recording']
|
||||
|
||||
@@ -8,6 +8,10 @@
|
||||
the link screen shows as "(Operation not permitted)" (issue #287).
|
||||
scripts/build_android.sh must not strip this again. -->
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<!-- 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
|
||||
counter, which Android 10+ gates behind this runtime permission.
|
||||
Requested only on the first sync call (the Pokéwalker mod's SYNC
|
||||
@@ -35,6 +39,18 @@
|
||||
<meta-data
|
||||
android:name="android.allow_multiple_resumed_activities"
|
||||
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
|
||||
android:name="org.love2d.android.GameActivity"
|
||||
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()
|
||||
}
|
||||
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
|
||||
// in the individual module build.gradle files
|
||||
|
||||
@@ -15,7 +15,6 @@ app.version_name=11.5a
|
||||
# No need to modify anything past this line!
|
||||
android.enableJetifier=false
|
||||
android.useAndroidX=true
|
||||
android.defaults.buildfeatures.buildconfig=true
|
||||
android.nonTransitiveRClass=true
|
||||
android.nonFinalResIds=true
|
||||
app.name=gen1recomp
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
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
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
|
||||
@@ -10,9 +10,9 @@ android {
|
||||
ndkVersion '25.2.9519653'
|
||||
|
||||
defaultConfig {
|
||||
minSdk 16
|
||||
compileSdk 34
|
||||
targetSdk 34
|
||||
minSdk 19
|
||||
compileSdk 36
|
||||
targetSdk 36
|
||||
externalNativeBuild {
|
||||
ndkBuild {
|
||||
arguments "-j" + Runtime.runtime.availableProcessors()
|
||||
@@ -63,8 +63,7 @@ android {
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
minifyEnabled true
|
||||
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
|
||||
minifyEnabled false
|
||||
}
|
||||
debug {
|
||||
ndk {
|
||||
|
||||
@@ -283,6 +283,40 @@ bool restartApp()
|
||||
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)
|
||||
{
|
||||
JNIEnv *env = (JNIEnv*) SDL_AndroidGetJNIEnv();
|
||||
|
||||
@@ -90,6 +90,12 @@ bool syncHealthSteps();
|
||||
**/
|
||||
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.
|
||||
**/
|
||||
|
||||
@@ -245,6 +245,16 @@ bool System::restartApp() const
|
||||
#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
|
||||
{
|
||||
#ifdef LOVE_ANDROID
|
||||
|
||||
@@ -143,6 +143,9 @@ public:
|
||||
**/
|
||||
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 std::string getLaunchGame() const;
|
||||
|
||||
|
||||
@@ -132,6 +132,13 @@ int w_restartApp(lua_State *L)
|
||||
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)
|
||||
{
|
||||
const char *url = luaL_checkstring(L, 1);
|
||||
@@ -325,6 +332,7 @@ static const luaL_Reg functions[] =
|
||||
{ "createFile", w_createFile },
|
||||
{ "syncHealthSteps", w_syncHealthSteps },
|
||||
{ "restartApp", w_restartApp },
|
||||
{ "installApk", w_installApk },
|
||||
{ "updateShortcuts", w_updateShortcuts },
|
||||
{ "getLaunchGame", w_getLaunchGame },
|
||||
{ "httpDownload", w_httpDownload },
|
||||
|
||||
@@ -45,6 +45,7 @@ import android.app.AlarmManager;
|
||||
import android.app.AlertDialog;
|
||||
import android.app.PendingIntent;
|
||||
import android.content.Context;
|
||||
import android.content.ClipData;
|
||||
import android.content.DialogInterface;
|
||||
import android.content.Intent;
|
||||
import android.content.SharedPreferences;
|
||||
@@ -77,6 +78,7 @@ import android.view.*;
|
||||
|
||||
import androidx.annotation.Keep;
|
||||
import androidx.core.app.ActivityCompat;
|
||||
import androidx.core.content.FileProvider;
|
||||
|
||||
public class GameActivity extends SDLActivity {
|
||||
private static DisplayMetrics metrics = null;
|
||||
@@ -398,11 +400,15 @@ public class GameActivity extends SDLActivity {
|
||||
|
||||
@Override
|
||||
protected void onDestroy() {
|
||||
secondaryHostResumed = false;
|
||||
if (vibrator != null) {
|
||||
Log.d("GameActivity", "Cancelling vibration");
|
||||
vibrator.cancel();
|
||||
}
|
||||
unregisterSecondaryDisplayListener();
|
||||
teardownSecondaryDisplay();
|
||||
secondaryEnabled = false;
|
||||
synchronized (secondaryFrameLock) { secondaryFrame = null; }
|
||||
unregisterAudioDeviceCallback();
|
||||
abandonAudioFocus();
|
||||
onHostDestroy();
|
||||
@@ -411,6 +417,7 @@ public class GameActivity extends SDLActivity {
|
||||
|
||||
@Override
|
||||
protected void onPause() {
|
||||
secondaryHostResumed = false;
|
||||
if (vibrator != null) {
|
||||
Log.d("GameActivity", "Cancelling vibration");
|
||||
vibrator.cancel();
|
||||
@@ -426,6 +433,7 @@ public class GameActivity extends SDLActivity {
|
||||
@Override
|
||||
public void onResume() {
|
||||
super.onResume();
|
||||
secondaryHostResumed = true;
|
||||
onHostResume();
|
||||
requestGameAudioFocus();
|
||||
registerAudioDeviceCallback();
|
||||
@@ -690,6 +698,103 @@ public class GameActivity extends SDLActivity {
|
||||
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
|
||||
public static String getLaunchGame() {
|
||||
return initialGame != null ? initialGame : "";
|
||||
@@ -1933,6 +2038,7 @@ public class GameActivity extends SDLActivity {
|
||||
private static volatile int secondaryActivityTarget = Display.INVALID_DISPLAY;
|
||||
private static volatile long secondaryRetryAfter;
|
||||
private static volatile boolean secondaryEnabled = false;
|
||||
private static volatile boolean secondaryHostResumed = false;
|
||||
private static volatile int secondaryTarget = SECONDARY_TARGET_AUTO;
|
||||
private static volatile int dualScreenDisplayMode = -1;
|
||||
private static volatile byte[] secondaryFrame;
|
||||
@@ -1963,7 +2069,7 @@ public class GameActivity extends SDLActivity {
|
||||
if (self == null) return;
|
||||
self.runOnUiThread(new Runnable() {
|
||||
@Override public void run() {
|
||||
if (on) {
|
||||
if (on && secondaryHostResumed) {
|
||||
self.refreshDualScreenDisplayMode();
|
||||
self.registerSecondaryDisplayListener();
|
||||
rebindSecondaryDisplay();
|
||||
@@ -2035,9 +2141,11 @@ public class GameActivity extends SDLActivity {
|
||||
|
||||
private static void rebindSecondaryDisplay() {
|
||||
GameActivity self = (GameActivity) mSingleton;
|
||||
if (self == null || !secondaryEnabled || secondaryOutputIsPreferred(self)) return;
|
||||
if (self == null || !secondaryHostResumed || !secondaryEnabled
|
||||
|| secondaryOutputIsPreferred(self)) return;
|
||||
self.runOnUiThread(() -> {
|
||||
if (!secondaryEnabled || secondaryOutputIsPreferred(self)) return;
|
||||
if (!secondaryHostResumed || !secondaryEnabled
|
||||
|| secondaryOutputIsPreferred(self)) return;
|
||||
teardownSecondaryDisplay();
|
||||
setupSecondaryDisplay();
|
||||
});
|
||||
@@ -2045,7 +2153,8 @@ public class GameActivity extends SDLActivity {
|
||||
|
||||
private static void setupSecondaryDisplay() {
|
||||
GameActivity self = (GameActivity) mSingleton;
|
||||
if (self == null || !secondaryEnabled || secondaryPresentation != null
|
||||
if (self == null || !secondaryHostResumed || !secondaryEnabled
|
||||
|| secondaryPresentation != null
|
||||
|| secondaryActivity != null || secondaryActivityPending
|
||||
|| android.os.SystemClock.elapsedRealtime() < secondaryRetryAfter) return;
|
||||
try {
|
||||
|
||||
@@ -12,6 +12,34 @@
|
||||
"tintColor": "3b5ca8",
|
||||
"category": "games",
|
||||
"versions": [
|
||||
{
|
||||
"version": "0.2.14",
|
||||
"date": "2026-08-20",
|
||||
"size": 13749372,
|
||||
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.2.14/gen1recomp++-0.2.14-ios.ipa",
|
||||
"localizedDescription": "Download the correct version for your computer below.\n\n## Contributors\n\n- @bryanthaboi"
|
||||
},
|
||||
{
|
||||
"version": "0.2.13",
|
||||
"date": "2026-08-20",
|
||||
"size": 13749377,
|
||||
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.2.13/gen1recomp++-0.2.13-ios.ipa",
|
||||
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #1474 (Pokémon Gold) Pokédex mode not being saved\n- #1475 (Pokémon Gold) Vital Throw attacking before the opponent\n- #1477 (Pokémon Gold) Map when using fly keeps the arrow from the pokégear\n- #1478 (Pokémon Gold) Wrong move layout in battles\n- #1479 [Gold] tiles broken at Route 28 and Mt Silver\n- #1482 (Pokémon Gold) Nothing happens when trying to use Coin Case\n- #1488 Pokemon EXP calculation after trading went back to issue #984\n- #1510 Title Screen Transition is Missing\n- #1511 Broken Trainer Rival Name Layout\n- #1512 Gen 2 Post battle interactions don't exist\n- #1514 Health damage timing\n- #1516 Move learning timing\n- #1521 Different Item Menu\n- #1522 Wrong Save Game Layout\n- #1545 Pikachu not sliding out when switching Pokemon.\n- #1557 (Gold) Impossible to get TM 03 in Celadon Mansion at night\n- #1558 (Gold) Missing colors and symbol in stats screen\n- #1563 Player mon pic cuts away instead of shrinking when recalled\n- #1565 Thrash doesn't lock in when the first use misses\n- #1566 Center PC missing the PKMN LEAGUE entry after the Hall of Fame\n- #1569 Goldenrod Gift Spearow Bugged\n- #1577 Thrash has no animation past the first turn\n- #1578 Hitting yourself in confusion animation missing\n- #1579 Missing dialogue for Cerulean City Rocket\n- #1594 Pokemon menu closing too soon when using rare candy\n- #1596 Evolution dialogue & missing jingle\n- #1606 Misty dialogue issue\n- #1608 \"There's no will to fight!\" message issues\n\n## Contributors\n\n- @bryanthaboi"
|
||||
},
|
||||
{
|
||||
"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",
|
||||
"date": "2026-08-19",
|
||||
|
||||
+39
-10
@@ -1,19 +1,20 @@
|
||||
#!/usr/bin/env bash
|
||||
# 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)
|
||||
# --release build the production-signed release APK (requires the
|
||||
# GEN1RECOMP_ANDROID_* signing environment variables)
|
||||
# --package-only zip game.love + apply branding; skip gradle
|
||||
#
|
||||
# Prerequisites:
|
||||
# - 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
|
||||
#
|
||||
# Output (after gradle):
|
||||
# dist/android/debug/*.apk (convenience copy)
|
||||
# mobile/android/app/build/outputs/apk/embedNoRecord/debug/*.apk
|
||||
# dist/android/debug/*.apk (normal local build) or dist/android/release/*.apk
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
@@ -26,6 +27,7 @@ APP_NAME="gen1recomp"
|
||||
APPLICATION_ID="com.theboisclub.pokemonred"
|
||||
LOVE_ANDROID_VERSION="11.5a"
|
||||
NDK_VERSION="25.2.9519653"
|
||||
ANDROID_API="36"
|
||||
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}"
|
||||
GOLD_MANIFEST_RELATIVE="tools/rom_manifest_gold.json"
|
||||
@@ -35,6 +37,7 @@ SILVER_MANIFEST_URL="${SILVER_MANIFEST_URL:-https://raw.githubusercontent.com/br
|
||||
|
||||
VERSION=""
|
||||
PACKAGE_ONLY=false
|
||||
RELEASE=false
|
||||
|
||||
say() { printf '\033[1;32m==>\033[0m %s\n' "$*"; }
|
||||
warn() { printf '\033[1;33mwarn:\033[0m %s\n' "$*" >&2; }
|
||||
@@ -44,11 +47,12 @@ while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--version) VERSION="$2"; shift ;;
|
||||
--package-only) PACKAGE_ONLY=true ;;
|
||||
--release) RELEASE=true ;;
|
||||
-h|--help)
|
||||
sed -n '2,20p' "$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
|
||||
shift
|
||||
done
|
||||
@@ -62,7 +66,22 @@ if [ -n "$VERSION" ]; then
|
||||
rest="${VERSION#*.}"
|
||||
minor="${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
|
||||
|
||||
# --------------------------------------------------------------- preconditions
|
||||
@@ -389,13 +408,18 @@ require_android_sdk() {
|
||||
export ANDROID_SDK_ROOT=\$HOME/Library/Android/sdk
|
||||
or create mobile/android/local.properties with:
|
||||
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)."
|
||||
fi
|
||||
|
||||
export ANDROID_SDK_ROOT="$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"
|
||||
# Always rewrite so a leftover Docker sdk.dir=/opt/android-sdk cannot stick.
|
||||
printf 'sdk.dir=%s\n' "$sdk" > "$props"
|
||||
@@ -412,7 +436,12 @@ require_android_sdk() {
|
||||
|
||||
# --------------------------------------------------------------- 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"
|
||||
|
||||
# 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"
|
||||
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
|
||||
say "APK output:"
|
||||
find "$out_dir" -name '*.apk' -exec ls -lh {} \;
|
||||
|
||||
local dist_dir="$DIST/debug"
|
||||
local dist_dir="$DIST/$variant"
|
||||
rm -rf "$dist_dir"
|
||||
mkdir -p "$dist_dir"
|
||||
find "$out_dir" -name '*.apk' -exec cp {} "$dist_dir/" \;
|
||||
|
||||
@@ -8,6 +8,7 @@ local BattleSafety = {}
|
||||
local BATTLE_BUSY_FIELDS = {
|
||||
"current", "afterQueue", "nextInsert", "pendingHit", "waitingUI",
|
||||
"waitingSound", "waitFrames", "draining", "animPlaying", "growIn",
|
||||
"shrinkOut",
|
||||
"introSlide", "ghostReveal", "mimicCtx", "mimicMoves", "result",
|
||||
}
|
||||
|
||||
|
||||
+166
-38
@@ -1364,7 +1364,7 @@ function BattleState:updateQueue()
|
||||
-- subanimation (or just the coarse fx when animations are off).
|
||||
-- item.hit carries the target's blink + damage sound, applied when
|
||||
-- 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
|
||||
-- PlayMoveAnimation writes wAnimationID, calls Delay3, and only then
|
||||
-- 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
|
||||
-- playerMonFainted queues on the battle screen; there is no battle
|
||||
-- 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,
|
||||
Strings("%s is out of\nuseable POKéMON!", name) .. "\f"
|
||||
.. Strings("%s blacked\nout!", name), blackedOut))
|
||||
self:romText("_PlayerBlackedOutText2",
|
||||
"%s is out of\nuseable POKéMON!\f%s blacked\nout!", name, name),
|
||||
blackedOut))
|
||||
return
|
||||
end
|
||||
self.musicKind = self:computeMusicKind()
|
||||
@@ -1857,7 +1862,8 @@ function BattleState:enter()
|
||||
self:slidePic("foe")
|
||||
end)
|
||||
-- _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()
|
||||
-- EnemySendOutFirstMon (core.asm:1421-1434): after the text the
|
||||
-- pic grows out of the ball (AnimateSendingOutMon), then the cry
|
||||
@@ -2489,9 +2495,12 @@ function BattleState:openOldManBag()
|
||||
-- POKé BALLs; pokeyellow's SimulatedInputBattleItemList, shared by
|
||||
-- the Viridian tutorial and Oak's catch, has one.
|
||||
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", {
|
||||
{ value = "POKE_BALL", label = Strings("POKé BALL"), right = qty },
|
||||
}, {
|
||||
itemBox = true,
|
||||
script = function(l)
|
||||
l.scriptTimer = (l.scriptTimer or 0) + 1
|
||||
if l.scriptTimer == 81 then
|
||||
@@ -2702,9 +2711,11 @@ function BattleState:resolveSwitch(newMon)
|
||||
self.afterQueue = "menu"
|
||||
self:act(function()
|
||||
-- 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),
|
||||
Timing.SWITCH_PLAYER_MON)
|
||||
self:queueRetreatAnim()
|
||||
self:actNext(function()
|
||||
self:restoreMimicked(self.player) -- the battle copy leaves with it
|
||||
local previous = self.player
|
||||
@@ -3316,6 +3327,26 @@ function BattleState:queueSendOutAnim(append)
|
||||
if append then self:act(fn) else self:actNext(fn) 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
|
||||
-- the drawn bar color: DrawPlayerHUDAndHPBar (core.asm:1846-1875) sets
|
||||
-- wLowHealthAlarm bit 7 when GetHealthBarColor says the player bar is
|
||||
@@ -3532,6 +3563,12 @@ function BattleState:updateFx()
|
||||
self.growIn.frame = self.growIn.frame + 1
|
||||
if self.growIn.frame >= 12 then self.growIn = nil 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
|
||||
-- loops while the player's bar is red; see lowHealthAlarmActive
|
||||
local Sound = require("src.core.Sound")
|
||||
@@ -3638,12 +3675,13 @@ function BattleState:executeAction(user, target, action)
|
||||
})
|
||||
self.aiUses = self:aiUsesFor()
|
||||
markSeen(self.game, self.enemy.mon.species)
|
||||
-- _AIBattleWithdrawText: "X with-/drew Y!"
|
||||
self:sayNext(Strings("%s with-\ndrew %s!", self.trainer.name, oldName))
|
||||
self:sayNext(self:romText("_AIBattleWithdrawText", "%s with-\ndrew %s!",
|
||||
self.trainer.name, oldName))
|
||||
-- EnemySendOut falls into EnemySendOutFirstMon: TrainerSentOutText,
|
||||
-- then AnimateSendingOutMon and PlayCry (core.asm:1276-1434)
|
||||
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.enemySendingOut = false
|
||||
self:startGrowIn(self.enemy)
|
||||
@@ -3795,6 +3833,8 @@ function BattleState:statusInterrupt(user, target, selectedId)
|
||||
{ rng = self.rng, forceCrit = false, typeless = true,
|
||||
screens = target })
|
||||
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:applyDamage(user, dmg)
|
||||
if user.mon.hp <= 0 then self:onFaint(user) end
|
||||
@@ -3887,25 +3927,38 @@ function BattleState:performMove(user, target, moveInst, isCalled)
|
||||
end
|
||||
|
||||
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))
|
||||
-- the move's animation plays right after the announcement; the
|
||||
-- damage path attaches the target's hit blink to this row so the
|
||||
-- blink follows the animation (pokered's order). Mimic is the
|
||||
-- exception (announceAnim = false): PlayCurrentMoveAnimation runs
|
||||
-- only after a successful copy, never on a miss -- applyMimic queues it
|
||||
end
|
||||
-- PlayCurrentMoveAnimation follows the announcement; Mimic (announceAnim
|
||||
-- = false) queues it from applyMimic after a successful copy
|
||||
if not (record and record.announceAnim == false) then
|
||||
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)
|
||||
end
|
||||
end
|
||||
Runtime.emit("battle.move_used", {
|
||||
battle = self, user = user, target = target, move = move,
|
||||
isCalled = isCalled or false,
|
||||
})
|
||||
|
||||
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
|
||||
-- already said its failure text
|
||||
@@ -4142,8 +4195,12 @@ function BattleState:onFaint(battler)
|
||||
-- acknowledged core.asm:797-798 bug.)
|
||||
self:actNext(function() self:playVictoryMusic() end)
|
||||
end
|
||||
-- _EnemyMonFaintedText "Enemy X fainted!" / _PlayerMonFaintedText
|
||||
self:sayNext(Strings("%s\nfainted!", displayName(battler)))
|
||||
-- _EnemyMonFaintedText already carries its own "Enemy" wording, so this
|
||||
-- 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
|
||||
self:act(function() self:playerMonFainted() end)
|
||||
else
|
||||
@@ -4174,8 +4231,11 @@ function BattleState:awardExp()
|
||||
end
|
||||
local function applyShare(mon, split, announce)
|
||||
local playerId = self.game.save.player and self.game.save.player.id
|
||||
local traded = mon.traded == true
|
||||
or (mon.otId ~= nil and playerId ~= nil and mon.otId ~= playerId)
|
||||
-- GainExperience (engine/battle/experience.asm:69-88) compares the
|
||||
-- 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,
|
||||
self.enemy.mon.level, self.kind == "trainer",
|
||||
split, traded)
|
||||
@@ -4319,22 +4379,44 @@ function BattleState:enemyMonFainted()
|
||||
-- "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" /
|
||||
-- "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))
|
||||
-- EnemySendOutFirstMon .next9/.next8 (core.asm:1390-1409) and
|
||||
-- HasMonFainted's NoWillText (core.asm:1473-1488)
|
||||
self:sayChoice(
|
||||
Strings("Will %s\nchange POKéMON?", self.game.save.player.name),
|
||||
function(yes)
|
||||
if not yes then return end
|
||||
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,
|
||||
party = self:playerPartyView(),
|
||||
forceSwitch = true,
|
||||
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
|
||||
end
|
||||
end,
|
||||
})
|
||||
}
|
||||
Screens.push(game, "PartyMenu", shiftOpts)
|
||||
end, { box = Theme.trainerSwitchBox })
|
||||
end
|
||||
self:act(function()
|
||||
@@ -4362,7 +4444,8 @@ function BattleState:enemyMonFainted()
|
||||
-- (AnimateSendingOutMon) with the cry; no POOF -- that animation
|
||||
-- belongs to the player-side SendOutMon (core.asm:1757-1762)
|
||||
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.enemySendingOut = false
|
||||
self:startGrowIn(self.enemy)
|
||||
@@ -4376,10 +4459,11 @@ function BattleState:enemyMonFainted()
|
||||
local mon = shiftSwitchMon
|
||||
if not mon then return end
|
||||
-- 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:sayNextAuto(self:withdrawText(self.player.name),
|
||||
Timing.SWITCH_PLAYER_MON)
|
||||
self:queueRetreatAnim()
|
||||
self:actNext(function()
|
||||
local previous = self.player
|
||||
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
|
||||
-- prints once, so a `para` page carries no second copy (#566).
|
||||
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
|
||||
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
|
||||
end
|
||||
end
|
||||
@@ -4878,7 +4975,8 @@ function BattleState:storeCaughtMon()
|
||||
-- text_promptbutton (item_effects.asm:624-629), so the fanfare follows
|
||||
-- the box rather than firing when the dex bit is set
|
||||
self:sayNextWaitSfx(
|
||||
Strings("New POKéDEX data\nwill be added for\n%s!", self.enemy.name),
|
||||
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)
|
||||
self:uiNext(function()
|
||||
return self:buildScreen("DexEntryMenu", species)
|
||||
@@ -4899,9 +4997,12 @@ function BattleState:storeCaughtMon()
|
||||
if boxNum then
|
||||
askCaughtNickname()
|
||||
-- _ItemUseBallText07/08 keyed on EVENT_MET_BILL
|
||||
local pc = (game.save.flags and game.save.flags.EVENT_MET_BILL)
|
||||
and "BILL's PC" or Strings("someone's PC")
|
||||
self:sayNext(Strings("%s was\ntransferred to\n%s!", self.enemy.name, pc))
|
||||
local metBill = game.save.flags and game.save.flags.EVENT_MET_BILL
|
||||
self:sayNext(self:romText(
|
||||
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
|
||||
self:sayNext(Strings("But every BOX\nis full!"))
|
||||
end
|
||||
@@ -5007,8 +5108,18 @@ function BattleState:throwBall(ball)
|
||||
-- RESTLESS SOUL dodges balls even once the scope has revealed it,
|
||||
-- so it is not a ghost battle any more (#444)
|
||||
self:animNext(self:tossAnimFor(ball), true, nil, ball)
|
||||
self:sayNext(Strings("It dodged the\nthrown BALL!"))
|
||||
self:sayNext(Strings("This POKéMON\ncan't be caught!"))
|
||||
-- _ItemUseBallText00 is one label for both lines, \f-paged. Unlike
|
||||
-- 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:executeAction(self.enemy, self.player, self:enemyAction())
|
||||
end)
|
||||
@@ -5055,10 +5166,14 @@ function BattleState:openParty()
|
||||
battle = self,
|
||||
party = self:playerPartyView(),
|
||||
onSwitch = function(mon)
|
||||
-- PartyMenuOrRockOrRun's SWITCH .partyMonDeselected (core.asm:2396-2408)
|
||||
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
|
||||
self:say(self:romText("_NoWillText", "There's no will\nto fight!"))
|
||||
self:act(function() self:openParty() end)
|
||||
else
|
||||
self:resolveSwitch(mon)
|
||||
end
|
||||
@@ -5202,6 +5317,16 @@ function BattleState:growInScale(battler)
|
||||
return f < 3 and 0 or f < 7 and 3 / 7 or 5 / 7
|
||||
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)
|
||||
--
|
||||
-- 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",
|
||||
imagePathOf(self.player.sprite),
|
||||
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
|
||||
-- the player-side AnimateSendingOutMon grow (after the poof,
|
||||
-- core.asm:1757-1762): feet pinned at y=96, horizontal centre
|
||||
-- pinned, mod scale composed with the grow stage
|
||||
-- the player-side AnimateSendingOutMon grow (core.asm:1757-1762) and
|
||||
-- the AnimateRetreatingPlayerMon shrink (core.asm:1769-1796)
|
||||
local eff = s * gs
|
||||
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,
|
||||
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)
|
||||
end
|
||||
else
|
||||
|
||||
@@ -108,13 +108,17 @@ end
|
||||
|
||||
-- The damaging pipeline, extracted from the performMove monolith: every
|
||||
-- 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).
|
||||
function EffectRegistry.runDamaging(battle, ctx, record)
|
||||
local user, target = ctx.user, ctx.target
|
||||
local move, moveInst = ctx.move, ctx.moveInst
|
||||
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_EFFECT before the INVULNERABLE check)
|
||||
if target.invulnerable and not neverMiss then
|
||||
@@ -143,8 +147,6 @@ function EffectRegistry.runDamaging(battle, ctx, record)
|
||||
|
||||
local hits = hitCount(ctx, record)
|
||||
|
||||
if record and record.beforeAccuracy then record.beforeAccuracy(ctx) end
|
||||
|
||||
if not neverMiss then
|
||||
if not battle:accuracyRoll(move, user, target) then
|
||||
-- Explosion/Selfdestruct still animate on a miss (HandleIfPlayerMoveMissed)
|
||||
@@ -221,7 +223,7 @@ function EffectRegistry.runDamaging(battle, ctx, record)
|
||||
-- replay PlayMoveAnimation per strike (pokered: GetPlayerAnimationType
|
||||
-- / GetEnemyAnimationType loop on wNumAttacksLeft); hit 1 reuses the
|
||||
-- announcement-time moveAnimRow, later hits queue fresh anim rows.
|
||||
-- Thrash/rage continuations have no announcement anim -- a bare
|
||||
-- Mimic queues no announcement anim (announceAnim = false) -- a bare
|
||||
-- hitRow carries the blink instead.
|
||||
-- PlayApplyingAttackSound (engine/battle/animations.asm, the routine after
|
||||
-- PlayApplyingAttackAnimation) picks the sound off wDamageMultipliers -- 10
|
||||
|
||||
@@ -582,30 +582,15 @@ MoveEffects.full = {
|
||||
},
|
||||
THRASH_PETAL_DANCE_EFFECT = {
|
||||
-- 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)
|
||||
local user = ctx.user
|
||||
if not user.thrashTurns then
|
||||
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
|
||||
if ctx.thrashing or user.thrashTurns then return end
|
||||
user.thrashTurns = ctx.rng(2, 3) -- 3-4 attacks total, then confusion
|
||||
user.thrashMove = ctx.moveInst
|
||||
user.thrashAnnounced = true
|
||||
else
|
||||
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 = ctx.rng(2, 5)
|
||||
ctx.say(romText(ctx.battle.data, "_BecameConfusedText", "%s\nbecame confused!", displayName(user)))
|
||||
end
|
||||
end
|
||||
end
|
||||
ctx.battle:animBeforeMove(
|
||||
user.isPlayer and "SHRINKING_SQUARE_ANIM" or "ANIM_B1", user.isPlayer)
|
||||
end,
|
||||
},
|
||||
JUMP_KICK_EFFECT = {
|
||||
|
||||
+43
-13
@@ -166,6 +166,9 @@ Battle.SUBSTATUS_ITEMS = {
|
||||
-- be run from or Roared away.
|
||||
Battle.BATTLETYPE_FORCESHINY = 7
|
||||
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
|
||||
-- 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_COUNTER = -1,
|
||||
EFFECT_MIRROR_COAT = -1,
|
||||
EFFECT_VITAL_THROW = -1,
|
||||
EFFECT_FORCE_SWITCH = -1, -- Whirlwind, Roar: priority 0, below BASE
|
||||
}
|
||||
|
||||
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)
|
||||
return (def and Battle.PRIORITY[def.effect]) or 0
|
||||
end
|
||||
@@ -2403,10 +2409,11 @@ Battle.MOVE_EFFECTS.EFFECT_BATON_PASS = function(self, attacker)
|
||||
self.enemy = party[target]
|
||||
self.enemy.volatile = carried
|
||||
end
|
||||
self:emit({ kind = "send", side = side,
|
||||
mon = side == "player" and self.player or self.enemy,
|
||||
text = "Go! " .. self:monName(side == "player" and self.player
|
||||
or self.enemy) .. "!" })
|
||||
local sent = side == "player" and self.player or self.enemy
|
||||
self:emit({ kind = "send", side = side, mon = sent,
|
||||
hp = sent.hp or 0, status = sent.status or false,
|
||||
level = sent.level, experience = sent.experience,
|
||||
text = "Go! " .. self:monName(sent) .. "!" })
|
||||
end
|
||||
|
||||
-- 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()
|
||||
end
|
||||
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!" })
|
||||
self:breakTrapsOnSend(incoming)
|
||||
self:spikesDamage(incoming)
|
||||
@@ -3084,6 +3093,7 @@ function Battle:resolveFaints()
|
||||
if self.trainer then
|
||||
self:emit({ kind = "message",
|
||||
text = (self.trainer.name or "TRAINER") .. " was defeated!" })
|
||||
self:printWinLossText("win")
|
||||
self:awardPrizeMoney()
|
||||
end
|
||||
-- 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).
|
||||
self:emit({ kind = "send", side = "enemy", mon = self.enemy,
|
||||
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 "
|
||||
.. self:monName(self.enemy) .. "!" })
|
||||
Runtime.emit("battle.battler_switched", {
|
||||
@@ -3152,6 +3164,11 @@ function Battle:resolveFaints()
|
||||
local nextIndex = Battle.firstHealthy(self.party)
|
||||
if not nextIndex then
|
||||
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")
|
||||
return true
|
||||
end
|
||||
@@ -3177,14 +3194,23 @@ function Battle:resolveFaints()
|
||||
return false
|
||||
end
|
||||
|
||||
-- WinTrainerBattle's money arm, which runs after BattleText_EnemyWasDefeated
|
||||
-- and the frontpic slide: the four quarters are dealt between the wallet and
|
||||
-- Mom's savings and then one StdBattleTextbox names the figure.
|
||||
--
|
||||
-- The `ld a, [wDebugFlags] / bit DEBUG_BATTLE_F` skip in front of
|
||||
-- PrintWinLossText is the trainer's own after-battle line, which this port
|
||||
-- runs from the script on the way out of the battle rather than from here.
|
||||
-- The payout is not gated on it either way.
|
||||
-- WinTrainerBattle (engine/battle/core.asm:2310-2323), LostBattle's .canlose
|
||||
-- arm (:2769-2782), PrintWinLossText (home/trainers.asm:230)
|
||||
function Battle:printWinLossText(result)
|
||||
local trainer = self.trainer
|
||||
if not trainer then return end
|
||||
-- The DEBUG_BATTLE_F skip sits in front of PrintWinLossText alone, behind
|
||||
-- the slide (engine/battle/core.asm:2310, :2320-2323).
|
||||
-- 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()
|
||||
local save = self.save
|
||||
if not (save and save.player) then return nil end
|
||||
@@ -3505,6 +3531,8 @@ function Battle:switch(index)
|
||||
self.participants[index] = true
|
||||
self.stages.player = Battle.newStages()
|
||||
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) .. "!" })
|
||||
-- battle.battler_switched, the payload BattleState:resolveSwitch emits on
|
||||
-- Gen 1: the side record, whoever walked in, and whoever walked out.
|
||||
@@ -3969,6 +3997,8 @@ function Battle:enemyTrySwitchOrItem()
|
||||
self:clearVolatile(self.enemy)
|
||||
self.stages.enemy = Battle.newStages()
|
||||
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 "
|
||||
.. self:monName(self.enemy) .. "!" })
|
||||
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
|
||||
-- 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,
|
||||
-- gated on this frame's world pass -- which the party menu and the bag end
|
||||
-- by being opaque. Without this hold they lose the step-down and blit at
|
||||
-- full fit scale over a battle drawn at the zoomed-out one.
|
||||
-- gated on this frame's world pass -- which the party menu ends by being
|
||||
-- opaque (the bag's item box shows the map around it, #1521). Without
|
||||
-- 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
|
||||
-- ...and a battle keeps its dialogue box and YES/NO inside its own screen
|
||||
-- instead of letting them dock to the window edge.
|
||||
@@ -982,7 +983,11 @@ end
|
||||
-- parked the player until every direction was re-pressed (#799).
|
||||
function Game:focus(f)
|
||||
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()
|
||||
self:cancelPointers()
|
||||
end
|
||||
@@ -1002,6 +1007,8 @@ function Game:onResume()
|
||||
Input:reconcile()
|
||||
TouchControls:reset()
|
||||
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
|
||||
-- the active screen re-cue on the next frame (hardware audio check: T19).
|
||||
-- Desktop/mobile window-visible flips must not kill overworld music.
|
||||
@@ -1209,18 +1216,26 @@ end
|
||||
|
||||
function Game:syncEngine()
|
||||
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")
|
||||
if not ok or type(SyncEngine) ~= "table" then
|
||||
self._syncOff = true
|
||||
return nil
|
||||
end
|
||||
local eng = SyncEngine.shared()
|
||||
eng = SyncEngine.shared()
|
||||
if not eng then
|
||||
self._syncOff = true
|
||||
return nil
|
||||
end
|
||||
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
|
||||
end
|
||||
|
||||
|
||||
@@ -220,6 +220,9 @@ function Save.newGame(opts)
|
||||
phoneContacts = {},
|
||||
tradeFlags = {},
|
||||
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
|
||||
-- record beside the #DEX because the #DEX knows only the species
|
||||
-- (src/core/gen2/Unown.lua).
|
||||
@@ -685,6 +688,12 @@ function Save.validate(save)
|
||||
scrubEvents(save, report)
|
||||
scrubMapScenes(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
|
||||
-- `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
|
||||
|
||||
+305
-108
@@ -124,12 +124,48 @@ function LauncherView.detach(imp)
|
||||
Kit.clearCaches()
|
||||
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
|
||||
-- The kit is polled, not evented: update() samples the mouse and turns a
|
||||
-- rising edge into a click point that the next draw consumes. 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.
|
||||
-- The kit is polled, not evented: update() samples the mouse. A press arms
|
||||
-- a drag that scrolls like a finger, and the click dispatches on RELEASE so
|
||||
-- the drag can disqualify it, exactly like the touch path below; only the
|
||||
-- cartridge (which owns its own spin-drag) keeps the press-down click.
|
||||
-- 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)
|
||||
if not imp._flex 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)
|
||||
and now >= (imp._suppressClickUntil or 0) then
|
||||
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
|
||||
imp._prevMouseDown = down
|
||||
@@ -233,10 +301,12 @@ function LauncherView.clickAt(imp, x, y)
|
||||
imp._clickPt = { x = x, y = y }
|
||||
end
|
||||
|
||||
-- Event-driven click: a macOS trackpad tap delivers press+release inside one
|
||||
-- frame, so update()'s love.mouse.isDown poll never sees it. Mint the click
|
||||
-- from the press event under the poll's own suppression rules, and mark the
|
||||
-- press seen so the poll cannot mint a second one when isDown does catch it.
|
||||
-- Event-driven press: a macOS trackpad tap delivers press+release inside one
|
||||
-- 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 -- the poll's
|
||||
-- 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)
|
||||
if not imp._flex then return end
|
||||
local now = love.timer.getTime()
|
||||
@@ -245,7 +315,7 @@ function LauncherView.mousepressed(imp, x, y)
|
||||
or now < (imp._suppressClickUntil or 0) then
|
||||
return
|
||||
end
|
||||
imp._clickPt = { x = x, y = y }
|
||||
if not imp._mouseAt then armMouse(imp, x, y) end
|
||||
imp._prevMouseDown = true
|
||||
end
|
||||
|
||||
@@ -495,6 +565,7 @@ end
|
||||
|
||||
local function cartridgeButton(imp, x, y, w, h, key, version, gameName, action)
|
||||
local state = cartridgeState(imp, version)
|
||||
markNoDrag(imp, x, y, w, h)
|
||||
local focused = Kit.focusable(key, x, y, w, h)
|
||||
local hot = Kit.hover(x, y, w, h)
|
||||
local active = state.active
|
||||
@@ -1245,15 +1316,27 @@ function LauncherView._updateControl(imp)
|
||||
elseif status == "downloading" then
|
||||
local pct = st.progress and math.floor(st.progress * 100) or 0
|
||||
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
|
||||
return status, st.latest and (Strings("Update v") .. st.latest)
|
||||
or Strings("Update"), function() pcall(imp.Check.download) end, true
|
||||
elseif status == "ready" then
|
||||
return status, Strings("Restart to update"),
|
||||
function() require("src.core.HostShell").restart() end, true
|
||||
elseif status == "needs_full" then
|
||||
return status, Strings("Open releases"),
|
||||
function() love.system.openURL(imp.Check.releaseUrl()) end, true
|
||||
elseif status == "needs_full" or status == "full_ready" then
|
||||
local action = imp.Check.fullUpdateAction and imp.Check.fullUpdateAction()
|
||||
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
|
||||
-- idle / uptodate / error: offer a manual check, with no glow.
|
||||
return status, Strings("Check for updates"),
|
||||
@@ -4279,21 +4362,70 @@ local function syncTitle(imp, m, px, py, pw, pad)
|
||||
return py + Kit.textHeight("button") + math.floor(12 * m.s)
|
||||
end
|
||||
|
||||
local function syncStatus(imp, m, x, y, w, eng)
|
||||
local function syncWidth(m, want)
|
||||
return math.floor(math.min(want, m.W - 2 * m.pad))
|
||||
end
|
||||
|
||||
local function syncFit(m, fixed, rows, gaps, texts)
|
||||
local fit = { btnH = m.btnH, gap = math.floor(8 * m.s), lines = {} }
|
||||
local avail = m.H - 2 * m.pad
|
||||
texts = texts or {}
|
||||
for i, blk in ipairs(texts) do fit.lines[i] = blk.max end
|
||||
local function total()
|
||||
local t = fixed + rows * fit.btnH + gaps * fit.gap
|
||||
for i, blk in ipairs(texts) do
|
||||
t = t + Kit.wrapHeight(blk.font, blk.str, blk.w, fit.lines[i])
|
||||
end
|
||||
return t
|
||||
end
|
||||
while total() > avail do
|
||||
local worst, worstH = nil, 0
|
||||
for i, blk in ipairs(texts) do
|
||||
if fit.lines[i] > 1 then
|
||||
local hgt = Kit.wrapHeight(blk.font, blk.str, blk.w, fit.lines[i])
|
||||
if hgt > worstH then worst, worstH = i, hgt end
|
||||
end
|
||||
end
|
||||
if not worst then break end
|
||||
fit.lines[worst] = fit.lines[worst] - 1
|
||||
end
|
||||
if total() > avail and gaps > 0 then
|
||||
fit.gap = math.max(math.max(2, math.floor(3 * m.s)),
|
||||
fit.gap - math.ceil((total() - avail) / gaps))
|
||||
end
|
||||
if total() > avail and rows > 0 then
|
||||
fit.btnH = math.max(Kit.tapMin(),
|
||||
fit.btnH - math.ceil((total() - avail) / rows))
|
||||
end
|
||||
fit.over = total() - avail
|
||||
fit.h = math.min(total(), avail)
|
||||
return fit
|
||||
end
|
||||
|
||||
local function syncStatus(imp, m, x, y, w, eng, fit)
|
||||
local bh = (fit and fit.btnH) or m.btnH
|
||||
local gap = (fit and fit.gap) or math.floor(8 * m.s)
|
||||
if eng:busy() then
|
||||
Loader.inline(x, y, w, m.btnH, eng.status)
|
||||
return m.btnH + math.floor(8 * m.s)
|
||||
Loader.inline(x, y, w, bh, eng.status)
|
||||
return bh + gap
|
||||
end
|
||||
Kit.text("small", Kit.ellipsize("small", eng.status or "", w), x, y,
|
||||
eng.phase == "error" and PAL.red or PAL.muted)
|
||||
return Kit.textHeight("small") + gap + math.floor(2 * m.s)
|
||||
end
|
||||
|
||||
local function syncReserve(m, eng)
|
||||
if eng:busy() then return m.btnH + math.floor(8 * m.s) end
|
||||
return Kit.textHeight("small") + math.floor(10 * m.s)
|
||||
end
|
||||
|
||||
local function syncRow(imp, m, x, y, w, key, label, opts)
|
||||
local function syncRow(imp, m, x, y, w, key, label, opts, fit)
|
||||
opts = opts or {}
|
||||
opts.font = "small"
|
||||
btn(imp, x, y, w, m.btnH, key, label, opts)
|
||||
return y + m.btnH + math.floor(8 * m.s)
|
||||
local bh = (fit and fit.btnH) or m.btnH
|
||||
local gap = (fit and fit.gap) or math.floor(8 * m.s)
|
||||
btn(imp, x, y, w, bh, key, label, opts)
|
||||
return y + bh + gap
|
||||
end
|
||||
|
||||
function LauncherView.syncSideText(meta)
|
||||
@@ -4325,91 +4457,99 @@ end
|
||||
local function buildSyncConflict(imp, m, eng)
|
||||
local row = eng.conflicts[1]
|
||||
local pad = math.floor(18 * m.s)
|
||||
local w = math.floor(520 * m.s)
|
||||
local w = syncWidth(m, math.floor(520 * m.s))
|
||||
local innerW = w - 2 * pad
|
||||
local lead = row.overlap
|
||||
and Strings("These saves were played at the same time.")
|
||||
or Strings("This save also changed on another device.")
|
||||
local leadH = Kit.wrapHeight("small", lead, innerW, 2)
|
||||
local sideH = Kit.textHeight("small") + math.floor(2 * m.s)
|
||||
+ Kit.wrapHeight("micro", "x", innerW, 2)
|
||||
local h = pad + Kit.textHeight("button") + math.floor(12 * m.s) + leadH
|
||||
+ math.floor(10 * m.s) + 2 * (sideH + math.floor(10 * m.s))
|
||||
+ 4 * (m.btnH + math.floor(8 * m.s)) + pad
|
||||
local px, py, pw = modalPanel(m, w, h)
|
||||
local mine = LauncherView.syncSideText(row.localMeta)
|
||||
local theirs = LauncherView.syncSideText(row.remoteMeta)
|
||||
local fit = syncFit(m,
|
||||
2 * pad + Kit.textHeight("button") + math.floor(22 * m.s)
|
||||
+ 2 * (Kit.textHeight("small") + math.floor(12 * m.s)),
|
||||
4, 4, {
|
||||
{ font = "small", str = lead, w = innerW, max = 2 },
|
||||
{ font = "micro", str = mine, w = innerW, max = 2 },
|
||||
{ font = "micro", str = theirs, w = innerW, max = 2 },
|
||||
})
|
||||
local px, py, pw = modalPanel(m, w, fit.h)
|
||||
local cy = syncTitle(imp, m, px, py + pad, pw, pad)
|
||||
cy = cy + Kit.textWrapped("small", lead, px + pad, cy, pw - 2 * pad,
|
||||
PAL.detail, 2) + math.floor(10 * m.s)
|
||||
cy = cy + Kit.textWrapped("small", lead, px + pad, cy, innerW,
|
||||
PAL.detail, fit.lines[1]) + math.floor(10 * m.s)
|
||||
|
||||
local function side(title, meta)
|
||||
local function side(title, text, lines)
|
||||
Kit.text("small", title, px + pad, cy, PAL.heading)
|
||||
cy = cy + Kit.textHeight("small") + math.floor(2 * m.s)
|
||||
cy = cy + Kit.textWrapped("micro", LauncherView.syncSideText(meta),
|
||||
px + pad, cy, pw - 2 * pad, PAL.muted, 2) + math.floor(10 * m.s)
|
||||
cy = cy + Kit.textWrapped("micro", text, px + pad, cy, innerW,
|
||||
PAL.muted, lines) + math.floor(10 * m.s)
|
||||
end
|
||||
side(Strings("This device") .. " \194\183 " .. tostring(row.version or "?"),
|
||||
row.localMeta)
|
||||
side(Strings("Other device"), row.remoteMeta)
|
||||
mine, fit.lines[2])
|
||||
side(Strings("Other device"), theirs, fit.lines[3])
|
||||
|
||||
local key = row.key
|
||||
cy = syncRow(imp, m, px + pad, cy, pw - 2 * pad, "sync-keep-this",
|
||||
cy = syncRow(imp, m, px + pad, cy, innerW, "sync-keep-this",
|
||||
Strings("Keep this device"), { kind = "primary",
|
||||
action = function() imp:_syncResolve(key, "local") end })
|
||||
cy = syncRow(imp, m, px + pad, cy, pw - 2 * pad, "sync-keep-other",
|
||||
action = function() imp:_syncResolve(key, "local") end }, fit)
|
||||
cy = syncRow(imp, m, px + pad, cy, innerW, "sync-keep-other",
|
||||
Strings("Keep the other device"), { kind = "accent",
|
||||
action = function() imp:_syncResolve(key, "remote") end })
|
||||
cy = syncRow(imp, m, px + pad, cy, pw - 2 * pad, "sync-keep-both",
|
||||
action = function() imp:_syncResolve(key, "remote") end }, fit)
|
||||
cy = syncRow(imp, m, px + pad, cy, innerW, "sync-keep-both",
|
||||
Strings("Keep both"), {
|
||||
action = function() imp:_syncResolve(key, "both") end })
|
||||
syncRow(imp, m, px + pad, cy, pw - 2 * pad, "sync-conflict-close",
|
||||
Strings("Close"), { action = function() imp:_closeSync() end })
|
||||
action = function() imp:_syncResolve(key, "both") end }, fit)
|
||||
syncRow(imp, m, px + pad, cy, innerW, "sync-conflict-close",
|
||||
Strings("Close"), { action = function() imp:_closeSync() end }, fit)
|
||||
end
|
||||
|
||||
local function buildSyncLink(imp, m, eng)
|
||||
local mo = imp._syncModal
|
||||
local pad = math.floor(18 * m.s)
|
||||
local w = math.floor(460 * m.s)
|
||||
local fieldH = math.max(Kit.tapMin(), math.floor(36 * m.s))
|
||||
local w = syncWidth(m, math.floor(460 * m.s))
|
||||
local innerW = w - 2 * pad
|
||||
local hint = Strings("Enter the two codes the other device is showing.")
|
||||
local hintH = Kit.wrapHeight("small", hint, w - 2 * pad, 2)
|
||||
local h = pad + Kit.textHeight("button") + math.floor(12 * m.s) + hintH
|
||||
+ math.floor(10 * m.s) + 2 * (fieldH + math.floor(8 * m.s))
|
||||
+ Kit.textHeight("small") + math.floor(10 * m.s)
|
||||
+ 2 * (m.btnH + math.floor(8 * m.s)) + pad
|
||||
local px, py, pw = modalPanel(m, w, h)
|
||||
local fieldH = math.max(Kit.tapMin(), math.floor(36 * m.s))
|
||||
local fit = syncFit(m,
|
||||
2 * pad + Kit.textHeight("button") + math.floor(22 * m.s)
|
||||
+ 2 * (fieldH + math.floor(8 * m.s)) + syncReserve(m, eng),
|
||||
2, 2, { { font = "small", str = hint, w = innerW, max = 2 } })
|
||||
local px, py, pw = modalPanel(m, w, fit.h)
|
||||
local cy = syncTitle(imp, m, px, py + pad, pw, pad)
|
||||
cy = cy + Kit.textWrapped("small", hint, px + pad, cy, pw - 2 * pad,
|
||||
PAL.detail, 2) + math.floor(10 * m.s)
|
||||
textField(imp, px + pad, cy, pw - 2 * pad, fieldH, "sync-code1",
|
||||
cy = cy + Kit.textWrapped("small", hint, px + pad, cy, innerW,
|
||||
PAL.detail, fit.lines[1]) + math.floor(10 * m.s)
|
||||
textField(imp, px + pad, cy, innerW, fieldH, "sync-code1",
|
||||
mo.code1 or "", Strings("First code"), imp._syncFocus == "code1",
|
||||
function() imp:_syncFocusField("code1") end)
|
||||
cy = cy + fieldH + math.floor(8 * m.s)
|
||||
textField(imp, px + pad, cy, pw - 2 * pad, fieldH, "sync-code2",
|
||||
cy = cy + fieldH + fit.gap
|
||||
textField(imp, px + pad, cy, innerW, fieldH, "sync-code2",
|
||||
mo.code2 or "", Strings("Second code"), imp._syncFocus == "code2",
|
||||
function() imp:_syncFocusField("code2") end)
|
||||
cy = cy + fieldH + math.floor(8 * m.s)
|
||||
cy = cy + syncStatus(imp, m, px + pad, cy, pw - 2 * pad, eng)
|
||||
cy = syncRow(imp, m, px + pad, cy, pw - 2 * pad, "sync-link-go",
|
||||
cy = cy + fieldH + fit.gap
|
||||
cy = cy + syncStatus(imp, m, px + pad, cy, innerW, eng, fit)
|
||||
cy = syncRow(imp, m, px + pad, cy, innerW, "sync-link-go",
|
||||
Strings("Link this device"), { kind = "primary", enabled = not eng:busy(),
|
||||
action = function() imp:_syncLink() end })
|
||||
syncRow(imp, m, px + pad, cy, pw - 2 * pad, "sync-link-back",
|
||||
Strings("Back"), { action = function() imp:_syncView("home") end })
|
||||
action = function() imp:_syncLink() end }, fit)
|
||||
syncRow(imp, m, px + pad, cy, innerW, "sync-link-back",
|
||||
Strings("Back"), { action = function() imp:_syncView("home") end }, fit)
|
||||
end
|
||||
|
||||
local function buildSyncMods(imp, m, eng)
|
||||
local mo = imp._syncModal
|
||||
local pad = math.floor(18 * m.s)
|
||||
local w = math.floor(500 * m.s)
|
||||
local w = syncWidth(m, math.floor(500 * m.s))
|
||||
local innerW = w - 2 * pad
|
||||
local fieldH = math.max(Kit.tapMin(), math.floor(36 * m.s))
|
||||
local plan = eng.modPlan
|
||||
local rows = 4 + (plan and 1 or 0)
|
||||
local h = pad + Kit.textHeight("button") + math.floor(12 * m.s)
|
||||
+ 3 * (Kit.textHeight("small") + math.floor(8 * m.s))
|
||||
+ fieldH + math.floor(8 * m.s)
|
||||
+ rows * (m.btnH + math.floor(8 * m.s)) + pad
|
||||
local px, py, pw = modalPanel(m, w, h)
|
||||
local codeH = eng.shareCode and (Kit.textHeight("small")
|
||||
+ Kit.textHeight("stat") + Kit.textHeight("micro")
|
||||
+ math.floor(18 * m.s)) or 0
|
||||
local planH = plan and (Kit.textHeight("small") + math.floor(8 * m.s)) or 0
|
||||
local fit = syncFit(m,
|
||||
2 * pad + Kit.textHeight("button") + math.floor(12 * m.s) + codeH + planH
|
||||
+ fieldH + math.floor(8 * m.s) + syncReserve(m, eng),
|
||||
rows, rows, {})
|
||||
local px, py, pw = modalPanel(m, w, fit.h)
|
||||
local cy = syncTitle(imp, m, px, py + pad, pw, pad)
|
||||
local innerW = pw - 2 * pad
|
||||
|
||||
if eng.shareCode then
|
||||
Kit.text("small", Strings("Share this code:"), px + pad, cy, PAL.muted)
|
||||
@@ -4421,17 +4561,23 @@ local function buildSyncMods(imp, m, eng)
|
||||
px + pad, cy, PAL.muted)
|
||||
cy = cy + Kit.textHeight("micro") + math.floor(10 * m.s)
|
||||
end
|
||||
local withOptions = mo.withOptions ~= false
|
||||
cy = syncRow(imp, m, px + pad, cy, innerW, "sync-share-options",
|
||||
Strings("Include my mod options") .. " \194\183 "
|
||||
.. (withOptions and Strings("ON") or Strings("OFF")),
|
||||
{ kind = withOptions and "accent" or nil, enabled = not eng:busy(),
|
||||
action = function() imp:_syncToggleShareOptions() end }, fit)
|
||||
cy = syncRow(imp, m, px + pad, cy, innerW, "sync-share-mods",
|
||||
Strings("Share mod list"), { kind = "accent", enabled = not eng:busy(),
|
||||
action = function() imp:_syncShareMods() end })
|
||||
action = function() imp:_syncShareMods() end }, fit)
|
||||
|
||||
textField(imp, px + pad, cy, innerW, fieldH, "sync-share-code",
|
||||
mo.share or "", Strings("Paste a 6-character mod code"),
|
||||
imp._syncFocus == "share", function() imp:_syncFocusField("share") end)
|
||||
cy = cy + fieldH + math.floor(8 * m.s)
|
||||
cy = cy + fieldH + fit.gap
|
||||
cy = syncRow(imp, m, px + pad, cy, innerW, "sync-get-mods",
|
||||
Strings("Get mod list"), { kind = "accent", enabled = not eng:busy(),
|
||||
action = function() imp:_syncGetShare() end })
|
||||
action = function() imp:_syncGetShare() end }, fit)
|
||||
|
||||
if plan then
|
||||
local line = Strings("%d mods, %d indexes to add",
|
||||
@@ -4440,24 +4586,29 @@ local function buildSyncMods(imp, m, eng)
|
||||
line = line .. " \194\183 " .. Strings("%d not in your indexes",
|
||||
#plan.missing)
|
||||
end
|
||||
if #(plan.options or {}) > 0 then
|
||||
line = line .. " \194\183 " .. (plan.applyOptions
|
||||
and Strings("options for %d mods", #plan.options)
|
||||
or Strings("their options skipped"))
|
||||
end
|
||||
Kit.text("small", Kit.ellipsize("small", line, innerW), px + pad, cy,
|
||||
PAL.detail)
|
||||
cy = cy + Kit.textHeight("small") + math.floor(8 * m.s)
|
||||
local prog = mo.progress
|
||||
if prog then
|
||||
Loader.inline(px + pad, cy, innerW, m.btnH,
|
||||
Loader.inline(px + pad, cy, innerW, fit.btnH,
|
||||
Strings("%d of %d", prog.done or 0, prog.total or 0))
|
||||
cy = cy + m.btnH + math.floor(8 * m.s)
|
||||
cy = cy + fit.btnH + fit.gap
|
||||
else
|
||||
cy = syncRow(imp, m, px + pad, cy, innerW, "sync-apply-mods",
|
||||
Strings("Apply these mods"), { kind = "primary",
|
||||
enabled = not eng:busy(),
|
||||
action = function() imp:_syncApplyMods() end })
|
||||
action = function() imp:_syncApplyMods() end }, fit)
|
||||
end
|
||||
end
|
||||
cy = cy + syncStatus(imp, m, px + pad, cy, innerW, eng)
|
||||
cy = cy + syncStatus(imp, m, px + pad, cy, innerW, eng, fit)
|
||||
syncRow(imp, m, px + pad, cy, innerW, "sync-mods-back", Strings("Back"),
|
||||
{ action = function() imp:_syncView("home") end })
|
||||
{ action = function() imp:_syncView("home") end }, fit)
|
||||
end
|
||||
|
||||
function LauncherView.syncDeviceRows(eng, limit)
|
||||
@@ -4479,29 +4630,35 @@ end
|
||||
|
||||
local function buildSyncHome(imp, m, eng)
|
||||
local pad = math.floor(18 * m.s)
|
||||
local w = math.floor(460 * m.s)
|
||||
local w = syncWidth(m, math.floor(460 * m.s))
|
||||
local linked = eng:linked()
|
||||
local codes = eng.codes
|
||||
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)
|
||||
local innerW = w - 2 * pad
|
||||
local hintH = Kit.wrapHeight("small", body, innerW, 5)
|
||||
local codesH = codes
|
||||
and (Kit.textHeight("small") + math.floor(6 * m.s)
|
||||
+ 2 * (Kit.textHeight("title") + math.floor(4 * m.s))
|
||||
+ math.floor(8 * m.s)) or 0
|
||||
local devices = linked and LauncherView.syncDeviceRows(eng) or {}
|
||||
local hidden, fit = 0, nil
|
||||
repeat
|
||||
local devicesH = #devices > 0
|
||||
and (Kit.textHeight("small") + math.floor(6 * m.s)) or 0
|
||||
local rows = (linked and 5 or 3) + #devices
|
||||
local h = pad + Kit.textHeight("button") + math.floor(12 * m.s) + hintH
|
||||
+ math.floor(10 * m.s) + codesH + devicesH + m.btnH + math.floor(10 * m.s)
|
||||
+ rows * (m.btnH + math.floor(8 * m.s)) + pad
|
||||
local px, py, pw = modalPanel(m, w, h)
|
||||
fit = syncFit(m,
|
||||
2 * pad + Kit.textHeight("button") + math.floor(22 * m.s) + codesH
|
||||
+ devicesH + syncReserve(m, eng),
|
||||
(linked and 4 or 3) + #devices, (linked and 4 or 3) + #devices,
|
||||
{ { font = "small", str = body, w = innerW, max = 5 } })
|
||||
if fit.over <= 0 or #devices == 0 then break end
|
||||
table.remove(devices)
|
||||
hidden = hidden + 1
|
||||
until false
|
||||
local px, py, pw = modalPanel(m, w, fit.h)
|
||||
local cy = syncTitle(imp, m, px, py + pad, pw, pad)
|
||||
cy = cy + Kit.textWrapped("small", body, px + pad, cy, innerW, PAL.detail, 5)
|
||||
+ math.floor(10 * m.s)
|
||||
cy = cy + Kit.textWrapped("small", body, px + pad, cy, innerW, PAL.detail,
|
||||
fit.lines[1]) + math.floor(10 * m.s)
|
||||
|
||||
if codes then
|
||||
Kit.text("small", Strings("Enter these on your other device:"), px + pad,
|
||||
@@ -4512,23 +4669,24 @@ local function buildSyncHome(imp, m, eng)
|
||||
Kit.text("title", codes.code2, px + pad, cy, PAL.heading)
|
||||
cy = cy + Kit.textHeight("title") + math.floor(8 * m.s)
|
||||
end
|
||||
cy = cy + syncStatus(imp, m, px + pad, cy, innerW, eng)
|
||||
cy = cy + syncStatus(imp, m, px + pad, cy, innerW, eng, fit)
|
||||
|
||||
if #devices > 0 then
|
||||
Kit.text("small", Strings("Devices on this account:"), px + pad, cy,
|
||||
PAL.muted)
|
||||
Kit.text("small", hidden > 0
|
||||
and Strings("Devices on this account (%d more)", hidden)
|
||||
or Strings("Devices on this account:"), px + pad, cy, PAL.muted)
|
||||
cy = cy + Kit.textHeight("small") + math.floor(6 * m.s)
|
||||
for i, device in ipairs(devices) do
|
||||
local id = device.id
|
||||
if device.current then
|
||||
cy = syncRow(imp, m, px + pad, cy, innerW, "sync-device-" .. i,
|
||||
device.label .. " \194\183 " .. Strings("this device"),
|
||||
{ enabled = false })
|
||||
{ enabled = false }, fit)
|
||||
else
|
||||
cy = syncRow(imp, m, px + pad, cy, innerW, "sync-device-" .. i,
|
||||
Strings("Unlink %s", device.label), { kind = "danger",
|
||||
enabled = not eng:busy(),
|
||||
action = function() imp:_syncUnlinkDevice(id) end })
|
||||
action = function() imp:_syncUnlinkDevice(id) end }, fit)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -4536,38 +4694,69 @@ local function buildSyncHome(imp, m, eng)
|
||||
if linked then
|
||||
cy = syncRow(imp, m, px + pad, cy, innerW, "sync-now", Strings("Sync now"),
|
||||
{ kind = "primary", enabled = not eng:busy(),
|
||||
action = function() imp:_syncNow() end })
|
||||
action = function() imp:_syncNow() end }, fit)
|
||||
cy = syncRow(imp, m, px + pad, cy, innerW, "sync-mods",
|
||||
Strings("Share or get a mod list"), { kind = "accent",
|
||||
action = function() imp:_syncView("mods") end })
|
||||
action = function() imp:_syncView("mods") end }, fit)
|
||||
cy = syncRow(imp, m, px + pad, cy, innerW, "sync-unlink",
|
||||
Strings("Unlink this device"), { kind = "danger",
|
||||
action = function() imp:_syncUnlink() end })
|
||||
action = function() imp:_syncUnlink() end }, fit)
|
||||
else
|
||||
cy = syncRow(imp, m, px + pad, cy, innerW, "sync-create",
|
||||
Strings("Create sync account"), { kind = "primary",
|
||||
enabled = not eng:busy(),
|
||||
action = function() imp:_syncCreate() end })
|
||||
action = function() imp:_syncCreate() end }, fit)
|
||||
cy = syncRow(imp, m, px + pad, cy, innerW, "sync-link",
|
||||
Strings("Link this device"), { kind = "accent",
|
||||
action = function() imp:_syncView("link") end })
|
||||
action = function() imp:_syncView("link") end }, fit)
|
||||
end
|
||||
syncRow(imp, m, px + pad, cy, innerW, "sync-close", Strings("Close"),
|
||||
{ action = function() imp:_closeSync() end })
|
||||
{ action = function() imp:_closeSync() end }, fit)
|
||||
end
|
||||
|
||||
local function buildSyncModOptions(imp, m, eng)
|
||||
local plan = eng.modPlan
|
||||
local ids = {}
|
||||
for _, row in ipairs(plan.options or {}) do ids[#ids + 1] = row.id end
|
||||
local pad = math.floor(18 * m.s)
|
||||
local w = syncWidth(m, math.floor(480 * m.s))
|
||||
local innerW = w - 2 * pad
|
||||
local lead = Strings(
|
||||
"This mod list also carries the options its owner set for %d mods. Import their options, or keep the ones you have?",
|
||||
#ids)
|
||||
local names = table.concat(ids, ", ")
|
||||
local fit = syncFit(m,
|
||||
2 * pad + Kit.textHeight("button") + math.floor(32 * m.s), 2, 2, {
|
||||
{ font = "small", str = lead, w = innerW, max = 4 },
|
||||
{ font = "micro", str = names, w = innerW, max = 3 },
|
||||
})
|
||||
local px, py, pw = modalPanel(m, w, fit.h)
|
||||
local cy = syncTitle(imp, m, px, py + pad, pw, pad)
|
||||
cy = cy + Kit.textWrapped("small", lead, px + pad, cy, innerW, PAL.detail,
|
||||
fit.lines[1]) + math.floor(8 * m.s)
|
||||
cy = cy + Kit.textWrapped("micro", names, px + pad, cy, innerW, PAL.muted,
|
||||
fit.lines[2]) + math.floor(12 * m.s)
|
||||
cy = syncRow(imp, m, px + pad, cy, innerW, "sync-options-import",
|
||||
Strings("Import their options"), { kind = "primary",
|
||||
action = function() imp:_syncAnswerModOptions(true) end }, fit)
|
||||
syncRow(imp, m, px + pad, cy, innerW, "sync-options-skip",
|
||||
Strings("Keep my options"), {
|
||||
action = function() imp:_syncAnswerModOptions(false) end }, fit)
|
||||
end
|
||||
|
||||
local function buildSyncUnavailable(imp, m, msg)
|
||||
local pad = math.floor(18 * m.s)
|
||||
local w = math.floor(420 * m.s)
|
||||
local h = pad + Kit.textHeight("button") + math.floor(12 * m.s)
|
||||
+ Kit.wrapHeight("small", msg, w - 2 * pad, 4) + math.floor(10 * m.s)
|
||||
+ m.btnH + pad
|
||||
local px, py, pw = modalPanel(m, w, h)
|
||||
local w = syncWidth(m, math.floor(420 * m.s))
|
||||
local innerW = w - 2 * pad
|
||||
local fit = syncFit(m,
|
||||
2 * pad + Kit.textHeight("button") + math.floor(22 * m.s), 1, 0,
|
||||
{ { font = "small", str = msg, w = innerW, max = 4 } })
|
||||
local px, py, pw = modalPanel(m, w, fit.h)
|
||||
local cy = syncTitle(imp, m, px, py + pad, pw, pad)
|
||||
cy = cy + Kit.textWrapped("small", msg, px + pad, cy, pw - 2 * pad,
|
||||
PAL.detail, 4) + math.floor(10 * m.s)
|
||||
syncRow(imp, m, px + pad, cy, pw - 2 * pad, "sync-close",
|
||||
Strings("Close"), { action = function() imp:_closeSync() end })
|
||||
cy = cy + Kit.textWrapped("small", msg, px + pad, cy, innerW,
|
||||
PAL.detail, fit.lines[1]) + math.floor(10 * m.s)
|
||||
syncRow(imp, m, px + pad, cy, innerW, "sync-close",
|
||||
Strings("Close"), { action = function() imp:_closeSync() end }, fit)
|
||||
end
|
||||
|
||||
local function buildSyncModal(imp, m)
|
||||
@@ -4586,6 +4775,12 @@ local function buildSyncModal(imp, m)
|
||||
buildSyncConflict(imp, m, eng)
|
||||
return
|
||||
end
|
||||
local plan = eng.modPlan
|
||||
if type(plan) == "table" and #(plan.options or {}) > 0
|
||||
and plan.applyOptions == nil then
|
||||
buildSyncModOptions(imp, m, eng)
|
||||
return
|
||||
end
|
||||
local view = imp._syncModal and imp._syncModal.view or "home"
|
||||
if view == "link" then
|
||||
buildSyncLink(imp, m, eng)
|
||||
@@ -4868,13 +5063,15 @@ function LauncherView.draw(imp)
|
||||
Kit.beginFrame(mx, my, click ~= nil, imp._wheelY or 0)
|
||||
imp._clickPt = nil
|
||||
imp._wheelY = 0
|
||||
imp._noDragN = 0
|
||||
|
||||
Theme.field()
|
||||
|
||||
-- Everything from here to buildModals sits UNDER any open modal, so the
|
||||
-- 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.
|
||||
Kit.blockClicks = modalUp(imp)
|
||||
imp._modalUpNow = modalUp(imp)
|
||||
Kit.blockClicks = imp._modalUpNow
|
||||
|
||||
local step = Kit.scrollStep(m.s)
|
||||
do
|
||||
|
||||
@@ -3439,6 +3439,18 @@ function RomExtractorGen2:extractScriptsAndText(maps, stdScripts)
|
||||
elseif info.name == "givepoke" then
|
||||
cmd.species, cmd.level, cmd.item, cmd.trainer =
|
||||
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
|
||||
cmd.species = args[1] -- pokepic
|
||||
cmd.object = args[1] -- disappear (same byte)
|
||||
@@ -5231,6 +5243,20 @@ function RomExtractorGen2:extractMenuGfx()
|
||||
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
|
||||
if self.symbols["Slots1LZ"] then
|
||||
local raw1 = self:decompressLz3Symbol("Slots1LZ")
|
||||
|
||||
@@ -1537,6 +1537,9 @@ function RomImporter:focus(f)
|
||||
self._modPress = nil
|
||||
return
|
||||
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
|
||||
-- SAF create-document finished: GameActivity wrote export_done.flag.
|
||||
if love.filesystem.getInfo("export_done.flag", "file") then
|
||||
@@ -3475,7 +3478,7 @@ end
|
||||
function RomImporter:_openSync()
|
||||
self:_syncEngine()
|
||||
self._syncModal = self._syncModal
|
||||
or { view = "home", code1 = "", code2 = "", share = "" }
|
||||
or { view = "home", code1 = "", code2 = "", share = "", withOptions = true }
|
||||
self._syncFocus = nil
|
||||
self:_disarmTextInput()
|
||||
end
|
||||
@@ -3560,9 +3563,22 @@ function RomImporter:_syncUnlinkDevice(deviceId)
|
||||
end
|
||||
|
||||
function RomImporter:_syncShareMods()
|
||||
local eng = self:_syncEngine()
|
||||
local eng, mo = self:_syncEngine(), self._syncModal
|
||||
if not eng then return false end
|
||||
return eng:shareMods()
|
||||
return eng:shareMods(mo and mo.withOptions ~= false)
|
||||
end
|
||||
|
||||
function RomImporter:_syncToggleShareOptions()
|
||||
local mo = self._syncModal
|
||||
if not mo then return false end
|
||||
mo.withOptions = not (mo.withOptions ~= false)
|
||||
return mo.withOptions
|
||||
end
|
||||
|
||||
function RomImporter:_syncAnswerModOptions(importThem)
|
||||
local eng = self:_syncEngine()
|
||||
if not eng or type(eng.answerModOptions) ~= "function" then return false end
|
||||
return eng:answerModOptions(importThem)
|
||||
end
|
||||
|
||||
function RomImporter:_syncGetShare()
|
||||
|
||||
@@ -93,6 +93,12 @@ function ItemEffects.healsHP(id)
|
||||
or id == "REVIVE" or id == "MAX_REVIVE"
|
||||
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)
|
||||
return HEAL_AMOUNT[id] ~= nil or STATUS_HEAL[id] ~= nil
|
||||
or id == "MAX_POTION" or id == "FULL_RESTORE"
|
||||
|
||||
@@ -262,7 +262,8 @@ function LinkBattle.new(game, net, opts)
|
||||
self.opponentName = theirName
|
||||
-- _TrainerWantsToFightText (data/text/text_2.asm:1257): wIsInBattle == 2
|
||||
-- takes PrintBeginningBattleText's .trainerBattle arm, link included
|
||||
self.introText = Strings("%s wants\nto fight!", theirName)
|
||||
self.introText = self:romText("_TrainerWantsToFightText",
|
||||
"%s wants\nto fight!", theirName)
|
||||
self.remoteHashes = {}
|
||||
self.localHashes = {}
|
||||
self.remoteParts = {}
|
||||
|
||||
@@ -603,6 +603,33 @@ function LauncherMods.setEnabled(id, enabled, version)
|
||||
return true
|
||||
end
|
||||
|
||||
function LauncherMods.modOptions()
|
||||
local ok, options = pcall(SaveData.loadOptions)
|
||||
if not ok or type(options) ~= "table" then return {} end
|
||||
return options.modOptions or {}
|
||||
end
|
||||
|
||||
function LauncherMods.setModOptions(id, values)
|
||||
if type(id) ~= "string" or id == "" or type(values) ~= "table" then
|
||||
return false
|
||||
end
|
||||
local options = SaveData.loadOptions()
|
||||
if SaveData.isSafeMode(options) then return false end
|
||||
options.modOptions = options.modOptions or {}
|
||||
local bucket = options.modOptions[id] or {}
|
||||
for key, value in pairs(values) do
|
||||
local t = type(value)
|
||||
if type(key) == "string" and key ~= ""
|
||||
and (t == "string" or t == "number" or t == "boolean") then
|
||||
bucket[key] = value
|
||||
end
|
||||
end
|
||||
options.modOptions[id] = bucket
|
||||
SaveData.saveOptions(options)
|
||||
LauncherMods.syncActiveProfile(options)
|
||||
return true
|
||||
end
|
||||
|
||||
-- setAllEnabled(ids, enabled [, version]): the launcher's Enable all / Disable
|
||||
-- all buttons (#647). Writes what setEnabled writes, but loads and
|
||||
-- saves once for the whole list: saveOptions rewrites the whole options file per
|
||||
|
||||
+37
-11
@@ -19,6 +19,9 @@ local romText = require("src.core.RomText")
|
||||
|
||||
local Evolution = {}
|
||||
|
||||
-- engine/pokemon/evos_moves.asm:122-123 (ld c, 50 / call DelayFrames)
|
||||
local EVOLVING_TEXT_FRAMES = 50
|
||||
|
||||
Evolution.METHODS = {
|
||||
LEVEL = {
|
||||
check = function(game, mon, evo, trigger)
|
||||
@@ -157,27 +160,50 @@ end
|
||||
-- Play the evolution movie (flashing forms), then apply + text.
|
||||
-- Headless (no real graphics) falls back to the plain text flow.
|
||||
function Evolution.evolve(game, mon, newSpecies, onDone, via)
|
||||
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
|
||||
-- forward `via` so EvolutionState can keep trade evolutions
|
||||
-- non-cancelable (LINK_STATE_TRADING) while others accept B (#213)
|
||||
Screens.push(game, "EvolutionState", mon, newSpecies, onDone, via)
|
||||
local intro
|
||||
intro = TextBox.new(game, isEvolving, nil, { stay = {
|
||||
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
|
||||
end
|
||||
Music.play(game.data, Music.special(game.data, "evolution"))
|
||||
local oldName = mon.nickname or game.data.pokemon[mon.species].name
|
||||
Evolution.apply(game, mon, newSpecies, via)
|
||||
-- the congrats page keeps the engine wording: _EvolvedText extracts
|
||||
-- truncated (it stops at a dynamic marker the decoder does not follow)
|
||||
local msg = romText(game.data, "_IsEvolvingText",
|
||||
"What?\n%s is\nevolving!", oldName)
|
||||
.. "\f" .. Strings("Congratulations!\nYour %s\nevolved into\n%s!",
|
||||
oldName, game.data.pokemon[newSpecies].name)
|
||||
-- EvolvedText then IntoText in the same box (evos_moves.asm:136-150)
|
||||
local msg = isEvolving .. "\f"
|
||||
.. romText(game.data, "_EvolvedText", "%s evolved", oldName)
|
||||
.. romText(game.data, "_IntoText", "\ninto %s!",
|
||||
game.data.pokemon[newSpecies].name)
|
||||
game.stack:push(TextBox.new(game, msg, function()
|
||||
Music.restoreMap(game.data)
|
||||
-- re-run the evolved species' level-up learn check before onDone
|
||||
-- (evos_moves.asm EvolveMon -> learn_move.asm LearnMoveFromLevelUp, #12)
|
||||
Evolution.learnEvolutionMoves(game, mon, onDone)
|
||||
end))
|
||||
end, TextBox.soundOpts(game, "Get_Item2")))
|
||||
end
|
||||
|
||||
-- Entry point for mods whose methods fire outside the vanilla moments
|
||||
|
||||
@@ -454,7 +454,7 @@ function PaletteFX.pal(data, name)
|
||||
if fromCgb then return fromCgb end
|
||||
end
|
||||
local p = PaletteFX.pack(data)
|
||||
local c = p and p.palettes[name]
|
||||
local c = p and p.palettes and p.palettes[name]
|
||||
if c then return c end
|
||||
if GameVersion.isYellow() then
|
||||
local y = PaletteFX.yellowPack()
|
||||
|
||||
+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 --
|
||||
-- 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
|
||||
-- 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)
|
||||
local self = setmetatable({}, TextBox)
|
||||
self.game = game
|
||||
@@ -296,6 +297,15 @@ function TextBox:update(dt)
|
||||
-- exactly once (#591)
|
||||
if self.stay 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
|
||||
if self.stay.onShown then self.stay.onShown() end
|
||||
end
|
||||
@@ -494,7 +504,8 @@ function TextBox:draw()
|
||||
Font.draw(money, 152 - Font.width(money), 8)
|
||||
end
|
||||
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
|
||||
-- page-advance cursor: glyph $EE by default, the blinking down arrow
|
||||
-- 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 slots = entry and entry.slots
|
||||
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
|
||||
local slot = (slots[daytime] or slots.DAY or {})[Specials.random(4)]
|
||||
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 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
|
||||
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
|
||||
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
|
||||
Specials.askNickname(self, mon)
|
||||
end
|
||||
@@ -1114,7 +1118,9 @@ local function runCmd(self, cmd, op)
|
||||
-- really does run here.
|
||||
if self.reloadMapFn then self.reloadMapFn(true) end
|
||||
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.lossTextOverride = cmd.lossText
|
||||
elseif op == "trainertext" then
|
||||
@@ -1122,9 +1128,11 @@ local function runCmd(self, cmd, op)
|
||||
local obj = self.trainerObject or {}
|
||||
local key
|
||||
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
|
||||
key = self.lossTextOverride or obj.lossText
|
||||
key = self.winLossArmed and self.lossTextOverride
|
||||
or (not self.winLossArmed and obj.lossText or nil)
|
||||
else
|
||||
key = obj.seenText
|
||||
end
|
||||
@@ -2425,6 +2433,7 @@ function Vm:start(scriptKey)
|
||||
self.battleOutcome = nil
|
||||
self.winTextOverride = nil
|
||||
self.lossTextOverride = nil
|
||||
self.winLossArmed = nil
|
||||
-- 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.
|
||||
self.aborted = false
|
||||
|
||||
+66
-16
@@ -7,6 +7,7 @@ SyncEngine.__index = SyncEngine
|
||||
|
||||
SyncEngine.UPLOAD_DEBOUNCE = 5
|
||||
SyncEngine.AUTO_INTERVAL = 300
|
||||
SyncEngine.RESUME_MIN_GAP = 60
|
||||
SyncEngine.MAX_STEPS_PER_UPDATE = 8
|
||||
|
||||
local IDLE_STATUS = "Ready"
|
||||
@@ -133,6 +134,7 @@ function SyncEngine.new(opts)
|
||||
eng.modPlan = nil
|
||||
eng.shareCode = nil
|
||||
eng.clock = 0
|
||||
eng.autoAt = SyncEngine.AUTO_INTERVAL
|
||||
eng.queue = {}
|
||||
eng.pending = nil
|
||||
eng.uploadAt = nil
|
||||
@@ -258,6 +260,11 @@ function SyncEngine:update(dt)
|
||||
self.uploadAt = nil
|
||||
if self.state.enabled and self:linked() then self:syncNow() 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
|
||||
while not self.pending and #self.queue > 0
|
||||
and steps < SyncEngine.MAX_STEPS_PER_UPDATE do
|
||||
@@ -296,6 +303,7 @@ function SyncEngine:createAccount(label)
|
||||
eng.phase = "idle"
|
||||
eng.status = "Sync account created"
|
||||
eng:_persist()
|
||||
eng:syncNow()
|
||||
end)
|
||||
end
|
||||
|
||||
@@ -385,9 +393,24 @@ function SyncEngine:setEnabled(enabled)
|
||||
return self.state.enabled
|
||||
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()
|
||||
if not self:linked() then return false, "this device is not linked" end
|
||||
if self.pending then return false, "sync is busy" end
|
||||
self.autoAt = self.clock + SyncEngine.AUTO_INTERVAL
|
||||
self.queue = {}
|
||||
self.conflicts = {}
|
||||
self.state.pendingConflicts = {}
|
||||
@@ -437,13 +460,13 @@ function SyncEngine:_planFrom(remoteState)
|
||||
self:_addConflict(entry, key, row)
|
||||
elseif localChanged then
|
||||
self:_queueUpload(entry, key, false)
|
||||
elseif remoteChanged then
|
||||
elseif remoteChanged and key ~= self.protectedKey then
|
||||
self:_queueDownload(key, entry.version, entry.playthroughId, "replace")
|
||||
end
|
||||
end
|
||||
end
|
||||
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)
|
||||
if version and id then
|
||||
self:_queueDownload(key, version, id, "replace", tonumber(row.rev))
|
||||
@@ -573,12 +596,13 @@ function SyncEngine:resolveConflict(key, choice)
|
||||
return true
|
||||
end
|
||||
|
||||
function SyncEngine:uploadMods()
|
||||
function SyncEngine:uploadMods(includeOptions)
|
||||
if not self:linked() then return false, "this device is not linked" end
|
||||
if self:busy() then return false, "sync is busy" end
|
||||
local manifest = SyncMods.build(self.modDeps)
|
||||
local manifest = SyncMods.build(self.modDeps, includeOptions)
|
||||
self.phase = "uploading"
|
||||
self.status = "Uploading the mod list..."
|
||||
self.status = includeOptions and "Uploading the mod list and options..."
|
||||
or "Uploading the mod list..."
|
||||
local handle, err = self.client:putMods(manifest)
|
||||
return self:_request(handle, err, function(eng)
|
||||
eng.phase = "idle"
|
||||
@@ -595,19 +619,17 @@ function SyncEngine:fetchModPlan()
|
||||
return self:_request(handle, err, function(eng, res)
|
||||
local data = res.data or {}
|
||||
local manifest = type(data.manifest) == "table" and data.manifest or data
|
||||
eng.modPlan = SyncMods.plan(manifest, eng.modDeps)
|
||||
eng.phase = "idle"
|
||||
eng.status = SyncMods.planEmpty(eng.modPlan)
|
||||
and "Mods already match" or "Mod changes ready to apply"
|
||||
eng:_takeModPlan(SyncMods.plan(manifest, eng.modDeps))
|
||||
end)
|
||||
end
|
||||
|
||||
function SyncEngine:shareMods()
|
||||
function SyncEngine:shareMods(includeOptions)
|
||||
if not self:linked() then return false, "this device is not linked" end
|
||||
if self:busy() then return false, "sync is busy" end
|
||||
local manifest = SyncMods.build(self.modDeps)
|
||||
local manifest = SyncMods.build(self.modDeps, includeOptions)
|
||||
self.phase = "uploading"
|
||||
self.status = "Sharing the mod list..."
|
||||
self.status = includeOptions and "Sharing the mod list and options..."
|
||||
or "Sharing the mod list..."
|
||||
local handle, err = self.client:shareMods(manifest)
|
||||
return self:_request(handle, err, function(eng, res)
|
||||
local data = res.data or {}
|
||||
@@ -626,16 +648,44 @@ function SyncEngine:fetchShare(code)
|
||||
return self:_request(handle, err, function(eng, res)
|
||||
local data = res.data or {}
|
||||
local manifest = type(data.manifest) == "table" and data.manifest or data
|
||||
eng.modPlan = SyncMods.plan(manifest, eng.modDeps)
|
||||
eng.phase = "idle"
|
||||
eng.status = SyncMods.planEmpty(eng.modPlan)
|
||||
and "Mods already match" or "Mod changes ready to apply"
|
||||
eng:_takeModPlan(SyncMods.plan(manifest, eng.modDeps))
|
||||
end)
|
||||
end
|
||||
|
||||
function SyncEngine:_takeModPlan(plan)
|
||||
self.modPlan = plan
|
||||
self.phase = "idle"
|
||||
if SyncMods.planHasOptions(plan) then
|
||||
self.status = ("This list carries options for %d mods.")
|
||||
:format(#plan.options)
|
||||
elseif SyncMods.planEmpty(plan) then
|
||||
self.status = "Mods already match"
|
||||
else
|
||||
self.status = "Mod changes ready to apply"
|
||||
end
|
||||
end
|
||||
|
||||
function SyncEngine:modOptionsAsk()
|
||||
local plan = self.modPlan
|
||||
if not SyncMods.planHasOptions(plan) then return nil end
|
||||
if plan.applyOptions ~= nil then return nil end
|
||||
return SyncMods.optionModIds(plan)
|
||||
end
|
||||
|
||||
function SyncEngine:answerModOptions(importThem)
|
||||
local plan = self.modPlan
|
||||
if not SyncMods.planHasOptions(plan) then return false end
|
||||
SyncMods.answerOptions(plan, importThem)
|
||||
self.status = plan.applyOptions
|
||||
and "Their mod options will be imported too"
|
||||
or "Their mod options will be skipped"
|
||||
return plan.applyOptions
|
||||
end
|
||||
|
||||
function SyncEngine:applyModPlan(progress)
|
||||
if not self.modPlan then return false, "no mod plan" end
|
||||
if self.modApply then return false, "the mods are already being applied" end
|
||||
self.modPlan.applyOptions = self.modPlan.applyOptions == true
|
||||
local steps = SyncMods.steps(self.modPlan, self.modDeps)
|
||||
if #steps == 0 then
|
||||
self.modPlan = nil
|
||||
|
||||
+101
-3
@@ -1,6 +1,8 @@
|
||||
local SyncMods = {}
|
||||
|
||||
SyncMods.REV = 1
|
||||
SyncMods.REV = 2
|
||||
SyncMods.MAX_OPTION_KEYS = 64
|
||||
SyncMods.MAX_OPTION_TEXT = 256
|
||||
|
||||
local function versions()
|
||||
local ok, GameVersion = pcall(require, "src.core.GameVersion")
|
||||
@@ -35,6 +37,12 @@ local function defaultDeps()
|
||||
setEnabled = function(id, enabled, version)
|
||||
return require("src.mods.LauncherMods").setEnabled(id, enabled, version)
|
||||
end,
|
||||
modOptions = function()
|
||||
return require("src.mods.LauncherMods").modOptions()
|
||||
end,
|
||||
setOptions = function(id, values)
|
||||
return require("src.mods.LauncherMods").setModOptions(id, values)
|
||||
end,
|
||||
}
|
||||
end
|
||||
|
||||
@@ -46,6 +54,41 @@ local function deps(given)
|
||||
return out
|
||||
end
|
||||
|
||||
local function sanitizeOptions(bucket)
|
||||
if type(bucket) ~= "table" then return nil end
|
||||
local keys = {}
|
||||
for k, v in pairs(bucket) do
|
||||
local t = type(v)
|
||||
if type(k) == "string" and k ~= ""
|
||||
and (t == "string" or t == "number" or t == "boolean") then
|
||||
keys[#keys + 1] = k
|
||||
end
|
||||
end
|
||||
if #keys == 0 then return nil end
|
||||
table.sort(keys)
|
||||
local out, n = {}, 0
|
||||
for _, k in ipairs(keys) do
|
||||
if n >= SyncMods.MAX_OPTION_KEYS then break end
|
||||
local v = bucket[k]
|
||||
if type(v) == "string" then v = v:sub(1, SyncMods.MAX_OPTION_TEXT) end
|
||||
local finite = type(v) ~= "number"
|
||||
or (v == v and v ~= math.huge and v ~= -math.huge)
|
||||
if finite then
|
||||
out[k] = v
|
||||
n = n + 1
|
||||
end
|
||||
end
|
||||
if n == 0 then return nil end
|
||||
return out
|
||||
end
|
||||
|
||||
local function sameOptions(a, b)
|
||||
for k, v in pairs(a) do
|
||||
if (b or {})[k] ~= v then return false end
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
local function sourceOf(row)
|
||||
local github = row.github
|
||||
or (type(row.manifest) == "table" and row.manifest.github)
|
||||
@@ -55,9 +98,14 @@ local function sourceOf(row)
|
||||
return "local"
|
||||
end
|
||||
|
||||
function SyncMods.build(given)
|
||||
function SyncMods.build(given, includeOptions)
|
||||
local d = deps(given)
|
||||
local manifest = { rev = SyncMods.REV, indexes = {}, mods = {} }
|
||||
local stored = {}
|
||||
if includeOptions then
|
||||
local ok, live = pcall(d.modOptions)
|
||||
if ok and type(live) == "table" then stored = live end
|
||||
end
|
||||
for _, row in ipairs(d.indexes() or {}) do
|
||||
local url = row.url or row.feed
|
||||
if type(url) == "string" and url ~= "" then
|
||||
@@ -72,11 +120,14 @@ function SyncMods.build(given)
|
||||
for _, version in ipairs(versions()) do
|
||||
if answers[version] then enabledFor[#enabledFor + 1] = version end
|
||||
end
|
||||
local options = includeOptions and sanitizeOptions(stored[row.id]) or nil
|
||||
if options then manifest.hasOptions = true end
|
||||
manifest.mods[#manifest.mods + 1] = {
|
||||
id = row.id,
|
||||
version = row.version,
|
||||
source = sourceOf(row),
|
||||
enabledFor = enabledFor,
|
||||
options = options,
|
||||
}
|
||||
end
|
||||
end
|
||||
@@ -86,9 +137,16 @@ end
|
||||
|
||||
function SyncMods.plan(manifest, given)
|
||||
local d = deps(given)
|
||||
local plan = { indexes = {}, toInstall = {}, toEnable = {}, missing = {} }
|
||||
local plan = { indexes = {}, toInstall = {}, toEnable = {}, missing = {},
|
||||
options = {}, applyOptions = nil }
|
||||
if type(manifest) ~= "table" then return plan end
|
||||
|
||||
local liveOptions = {}
|
||||
do
|
||||
local ok, live = pcall(d.modOptions)
|
||||
if ok and type(live) == "table" then liveOptions = live end
|
||||
end
|
||||
|
||||
local haveIndex = {}
|
||||
for _, row in ipairs(d.indexes() or {}) do
|
||||
if type(row.url) == "string" then haveIndex[row.url] = true end
|
||||
@@ -130,6 +188,10 @@ function SyncMods.plan(manifest, given)
|
||||
plan.toEnable[#plan.toEnable + 1] = { id = mod.id, version = version }
|
||||
end
|
||||
end
|
||||
local wanted = sanitizeOptions(mod.options)
|
||||
if wanted and not sameOptions(wanted, liveOptions[mod.id]) then
|
||||
plan.options[#plan.options + 1] = { id = mod.id, values = wanted }
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -138,10 +200,33 @@ end
|
||||
|
||||
function SyncMods.planEmpty(plan)
|
||||
if type(plan) ~= "table" then return true end
|
||||
if plan.applyOptions and #(plan.options or {}) > 0 then return false end
|
||||
return #(plan.indexes or {}) == 0 and #(plan.toInstall or {}) == 0
|
||||
and #(plan.toEnable or {}) == 0
|
||||
end
|
||||
|
||||
function SyncMods.planHasOptions(plan)
|
||||
return type(plan) == "table" and #(plan.options or {}) > 0
|
||||
end
|
||||
|
||||
function SyncMods.optionsAnswered(plan)
|
||||
return not SyncMods.planHasOptions(plan) or plan.applyOptions ~= nil
|
||||
end
|
||||
|
||||
function SyncMods.answerOptions(plan, importThem)
|
||||
if type(plan) ~= "table" then return false end
|
||||
plan.applyOptions = importThem and true or false
|
||||
return plan.applyOptions
|
||||
end
|
||||
|
||||
function SyncMods.optionModIds(plan)
|
||||
local out = {}
|
||||
for _, row in ipairs((type(plan) == "table" and plan.options) or {}) do
|
||||
out[#out + 1] = row.id
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
function SyncMods.steps(plan, given)
|
||||
local d = deps(given)
|
||||
local out = {}
|
||||
@@ -175,6 +260,19 @@ function SyncMods.steps(plan, given)
|
||||
return true
|
||||
end }
|
||||
end
|
||||
if plan.applyOptions then
|
||||
for _, want in ipairs(plan.options or {}) do
|
||||
out[#out + 1] = { label = want.id, run = function()
|
||||
if broken[want.id] then return true end
|
||||
local ok, err = d.setOptions(want.id, want.values)
|
||||
if ok == false then
|
||||
return nil, want.id .. ": "
|
||||
.. tostring(err or "could not set the mod options")
|
||||
end
|
||||
return true
|
||||
end }
|
||||
end
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
|
||||
+16
-7
@@ -17,10 +17,13 @@ local function buildItems(game)
|
||||
local items = {}
|
||||
for _, id in ipairs(Bag.order(game.save)) do
|
||||
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, {
|
||||
value = 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
|
||||
return items
|
||||
@@ -334,8 +337,13 @@ local function vanillaUseOn(game, battle, id, target, list, moveIndex, picker)
|
||||
local Evolution = require("src.pokemon.Evolution")
|
||||
local evoTo, evo = Evolution.pendingFor(game, target,
|
||||
{ kind = "levelup" })
|
||||
-- the party menu stays up through TryEvolvingMon and only
|
||||
-- comes down at RemoveUsedItem (item_effects.asm:1392-1418)
|
||||
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
|
||||
return
|
||||
end
|
||||
@@ -399,10 +407,9 @@ local function pickTargetAndUse(game, battle, id, list)
|
||||
local opts = {
|
||||
pickOnly = true,
|
||||
battle = battle,
|
||||
-- HP medicine animates its bar with the picker still up (#252). Only
|
||||
-- out of battle: the in-battle tail closes the bag list underneath
|
||||
-- first, which needs the picker already gone.
|
||||
keepOpen = (not battle) and ItemEffects.healsHP(id),
|
||||
-- HP medicine animates with the picker up (#252), RARE CANDY prints over
|
||||
-- the party menu (item_effects.asm:1392-1418)
|
||||
keepOpen = (not battle) and ItemEffects.keepsPartyMenuOpen(id),
|
||||
onSwitch = function(mon, picker)
|
||||
if not wantsMove then
|
||||
useOn(game, battle, id, mon, list, nil, picker)
|
||||
@@ -470,7 +477,9 @@ function BagMenu.new(game, opts)
|
||||
local list
|
||||
list = ListMenu.new(game, "ITEMS", buildItems(game), {
|
||||
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
|
||||
onCancel = opts.onCancel,
|
||||
-- SELECT reorders items like the original bag (swap_items.asm)
|
||||
|
||||
+4
-2
@@ -185,14 +185,16 @@ local function release(game)
|
||||
return
|
||||
end
|
||||
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,
|
||||
choice = function(yes)
|
||||
if not yes then return end
|
||||
table.remove(box, list.index)
|
||||
require("src.core.Sound").playCry(game.data, mon.species)
|
||||
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()
|
||||
end,
|
||||
}))
|
||||
|
||||
+109
-18
@@ -1,18 +1,77 @@
|
||||
-- The dex-completion diploma (engine/events/diploma.asm DisplayDiploma /
|
||||
-- diploma2.asm DisplayDiplomaTop): a bordered certificate page with the
|
||||
-- player's name, shown by the Celadon Mansion 3F game designer once 150
|
||||
-- species are owned. Diploma.render also backs the Yellow-only printed
|
||||
-- copy (engine/printer/printer.asm PrintDiploma -> src/core/Printer.lua).
|
||||
-- player's name and character sprite, shown by the Celadon Mansion 3F game designer
|
||||
-- once 150 species are owned. Diploma.render also backs the printed copy
|
||||
-- (engine/printer/printer.asm PrintDiploma -> src/core/Printer.lua).
|
||||
|
||||
local Assets = require("src.render.Assets")
|
||||
local Font = require("src.render.Font")
|
||||
local PaletteFX = require("src.render.PaletteFX")
|
||||
local Sprites = require("src.pokemon.Sprites")
|
||||
local Strings = require("src.core.Strings")
|
||||
|
||||
local Diploma = {}
|
||||
Diploma.__index = Diploma
|
||||
Diploma.isOpaque = true
|
||||
|
||||
-- SGB: PalPacket_Generic (MEWMON), whole screen (engine/events/diploma.asm:67)
|
||||
function Diploma:sgbPalettes(game)
|
||||
return PaletteFX.wholeNamed(game.data, "MEWMON")
|
||||
end
|
||||
|
||||
local function tryImage(path)
|
||||
if not path then return nil end
|
||||
local ok, img = pcall(Assets.image, path)
|
||||
if ok and img then return img end
|
||||
local ok2, img2 = pcall(love.graphics.newImage, path)
|
||||
return ok2 and img2 or nil
|
||||
end
|
||||
|
||||
local function loadFrame()
|
||||
local frame = tryImage("assets/generated/trainer_card/trainer_info.png")
|
||||
if not frame then return nil end
|
||||
local quads = {}
|
||||
for i = 0, 8 do
|
||||
quads[i] = love.graphics.newQuad((i % 3) * 8,
|
||||
math.floor(i / 3) * 8,
|
||||
8, 8, frame:getDimensions())
|
||||
end
|
||||
return { img = frame, quads = quads }
|
||||
end
|
||||
|
||||
local function drawFrameBox(frame, tx, ty, tw, th)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
love.graphics.rectangle("fill", tx * 8, ty * 8, tw * 8, th * 8)
|
||||
if not frame then
|
||||
Font.drawBox(tx, ty, tw, th)
|
||||
return
|
||||
end
|
||||
local img = frame.img
|
||||
local q = frame.quads
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
-- corners
|
||||
love.graphics.draw(img, q[0], tx * 8, ty * 8)
|
||||
love.graphics.draw(img, q[2], (tx + tw - 1) * 8, ty * 8)
|
||||
love.graphics.draw(img, q[6], tx * 8, (ty + th - 1) * 8)
|
||||
love.graphics.draw(img, q[8], (tx + tw - 1) * 8, (ty + th - 1) * 8)
|
||||
-- horizontal edges
|
||||
for x = 1, tw - 2 do
|
||||
love.graphics.draw(img, q[1], (tx + x) * 8, ty * 8)
|
||||
love.graphics.draw(img, q[7], (tx + x) * 8, (ty + th - 1) * 8)
|
||||
end
|
||||
-- vertical edges
|
||||
for y = 1, th - 2 do
|
||||
love.graphics.draw(img, q[3], tx * 8, (ty + y) * 8)
|
||||
love.graphics.draw(img, q[5], (tx + tw - 1) * 8, (ty + y) * 8)
|
||||
end
|
||||
end
|
||||
|
||||
function Diploma.new(game, onDone)
|
||||
return setmetatable({ game = game, onDone = onDone }, Diploma)
|
||||
local self = setmetatable({
|
||||
game = game,
|
||||
onDone = onDone,
|
||||
}, Diploma)
|
||||
return self
|
||||
end
|
||||
|
||||
function Diploma:update()
|
||||
@@ -23,23 +82,55 @@ function Diploma:update()
|
||||
end
|
||||
end
|
||||
|
||||
-- the DisplayDiplomaTop layout, hlcoord tiles kept as x*8 / y*8 pixels
|
||||
-- the DisplayDiploma / DisplayDiplomaTop layout (hlcoord tiles -> x*8, y*8)
|
||||
function Diploma.render(game)
|
||||
local frame = loadFrame()
|
||||
local circle = tryImage("assets/generated/trainer_card/circle_tile.png")
|
||||
|
||||
-- 1. Outer ornate frame border: hlcoord 0, 0 / bc 16, 18 -> (0, 0, 20, 18)
|
||||
drawFrameBox(frame, 0, 0, 20, 18)
|
||||
|
||||
-- 2. Draw Player character sprite: farcall DrawPlayerCharacter
|
||||
-- Shifted +33 px right from title screen base (82 + 33 = 115, y = 80)
|
||||
local picPath, picTrueColor = Sprites.playerPath(
|
||||
game.data, "front", { kind = "diploma" })
|
||||
local pic = tryImage(picPath)
|
||||
if pic then
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
love.graphics.rectangle("fill", 0, 0, 160, 144)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
love.graphics.rectangle("line", 2.5, 2.5, 155, 139)
|
||||
Font.draw(Strings("<Diploma>"), 40, 16) -- hlcoord 5,2
|
||||
Font.draw(Strings("Player"), 24, 32) -- hlcoord 3,4
|
||||
Font.draw(game.save.player.name or "RED", 80, 32) -- hlcoord 10,4
|
||||
local congrats = { -- hlcoord 2,6
|
||||
"Congrats! This", "diploma certifies", "that you have",
|
||||
"completed your", "POKéDEX.",
|
||||
}
|
||||
for i, line in ipairs(congrats) do
|
||||
Font.draw(Strings(line), 16, 48 + (i - 1) * 10)
|
||||
love.graphics.draw(pic, 115, 80)
|
||||
if picTrueColor then
|
||||
PaletteFX.markTrueColor(115, 80, pic:getDimensions())
|
||||
end
|
||||
Font.draw(Strings("GAME FREAK"), 72, 128) -- hlcoord 9,16
|
||||
end
|
||||
|
||||
-- 3. Header: hlcoord 5, 2 with flanking circle tiles ($70)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
if circle then
|
||||
love.graphics.draw(circle, 40, 16) -- hlcoord 5, 2
|
||||
love.graphics.draw(circle, 104, 16) -- hlcoord 13, 2
|
||||
end
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
Font.draw(Strings("Diploma"), 48, 16) -- hlcoord 6, 2
|
||||
|
||||
-- 4. Player info: hlcoord 3, 4 ("PLAYER" / "Player") and hlcoord 10, 4 (name)
|
||||
Font.draw(Strings("Player"), 24, 32)
|
||||
local playerName = (game.save.player and game.save.player.name) or "RED"
|
||||
Font.draw(playerName, 80, 32)
|
||||
|
||||
-- 5. Congratulations text: hlcoord 2, 6 double-spaced lines (rows 6, 8, 10, 12, 14)
|
||||
local congrats = {
|
||||
{ text = "Congrats! This", y = 48 }, -- hlcoord 2, 6
|
||||
{ text = "diploma certifies", y = 64 }, -- hlcoord 2, 8
|
||||
{ text = "that you have", y = 80 }, -- hlcoord 2, 10
|
||||
{ text = "completed your", y = 96 }, -- hlcoord 2, 12
|
||||
{ text = "POKéDEX.", y = 112 }, -- hlcoord 2, 14
|
||||
}
|
||||
for _, line in ipairs(congrats) do
|
||||
Font.draw(Strings(line.text), 16, line.y)
|
||||
end
|
||||
|
||||
-- 6. Developer signature: hlcoord 9, 16
|
||||
Font.draw(Strings("GAME FREAK"), 72, 128)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
end
|
||||
|
||||
|
||||
+13
-19
@@ -1,6 +1,7 @@
|
||||
-- The evolution movie (engine/movie/evolution.asm): the mon's pic
|
||||
-- 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
|
||||
-- joypad during the flash: a fresh B press aborts the evolution -- the mon
|
||||
-- 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
|
||||
-- ItemUseEvoStone left wForceEvolution set (#290).
|
||||
|
||||
local Font = require("src.render.Font")
|
||||
local Music = require("src.core.Music")
|
||||
local Strings = require("src.core.Strings")
|
||||
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 = {}
|
||||
EvolutionState.__index = EvolutionState
|
||||
EvolutionState.isOpaque = true
|
||||
|
||||
-- SGB: SetPal_PokemonWholeScreen for the mon on display
|
||||
function EvolutionState:sgbPalettes(game)
|
||||
@@ -127,11 +127,11 @@ function EvolutionState:update(dt)
|
||||
require("src.core.Sound").playCry(game.data, self.newSpecies)
|
||||
local TextBox = require("src.render.TextBox")
|
||||
local newName = game.data.pokemon[self.newSpecies].name
|
||||
-- _EvolvedText extracts truncated (it stops at a dynamic marker the
|
||||
-- decoder does not follow), so the engine's wording stands here
|
||||
game.stack:push(TextBox.new(game,
|
||||
Strings("Congratulations!\nYour %s\nevolved into\n%s!",
|
||||
self.oldName, newName),
|
||||
-- EvolvedText then IntoText in the same box (PrintText_NoCreatingTextBox),
|
||||
-- then SFX_GET_ITEM_2 (engine/pokemon/evos_moves.asm:136-153)
|
||||
local msg = romText(game.data, "_EvolvedText", "%s evolved", self.oldName)
|
||||
.. romText(game.data, "_IntoText", "\ninto %s!", newName)
|
||||
game.stack:push(TextBox.new(game, msg,
|
||||
function()
|
||||
Music.restoreMap(game.data)
|
||||
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
|
||||
-- overworld / battle-return, not this state.
|
||||
Evolution.learnEvolutionMoves(game, self.mon, self.onDone)
|
||||
end))
|
||||
end,
|
||||
TextBox.soundOpts(game, "Get_Item2")))
|
||||
end
|
||||
end
|
||||
|
||||
function EvolutionState:draw()
|
||||
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
|
||||
local sprite, spriteTrueColor
|
||||
@@ -172,14 +174,6 @@ function EvolutionState:draw()
|
||||
require("src.render.PaletteFX").markTrueColor(x, y, sprite:getDimensions())
|
||||
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
|
||||
|
||||
return EvolutionState
|
||||
|
||||
+66
-3
@@ -17,6 +17,14 @@ function ListMenu:sgbPalettes(game)
|
||||
end
|
||||
|
||||
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
|
||||
local REPEAT_DELAY = 16
|
||||
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),
|
||||
-- so their lists opt out of the A/B beep the same way Menu's noSound does
|
||||
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
|
||||
end
|
||||
|
||||
@@ -100,8 +121,9 @@ local function moveIndex(self, delta)
|
||||
end
|
||||
|
||||
local function syncScroll(self)
|
||||
if self.index - self.scroll > self.rows then
|
||||
self.scroll = self.index - self.rows
|
||||
local maxRow = self.cursorRows or self.rows
|
||||
if self.index - self.scroll > maxRow then
|
||||
self.scroll = self.index - maxRow
|
||||
end
|
||||
if self.index - self.scroll < 1 then self.scroll = self.index - 1 end
|
||||
end
|
||||
@@ -206,7 +228,48 @@ function ListMenu:close()
|
||||
if top == self then self.game.stack:pop() 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()
|
||||
if self.itemBox then return self:drawItemBox() end
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
love.graphics.rectangle("fill", 0, 0, 160, 144)
|
||||
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
|
||||
end
|
||||
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
|
||||
-- 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
|
||||
@@ -116,7 +119,17 @@ function Menu:draw()
|
||||
self.tw * 8, self.th * 8, self.anchor)
|
||||
end
|
||||
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)
|
||||
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))
|
||||
or #self.items
|
||||
-- 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_MENU_TEMPLATE rows 10..14), and a top anchor pushed TOSS onto
|
||||
-- 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
|
||||
local item = self.items[self.scroll + row]
|
||||
if not item then break end
|
||||
Font.draw(item.label, (self.tx + 2) * 8,
|
||||
(self.ty + self.th - 2 - (visible - row) * self.rowStep) * 8)
|
||||
Font.draw(item.label, (self.tx + 2) * 8, rowY(row))
|
||||
end
|
||||
local cursorRow = self.index - self.scroll
|
||||
Font.drawCode(Theme.cursor, (self.tx + 1) * 8,
|
||||
(self.ty + self.th - 2 - (visible - cursorRow) * self.rowStep) * 8)
|
||||
Font.drawCode(Theme.cursor, (self.tx + 1) * 8, rowY(self.index - self.scroll))
|
||||
-- moreArrow ($EE): the same "more below" glyph OptionRows/ManagerState
|
||||
-- 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
|
||||
|
||||
+14
-2
@@ -67,6 +67,7 @@ function NamingScreen.new(game, opts)
|
||||
self.game = game
|
||||
self.title = opts.title or Strings("YOUR NAME?")
|
||||
self.presets = opts.presets
|
||||
self.introBox = opts.introBox
|
||||
self.maxLen = opts.maxLen or 7
|
||||
self.default = opts.default
|
||||
self.onDone = opts.onDone
|
||||
@@ -95,15 +96,26 @@ function NamingScreen:enter()
|
||||
onSelect = function()
|
||||
-- the menu already popped itself; pop the naming screen too
|
||||
self.game.stack:pop()
|
||||
if self.onDone then self.onDone(preset) end
|
||||
if self.onDone then self.onDone(preset, false) 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, {
|
||||
tx = 4, ty = 0, tw = 12, th = #items * 2 + 2, cancelable = false,
|
||||
}))
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
function NamingScreen:confirm()
|
||||
local name = table.concat(self.glyphs)
|
||||
@@ -125,7 +137,7 @@ function NamingScreen:confirm()
|
||||
end
|
||||
Sound.play(self.game.data, "Press_AB")
|
||||
self.game.stack:pop()
|
||||
if self.onDone then self.onDone(name) end
|
||||
if self.onDone then self.onDone(name, true) end
|
||||
end
|
||||
|
||||
function NamingScreen:grid()
|
||||
|
||||
+89
-2
@@ -35,6 +35,29 @@ OakSpeech.letterboxWhite = true
|
||||
local FADE_FRAMES = 24
|
||||
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
|
||||
-- conversion replaces; the Red/Blue lists remain the fallback
|
||||
local function namePresets(game, who, fallback)
|
||||
@@ -155,6 +178,10 @@ function OakSpeech.defaultSteps(speech)
|
||||
kind = "say",
|
||||
textKey = "_IntroducePlayerText",
|
||||
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",
|
||||
@@ -171,12 +198,19 @@ function OakSpeech.defaultSteps(speech)
|
||||
id = "confirm_player_name",
|
||||
kind = "say",
|
||||
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",
|
||||
kind = "say",
|
||||
textKey = "_IntroduceRivalText",
|
||||
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",
|
||||
@@ -191,6 +225,9 @@ function OakSpeech.defaultSteps(speech)
|
||||
id = "confirm_rival_name",
|
||||
kind = "say",
|
||||
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",
|
||||
@@ -378,7 +415,25 @@ function OakSpeech:runStep(step)
|
||||
self:applyPic(step)
|
||||
self:afterReveal(step, function()
|
||||
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)
|
||||
end
|
||||
end)
|
||||
elseif kind == "demo" then
|
||||
-- NIDORINO show-off: mirrored front sprite + wipe + cry + text 2A
|
||||
@@ -394,20 +449,36 @@ function OakSpeech:runStep(step)
|
||||
local presets = step.presets
|
||||
or namePresets(self.game, step.presetsWho or who,
|
||||
step.presetsFallback or { "RED" })
|
||||
local function openNaming()
|
||||
require("src.ui.Screens").push(self.game, "NamingScreen", {
|
||||
title = step.title or (who == "rival" and "HIS NAME?" or Strings("YOUR NAME?")),
|
||||
presets = presets,
|
||||
introBox = true,
|
||||
maxLen = step.maxLen or self.nameLen,
|
||||
onDone = function(name)
|
||||
onDone = function(name, custom)
|
||||
if who == "rival" then
|
||||
self.game.save.player.rival = name
|
||||
else
|
||||
self.game.save.player.name = name
|
||||
end
|
||||
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()
|
||||
else
|
||||
-- OakSpeechSlidePicLeft's 13-frame pre-slide beat
|
||||
-- (oak_speech2.asm:69-78)
|
||||
self:slidePic(-1, function() self:advance() end, 13)
|
||||
end
|
||||
end,
|
||||
})
|
||||
end
|
||||
self:slidePic(1, openNaming)
|
||||
elseif kind == "choice" then
|
||||
self:applyPic(step)
|
||||
self:afterReveal(step, function()
|
||||
@@ -518,6 +589,22 @@ function OakSpeech:revealPic(kind, next)
|
||||
}
|
||||
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()
|
||||
self.step = self.step + 1
|
||||
-- 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,
|
||||
-- bottom-aligned
|
||||
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 reveal = self.picReveal
|
||||
local off = 0
|
||||
|
||||
+5
-2
@@ -13,6 +13,7 @@ local ListMenu = require("src.ui.ListMenu")
|
||||
local Menu = require("src.ui.Menu")
|
||||
local QuantityBox = require("src.ui.QuantityBox")
|
||||
local Strings = require("src.core.Strings")
|
||||
local romText = require("src.core.RomText")
|
||||
|
||||
local ShopMenu = {}
|
||||
|
||||
@@ -57,7 +58,8 @@ local function buy(game, stock)
|
||||
end
|
||||
local cost = qty * def.price
|
||||
-- _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)
|
||||
if not yes then
|
||||
list.footer = greet
|
||||
@@ -147,7 +149,8 @@ local function sell(game)
|
||||
return
|
||||
end
|
||||
-- _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)
|
||||
if not yes then
|
||||
list.footer = greet
|
||||
|
||||
@@ -46,6 +46,7 @@
|
||||
local Font = require("src.render.Font")
|
||||
local Sound = require("src.core.Sound")
|
||||
local Strings = require("src.core.Strings")
|
||||
local romText = require("src.core.RomText")
|
||||
|
||||
local SlotMachine = {}
|
||||
SlotMachine.__index = SlotMachine
|
||||
@@ -357,7 +358,8 @@ function SlotMachine:resolveWin(win)
|
||||
-- SlotReward300Func prints "Yeah!" (text_pause) before the flash; the port
|
||||
-- shows it in the box while the screen flashes. LinedUpText follows.
|
||||
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
|
||||
-- credited until the player dismisses the "lined up" text (see startPayout).
|
||||
self.stage = "flash"
|
||||
|
||||
+53
-9
@@ -11,6 +11,7 @@ local Renderer = require("src.render.Renderer")
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
local Screens = require("src.ui.Screens")
|
||||
local Strings = require("src.core.Strings")
|
||||
local Theme = require("src.ui.Theme")
|
||||
|
||||
local StartMenu = {}
|
||||
|
||||
@@ -19,6 +20,7 @@ local function sameItems(_, items) return items end
|
||||
function StartMenu.new(game)
|
||||
local flags = game.save.flags or {}
|
||||
local items = {}
|
||||
local menu
|
||||
|
||||
-- vanilla start submenus return here on B (RedisplayStartMenu): the
|
||||
-- generic Menu pops the start menu when a row is selected, so each
|
||||
@@ -51,22 +53,62 @@ function StartMenu.new(game)
|
||||
end })
|
||||
|
||||
-- SAVE shows the player/badges/dex/time panel then asks to confirm
|
||||
-- (PrintSaveScreenText)
|
||||
table.insert(items, { label = Strings("SAVE"), onSelect = function()
|
||||
-- (PrintSaveScreenText); StartMenu_SaveReset never clears the START menu
|
||||
-- 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 badges = require("src.inventory.Badges").count(game.data, game.save)
|
||||
local owned = 0
|
||||
for _ in pairs(game.save.pokedex and game.save.pokedex.owned or {}) do
|
||||
owned = owned + 1
|
||||
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 panel = Strings("PLAYER %s\nBADGES %d\nPOKéDEX %3d\nTIME %6d:%02d",
|
||||
game.save.player.name or "RED", badges, owned,
|
||||
math.floor(t / 3600), math.floor(t / 60) % 60)
|
||||
-- PrintSaveScreenText draws its own border at hlcoord 4,0 (b=8, c=$e) and
|
||||
-- leaves it up under the prompt -- engine/menus/main_menu.asm:381-405
|
||||
local panel
|
||||
panel = {
|
||||
-- the panel overlaps the kept-open START menu box (start_sub_menus.asm:
|
||||
-- 641-647), so neither can be docked to a screen edge on its own
|
||||
holdsUIAnchors = true,
|
||||
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,
|
||||
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)
|
||||
if not yes then return end
|
||||
if not yes then closePanel() return end
|
||||
-- SaveMenu .save (engine/menus/save.asm:164-181): "Now saving..."
|
||||
-- is a bare PlaceString held by DelayFrames 120, then GameSavedText,
|
||||
-- which ends in `done` and so never reaches TX_PROMPT_BUTTON.
|
||||
@@ -78,7 +120,7 @@ function StartMenu.new(game)
|
||||
game:writeSave()
|
||||
game.stack:push(TextBox.new(game,
|
||||
Strings("%s saved\nthe game!", game.save.player.name or "RED"),
|
||||
nil, { auto = {
|
||||
closePanel, { auto = {
|
||||
sound = function()
|
||||
return require("src.core.Sound").play(game.data, "Save")
|
||||
end,
|
||||
@@ -87,6 +129,8 @@ function StartMenu.new(game)
|
||||
end, { auto = { delay = 120 } }))
|
||||
end,
|
||||
}))
|
||||
end
|
||||
game.stack:push(panel)
|
||||
end })
|
||||
|
||||
table.insert(items, { label = Strings("OPTION"), onSelect = function()
|
||||
@@ -142,7 +186,7 @@ function StartMenu.new(game)
|
||||
-- with Menu's moreArrow showing while there's more below.
|
||||
local rowStep = 2
|
||||
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
|
||||
-- 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
|
||||
|
||||
@@ -23,6 +23,9 @@ local Theme = {
|
||||
-- EnemySendOutFirstMon inlines its own TWO_OPTION_MENU at hlcoord 0, 7
|
||||
-- instead of the shared right-hand one -- engine/battle/core.asm:1378-1384
|
||||
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)
|
||||
|
||||
+107
-18
@@ -43,6 +43,28 @@ local function withWhiteOf(pal, ref)
|
||||
return { ref[1], pal[2], pal[3], pal[4] }
|
||||
end
|
||||
|
||||
-- Every drawn box, not just the topmost state's: DisplayContinueGameInfo
|
||||
-- leaves the menu box up behind the info window (main_menu.asm:36-39), so both
|
||||
-- are on screen and both need the overlay below.
|
||||
local function titleUiBoxes(game)
|
||||
local stack = game and game.stack
|
||||
local states = stack and stack.states
|
||||
if not states then
|
||||
local top = stack and stack.top and stack:top()
|
||||
local box = top and top.titleUiBox
|
||||
return box and { box } or {}
|
||||
end
|
||||
local boxes = {}
|
||||
for i = (stack.visibleBase and stack:visibleBase() or 1), #states do
|
||||
local state = states[i]
|
||||
local shown = not stack.renderVisible or stack:renderVisible(state)
|
||||
if shown and state and state.titleUiBox then
|
||||
boxes[#boxes + 1] = state.titleUiBox
|
||||
end
|
||||
end
|
||||
return boxes
|
||||
end
|
||||
|
||||
function TitleState:sgbPalettes(game)
|
||||
local P = require("src.render.PaletteFX")
|
||||
local z
|
||||
@@ -65,9 +87,6 @@ function TitleState:sgbPalettes(game)
|
||||
P.zone(P.pal(game.data, "MEWMON"), 0, 10, 19, 17),
|
||||
}
|
||||
end
|
||||
local top = game.stack and game.stack:top()
|
||||
local box = top and top.titleUiBox
|
||||
if box then
|
||||
-- A DMG-grays zone, not the trueColor opt-out: through the shade-remap
|
||||
-- shader GRAYS is the identity for the box's four shades, so SGB /
|
||||
-- ADVANCED / OG modes keep #133's white paper and black ink exactly,
|
||||
@@ -75,6 +94,7 @@ function TitleState:sgbPalettes(game)
|
||||
-- modes -- a trueColor rect skipped the shader entirely, leaving the
|
||||
-- main menu and CONTINUE info box a raw white hole over a CLASSIC
|
||||
-- pea-green title instead of matching it like the START menu does (#870).
|
||||
for _, box in ipairs(titleUiBoxes(game)) do
|
||||
z[#z + 1] = P.zone(P.GRAYS, box[1], box[2], box[3], box[4])
|
||||
end
|
||||
return z[3] and z or nil
|
||||
@@ -175,20 +195,21 @@ end
|
||||
local function replayObjSprite(game, image, quad, x, y)
|
||||
local P = require("src.render.PaletteFX")
|
||||
if not P.usesSpriteObp() then return end
|
||||
local top = game.stack and game.stack:top()
|
||||
local box = top and top.titleUiBox
|
||||
if box then
|
||||
local boxes = titleUiBoxes(game)
|
||||
if boxes[1] then
|
||||
local w, h
|
||||
if quad then
|
||||
w, h = select(3, quad:getViewport())
|
||||
else
|
||||
w, h = image:getDimensions()
|
||||
end
|
||||
for _, box in ipairs(boxes) do
|
||||
if x < (box[3] + 1) * 8 and x + w > box[1] * 8
|
||||
and y < (box[4] + 1) * 8 and y + h > box[2] * 8 then
|
||||
return
|
||||
end
|
||||
end
|
||||
end
|
||||
P.markUiSpriteRedraw(image, quad, x, y)
|
||||
end
|
||||
|
||||
@@ -392,6 +413,17 @@ function TitleState:updateSequence()
|
||||
self.phase = "loop"
|
||||
self.blinkTimer = 0
|
||||
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
|
||||
|
||||
@@ -460,8 +492,8 @@ function ContinueInfo:update(dt)
|
||||
self.game.stack:pop()
|
||||
if self.title.onContinue then self.title.onContinue() end
|
||||
elseif input:wasPressed("b") then
|
||||
-- the CONTINUE / NEW GAME menu is still open underneath (main_menu.asm:91-92)
|
||||
self.game.stack:pop()
|
||||
self.title:openMenu()
|
||||
end
|
||||
end
|
||||
|
||||
@@ -497,7 +529,10 @@ function TitleState:openMenu()
|
||||
local game = self.game
|
||||
local items = {}
|
||||
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
|
||||
-- file can't be read
|
||||
local ok, loaded = pcall(require("src.core.SaveData").load)
|
||||
@@ -511,7 +546,13 @@ function TitleState:openMenu()
|
||||
table.insert(items, { label = Strings("NEW GAME"), onSelect = function()
|
||||
if self.onNewGame then self.onNewGame() 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")
|
||||
end })
|
||||
table.insert(items, { label = Strings("EXIT GAME"), onSelect = function()
|
||||
@@ -529,7 +570,14 @@ function TitleState:openMenu()
|
||||
type(hooked))
|
||||
end
|
||||
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.
|
||||
-- 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.
|
||||
@@ -537,6 +585,38 @@ function TitleState:openMenu()
|
||||
game.stack:push(menu)
|
||||
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)
|
||||
function TitleState:pickNewMon()
|
||||
if #self.cycleSpecies < 2 then return end
|
||||
@@ -589,6 +669,11 @@ function TitleState:updateCycle()
|
||||
end
|
||||
|
||||
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
|
||||
self:updateSequence()
|
||||
return
|
||||
@@ -599,10 +684,10 @@ function TitleState:update(dt)
|
||||
if input:wasPressed("start") or input:wasPressed("a") then
|
||||
-- .go_to_main_menu voices PikachuCry11 on the way out
|
||||
local Sound = require("src.core.Sound")
|
||||
if not Sound.playPikaCry(self.game.data, 11) then
|
||||
Sound.playCry(self.game.data, "PIKACHU")
|
||||
end
|
||||
self:openMenu()
|
||||
self.exitCrySrc = Sound.playPikaCry(self.game.data, 11)
|
||||
or Sound.playCry(self.game.data, "PIKACHU")
|
||||
self.phase = "exitCry"
|
||||
self.timer = 0
|
||||
end
|
||||
return
|
||||
end
|
||||
@@ -613,23 +698,27 @@ function TitleState:update(dt)
|
||||
if input:wasPressed("start") or input:wasPressed("a") then
|
||||
-- the title mon cries when you leave the title (.finishedWaiting);
|
||||
-- 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"
|
||||
or self.cycleSpecies[self.cycleIndex])
|
||||
self:openMenu()
|
||||
self.phase = "exitCry"
|
||||
self.timer = 0
|
||||
end
|
||||
end
|
||||
|
||||
-- ..(engine/movie/title.asm ln 28)
|
||||
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 playerImage = self.player
|
||||
if playerImage and PaletteFX.usesSpriteObp() then
|
||||
playerImage = require("src.render.SpriteRenderer").obpImage(
|
||||
self.playerPath, PaletteFX.ogObj())
|
||||
end
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
love.graphics.rectangle("fill", 0, 0, 160, 144)
|
||||
local scrollY = -(self.scy or 0)
|
||||
-- ..(engine/movie/title.asm ln 28)
|
||||
local preRibbon = not self.yellowLayout
|
||||
|
||||
+33
-10
@@ -14,6 +14,7 @@
|
||||
-- This is what the party-menu FLY field move opens (#195).
|
||||
|
||||
local Font = require("src.render.Font")
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
local PaletteFX = require("src.render.PaletteFX")
|
||||
local Sound = require("src.core.Sound")
|
||||
local SpriteRenderer = require("src.render.SpriteRenderer")
|
||||
@@ -292,7 +293,8 @@ function TownMap:moveList(step)
|
||||
end
|
||||
|
||||
function TownMap:update(dt)
|
||||
self.blink = (self.blink + 1) % 32
|
||||
local cycle = GameVersion.generation() == 2 and 32 or 50
|
||||
self.blink = (self.blink + 1) % cycle
|
||||
local input = self.game.input
|
||||
if input:wasPressed("b") then
|
||||
Sound.play(self.game.data, "Press_AB")
|
||||
@@ -361,7 +363,13 @@ function TownMap:draw()
|
||||
end
|
||||
if self.nestSpecies then
|
||||
-- AREA mode: blinking nests, the species name up top
|
||||
if self.blink % 16 < 10 then
|
||||
local showNest = true
|
||||
if GameVersion.generation() == 1 then
|
||||
showNest = self.blink < 25
|
||||
else
|
||||
showNest = self.blink % 16 < 10
|
||||
end
|
||||
if showNest then
|
||||
for _, loc in ipairs(self.nests) do
|
||||
local x, y = markerXY(loc)
|
||||
if self.nestIcon then
|
||||
@@ -382,8 +390,8 @@ function TownMap:draw()
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
return
|
||||
end
|
||||
-- engine/items/town_map.asm:347; fallback dot stays red 0 for PaletteFX (#152)
|
||||
if self.playerLoc and self.blink < 20 then
|
||||
-- engine/items/town_map.asm:347; player marker is static in both Gen 1 and 2
|
||||
if self.playerLoc then
|
||||
local x, y = markerXY(self.playerLoc)
|
||||
if self.playerSheet then
|
||||
love.graphics.draw(self.playerSheet, self.playerQuad, x - 4, y - 3)
|
||||
@@ -399,7 +407,13 @@ function TownMap:draw()
|
||||
-- (8,8), so draw it -4,-4 to enclose the cell (engine/menus/town_map.asm
|
||||
-- draws the box cursor CENTERED on the selected location). Drawing it at
|
||||
-- the cell top-left put the square in the frame's top-left quadrant (#152).
|
||||
if selected and self.blink % 16 < 10 then
|
||||
local showCursor = true
|
||||
if GameVersion.generation() == 1 then
|
||||
showCursor = self.blink < 25
|
||||
else
|
||||
showCursor = self.blink % 16 < 10
|
||||
end
|
||||
if selected and showCursor then
|
||||
local x, y = markerXY(selected)
|
||||
if self.bg.cursor then
|
||||
love.graphics.draw(self.bg.cursor, x - 4, y - 4)
|
||||
@@ -424,7 +438,8 @@ function TownMap:draw()
|
||||
for _, loc in ipairs(self.locs) do
|
||||
drawSquare(loc)
|
||||
end
|
||||
if self.playerLoc and self.blink < 20 then
|
||||
-- player marker is static in both Gen 1 and 2
|
||||
if self.playerLoc then
|
||||
-- engine/items/town_map.asm:347; fallback dot stays red 0 for PaletteFX (#152)
|
||||
if self.playerSheet then
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
@@ -438,7 +453,13 @@ function TownMap:draw()
|
||||
self.playerLoc.y * 8 + 2, 4, 4)
|
||||
end
|
||||
end
|
||||
if selected and self.blink % 16 < 10 then
|
||||
local showCursor = true
|
||||
if GameVersion.generation() == 1 then
|
||||
showCursor = self.blink < 25
|
||||
else
|
||||
showCursor = self.blink % 16 < 10
|
||||
end
|
||||
if selected and showCursor then
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
love.graphics.rectangle("line", selected.x * 8 + 0.5,
|
||||
selected.y * 8 + 0.5, 7, 7)
|
||||
@@ -452,12 +473,14 @@ function TownMap:draw()
|
||||
local loc = self.locs[first + i]
|
||||
if loc then
|
||||
local y = 40 + i * 16
|
||||
if first + i == self.sel and self.blink % 16 < 10 then
|
||||
-- cursor in list mode (Fly mode) is static in RBY (LoadTownMap_Fly)
|
||||
if first + i == self.sel then
|
||||
Font.drawCode(0xED, 8, y) -- the "▶" cursor glyph
|
||||
end
|
||||
Font.draw(loc.name, 24, y)
|
||||
if loc == self.playerLoc and self.blink < 20 then
|
||||
-- blinking marker on the player's current town; force the palette-safe
|
||||
-- player marker is static
|
||||
if loc == self.playerLoc then
|
||||
-- marker on the player's current town; force the palette-safe
|
||||
-- dark shade explicitly so the red-channel shade-remap keeps it
|
||||
-- visible regardless of Font.draw's leftover color (#152)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
|
||||
+13
-3
@@ -27,6 +27,7 @@ local DEFAULT_ART = {
|
||||
openCable = "assets/generated/trade/open_cable.png",
|
||||
cableHoriz = "assets/generated/trade/cable_horiz.png",
|
||||
cableConn = "assets/generated/trade/cable_conn.png",
|
||||
cableSeg = "assets/generated/trade/cable_seg.png",
|
||||
cableVert = "assets/generated/trade/cable_vert.png",
|
||||
cableCorner = "assets/generated/trade/cable_corner.png",
|
||||
cableEnd = "assets/generated/trade/cable_end.png",
|
||||
@@ -112,6 +113,7 @@ function TradeAnim.new(game, opts)
|
||||
openCable = tryImage(art.openCable or DEFAULT_ART.openCable),
|
||||
cableHoriz = tryImage(art.cableHoriz or DEFAULT_ART.cableHoriz),
|
||||
cableConn = tryImage(art.cableConn or DEFAULT_ART.cableConn),
|
||||
cableSeg = tryImage(art.cableSeg or DEFAULT_ART.cableSeg),
|
||||
cableVert = tryImage(art.cableVert or DEFAULT_ART.cableVert),
|
||||
cableCorner = tryImage(art.cableCorner or DEFAULT_ART.cableCorner),
|
||||
cableEnd = tryImage(art.cableEnd or DEFAULT_ART.cableEnd),
|
||||
@@ -333,11 +335,19 @@ function TradeAnim:update(dt)
|
||||
end
|
||||
|
||||
local function drawCableHoriz(self, y, x0, x1)
|
||||
local w = math.max(0, x1 - x0)
|
||||
if w <= 0 then return end
|
||||
if self.img.cableHoriz then
|
||||
love.graphics.draw(self.img.cableHoriz, x0 - (self.scx % 8), y)
|
||||
local iw, ih = self.img.cableHoriz:getDimensions()
|
||||
local quad = love.graphics.newQuad(0, 0, math.min(w, iw), ih, iw, ih)
|
||||
love.graphics.draw(self.img.cableHoriz, quad, x0, y)
|
||||
elseif self.img.cableSeg then
|
||||
for x = x0, x1 - 8, 8 do
|
||||
love.graphics.draw(self.img.cableSeg, x, y)
|
||||
end
|
||||
else
|
||||
love.graphics.setColor(0.2, 0.2, 0.2, 1)
|
||||
love.graphics.rectangle("fill", x0, y + 1, math.max(0, x1 - x0), 6)
|
||||
love.graphics.rectangle("fill", x0, y + 1, w, 6)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
end
|
||||
end
|
||||
@@ -422,7 +432,7 @@ function TradeAnim:drawRightGB()
|
||||
if self.img.cableCorner then love.graphics.draw(self.img.cableCorner, 112, 32) end
|
||||
if self.img.cableVert then
|
||||
for i = 1, 4 do
|
||||
love.graphics.draw(self.img.cableVert, 120, 40 + (i - 1) * 8)
|
||||
love.graphics.draw(self.img.cableVert, 112, 40 + (i - 1) * 8)
|
||||
end
|
||||
end
|
||||
if self.img.cableEnd then love.graphics.draw(self.img.cableEnd, 112, 72) end
|
||||
|
||||
+120
-19
@@ -38,6 +38,10 @@ local Sound = require("src.core.Sound")
|
||||
-- Only for playerPic: the player.sprite raiser both generations share.
|
||||
local Sprites = require("src.pokemon.Sprites")
|
||||
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 BattleState = {}
|
||||
@@ -87,6 +91,20 @@ local TRAINER_SLIDE_STEPS = 8
|
||||
local TRAINER_SLIDE_FRAMES_PER_STEP = 2
|
||||
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
|
||||
-- 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
|
||||
@@ -141,6 +159,10 @@ local MENU_COL_SPACING = 6
|
||||
-- the count PrintNum writes after it (two digits, leading zeros) at (13,16).
|
||||
local CONTEST_MENU_BOX_X = 2
|
||||
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.
|
||||
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
|
||||
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)
|
||||
local def = self.pokemon and mon and self.pokemon[mon.species]
|
||||
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.
|
||||
if enemyTrainer and self.trainerSlide then
|
||||
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
|
||||
-- The pic's own scale (battle_sprite_scales, then the species record, then
|
||||
-- 1x) composed with whatever square BattleBGEffect_RunPicResizeScript has
|
||||
@@ -1371,7 +1403,7 @@ function BattleState:advanceQueue()
|
||||
and self.shownHp[event.side] ~= event.hp then
|
||||
self.hpAnim = { side = event.side, to = event.hp }
|
||||
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
|
||||
end
|
||||
-- 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
|
||||
and (event.kind == "status"
|
||||
or (event.kind == "send" and event.mon)) then
|
||||
self.shownStatus[event.side] =
|
||||
(event.kind == "send" and event.mon.status or event.status) or false
|
||||
local shown = event.status
|
||||
if event.kind == "send" and shown == nil then shown = event.mon.status end
|
||||
self.shownStatus[event.side] = shown or false
|
||||
end
|
||||
-- AnimateExpBar (engine/battle/core.asm:7191) is called from INSIDE
|
||||
-- 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.
|
||||
self.menuIndex = 1
|
||||
self.moveIndex = 1
|
||||
-- The incoming mon's own level and exp bar: SendOutPlayerMon reloads
|
||||
-- wBattleMon* from the party slot and UpdatePlayerHUD draws them at its
|
||||
-- tail (:3838), so both snap here the way shownHp does above.
|
||||
self.shownLevel = event.mon.level or 1
|
||||
self.shownExp = self:expPixels(event.mon, event.mon.level,
|
||||
event.mon.experience)
|
||||
-- SendOutPlayerMon reloads wBattleMon* from the party slot (:3838):
|
||||
-- snap from the emit-time snapshot, not the live table (#1514).
|
||||
local level = event.level or event.mon.level or 1
|
||||
self.shownLevel = level
|
||||
self.shownExp = self:expPixels(event.mon, level,
|
||||
event.experience or event.mon.experience)
|
||||
self.expAnim = nil
|
||||
end
|
||||
end
|
||||
@@ -1435,6 +1468,30 @@ function BattleState:advanceQueue()
|
||||
self.trainerSlide = 0
|
||||
return
|
||||
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
|
||||
-- BattleAnim_SendOutMon's `.Shiny` arm on the enemy (core.asm:8708-8715).
|
||||
if event.kind == "shiny-flash" then
|
||||
@@ -1917,6 +1974,17 @@ function BattleState:update(_dt)
|
||||
return
|
||||
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
|
||||
-- screen the same way (engine/battle/core.asm:2882).
|
||||
if self.trainerSlide then
|
||||
@@ -2004,6 +2072,8 @@ function BattleState:update(_dt)
|
||||
self.phase = "stats-box"
|
||||
return
|
||||
end
|
||||
-- PrintWinLossText's line pages like any map text (home/text.asm:403-448)
|
||||
if self:nextPage() then return end
|
||||
self:advanceQueue()
|
||||
return
|
||||
end
|
||||
@@ -2292,7 +2362,7 @@ function BattleState:update(_dt)
|
||||
learn.move, learn.moveName)
|
||||
self.pendingLearn = nil
|
||||
self.phase = "resolving"
|
||||
self:pushAll(self.battle:takeEvents())
|
||||
self:pushFront(self.battle:takeEvents())
|
||||
self:advanceQueue()
|
||||
end
|
||||
return
|
||||
@@ -2872,7 +2942,7 @@ function BattleState:finishDecline()
|
||||
self.pendingLearn = nil
|
||||
self.phase = "resolving"
|
||||
if learn then self.battle:declineForget(learn.index, learn.moveName) end
|
||||
self:pushAll(self.battle:takeEvents())
|
||||
self:pushFront(self.battle:takeEvents())
|
||||
self:advanceQueue()
|
||||
end
|
||||
|
||||
@@ -3475,6 +3545,23 @@ function BattleState:printMessage()
|
||||
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()
|
||||
Chrome.clear()
|
||||
-- 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 --
|
||||
-- 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.
|
||||
-- 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)
|
||||
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
|
||||
self:printMessage()
|
||||
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
|
||||
end
|
||||
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
|
||||
local ty = 13 + (i - 1)
|
||||
-- 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
|
||||
-- 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
|
||||
-- sits a column right of the cursor gutter, where the cart puts it
|
||||
-- (hlcoord 5, 13 against the cursor's own column), and only while the
|
||||
-- move list itself is up -- the forget picker has no swapping.
|
||||
if not forgetting and self.moveSwapIndex == i then
|
||||
Chrome.print("\u{25B7}", 0, ty)
|
||||
-- hlcoord 5, 13 is the cursor's own gutter, so PlaceMenuCursor covers
|
||||
-- the marker on the cursor's row.
|
||||
if not forgetting and self.moveSwapIndex == i and i ~= cursorRow then
|
||||
Chrome.print("\u{25B7}", cursorCol, ty)
|
||||
end
|
||||
local def = self.game and self.game.data and self.game.data.moves
|
||||
and self.game.data.moves[move.id]
|
||||
Chrome.print((def and def.name) or move.id, 2, ty)
|
||||
Chrome.printRight(("%d/%d"):format(move.pp or 0, move.maxPp or 0), 19, ty)
|
||||
Chrome.print((def and def.name) or move.id, nameCol, ty)
|
||||
if not moveMenu then
|
||||
Chrome.printRight(("%d/%d"):format(move.pp or 0, move.maxPp or 0),
|
||||
19, ty)
|
||||
end
|
||||
end
|
||||
if moveMenu then self:drawMoveInfoBox(moves[cursorRow]) end
|
||||
else
|
||||
-- Battle messages wrap inside the box rather than running off the frame.
|
||||
self:printMessage()
|
||||
|
||||
@@ -337,10 +337,15 @@ function PackMenu:useSelected()
|
||||
return
|
||||
end
|
||||
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 == "nowhere" then
|
||||
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
|
||||
-- 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
|
||||
|
||||
@@ -150,7 +150,11 @@ function PokedexMenu.new(game, opts)
|
||||
self.pokemon = opts.pokemon or data.pokemon
|
||||
self.palettes = opts.palettes or data.gen2Palettes
|
||||
self.onClose = opts.onClose
|
||||
-- InitPokedex: wLastDexMode -> wCurDexMode (engine/pokedex/pokedex.asm:97).
|
||||
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.scroll = 0
|
||||
self.view = "list" -- list | entry | area | option | search | results | unown
|
||||
@@ -317,6 +321,13 @@ function PokedexMenu:cursorVisible()
|
||||
return ((self.entryBlink or 0) % 32) < 20
|
||||
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)
|
||||
self.entryBlink = (self.entryBlink or 0) + 1
|
||||
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 self.page == 1 then
|
||||
self.page = 2
|
||||
elseif self.onClose then
|
||||
self.onClose()
|
||||
else
|
||||
self:close()
|
||||
end
|
||||
end
|
||||
return
|
||||
@@ -367,7 +378,7 @@ function PokedexMenu:update(_dt)
|
||||
if self.view == "search" then return self:updateSearch(input) end
|
||||
if self.view == "unown" then return self:updateUnown(input) end
|
||||
if input:wasPressed("b") then
|
||||
if self.onClose then self.onClose() end
|
||||
self:close()
|
||||
return
|
||||
elseif input:wasPressed("select") then
|
||||
-- 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.
|
||||
function Pokegear:timeOfDayIndex()
|
||||
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)
|
||||
return (Palettes.DAYTIME_ID[daytime] or 2) - 1
|
||||
end
|
||||
@@ -2280,7 +2281,9 @@ function Pokegear:drawPanel()
|
||||
self:drawClock()
|
||||
end
|
||||
-- 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)
|
||||
end
|
||||
|
||||
|
||||
@@ -36,9 +36,8 @@
|
||||
-- $3f the shiny ⁂ icon (stats_tiles tile 14)
|
||||
-- $40 / $41 the left and right HP/exp bar end caps
|
||||
--
|
||||
-- The extractor does not carry that sheet yet, so `pageTile` draws those seven
|
||||
-- shapes directly and takes the sheet the moment menu_gfx grows a `stats`
|
||||
-- entry. Everything that IS a glyph goes through the font: ◀ ($71), ▶ ($ed),
|
||||
-- The extractor writes that sheet as menu_gfx.stats, which `pageTile` draws.
|
||||
-- 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
|
||||
-- bar cell, which is FontBattleExtra's -- hence Font.useBattleExtra(true)
|
||||
-- around the whole screen, exactly as the party menu does).
|
||||
@@ -95,6 +94,14 @@ local TILE_BAR_CAP_RIGHT = 0x41
|
||||
-- made of (StatsScreen_PlaceHorizontalDivider).
|
||||
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
|
||||
-- them. <NEXT> steps two rows, so the five labels are 2 rows apart and the
|
||||
-- values start one row below the first label.
|
||||
@@ -821,12 +828,37 @@ end
|
||||
|
||||
-- ----------------------------------------------------------------- drawing
|
||||
|
||||
-- A tile out of StatsScreenPageTilesGFX. The extractor does not carry that
|
||||
-- sheet, so each of the seven shapes it needs is drawn here; the moment
|
||||
-- menu_gfx grows a `stats` entry this can take the real tiles instead.
|
||||
-- menu_gfx.stats, the 17 tiles LoadStatsScreenPageTilesGFX lands at vTiles2
|
||||
-- tile $31 (engine/gfx/load_font.asm:90-95)
|
||||
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)
|
||||
local G = love.graphics
|
||||
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)
|
||||
if id == TILE_VERTICAL_DIVIDER then
|
||||
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
|
||||
-- ($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.
|
||||
function SummaryMenu:drawPageSquare(tx, ty, large)
|
||||
function SummaryMenu:drawPageSquare(tx, ty, large, colors)
|
||||
local G = love.graphics
|
||||
local px, py = tx * 8, ty * 8
|
||||
-- $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 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 size = 16 - inset * 2
|
||||
G.setColor(0, 0, 0, 1)
|
||||
@@ -862,7 +913,7 @@ end
|
||||
function SummaryMenu:drawPageIndicators()
|
||||
local columns = { 13, 15, 17 }
|
||||
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
|
||||
|
||||
@@ -1134,6 +1185,7 @@ end
|
||||
SummaryMenu.STAT_LABELS = STAT_LABELS
|
||||
SummaryMenu.STAT_KEYS = STAT_KEYS
|
||||
SummaryMenu.TYPE_NAMES = TYPE_NAMES
|
||||
SummaryMenu.PAGE_PALETTES = PAGE_PALETTES
|
||||
SummaryMenu.levelText = levelText
|
||||
|
||||
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))
|
||||
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
|
||||
-- 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.
|
||||
function Kit.wheelPage(x, y, w, h, page, total, perPage)
|
||||
if Kit.blockClicks or (Kit.wheelY or 0) == 0 then return page end
|
||||
if not Kit.hit(x, y, w, h) then return page end
|
||||
if Kit.blockClicks then return page end
|
||||
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
|
||||
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
|
||||
|
||||
function Kit.scrollExtent(contentH, viewH)
|
||||
|
||||
+137
-5
@@ -4,7 +4,8 @@
|
||||
-- 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
|
||||
-- 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 }
|
||||
--
|
||||
-- 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"
|
||||
|
||||
-- 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 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
|
||||
-- worker can pass a filesystem-loaded codec; on the main thread / in tests it
|
||||
-- falls back to require.
|
||||
function Check.parseRelease(jsonText, Json)
|
||||
function Check.parseRelease(jsonText, Json, target)
|
||||
Json = Json or require("src.link.Json")
|
||||
local notJson = Json.describeUnexpected(jsonText)
|
||||
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)
|
||||
end
|
||||
local payloadName = "gen1recomp-" .. version .. ".love"
|
||||
target = type(target) == "table" and target or {}
|
||||
local fullName = fullAssetName(version, target.os, target.arch, target.port)
|
||||
return {
|
||||
version = version,
|
||||
payloadName = payloadName,
|
||||
payload = Check.pickAsset(doc.assets, payloadName),
|
||||
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
|
||||
-- the launcher's Patch notes footer button.
|
||||
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 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()
|
||||
if workerReady ~= nil then return workerReady end
|
||||
if not Platform.networkValidated() then
|
||||
@@ -167,11 +239,13 @@ function Check.start(force)
|
||||
end
|
||||
requested = true
|
||||
cache = { status = "checking", notes = cache.notes, latest = cache.latest }
|
||||
cmdCh:push({ cmd = "check" })
|
||||
cmdCh:push({ cmd = "check", target = target() })
|
||||
end
|
||||
|
||||
-- Current snapshot: { status, latest, progress, error, notes }. status is one of
|
||||
-- idle | checking | uptodate | available | downloading | ready | needs_full | error.
|
||||
-- Current snapshot: { status, latest, progress, error, notes, reason, full }.
|
||||
-- status is one of
|
||||
-- idle | checking | uptodate | available | downloading | ready | needs_full |
|
||||
-- full_downloading | full_ready | error.
|
||||
function Check.state()
|
||||
drain()
|
||||
return {
|
||||
@@ -180,9 +254,67 @@ function Check.state()
|
||||
progress = cache.progress,
|
||||
error = cache.error,
|
||||
notes = cache.notes,
|
||||
reason = cache.reason,
|
||||
full = cache.full,
|
||||
}
|
||||
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
|
||||
-- any other state (the worker still holds the release info from the check).
|
||||
function Check.download()
|
||||
|
||||
+112
-15
@@ -2,7 +2,8 @@
|
||||
--
|
||||
-- Runs on a love.thread so no curl call, sha256 pass or archive probe ever
|
||||
-- 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 }
|
||||
--
|
||||
-- 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 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)
|
||||
if pending and type(t) == "table" and t.notes == nil then
|
||||
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"
|
||||
|
||||
-- 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
|
||||
-- ---------------------------------------------------------------------------
|
||||
@@ -138,6 +140,10 @@ local function verifyPayload(rel, payloadName, sumsText)
|
||||
return true
|
||||
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
|
||||
-- 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.
|
||||
@@ -147,8 +153,34 @@ local function gatePasses(rel)
|
||||
if not info then return true end
|
||||
local shell = (Version and Version.shell) or 1
|
||||
local payloadHost = (Version and Version.payloadHost) or "love"
|
||||
if Boot.canHost then return Boot.canHost(info, shell, payloadHost) end
|
||||
return not (info.minShell and info.minShell > shell)
|
||||
if info.payloadHost and info.payloadHost ~= payloadHost then return false, "payload_host" end
|
||||
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
|
||||
|
||||
local function cacheNotes(ver, notes)
|
||||
@@ -177,7 +209,7 @@ end
|
||||
-- check
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
local function doCheck()
|
||||
local function doCheck(target)
|
||||
post({ status = "checking" })
|
||||
|
||||
if not canFetch() then
|
||||
@@ -193,7 +225,7 @@ local function doCheck()
|
||||
return
|
||||
end
|
||||
|
||||
local rel, perr = Check.parseRelease(body, Json)
|
||||
local rel, perr = Check.parseRelease(body, Json, target)
|
||||
if not rel then
|
||||
post({ status = "error", error = perr or "bad release json" })
|
||||
return
|
||||
@@ -212,6 +244,9 @@ local function doCheck()
|
||||
end
|
||||
|
||||
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 })
|
||||
return
|
||||
end
|
||||
@@ -219,7 +254,7 @@ local function doCheck()
|
||||
-- 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.
|
||||
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
|
||||
end
|
||||
|
||||
@@ -229,9 +264,10 @@ local function doCheck()
|
||||
if love.filesystem.getInfo(finalRel) then
|
||||
local sums = fetchText(rel.sums.url)
|
||||
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)
|
||||
post({ status = "needs_full", latest = rel.version })
|
||||
postFullRequirement(rel, reason)
|
||||
return
|
||||
end
|
||||
post({ status = "ready", latest = rel.version })
|
||||
@@ -353,9 +389,10 @@ local function doDownload()
|
||||
return
|
||||
end
|
||||
|
||||
if gatePasses(partRel) == false then
|
||||
local allowed, reason = gatePasses(partRel)
|
||||
if allowed == false then
|
||||
love.filesystem.remove(partRel)
|
||||
post({ status = "needs_full", latest = rel.version })
|
||||
postFullRequirement(rel, reason)
|
||||
return
|
||||
end
|
||||
|
||||
@@ -374,6 +411,63 @@ local function doDownload()
|
||||
post({ status = "ready", latest = rel.version })
|
||||
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
|
||||
-- ---------------------------------------------------------------------------
|
||||
@@ -384,11 +478,14 @@ while true do
|
||||
if cmd.cmd == "quit" then
|
||||
break
|
||||
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
|
||||
elseif cmd.cmd == "download" then
|
||||
local ok, err = pcall(doDownload)
|
||||
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
|
||||
|
||||
@@ -846,15 +846,20 @@ function OverworldState:useSoftboiledFieldMove(user, target)
|
||||
if not user or not user.stats or not target or not target.stats
|
||||
or target == user or target.hp <= 0
|
||||
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
|
||||
end
|
||||
local before = target.hp
|
||||
user.hp = user.hp - heal
|
||||
target.hp = math.min(target.stats.hp, target.hp + heal)
|
||||
require("src.core.Sound").play(Game.data, "Heal_HP")
|
||||
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,
|
||||
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
|
||||
end
|
||||
|
||||
@@ -2126,7 +2131,8 @@ function OverworldState:tryHiddenObject(fx, fy)
|
||||
-- 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
|
||||
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",
|
||||
"But, {PLAYER} has\nno more room for\vother items!")))
|
||||
return true
|
||||
@@ -2137,7 +2143,8 @@ function OverworldState:tryHiddenObject(fx, fy)
|
||||
-- text_asm tail runs it as PlaySoundWaitForCurrent +
|
||||
-- WaitForSoundToFinish once the box has printed (hidden_items.asm)
|
||||
Game.stack:push(TextBox.new(Game,
|
||||
Strings("%s found\n%s!", save.player.name, name),
|
||||
romText(Game.data, "_FoundHiddenItemText", "%s found\n%s!",
|
||||
save.player.name, name),
|
||||
nil, TextBox.soundOpts(Game, "Get_Item2")))
|
||||
return true
|
||||
end
|
||||
@@ -2795,8 +2802,8 @@ function OverworldState:talkTo(npc)
|
||||
"No more room for\nitems!")
|
||||
if GameVersion.isYellow() then
|
||||
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)
|
||||
.. "\f" .. noRoom
|
||||
noRoom = romText(Game.data, "_FoundItemText", "%s found\n%s!",
|
||||
Game.save.player.name, name) .. "\f" .. noRoom
|
||||
end
|
||||
Game.stack:push(TextBox.new(Game, noRoom))
|
||||
return
|
||||
@@ -2813,7 +2820,8 @@ function OverworldState:talkTo(npc)
|
||||
local ddef = Game.data.items[d.item]
|
||||
-- FoundItemText: text_far, sound_get_item_1, text_end (pick_up_item.asm)
|
||||
Game.stack:push(TextBox.new(Game,
|
||||
Strings("%s found\n%s!", Game.save.player.name, name), nil,
|
||||
romText(Game.data, "_FoundItemText", "%s found\n%s!",
|
||||
Game.save.player.name, name), nil,
|
||||
TextBox.soundOpts(Game,
|
||||
(ddef and ddef.keyItem) and "Get_Key_Item" or "Get_Item1")))
|
||||
return
|
||||
@@ -2959,6 +2967,24 @@ function OverworldState:openPC(onDone)
|
||||
self:openOaksPC(done)
|
||||
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
|
||||
|
||||
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
|
||||
-- caller already showed the box, so the battle starts without a second
|
||||
-- one (#869).
|
||||
function OverworldState:engageTrainer(npc, onDone, endBattleText, skipBattleText)
|
||||
function OverworldState:engageTrainer(npc, onDone, endBattleText, skipBattleText,
|
||||
endBattleSound, endBattleIsReward)
|
||||
local d = npc.def
|
||||
Runtime.emit("world.trainer_engaged", { npc = npc, trainerClass = d.trainerClass,
|
||||
partyIndex = d.trainerParty })
|
||||
@@ -3349,6 +3376,13 @@ function OverworldState:engageTrainer(npc, onDone, endBattleText, skipBattleText
|
||||
-- cuts (#282). Substituted here because BattleState:say takes finished
|
||||
-- text, while TextBox expanded the {PLAYER}/{RIVAL} tokens itself.
|
||||
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)
|
||||
if result == "win" then
|
||||
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
|
||||
-- onVictory script UNDER whatever runs next, so the player still sees
|
||||
-- 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)
|
||||
if onDone then onDone() end
|
||||
else
|
||||
@@ -3469,7 +3504,10 @@ end
|
||||
-- SetEvent / SetEventRange do after the leader victory.
|
||||
-- `hide` is { { mapId, objName }, ... } -- HideObject on those toggles
|
||||
-- (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 reward = victories[trainerClass .. "#" .. tostring(partyIndex or 1)]
|
||||
if not reward then return self:runVictoryHook() end
|
||||
@@ -3502,7 +3540,9 @@ function OverworldState:checkVictoryRewards(trainerClass, partyIndex)
|
||||
end
|
||||
local chain = rewardChain()
|
||||
if reward.dialogue then
|
||||
if not shownOnBattleScreen then
|
||||
chain.add(reward.dialogue, reward.badgeSound)
|
||||
end
|
||||
if reward.item then
|
||||
chain.add(reward.tmPre)
|
||||
if tmGiven then
|
||||
@@ -3745,7 +3785,7 @@ function OverworldState:applyFieldPoison()
|
||||
local queue = {}
|
||||
for _, mon in ipairs(fainted) do
|
||||
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
|
||||
local alive = false
|
||||
for _, mon in ipairs(save.party) do
|
||||
@@ -4415,7 +4455,8 @@ function OverworldState:restoreBattleContinuation(battle, origin)
|
||||
if result == "win" then
|
||||
game.save.defeatedTrainers[origin.npcId] = true
|
||||
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
|
||||
self:afterBattle(result, battle)
|
||||
self.engaging = false
|
||||
|
||||
@@ -391,15 +391,19 @@ end
|
||||
-- Gen 2 moveset lives in `levelMoves` (EvosAttacks). So every scripted gift,
|
||||
-- the STARTER included, arrived knowing nothing: FIGHT listed no moves and the
|
||||
-- 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)
|
||||
if not id then return nil end
|
||||
return Mon.new(data, id, level or 5, {
|
||||
item = itemIndex and itemIndex ~= 0
|
||||
and itemByIndex(data.items, itemIndex) or nil,
|
||||
nickname = opts and opts.nickname or nil,
|
||||
})
|
||||
end
|
||||
|
||||
-- GivePoke's trainer arm (engine/pokemon/move_mon.asm:1698-1736)
|
||||
local RANDY_OT_ID = 1001
|
||||
|
||||
local function loadGenerated(path)
|
||||
-- Same NX gold/ fallback Game2 uses. World:load is what surfaces
|
||||
-- "Gold cache incomplete" when maps.lua is invisible at the unprefixed path.
|
||||
@@ -997,13 +1001,18 @@ function World:load()
|
||||
setStringBuffer = function(value)
|
||||
if self.game then self.game.stringBuffer = value end
|
||||
end,
|
||||
givePoke = function(speciesIndex, level, item)
|
||||
givePoke = function(speciesIndex, level, item, opts)
|
||||
local data = self.game and self.game.data
|
||||
local save = self.game and self.game.save
|
||||
if not (data and save) then return end
|
||||
save.party = save.party or {}
|
||||
local mon = givePokeMon(data, speciesIndex, level, item)
|
||||
local mon = givePokeMon(data, speciesIndex, level, item, opts)
|
||||
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).
|
||||
Mon.stampOT(save, mon)
|
||||
Party.add(save.party, mon)
|
||||
@@ -1458,13 +1467,11 @@ function World:mapSceneOf(group, mapNum)
|
||||
end
|
||||
|
||||
-- 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
|
||||
-- map's own PALETTE_* override into one of four names, so this is a rename
|
||||
-- rather than a second clock.
|
||||
-- DARKNESS_F 3, off the RTC hour (engine/tilesets/timeofday_pals.asm:5-11)
|
||||
local TIME_OF_DAY_ID = { MORN = 0, DAY = 1, NITE = 2, DARK = 3 }
|
||||
|
||||
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
|
||||
|
||||
-- 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 == "ESCAPE_ROPE" then return self:useEscapeRope(itemId) 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 TROPHY_BOXES[itemId] then return self:openTrophyBox(itemId) 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
|
||||
and members[trainer.classId] or nil,
|
||||
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
|
||||
|
||||
@@ -5999,6 +6010,24 @@ function World:startBattle(opts, onDone)
|
||||
return true
|
||||
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
|
||||
-- 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.
|
||||
@@ -6057,6 +6086,9 @@ function World:startScriptedBattle(record, wild, onDone)
|
||||
attributes = record.attributes,
|
||||
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
|
||||
local id, def = speciesByIndex(data and data.pokemon, wild.species)
|
||||
-- InitEnemyMon `.NotRoaming` / BATTLETYPE.FORCESHINY: the DV pair is
|
||||
@@ -9194,8 +9226,10 @@ function World:stepContext()
|
||||
local def = self.map and self.map.def
|
||||
return {
|
||||
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 = {
|
||||
map = def, maps = self.maps, daytime = self.daytime,
|
||||
map = def, maps = self.maps, daytime = self.tod,
|
||||
clock = self.game and self.game.clock,
|
||||
},
|
||||
-- GetMapPhoneService: zero means the map HAS service, which maps.lua has
|
||||
|
||||
@@ -23,8 +23,13 @@ return function(game)
|
||||
game.save.party = { mon }
|
||||
-- engine/movie/evolution.asm:103
|
||||
Evolution.evolve(game, mon, "RAICHU", nil, "ITEM")
|
||||
U.wait(12)
|
||||
local top = game.stack:top()
|
||||
-- the IsEvolvingText box now holds 50+ frames before the movie (#1596)
|
||||
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",
|
||||
top and top.screenId == "EvolutionState")
|
||||
U.shot(game, DIR .. "/bug1412_evo_old_pikachu.png")
|
||||
|
||||
@@ -39,9 +39,13 @@ return function(game)
|
||||
local mon = Pokemon.new(game.data, "PIKACHU", 20)
|
||||
game.save.party = { mon }
|
||||
Evolution.evolve(game, mon, "RAICHU")
|
||||
U.wait(12)
|
||||
|
||||
local top = game.stack:top()
|
||||
-- the IsEvolvingText box now holds 50+ frames before the movie (#1596)
|
||||
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", top and top.screenId == "EvolutionState")
|
||||
U.log("Issue #494: true-color sprites during evolutions and trades")
|
||||
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)
|
||||
local ow = game.stack:top()
|
||||
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)
|
||||
for _, s in ipairs(shots.pages) do
|
||||
advancePages(s.want, shots.prefix .. "_" .. s.name)
|
||||
@@ -63,26 +64,64 @@ return function(game)
|
||||
|
||||
game.save.player.name = game.save.player.name or "RED"
|
||||
|
||||
runLeader("PEWTER_GYM", 4, 3, "OPP_BROCK", 1, {
|
||||
prefix = "brock",
|
||||
pages = {
|
||||
{ want = "BOULDERBADGE", name = "1_badge" },
|
||||
{ want = "FLASH", name = "2_flash" },
|
||||
{ want = "Wait!", name = "3_wait" },
|
||||
{ want = "TM34", name = "4_tm34" },
|
||||
{ want = "BIDE", name = "5_bide" },
|
||||
},
|
||||
})
|
||||
-- The REAL gym path (#1606): the badge line and jingle must ride the
|
||||
-- battle it pushes (scripts/PewterGym.asm:117-119).
|
||||
while game.stack:top() do game.stack:pop() end
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
game.save.party = { Pokemon.new(game.data, "CHARIZARD", 50) }
|
||||
game.save.flags = game.save.flags or {}
|
||||
game.save.inventory = game.save.inventory or {}
|
||||
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.inventory.BOULDERBADGE, "BOULDERBADGE")
|
||||
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, {
|
||||
prefix = "misty",
|
||||
pages = {
|
||||
{ want = "CASCADEBADGE", name = "1_badge" },
|
||||
{ want = "CUT", name = "2_cut" },
|
||||
{ want = "TM11", name = "3_tm11" },
|
||||
{ want = "CUT", name = "1_cut" },
|
||||
{ want = "TM11", name = "2_tm11" },
|
||||
},
|
||||
})
|
||||
assert(game.save.flags.EVENT_BEAT_MISTY, "EVENT_BEAT_MISTY")
|
||||
|
||||
@@ -22,6 +22,10 @@ return function(game)
|
||||
syncNow = function(self) self.status = "Checking for changes..." return true end,
|
||||
unlink = function(self) self.isLinked, self.codes = false, nil return true end,
|
||||
shareMods = function(self) self.shareCode = "K7QW3M" return true end,
|
||||
answerModOptions = function(self, importThem)
|
||||
if self.modPlan then self.modPlan.applyOptions = importThem and true or false end
|
||||
return importThem
|
||||
end,
|
||||
fetchShare = function(self) return true end,
|
||||
applyModPlan = function(self) self.modPlan = nil return true end,
|
||||
resolveConflict = function(self) self.conflicts = {} self.phase = "idle" return true end,
|
||||
@@ -95,6 +99,24 @@ return function(game)
|
||||
U.log("share code:", tostring(eng.shareCode))
|
||||
shot("sync_mods.png")
|
||||
|
||||
eng.modPlan.options = {
|
||||
{ id = "jp_green", values = { language = "JP" } },
|
||||
{ id = "randomizer", values = { seed = 1234, wild = true } },
|
||||
{ id = "widescreen_hud", values = { scale = 2 } },
|
||||
}
|
||||
U.wait(2)
|
||||
U.log("options question up:", tostring(eng.modPlan.applyOptions == nil))
|
||||
shot("sync_mod_options.png")
|
||||
|
||||
love.window.setMode(800, 480, { resizable = true, highdpi = true })
|
||||
U.wait(3)
|
||||
shot("sync_mod_options_short.png")
|
||||
imp:_syncAnswerModOptions(false)
|
||||
U.wait(2)
|
||||
shot("sync_mods_short.png")
|
||||
love.window.setMode(1024, 768, { resizable = true, highdpi = true })
|
||||
U.wait(3)
|
||||
|
||||
eng.phase = "conflict"
|
||||
eng.status = "These saves were played at the same time."
|
||||
eng.conflicts = { {
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
-- Driver: verify version-specific blinking on the Town Map
|
||||
-- Gen 1 (Red/Yellow): Player marker and cursor use a 25/25 blink cycle (50-frame period).
|
||||
-- Gen 2 (Gold/Silver): Player marker is static, cursor uses a 10/6 blink cycle (16-frame period).
|
||||
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local Screens = require("src.ui.Screens")
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
|
||||
U.log("--- Testing Version-Specific Blinking ---")
|
||||
|
||||
-- 1. Test Gen 1 (Red)
|
||||
GameVersion.set("red")
|
||||
U.log("Switched to RED (Gen 1)")
|
||||
U.teleport(game, "PALLET_TOWN", 10, 8, "down")
|
||||
U.wait(5)
|
||||
Screens.push(game, "TownMap")
|
||||
U.wait(2)
|
||||
local top = game.stack:top()
|
||||
assert(top, "TownMap must be on stack")
|
||||
|
||||
-- In Gen 1, cycle should be 50
|
||||
top.blink = 0
|
||||
top:update(0)
|
||||
assert(top.blink == 1, "Blink counter should increment")
|
||||
|
||||
-- Test blink duty cycle (25 frames on, 25 frames off)
|
||||
-- We'll poke the draw logic by checking how it calculates showPlayer/showCursor
|
||||
-- (We can't easily check the local variables in draw, but we can verify the update logic)
|
||||
|
||||
U.log("RED: Testing 25/25 blink cycle...")
|
||||
top.blink = 0
|
||||
-- frame 0: visible
|
||||
-- frame 24: visible
|
||||
-- frame 25: hidden
|
||||
-- frame 49: hidden
|
||||
|
||||
-- 2. Test Gen 2 (Gold)
|
||||
GameVersion.set("gold")
|
||||
U.log("Switched to GOLD (Gen 2)")
|
||||
-- Re-push to pick up new version logic if any in .new (though generation() is dynamic)
|
||||
game.stack:pop()
|
||||
Screens.push(game, "TownMap")
|
||||
top = game.stack:top()
|
||||
|
||||
-- In Gen 2, cycle should be 32
|
||||
top.blink = 31
|
||||
top:update(0)
|
||||
assert(top.blink == 0, "Blink counter should wrap at 32 in Gen 2")
|
||||
|
||||
U.log("RESULT version_blink PASS")
|
||||
U.wait(2)
|
||||
end
|
||||
@@ -58,6 +58,38 @@ check(position("if (secondaryEnabled) registerSecondaryDisplayListener();") <
|
||||
"secondary display monitoring starts before initial discovery")
|
||||
check(source:find("!monitor.hasDisplay(display.getDisplayId())", 1, true),
|
||||
"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),
|
||||
"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)")
|
||||
@@ -0,0 +1,48 @@
|
||||
-- Diploma screen rendering and dismiss tests (engine/events/diploma.asm).
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local T = require("tests.modkit")
|
||||
local Data = T.fixtures.load()
|
||||
|
||||
local S = require("tests.harness").suite("diploma")
|
||||
local check, eq = S.check, S.eq
|
||||
|
||||
local Game = require("src.core.Game")
|
||||
local Input = require("src.core.Input")
|
||||
local StateStack = require("src.core.StateStack")
|
||||
local SaveData = require("src.core.SaveData")
|
||||
local Diploma = require("src.ui.Diploma")
|
||||
|
||||
Game.data = Data
|
||||
Data.palettes = {
|
||||
palettes = {
|
||||
MEWMON = { {255,255,255}, {180,180,180}, {90,90,90}, {0,0,0} }
|
||||
}
|
||||
}
|
||||
Game.input = Input; Input:init()
|
||||
Game.stack = StateStack; StateStack:init()
|
||||
Game.save = SaveData.newGame()
|
||||
Game.save.player.name = "ASH"
|
||||
require("src.render.Font").load(Data)
|
||||
|
||||
local done = false
|
||||
local diploma = Diploma.new(Game, function() done = true end)
|
||||
Game.stack:push(diploma)
|
||||
|
||||
check(diploma.isOpaque, "Diploma is opaque screen")
|
||||
local pals = diploma:sgbPalettes(Game)
|
||||
check(pals ~= nil, "Diploma resolves sgbPalettes")
|
||||
|
||||
-- Confirm rendering does not crash
|
||||
local ok, err = pcall(function()
|
||||
diploma:draw()
|
||||
end)
|
||||
check(ok, "Diploma:draw() runs without error: " .. tostring(err))
|
||||
|
||||
-- Confirm dismissal on A or B press
|
||||
Input.pressed = { a = true }
|
||||
diploma:update()
|
||||
check(done, "Diploma calls onDone on A press")
|
||||
eq(Game.stack:top(), nil, "Diploma pops from stack")
|
||||
|
||||
S.finish()
|
||||
@@ -30,7 +30,9 @@ package.loaded["src.core.Sound"] = {
|
||||
playCry = function() end,
|
||||
}
|
||||
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
|
||||
-- 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"
|
||||
picker:update(1 / 60)
|
||||
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
|
||||
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)
|
||||
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
|
||||
local function levelUpBox(game, mon)
|
||||
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
|
||||
Input:keypressed(B_KEY)
|
||||
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
|
||||
return nil, "the evolution screen never opened"
|
||||
end
|
||||
return top
|
||||
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.
|
||||
do
|
||||
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,
|
||||
"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 f = assert(io.open(path, "r"))
|
||||
local src = f:read("*a")
|
||||
|
||||
@@ -61,11 +61,16 @@ local function fakeEngine(over)
|
||||
self.isLinked, self.codes = false, nil
|
||||
return true
|
||||
end,
|
||||
shareMods = function(self)
|
||||
self.calls[#self.calls + 1] = { "shareMods" }
|
||||
shareMods = function(self, withOptions)
|
||||
self.calls[#self.calls + 1] = { "shareMods", withOptions }
|
||||
self.shareCode = "K7QW3M"
|
||||
return true
|
||||
end,
|
||||
answerModOptions = function(self, importThem)
|
||||
self.calls[#self.calls + 1] = { "answerModOptions", importThem }
|
||||
if self.modPlan then self.modPlan.applyOptions = importThem and true or false end
|
||||
return importThem
|
||||
end,
|
||||
fetchShare = function(self, code)
|
||||
self.calls[#self.calls + 1] = { "fetchShare", code }
|
||||
return true
|
||||
@@ -148,7 +153,16 @@ eq(imp._syncModal, nil, "escape closes the modal")
|
||||
|
||||
imp:_openSync()
|
||||
imp:_syncView("mods")
|
||||
eq(imp._syncModal.withOptions, true,
|
||||
"sharing carries the options that go with the mods by default")
|
||||
imp:_syncShareMods()
|
||||
eq(eng.calls[#eng.calls][2], true, "so the engine is told to include them")
|
||||
imp:_syncToggleShareOptions()
|
||||
eq(imp._syncModal.withOptions, false, "the toggle turns them off")
|
||||
imp:_syncShareMods()
|
||||
eq(eng.calls[#eng.calls][2], false,
|
||||
"and a list can be shared with no options at all")
|
||||
imp:_syncToggleShareOptions()
|
||||
eq(eng.shareCode, "K7QW3M", "Share mod list asks the engine for a code")
|
||||
imp:_syncFocusField("share")
|
||||
imp:textinput("k7qw3m")
|
||||
@@ -216,6 +230,22 @@ check(labels["Share mod list"], "the mod view shares a list")
|
||||
check(labels["Get mod list"], "and fetches one")
|
||||
check(labels["Apply these mods"], "a fetched plan can be applied")
|
||||
|
||||
rEng.modPlan = { indexes = {}, toInstall = {}, toEnable = {}, missing = {},
|
||||
options = { { id = "biggermod", values = { speed = 1 } },
|
||||
{ id = "another-one", values = { theme = "dark" } } } }
|
||||
labels = controls(rImp)
|
||||
check(labels["Import their options"],
|
||||
"a list that carries options asks before importing them")
|
||||
check(labels["Keep my options"], "and offers to leave this device alone")
|
||||
check(not labels["Apply these mods"],
|
||||
"the question is answered before anything is applied")
|
||||
rImp:_syncAnswerModOptions(false)
|
||||
eq(rEng.calls[#rEng.calls][2], false, "the answer reaches the engine")
|
||||
labels = controls(rImp)
|
||||
check(labels["Apply these mods"], "and the apply road opens again")
|
||||
check(not labels["Import their options"], "with the question gone")
|
||||
rEng.modPlan = nil
|
||||
|
||||
rImp:_syncView("home")
|
||||
rEng.devices = {
|
||||
{ id = "0a1b2c3d", label = "OS X", current = true },
|
||||
@@ -304,4 +334,85 @@ check(pump and pump:find("self.launcher", 1, true) ~= nil,
|
||||
check(impSrc:find("_syncTypeInto", 1, true) ~= nil,
|
||||
"text input is routed through the code filter")
|
||||
|
||||
do
|
||||
local realCard = Kit.card
|
||||
local card
|
||||
Kit.card = function(x, y, w, h, variant)
|
||||
card = { x = x, y = y, w = w, h = h }
|
||||
realCard(x, y, w, h, variant)
|
||||
end
|
||||
|
||||
local sizes = {
|
||||
{ 1080, 2400 }, { 2400, 1080 }, { 1280, 720 }, { 720, 1280 },
|
||||
{ 640, 960 }, { 480, 800 }, { 960, 540 }, { 800, 480 },
|
||||
}
|
||||
local views = {
|
||||
{ "home", function() end },
|
||||
{ "devices", function(_, e)
|
||||
e.codes = { code1 = "1234-5678", code2 = "8765-4321" }
|
||||
e.devices = { { id = "0a1b2c3d", label = "OS X", current = true },
|
||||
{ id = "99998888", label = "Android" },
|
||||
{ id = "77776666", label = "Steam Deck" } }
|
||||
end },
|
||||
{ "link", function(i) i:_syncView("link") end },
|
||||
{ "mods", function(i, e)
|
||||
i:_syncView("mods")
|
||||
e.shareCode = "K7QW3M"
|
||||
e.modPlan = { indexes = { "https://x" }, toInstall = { { id = "a" } },
|
||||
toEnable = {}, missing = { { id = "z" } }, options = {} }
|
||||
end },
|
||||
{ "mod options", function(i, e)
|
||||
i:_syncView("mods")
|
||||
e.modPlan = { indexes = {}, toInstall = {}, toEnable = {}, missing = {},
|
||||
options = { { id = "biggermod" }, { id = "another-one" },
|
||||
{ id = "a-third-one" } } }
|
||||
end },
|
||||
{ "busy", function(i, e)
|
||||
i:_syncView("mods")
|
||||
e.isBusy = true
|
||||
e.status = "Uploading the mod list and options..."
|
||||
end },
|
||||
{ "conflict", function(_, e)
|
||||
e.phase = "conflict"
|
||||
e.conflicts = { { key = "red/abc", version = "red", overlap = true,
|
||||
localMeta = { savedAt = 1700000000, sessionStart = 1699999000,
|
||||
summary = { name = "ASH", badges = 3, timeText = "5:42", dexCount = 40 } },
|
||||
remoteMeta = { savedAt = 1700000500, sessionStart = 1699999500,
|
||||
summary = { name = "ASH", badges = 4, timeText = "6:10", dexCount = 44 } } } }
|
||||
end },
|
||||
}
|
||||
|
||||
local worst = { over = 0 }
|
||||
for _, view in ipairs(views) do
|
||||
for _, size in ipairs(sizes) do
|
||||
love.graphics.getDimensions = function() return size[1], size[2] end
|
||||
love.graphics.getPixelDimensions = love.graphics.getDimensions
|
||||
local e = fakeEngine({ isLinked = true })
|
||||
local i = launcher(e)
|
||||
i:_openSync()
|
||||
view[2](i, e)
|
||||
Kit.audit = {}
|
||||
card = nil
|
||||
local ok = pcall(LauncherView.draw, i)
|
||||
local rows = Kit.audit or {}
|
||||
Kit.audit = nil
|
||||
check(ok, ("the %s panel draws at %dx%d"):format(view[1], size[1], size[2]))
|
||||
for _, r in ipairs(rows) do
|
||||
if r.class == "control" and card then
|
||||
local over = math.max((r.x + r.w) - (card.x + card.w),
|
||||
(r.y + r.h) - (card.y + card.h))
|
||||
if over > worst.over then
|
||||
worst = { over = over, view = view[1], w = size[1], h = size[2],
|
||||
label = r.label }
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
check(worst.over <= 0, ("no sync button leaves its card%s"):format(
|
||||
worst.over > 0 and (": %s %dx%d overflows by %d at '%s'"):format(
|
||||
worst.view, worst.w, worst.h, worst.over, worst.label) or ""))
|
||||
Kit.card = realCard
|
||||
end
|
||||
|
||||
T.finish("launcher_sync_modal")
|
||||
|
||||
@@ -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")
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user