mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-14 01:11:07 +02:00
Compare commits
60 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fea81f03fc | |||
| b549e319c2 | |||
| 378246728b | |||
| f9adeca5ca | |||
| 838f87c224 | |||
| 5b519165fc | |||
| 1a2b23a066 | |||
| fe16b3259c | |||
| 2e46954fe0 | |||
| 00d13d3c35 | |||
| 1b8b3ad538 | |||
| 5f75cfd691 | |||
| 5f89def2ce | |||
| 7c26eb9a24 | |||
| d951fe8fc5 | |||
| ac01135ca8 | |||
| 5e89e35e02 | |||
| 7e7afeaf82 | |||
| c6fa6d294b | |||
| fc2d17fc78 | |||
| 9f072285a6 | |||
| c58ebe4b9b | |||
| 0dd187fe30 | |||
| ea78792c03 | |||
| 9954225701 | |||
| 6bb2e078c0 | |||
| 323c59e54f | |||
| d26d63ed38 | |||
| 2009df3dd1 | |||
| f0f3e9634b | |||
| ca1beaefe7 | |||
| 24cf1b3317 | |||
| 88e2ec2042 | |||
| be60c7c1d5 | |||
| 9be21380c7 | |||
| 22fd196045 | |||
| d5293dffb8 | |||
| 9326b07583 | |||
| 90437a5d8d | |||
| c4ec5dc1c5 | |||
| 3b19826f1e | |||
| 95e4715bc3 | |||
| 81edc97d86 | |||
| 92c8bac714 | |||
| 1df04aa18d | |||
| 218b9249a2 | |||
| 50947eec2a | |||
| 2624bb0210 | |||
| 6e9685f302 | |||
| 898bf0c71e | |||
| 5a7add8eaa | |||
| 66334dfacd | |||
| a33f3b1ceb | |||
| 6e724cedf7 | |||
| fccb122c59 | |||
| 33899c237e | |||
| 0fad222569 | |||
| ddcce5b95a | |||
| 6fd9741fab | |||
| caa65182dc |
@@ -0,0 +1,6 @@
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: github-actions
|
||||
directory: /
|
||||
schedule:
|
||||
interval: weekly
|
||||
@@ -10,8 +10,6 @@ name: ci
|
||||
# The T3 content tier asserts Pokemon Red facts; scripts/test.sh detects
|
||||
# data/generated/ is absent and skips it rather than failing.
|
||||
#
|
||||
# Runs alongside release.yml, which is untouched by this file.
|
||||
|
||||
on:
|
||||
push:
|
||||
# Integration branch + release branch. PRs already run via pull_request
|
||||
@@ -24,7 +22,86 @@ concurrency:
|
||||
group: ci-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
ios-changes:
|
||||
name: detect iOS changes
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
changed: ${{ steps.paths.outputs.changed }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- id: paths
|
||||
env:
|
||||
BASE_SHA: ${{ github.event.pull_request.base.sha || github.event.before }}
|
||||
HEAD_SHA: ${{ github.sha }}
|
||||
run: |
|
||||
if [ -z "$BASE_SHA" ] || [ "$BASE_SHA" = "0000000000000000000000000000000000000000" ]; then
|
||||
echo "changed=true" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
if git diff --name-only "$BASE_SHA" "$HEAD_SHA" | grep -Eq '^(mobile/ios/|scripts/build_ios\.sh$|\.github/workflows/(ci|release)\.yml$)'; then
|
||||
echo "changed=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "changed=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
ios-build:
|
||||
name: iOS build
|
||||
needs: ios-changes
|
||||
if: needs.ios-changes.outputs.changed == 'true'
|
||||
runs-on: ${{ fromJSON(github.repository == 'bryanthaboi/gen1recomp' && '["self-hosted", "macOS"]' || '"macos-latest"') }}
|
||||
outputs:
|
||||
ipa_url: ${{ steps.upload-ipa.outputs.artifact-url }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: import signing certificate
|
||||
if: github.repository == 'bryanthaboi/gen1recomp'
|
||||
run: |
|
||||
keychain_path="$RUNNER_TEMP/gen1recomp-ci-signing.keychain-db"
|
||||
ci_dir="${POKEMON_CI_DIR:-$HOME/.config/pokemon-ci}"
|
||||
p12="$ci_dir/signing.p12"
|
||||
passfile="$ci_dir/signing.pass"
|
||||
[ -f "$p12" ] && [ -f "$passfile" ] || exit 1
|
||||
p12pw="$(cat "$passfile")"
|
||||
kcpw="$(openssl rand -base64 24)"
|
||||
echo "::add-mask::$kcpw"
|
||||
security delete-keychain "$keychain_path" 2>/dev/null || true
|
||||
security create-keychain -p "$kcpw" "$keychain_path"
|
||||
security set-keychain-settings "$keychain_path"
|
||||
security unlock-keychain -p "$kcpw" "$keychain_path"
|
||||
security import "$p12" -P "$p12pw" -k "$keychain_path" -T /usr/bin/codesign -T /usr/bin/security
|
||||
security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$kcpw" "$keychain_path" >/dev/null
|
||||
existing="$(security list-keychains -d user | sed -e 's/^[[:space:]]*//' -e 's/"//g')"
|
||||
security list-keychains -d user -s "$keychain_path" $existing
|
||||
- name: install xcbeautify
|
||||
run: brew list xcbeautify >/dev/null 2>&1 || brew install xcbeautify
|
||||
- name: build iOS release
|
||||
env:
|
||||
CANONICAL_REPOSITORY: ${{ github.repository == 'bryanthaboi/gen1recomp' }}
|
||||
run: |
|
||||
if [ "$CANONICAL_REPOSITORY" = true ]; then
|
||||
scripts/build_ios.sh --fetch --device --release
|
||||
else
|
||||
scripts/build_ios.sh --fetch --release
|
||||
fi
|
||||
- name: upload iOS release artifact
|
||||
id: upload-ipa
|
||||
if: github.repository == 'bryanthaboi/gen1recomp'
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: gen1recomp-ios-ipa
|
||||
path: dist/ios/gen1recomp.ipa
|
||||
if-no-files-found: error
|
||||
retention-days: 7
|
||||
- name: clean up signing keychain
|
||||
if: ${{ always() && github.repository == 'bryanthaboi/gen1recomp' }}
|
||||
run: security delete-keychain "$RUNNER_TEMP/gen1recomp-ci-signing.keychain-db" 2>/dev/null || true
|
||||
|
||||
headless:
|
||||
name: headless suites (no ROM)
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
name: iOS artifact comment
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: [ci]
|
||||
types: [completed]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
comment:
|
||||
if: github.event.workflow_run.event == 'pull_request' && github.event.workflow_run.conclusion == 'success'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- id: artifact
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
RUN_ID: ${{ github.event.workflow_run.id }}
|
||||
HEAD_BRANCH: ${{ github.event.workflow_run.head_branch }}
|
||||
HEAD_REPOSITORY: ${{ github.event.workflow_run.head_repository.full_name }}
|
||||
run: |
|
||||
artifact_id="$(gh api "repos/$GITHUB_REPOSITORY/actions/runs/$RUN_ID/artifacts" --jq '.artifacts[] | select(.name == "gen1recomp-ios-ipa") | .id')"
|
||||
[ -n "$artifact_id" ] || exit 0
|
||||
head_owner="${HEAD_REPOSITORY%%/*}"
|
||||
pr_number="$(gh api "repos/$GITHUB_REPOSITORY/pulls?state=open&head=$head_owner:$HEAD_BRANCH" --jq '.[0].number // empty')"
|
||||
[ -n "$pr_number" ] || exit 0
|
||||
echo "artifact_url=https://github.com/$GITHUB_REPOSITORY/actions/runs/$RUN_ID/artifacts/$artifact_id" >> "$GITHUB_OUTPUT"
|
||||
echo "pr_number=$pr_number" >> "$GITHUB_OUTPUT"
|
||||
- name: comment iOS artifact
|
||||
if: steps.artifact.outputs.pr_number != ''
|
||||
uses: thollander/actions-comment-pull-request@v3
|
||||
with:
|
||||
message: |
|
||||
#### iOS Release IPA
|
||||
|
||||
- [Download gen1recomp.ipa](${{ steps.artifact.outputs.artifact_url }})
|
||||
|
||||
<sub>Automatically generated. [View workflow run](https://github.com/${{ github.repository }}/actions/runs/${{ github.event.workflow_run.id }})</sub>
|
||||
pr-number: ${{ steps.artifact.outputs.pr_number }}
|
||||
comment-tag: ios-build-result
|
||||
github-token: ${{ github.token }}
|
||||
@@ -42,9 +42,15 @@ concurrency:
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: [self-hosted, macOS]
|
||||
runs-on: ${{ fromJSON(github.repository == 'bryanthaboi/gen1recomp' && '["self-hosted", "macOS"]' || '"macos-latest"') }}
|
||||
|
||||
steps:
|
||||
# The self-hosted runner lives under the machine owner's home
|
||||
# directory; mask it first so absolute paths in every later step's
|
||||
# output show up as *** in the public workflow logs.
|
||||
- name: Mask runner paths
|
||||
run: echo "::add-mask::$HOME"
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
@@ -121,6 +127,7 @@ jobs:
|
||||
echo "tag=$tag" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Import signing certificate into a temporary keychain
|
||||
if: github.repository == 'bryanthaboi/gen1recomp'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
KEYCHAIN_PATH="$RUNNER_TEMP/pokemon-signing.keychain-db"
|
||||
@@ -170,14 +177,23 @@ jobs:
|
||||
set -euo pipefail
|
||||
scripts/build_android.sh --version "${{ steps.ver.outputs.version }}"
|
||||
|
||||
- name: Build iOS
|
||||
- name: Install xcbeautify
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Device Release IPA; signs with the Apple Development identity on
|
||||
# the runner (auto team detection). Users on other Apple IDs still
|
||||
# re-sign or build via docs/ios-install.md.
|
||||
scripts/build_ios.sh --fetch --device --release \
|
||||
--version "${{ steps.ver.outputs.version }}"
|
||||
brew list xcbeautify >/dev/null 2>&1 || brew install xcbeautify
|
||||
|
||||
- name: Build iOS
|
||||
env:
|
||||
CANONICAL_REPOSITORY: ${{ github.repository == 'bryanthaboi/gen1recomp' }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ "$CANONICAL_REPOSITORY" = true ]; then
|
||||
scripts/build_ios.sh --fetch --device --release \
|
||||
--version "${{ steps.ver.outputs.version }}"
|
||||
else
|
||||
scripts/build_ios.sh --fetch --release \
|
||||
--version "${{ steps.ver.outputs.version }}"
|
||||
fi
|
||||
|
||||
- name: Build Anbernic RG34XXSP port
|
||||
run: |
|
||||
@@ -187,6 +203,7 @@ jobs:
|
||||
./build-rg34xxsp.sh --version "${{ steps.ver.outputs.version }}"
|
||||
|
||||
- name: Notarize & staple macOS app
|
||||
if: github.repository == 'bryanthaboi/gen1recomp'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
ci_dir="${POKEMON_CI_DIR:-$HOME/.config/pokemon-ci}"
|
||||
@@ -221,6 +238,7 @@ jobs:
|
||||
echo "Notarized + stapled ✓"
|
||||
|
||||
- name: Stage release assets
|
||||
if: github.repository == 'bryanthaboi/gen1recomp'
|
||||
id: assets
|
||||
run: |
|
||||
set -euo pipefail
|
||||
@@ -260,6 +278,7 @@ jobs:
|
||||
cat "$outdir/sha256sums.txt"
|
||||
|
||||
- name: Publish GitHub Release
|
||||
if: github.repository == 'bryanthaboi/gen1recomp'
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
@@ -365,6 +384,6 @@ jobs:
|
||||
echo "Published release $tag"
|
||||
|
||||
- name: Clean up signing keychain
|
||||
if: always()
|
||||
if: ${{ always() && github.repository == 'bryanthaboi/gen1recomp' }}
|
||||
run: |
|
||||
security delete-keychain "$RUNNER_TEMP/pokemon-signing.keychain-db" 2>/dev/null || true
|
||||
|
||||
@@ -4,6 +4,9 @@ A native LÖVE2D recreation of Poke Red, Blue and Yellow. The engine and map
|
||||
behavior are hand-written Lua; game data and graphics are decoded from a ROM
|
||||
supplied by the player.
|
||||
|
||||
> [!WARNING]
|
||||
> **We are NOT affiliated with the website `gen1recomp[.]com`** That website is not run by this project, was not authorized by us, and we have no idea who operates it. It is impersonating this project; do not download anything from it, and treat anything it hosts or claims as untrustworthy. Even if the site currently links back to this repository, the people behind it can change its content at any time, so nothing on it should ever be trusted. This GitHub repository and the Discord linked below are the only official sources for this project.
|
||||
|
||||
<p align="center"><img src="https://raw.githubusercontent.com/bryanthaboi/gen1recomp/refs/heads/dev/assets/logo/logo.png"></p>
|
||||
|
||||
**SUPPORT / ANNOUNCEMENTS / MODS:** [Discord](https://bois.icu)
|
||||
@@ -75,6 +78,19 @@ The packaged app contains neither a ROM nor pre-extracted game data. Music,
|
||||
sound effects, and cries are synthesized while the game runs from compact
|
||||
audio channel programs copied out of the verified ROM.
|
||||
|
||||
### A note on Windows Defender warnings
|
||||
|
||||
Windows Defender sometimes flags the Windows build with a generic
|
||||
machine-learning detection such as `Trojan:Win32/Wacatac!ml` (#621). This is
|
||||
a known false positive: the exe is the official LÖVE runtime with the game
|
||||
archive appended (the standard way LÖVE games ship), and Defender's
|
||||
heuristics distrust unsigned executables with appended data. Every release
|
||||
publishes SHA-256 checksums (`sha256sums.txt`) so you can verify your
|
||||
download, and you can confirm a flagged file yourself on
|
||||
[VirusTotal](https://www.virustotal.com), where these builds come back clean
|
||||
on every engine except Defender's heuristic. False positives are reported to
|
||||
Microsoft as they come up.
|
||||
|
||||
## Controls
|
||||
|
||||
|
||||
|
||||
@@ -49,7 +49,7 @@ function love.conf(t)
|
||||
t.window.minwidth = 480
|
||||
t.window.minheight = 360
|
||||
end
|
||||
t.version = "11.5"
|
||||
t.version = love._os == "iOS" and "12.0" or "11.5"
|
||||
t.window.vsync = 1
|
||||
t.modules.joystick = true
|
||||
t.modules.physics = false
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
-- BikeShop (BIKE_SHOP) flavor dialogue
|
||||
-- Source: pokered/scripts/BikeShop.asm, pokered/text/BikeShop.asm
|
||||
--
|
||||
-- TEXT_BIKESHOP_CLERK is skipped: it drives the actual voucher-for-bicycle
|
||||
-- exchange (YesNoChoice purchase menu, GiveItem, RemoveItemByID, SetEvent
|
||||
-- EVENT_GOT_BICYCLE). That is a significant standalone feature outside the
|
||||
-- scope of these two flavor NPCs and is left unported here.
|
||||
-- TEXT_BIKESHOP_CLERK lives in data/scripts/story2.lua (M.BIKE_SHOP): the
|
||||
-- voucher exchange and the BICYCLE/CANCEL price window need more than
|
||||
-- command rows (#568).
|
||||
|
||||
return {
|
||||
BIKE_SHOP = {
|
||||
@@ -20,12 +19,15 @@ return {
|
||||
-- CheckEvent EVENT_GOT_BICYCLE ; jr nz, .gotBike
|
||||
-- before the player owns a bike -> TheseBikesAreExpensiveText
|
||||
-- after the player owns a bike -> CoolBikeText
|
||||
-- The check reads the bag, not the event: the port hands out the
|
||||
-- BICYCLE itself (a key item, so it cannot be tossed), and that also
|
||||
-- reads right on saves written before the clerk set the event (#567).
|
||||
TEXT_BIKESHOP_YOUNGSTER = {
|
||||
{ "face_player" }, -- 1
|
||||
{ "check_flag", "EVENT_GOT_BICYCLE" }, -- 2
|
||||
{ "jump_if_true", 5 }, -- 3
|
||||
{ "check_item", "BICYCLE" }, -- 2
|
||||
{ "jump_if_true", 6 }, -- 3
|
||||
{ "show_text", "_BikeShopYoungsterTheseBikesAreExpensiveText" }, -- 4
|
||||
{ "jump", 6 }, -- 5
|
||||
{ "jump", "end" }, -- 5
|
||||
{ "show_text", "_BikeShopYoungsterCoolBikeText" }, -- 6
|
||||
},
|
||||
},
|
||||
|
||||
@@ -35,14 +35,25 @@ local function middleAgedMan(game, ow, npc, done)
|
||||
-- .loop: print WhichBadgeText, then show the badge list menu again
|
||||
push(game, t._CeruleanBadgeHouseMiddleAgedManWhichBadgeText, function()
|
||||
local ListMenu = require("src.ui.ListMenu")
|
||||
local Strings = require("src.core.Strings")
|
||||
local items = {}
|
||||
for _, id in ipairs(BADGE_ORDER) do
|
||||
local idef = game.data.items[id]
|
||||
items[#items + 1] = { label = idef and idef.name or id, value = id }
|
||||
end
|
||||
-- PrintListMenuEntries prints ListMenuCancelText once it hits the
|
||||
-- list's $FF terminator (home/list_menu.asm), so every
|
||||
-- DisplayListMenuID list ends in an on-screen CANCEL row; picking it
|
||||
-- takes DisplayListMenuIDLoop's ExitListMenu path, the same .done
|
||||
-- exit as B (#569)
|
||||
items[#items + 1] = { label = Strings("CANCEL") }
|
||||
local menu = ListMenu.new(game, "", items, {
|
||||
onChoose = function(item)
|
||||
game.stack:pop()
|
||||
if not item.value then
|
||||
push(game, t._CeruleanBadgeHouseMiddleAgedManVisitAnyTimeText, done)
|
||||
return
|
||||
end
|
||||
push(game, t[BADGE_TEXT[item.value]], loop)
|
||||
end,
|
||||
onCancel = function()
|
||||
|
||||
@@ -85,6 +85,47 @@ return {
|
||||
alreadyGotLabel = "_GameCornerGentlemanCloselyWatchTheReelsText",
|
||||
alreadyGotFallback = "The trick is to\nwatch the reels\vclosely!",
|
||||
}),
|
||||
|
||||
-- Yellow keeps all three giveaways with the same events and amounts
|
||||
-- but renames the objects and their text labels: FISHING_GURU ->
|
||||
-- FISHING_GURU1, CLERK2 -> MIDDLE_AGED_MAN2, GENTLEMAN ->
|
||||
-- FISHING_GURU2 (pokeyellow/scripts/GameCorner.asm). The Red/Blue
|
||||
-- keys above never match there, so Yellow needs its own three (#552).
|
||||
TEXT_GAMECORNER_FISHING_GURU1 = coinGiver({
|
||||
event = "EVENT_GOT_10_COINS",
|
||||
amount = 10,
|
||||
askLabel = "_GameCornerFishingGuru1WantToPlayText",
|
||||
askFallback = "Kid, do you want\nto play?",
|
||||
receivedLabel = "_GameCornerFishingGuru1Received10CoinsText",
|
||||
coinCaseFullLabel = "_GameCornerFishingGuru1DontNeedMyCoinsText",
|
||||
coinCaseFullFallback = "You don't need my\ncoins!",
|
||||
alreadyGotLabel = "_GameCornerFishingGuru1WinsComeAndGoText",
|
||||
alreadyGotFallback = "Wins seem to come\nand go.",
|
||||
}),
|
||||
|
||||
TEXT_GAMECORNER_MIDDLE_AGED_MAN2 = coinGiver({
|
||||
event = "EVENT_GOT_20_COINS_2",
|
||||
amount = 20,
|
||||
askLabel = "_GameCornerMiddleAgedMan2WantSomeCoinsText",
|
||||
askFallback = "What's up? Want\nsome coins?",
|
||||
receivedLabel = "_GameCornerMiddleAgedMan2Received20CoinsText",
|
||||
coinCaseFullLabel = "_GameCornerMiddleAgedMan2YouHaveLotsOfCoinsText",
|
||||
coinCaseFullFallback = "You have lots of\ncoins!",
|
||||
alreadyGotLabel = "_GameCornerMiddleAgedMan2INeedMoreCoinsText",
|
||||
alreadyGotFallback = "Darn! I need more\ncoins for the\vPOKéMON I want!",
|
||||
}),
|
||||
|
||||
TEXT_GAMECORNER_FISHING_GURU2 = coinGiver({
|
||||
event = "EVENT_GOT_20_COINS",
|
||||
amount = 20,
|
||||
askLabel = "_GameCornerFishingGuru2ThrowingMeOffText",
|
||||
askFallback = "Hey, what? You're\nthrowing me off!\vHere are some\vcoins, shoo!",
|
||||
receivedLabel = "_GameCornerFishingGuru2Received20CoinsText",
|
||||
coinCaseFullLabel = "_GameCornerFishingGuru2YouGotYourOwnCoinsText",
|
||||
coinCaseFullFallback = "You've got your\nown coins!",
|
||||
alreadyGotLabel = "_GameCornerFishingGuru2CloselyWatchTheReelsText",
|
||||
alreadyGotFallback = "The trick is to\nwatch the reels\vclosely!",
|
||||
}),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -48,7 +48,8 @@ end
|
||||
if GameVersion.isYellow() then
|
||||
for _, file in ipairs({ "data.scripts.yellow_gifts",
|
||||
"data.scripts.yellow_jessie_james",
|
||||
"data.scripts.yellow_beach_house" }) do
|
||||
"data.scripts.yellow_beach_house",
|
||||
"data.scripts.yellow_viridian_old_man" }) do
|
||||
for mapId, mod in pairs(require(file)) do
|
||||
MapScripts.attachBase(mapId, mod)
|
||||
end
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
-- takes it ("I'll take this one, then!") and both balls disappear.
|
||||
-- Source: scripts/OaksLab.asm OaksLabCharmanderPokeBallText /
|
||||
-- OaksLabRivalTakePokeBallScript.
|
||||
-- * Leftover ball (after the pick): Oak turns and reads the last-mon
|
||||
-- line instead of re-offering the starter (OaksLabLastMonScript, #601).
|
||||
-- * Rival (object 1): before starter -> "go ahead and choose" once Oak
|
||||
-- has walked you in, else "gramps isn't around" (#218); with
|
||||
-- starter -> taunt + battle OPP_RIVAL1 with the counter-pick party
|
||||
@@ -23,7 +25,7 @@ local function starterBall(askText, species, choseFlag, ownBall,
|
||||
{ "jump_if_true", 20 }, -- 2
|
||||
-- no picking until Oak has walked you in (OaksLabScript gating)
|
||||
{ "check_flag", "EVENT_FOLLOWED_OAK_INTO_LAB" }, -- 3
|
||||
{ "jump_if_false", 20 }, -- 4
|
||||
{ "jump_if_false", 22 }, -- 4
|
||||
-- the Pokédex "new species" entry shows before the ask (predef
|
||||
-- StarterDex ahead of OaksLabYouWant...Text). StarterDex temporarily
|
||||
-- sets the owned bits so ShowPokedexData prints height/weight/text;
|
||||
@@ -31,7 +33,7 @@ local function starterBall(askText, species, choseFlag, ownBall,
|
||||
{ "push_screen", "DexEntryMenu",
|
||||
{ species = species, forceOwned = true } }, -- 5
|
||||
{ "ask", askText }, -- 6
|
||||
{ "jump_if_false", 21 }, -- 7
|
||||
{ "jump_if_false", "end" }, -- 7
|
||||
-- OaksLab.asm prints ReceivedMon then AddPartyMon (AskName lives
|
||||
-- inside give_pokemon). Show the received text first so the
|
||||
-- nickname prompt follows "you got X", matching Gen1.
|
||||
@@ -52,9 +54,16 @@ local function starterBall(askText, species, choseFlag, ownBall,
|
||||
{ RAM = rivalBall == "OAKSLAB_CHARMANDER_POKE_BALL" and "CHARMANDER"
|
||||
or rivalBall == "OAKSLAB_SQUIRTLE_POKE_BALL" and "SQUIRTLE"
|
||||
or "BULBASAUR" } }, -- 17
|
||||
{ "jump", 21 }, -- 18
|
||||
{ "jump", 21 }, -- 19 (spacer)
|
||||
{ "show_text", "_OaksLabThoseArePokeBallsText" }, -- 20
|
||||
{ "jump", "end" }, -- 18
|
||||
{ "jump", "end" }, -- 19 (spacer)
|
||||
-- a leftover ball after the player's pick: Oak turns to face the
|
||||
-- player and reads the last-mon line instead of re-offering the
|
||||
-- starter (scripts/OaksLab.asm OaksLabSelectedPokeBallScript ->
|
||||
-- OaksLabLastMonScript; #601). The ROM's "#MON" ligature is spelled
|
||||
-- out as Pokémon here.
|
||||
{ "face_object", 5, "down" }, -- 20
|
||||
{ "show_text", "That's PROF.OAK's\nlast Pokémon!" }, -- 21
|
||||
{ "show_text", "_OaksLabThoseArePokeBallsText" }, -- 22
|
||||
}
|
||||
end
|
||||
|
||||
@@ -245,7 +254,12 @@ return {
|
||||
and y >= 6 then
|
||||
local rival = ow:npcByIndex(1)
|
||||
if not rival then return false end
|
||||
-- OaksLabRivalChallengesPlayerScript swaps in the rival encounter
|
||||
-- fanfare for the taunt/challenge exchange, same as the Yellow port
|
||||
-- (oaks_lab_yellow.lua); it was silently dropped here (#596).
|
||||
local rows = {
|
||||
{ "stop_music" },
|
||||
{ "play_music", "Music_MeetRival" },
|
||||
{ "show_text", "_OaksLabRivalIllTakeYouOnText" }, -- 1
|
||||
}
|
||||
-- the rival routes to a free cell beside the player
|
||||
@@ -285,6 +299,8 @@ return {
|
||||
table.insert(rows, { "show_text", "_OaksLabRivalSmellYouLaterText" })
|
||||
table.insert(rows, { "move_npc_to", 1, 4, 11 })
|
||||
table.insert(rows, { "hide_object", "OAKS_LAB", "OAKSLAB_RIVAL" })
|
||||
-- restore the lab theme once he's walked out, same as the Yellow port
|
||||
table.insert(rows, { "play_music", "Music_OaksLab" })
|
||||
ow.runner:run(rows, { npc = rival })
|
||||
return true
|
||||
end
|
||||
|
||||
@@ -71,8 +71,12 @@ return {
|
||||
{ "show_text", "_OaksLabRivalLeaveItAllToMeText" },
|
||||
{ "set_flag", "EVENT_GOT_POKEDEX" },
|
||||
{ "set_flag", "EVENT_OAK_GOT_PARCEL" },
|
||||
-- OaksLabOakGivesPokedexScript: HideObject TOGGLE_LYING_OLD_MAN /
|
||||
-- ShowObject TOGGLE_OLD_MAN_2 -- Yellow's tutorial old man stands
|
||||
-- on the sleeper's cell (18,9); the Red/Blue walker OLD_MAN at
|
||||
-- (17,5) never appears in Yellow (#617)
|
||||
{ "hide_object", "VIRIDIAN_CITY", "VIRIDIANCITY_OLD_MAN_SLEEPY" },
|
||||
{ "show_object", "VIRIDIAN_CITY", "VIRIDIANCITY_OLD_MAN" },
|
||||
{ "show_object", "VIRIDIAN_CITY", "VIRIDIANCITY_OLD_MAN2" },
|
||||
{ "stop_music" },
|
||||
{ "play_music", "Music_MeetRival" },
|
||||
{ "move_npc_to", RIVAL, 4, 7 },
|
||||
@@ -143,17 +147,25 @@ return {
|
||||
-- OaksLabRivalExclamationScript: "!" over the rival
|
||||
{ "emote", RIVAL, "shock" },
|
||||
}
|
||||
-- .RivalPushesPlayerAwayFromEeveeBall + the PAD_RIGHT x2 shove:
|
||||
-- the rival cuts across to the ball WHILE the player standing
|
||||
-- below it is bumped two tiles right (both movements run in the
|
||||
-- same beat, so the walk overlaps the shove like the original)
|
||||
-- .RivalPushesPlayerAwayFromEeveeBall is DOWN then RIGHT x3 (the $07
|
||||
-- bytes are Yellow's own step-right encoding, decoded by
|
||||
-- engine/overworld/movement.asm Func_5288 -> Func_532b), and the
|
||||
-- PAD_RIGHT x2 shove is NOT queued alongside it:
|
||||
-- OaksLabRivalTakesPokeballScript .asm_1c564 polls every frame and
|
||||
-- only simulates the pair once wNPCNumScriptedSteps reads 1 -- i.e.
|
||||
-- as the rival begins the LAST byte, the step onto the tile the
|
||||
-- player is standing on. Starting both on one row had Red stroll
|
||||
-- off the Eevee while the rival was still at the top of the table
|
||||
-- (#559).
|
||||
if py == 4 then
|
||||
rows[#rows + 1] = { "walk_npc", RIVAL,
|
||||
{ "down", "right", "right", "right" }, { wait = false } }
|
||||
rows[#rows + 1] = { "walk_npc", RIVAL, { "down", "right", "right" } }
|
||||
-- this one runs concurrently with the shove below
|
||||
rows[#rows + 1] = { "walk_npc", RIVAL, { "right" }, { wait = false } }
|
||||
rows[#rows + 1] = { "face_player_dir", "left" }
|
||||
rows[#rows + 1] = { "move_player", "right", 2 }
|
||||
-- let the rival finish the last stretch to (7,4)
|
||||
rows[#rows + 1] = { "wait", 40 }
|
||||
-- move_player blocks for both tiles, so the rival has already
|
||||
-- landed on (7,4); this is just the beat before he turns up
|
||||
rows[#rows + 1] = { "wait", 20 }
|
||||
else
|
||||
rows[#rows + 1] = { "move_npc_to", RIVAL, 7, 4 }
|
||||
end
|
||||
|
||||
+130
-11
@@ -413,22 +413,141 @@ M.POKEMON_FAN_CLUB = {
|
||||
},
|
||||
}
|
||||
|
||||
-- BikeShopClerkText (scripts/BikeShop.asm) runs three ways: the BICYCLE is
|
||||
-- already yours, you are carrying the BIKE VOUCHER, or you get the sales
|
||||
-- pitch. The pitch draws its own window (TextBoxBorder hlcoord 0,0, b=4
|
||||
-- c=15) holding BikeShopMenuText and BikeShopMenuPrice, and leaves it up
|
||||
-- while the clerk keeps talking in the bottom box: the original never
|
||||
-- erases it before TextScriptEnd (#568).
|
||||
local BikeShopWindow = {}
|
||||
BikeShopWindow.__index = BikeShopWindow
|
||||
|
||||
function BikeShopWindow.new(game, footer, onChoose)
|
||||
local self = setmetatable({}, BikeShopWindow)
|
||||
self.game = game
|
||||
self.onChoose = onChoose
|
||||
self.index = 1
|
||||
self.active = true
|
||||
-- BikeShopClerkDoYouLikeItText stays on screen under the window for as
|
||||
-- long as the menu is up. The text box pushed on top types it out and
|
||||
-- pops itself; this copy of its last page takes over from there, so the
|
||||
-- bottom box never blanks between the pitch and the answer. Paginated
|
||||
-- with the themed column budget so the copy breaks where the box did.
|
||||
local TextBox = require("src.render.TextBox")
|
||||
local box = require("src.ui.Theme").textBox or {}
|
||||
local pages = TextBox.paginate(TextBox.substitute(game, footer), box.maxCols)
|
||||
self.footer = pages[#pages]
|
||||
return self
|
||||
end
|
||||
|
||||
function BikeShopWindow:update()
|
||||
-- one answer only: the boxes pushed by onChoose sit on top of this
|
||||
-- state, but a second A on the same frame must not fire it twice
|
||||
if not self.active then return end
|
||||
local input = self.game.input
|
||||
-- HandleMenuInput with wMenuWrappingEnabled clear: the two rows clamp
|
||||
if input:wasPressed("up") then
|
||||
self.index = 1
|
||||
elseif input:wasPressed("down") then
|
||||
self.index = 2
|
||||
elseif input:wasPressed("a") or input:wasPressed("b") then
|
||||
local cancelled = input:wasPressed("b") -- bit B_PAD_B -> .cancel
|
||||
require("src.core.Sound").play(self.game.data, "Press_AB")
|
||||
self.active = false
|
||||
self.onChoose(not cancelled and self.index == 1)
|
||||
end
|
||||
end
|
||||
|
||||
function BikeShopWindow:draw()
|
||||
local Font = require("src.render.Font")
|
||||
local Strings = require("src.core.Strings")
|
||||
local Theme = require("src.ui.Theme")
|
||||
Font.drawBox(0, 0, 17, 6)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
local bike = self.game.data.items.BICYCLE
|
||||
Font.draw(bike and bike.name or "BICYCLE", 16, 16) -- hlcoord 2, 2
|
||||
Font.draw("¥1000000", 64, 24) -- hlcoord 8, 3
|
||||
Font.draw(Strings("CANCEL"), 16, 32) -- `next` skips a row
|
||||
-- wTopMenuItemX 1, wTopMenuItemY 2, rows two apart
|
||||
Font.drawCode(Theme.cursor, 8, self.index == 1 and 16 or 32)
|
||||
if self.footer then
|
||||
-- same geometry TextBox resolves against, so a themed box matches
|
||||
local box = Theme.textBox or {}
|
||||
local tx, ty = box.tx or 0, box.ty or 12
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
Font.drawBox(tx, ty, box.tw or 20, box.th or 6)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
for i, line in ipairs(self.footer) do
|
||||
Font.draw(line, (tx + 1) * 8, (ty + 2 * i) * 8)
|
||||
end
|
||||
end
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
end
|
||||
|
||||
M.BIKE_SHOP = {
|
||||
talk = {
|
||||
TEXT_BIKESHOP_CLERK = function(game, ow, npc, done)
|
||||
local Bag = require("src.inventory.Bag")
|
||||
local Flags = require("src.script.Flags")
|
||||
local TextBox = require("src.render.TextBox")
|
||||
if (game.save.inventory.BICYCLE or 0) > 0 then
|
||||
game.stack:push(TextBox.new(game, "How's the\nBICYCLE treating\nyou?", done))
|
||||
elseif (game.save.inventory.BIKE_VOUCHER or 0) > 0 then
|
||||
game.save.inventory.BIKE_VOUCHER = nil
|
||||
game.save.inventory.BICYCLE = 1
|
||||
game.stack:push(TextBox.new(game,
|
||||
("Oh, that's a\nBIKE VOUCHER!\f%s exchanged\nit for a BICYCLE!")
|
||||
:format(game.save.player.name), done))
|
||||
else
|
||||
game.stack:push(TextBox.new(game,
|
||||
"A BICYCLE costs\n¥1000000. Sorry,\nno instalments!", done))
|
||||
local t = game.data.text
|
||||
local function say(text, after, opts)
|
||||
game.stack:push(TextBox.new(game, text, after, opts))
|
||||
end
|
||||
|
||||
-- CheckEvent EVENT_GOT_BICYCLE. Saves made before the clerk started
|
||||
-- setting the event still have the bike in the bag, so either counts.
|
||||
if (game.save.inventory.BICYCLE or 0) > 0
|
||||
or Flags.get(game.save, "EVENT_GOT_BICYCLE") then
|
||||
say(t._BikeShopClerkHowDoYouLikeYourBicycleText, done)
|
||||
return
|
||||
end
|
||||
|
||||
-- .dontHaveBike: IsItemInBag BIKE_VOUCHER
|
||||
if (game.save.inventory.BIKE_VOUCHER or 0) > 0 then
|
||||
say(t._BikeShopClerkOhThatsAVoucherText, function()
|
||||
-- GiveItem's `jr nc, .BagFull`: the voucher is only spent once
|
||||
-- the BICYCLE is actually in the bag
|
||||
if not Bag.add(game.save, "BICYCLE", 1) then
|
||||
say(t._BikeShopBagFullText, done)
|
||||
return
|
||||
end
|
||||
Bag.remove(game.save, "BIKE_VOUCHER", 1)
|
||||
Flags.set(game.save, "EVENT_GOT_BICYCLE")
|
||||
-- BikeShopExchangedVoucherText carries sound_get_key_item; the
|
||||
-- map runs EnableAutoTextBoxDrawing, so the box still waits for
|
||||
-- a button once the jingle has played (auto.wait, #247)
|
||||
say(t._BikeShopExchangedVoucherText, done, {
|
||||
auto = { wait = true, sound = function()
|
||||
return require("src.core.Sound").play(game.data, "Get_Key_Item")
|
||||
end },
|
||||
})
|
||||
end)
|
||||
return
|
||||
end
|
||||
|
||||
-- .dontHaveVoucher: welcome, then the BICYCLE/CANCEL window
|
||||
say(t._BikeShopClerkWelcomeText, function()
|
||||
local pitch = t._BikeShopClerkDoYouLikeItText
|
||||
game.stack:push(BikeShopWindow.new(game, pitch, function(bought)
|
||||
local function comeAgain()
|
||||
say(t._BikeShopComeAgainText, function()
|
||||
game.stack:pop() -- the window, still up under the text
|
||||
done()
|
||||
end)
|
||||
end
|
||||
if bought then
|
||||
-- a million is out of anyone's reach: BikeShopCantAffordText
|
||||
say(t._BikeShopCantAffordText, comeAgain)
|
||||
else
|
||||
comeAgain()
|
||||
end
|
||||
end))
|
||||
-- PrintText BikeShopClerkDoYouLikeItText, then straight into
|
||||
-- HandleMenuInput: the box types out and hands over without
|
||||
-- waiting, leaving the window's copy of the line on screen
|
||||
say(pitch, nil, { auto = { delay = 0 } })
|
||||
end)
|
||||
end,
|
||||
},
|
||||
}
|
||||
|
||||
+72
-15
@@ -111,8 +111,9 @@ M.POKEMON_TOWER_5F = {
|
||||
--
|
||||
-- PokemonTower6FDefaultScript starts the RESTLESS SOUL battle with NO
|
||||
-- Silph Scope check at the trigger -- the scope only decides whether the
|
||||
-- battle is disguised (IsGhostBattle -> makeGhost: "too scared to move",
|
||||
-- balls dodged). An earlier version of this port turned the player back
|
||||
-- disguise sticks (IsGhostBattle -> makeGhost: "too scared to move", balls
|
||||
-- dodged) or comes off in the unveil (makeUnveiledGhost, #492). An earlier
|
||||
-- version of this port turned the player back
|
||||
-- without the scope and never opened the battle, which made 6F
|
||||
-- impassable on any route that skips Rocket Hideout; vanilla lets the
|
||||
-- battle open and a POKE_DOLL end it (see wBattleResult below).
|
||||
@@ -133,7 +134,13 @@ M.POKEMON_TOWER_6F = {
|
||||
-- or not the scope revealed it, so the "can't be caught" state rides
|
||||
-- the battle instead of IsGhostBattle alone (#444)
|
||||
battle.noCatch = true
|
||||
if not game.save.inventory.SILPH_SCOPE then
|
||||
-- InitWildBattle enters disguised for the RESTLESS SOUL either way
|
||||
-- (core.asm:6698-6700). The scope does not skip the disguise, it
|
||||
-- buys the unveil PrintBeginningBattleText .isMarowak plays over it
|
||||
-- before the battle proceeds as an ordinary wild one (#492).
|
||||
if game.save.inventory.SILPH_SCOPE then
|
||||
battle:makeUnveiledGhost()
|
||||
else
|
||||
battle:makeGhost()
|
||||
end
|
||||
battle.onFinish = function(result)
|
||||
@@ -353,6 +360,38 @@ M.ROCKET_HIDEOUT_B4F = {
|
||||
end))
|
||||
end,
|
||||
|
||||
-- Yellow swaps Rocket1/Rocket2 for Jessie & James and keeps a single
|
||||
-- grunt, spelled ROCKETHIDEOUTB4F_ROCKET / TEXT_ROCKETHIDEOUTB4F_ROCKET
|
||||
-- (pokeyellow/scripts/RocketHideoutB4F.asm), so the Red/Blue key above
|
||||
-- never matched there and the LIFT KEY never dropped (#552, a
|
||||
-- regression of #105). Yellow also moves the drop off the second
|
||||
-- talk: RocketHideoutB4FRocketEndBattleText is text_promptbutton +
|
||||
-- text_asm, so its SetEvent EVENT_ROCKET_DROPPED_LIFT_KEY / ShowObject
|
||||
-- TOGGLE_ROCKET_HIDEOUT_B4F_ITEM_5 run the instant the battle ends.
|
||||
TEXT_ROCKETHIDEOUTB4F_ROCKET = function(game, ow, npc, done)
|
||||
if not ow:trainerDefeated(npc) then
|
||||
ow:engageTrainer(npc, function()
|
||||
-- engageTrainer records the win before it calls back, so this
|
||||
-- is the end-battle text's SetEvent + ShowObject
|
||||
if ow:trainerDefeated(npc)
|
||||
and not game.save.flags.EVENT_ROCKET_DROPPED_LIFT_KEY then
|
||||
game.save.flags.EVENT_ROCKET_DROPPED_LIFT_KEY = true
|
||||
local Commands = require("src.script.Commands")
|
||||
Commands.show_object(
|
||||
{ game = game, save = game.save, overworld = ow },
|
||||
"ROCKET_HIDEOUT_B4F", "ROCKETHIDEOUTB4F_LIFT_KEY")
|
||||
end
|
||||
done()
|
||||
end)
|
||||
return
|
||||
end
|
||||
-- RocketHideoutB4FRocketAfterBattleText: later talks only reprint
|
||||
local TextBox = require("src.render.TextBox")
|
||||
game.stack:push(TextBox.new(game,
|
||||
game.data.text._RocketHideoutB4FRocketAfterBattleText
|
||||
or "Oh no! I dropped\nthe LIFT KEY!", done))
|
||||
end,
|
||||
|
||||
TEXT_ROCKETHIDEOUTB4F_GIOVANNI = function(game, ow, npc, done)
|
||||
-- Giovanni has no trainer-header row (def_trainers 2); his text_asm
|
||||
-- owns both the engage and the BeatGiovanniScript aftermath.
|
||||
@@ -502,45 +541,56 @@ M.GAME_CORNER = {
|
||||
end))
|
||||
end)
|
||||
end,
|
||||
-- GameCornerClerk1Text (scripts/GameCorner.asm): the offer, a
|
||||
-- YesNoChoice, then ¥1000 for 50 coins. Yellow drops the "1" from the
|
||||
-- object const and from every one of his text labels
|
||||
-- (GameCornerClerkText, pokeyellow/scripts/GameCorner.asm) with an
|
||||
-- identical body, so each line resolves under both spellings and the
|
||||
-- handler is bound to both text ids just below (#552).
|
||||
TEXT_GAMECORNER_CLERK1 = function(game, ow, npc, done)
|
||||
local TextBox = require("src.render.TextBox")
|
||||
local ChoiceBox = require("src.ui.ChoiceBox")
|
||||
local t = game.data.text
|
||||
local function line(suffix, fallback)
|
||||
return t["_GameCornerClerk1" .. suffix]
|
||||
or t["_GameCornerClerk" .. suffix]
|
||||
or fallback
|
||||
end
|
||||
game.stack:push(TextBox.new(game,
|
||||
(t._GameCornerClerk1DoYouNeedSomeGameCoinsText
|
||||
or "Do you need some\ngame coins?\f¥1000 for 50."), function()
|
||||
line("DoYouNeedSomeGameCoinsText",
|
||||
"Do you need some\ngame coins?\f¥1000 for 50."), function()
|
||||
game.stack:push(ChoiceBox.new(game, function(yes)
|
||||
if not yes then
|
||||
game.stack:push(TextBox.new(game,
|
||||
t._GameCornerClerk1PleaseComePlaySometimeText
|
||||
or "No? Please come\nplay sometime!", done))
|
||||
line("PleaseComePlaySometimeText",
|
||||
"No? Please come\nplay sometime!"), done))
|
||||
return
|
||||
end
|
||||
-- scripts/GameCorner.asm GameCornerClerk1Text: coins need
|
||||
-- the COIN CASE and room for at least 9 coins (Has9990Coins)
|
||||
if not game.save.inventory.COIN_CASE then
|
||||
game.stack:push(TextBox.new(game,
|
||||
t._GameCornerClerk1DontHaveCoinCaseText
|
||||
or "You don't have a\nCOIN CASE!", done))
|
||||
line("DontHaveCoinCaseText",
|
||||
"You don't have a\nCOIN CASE!"), done))
|
||||
return
|
||||
end
|
||||
if (game.save.coins or 0) >= 9990 then
|
||||
game.stack:push(TextBox.new(game,
|
||||
t._GameCornerClerk1CoinCaseIsFullText
|
||||
or "Oops! Your COIN\nCASE is full.", done))
|
||||
line("CoinCaseIsFullText",
|
||||
"Oops! Your COIN\nCASE is full."), done))
|
||||
return
|
||||
end
|
||||
if game.save.money < 1000 then
|
||||
game.stack:push(TextBox.new(game,
|
||||
t._GameCornerClerk1CantAffordTheCoinsText
|
||||
or "You can't afford\nthe coins!", done))
|
||||
line("CantAffordTheCoinsText",
|
||||
"You can't afford\nthe coins!"), done))
|
||||
return
|
||||
end
|
||||
game.save.money = game.save.money - 1000
|
||||
game.save.coins = math.min(9999, (game.save.coins or 0) + 50)
|
||||
game.stack:push(TextBox.new(game,
|
||||
(t._GameCornerClerk1ThanksHereAre50CoinsText
|
||||
or "Thanks! Here are\nyour 50 coins!")
|
||||
line("ThanksHereAre50CoinsText",
|
||||
"Thanks! Here are\nyour 50 coins!")
|
||||
.. ("\fCOINS: %d"):format(game.save.coins), done))
|
||||
end))
|
||||
end))
|
||||
@@ -548,6 +598,13 @@ M.GAME_CORNER = {
|
||||
},
|
||||
}
|
||||
|
||||
-- Yellow's coin clerk is GAMECORNER_CLERK / TEXT_GAMECORNER_CLERK where Red
|
||||
-- and Blue spell him CLERK1, and the two text_asm bodies are identical, so
|
||||
-- one handler answers both ids. Without the alias the Yellow clerk fell
|
||||
-- through to his extracted offer line with no yes/no box behind it (#552).
|
||||
M.GAME_CORNER.talk.TEXT_GAMECORNER_CLERK =
|
||||
M.GAME_CORNER.talk.TEXT_GAMECORNER_CLERK1
|
||||
|
||||
-- Game Corner prize lists (data/events/prizes.asm, prize_mon_levels.asm).
|
||||
-- The six mon prizes differ between Red and Blue; the three TM prizes are
|
||||
-- identical, so they are shared and appended to each version's mon list.
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
-- The Viridian City old-man catch tutorial, Yellow's way
|
||||
-- (pokeyellow scripts/ViridianCity.asm, scripts/ViridianCity_2.asm,
|
||||
-- scripts/OaksLab.asm). Registered on top of the shared tables by
|
||||
-- data/scripts/init.lua on a Yellow boot.
|
||||
--
|
||||
-- Yellow has TWO gambler objects where Red/Blue have one:
|
||||
-- * VIRIDIANCITY_OLD_MAN at (17,5) -- a Red/Blue leftover; its
|
||||
-- toggle stays OFF forever in Yellow, no script ever shows it.
|
||||
-- * VIRIDIANCITY_OLD_MAN2 at (18,9) -- replaces the sleeper the
|
||||
-- moment the Pokédex is given (OaksLabOakGivesPokedexScript:
|
||||
-- HideObject TOGGLE_LYING_OLD_MAN / ShowObject TOGGLE_OLD_MAN_2).
|
||||
--
|
||||
-- This is the tutorial old man. The Red/Blue "Are you in a hurry?"
|
||||
-- yes/no script must NOT run against him: Yellow's
|
||||
-- _ViridianCityOldManHadMyCoffeeNowText is the apology speech ("I've had
|
||||
-- my coffee now ... I'll show you how to catch POKéMON as my apology"),
|
||||
-- and the shared story.lua TEXT_VIRIDIANCITY_OLD_MAN rows hang an
|
||||
-- invented yes/no over it -- YES printed the TimeIsMoney alias
|
||||
-- (_ViridianCityOldManLosingMyTouchText) and NO ran the demo, every
|
||||
-- talk, forever (#617).
|
||||
--
|
||||
-- The real flow (ViridianCityCheckWaitingOldMan + ViridianCityOldMan2Text
|
||||
-- + ViridianCityOldManInitialCatchTrainingScript + ...EndInitial... +
|
||||
-- ViridianCityPostInitialCatchTraining): stepping into (19,9) -- the gap
|
||||
-- east of the sleeper's cell -- faces the old man right and the player
|
||||
-- left, prints the apology, and without any choice runs the demo battle
|
||||
-- (BATTLE_TYPE_OLD_MAN, RATTATA lvl 5). After it, the same text pointer
|
||||
-- now prints _ViridianCityOldManLosingMyTouchText ("That didn't work!
|
||||
-- I must be losing my touch."), the old man walks off (down 6 with the
|
||||
-- player on (19,9), right 1 otherwise, Pikachu nudged out of the way
|
||||
-- first) and TOGGLE_OLD_MAN_2 hides. A direct talk does the same.
|
||||
|
||||
local M = {}
|
||||
|
||||
local OLD_MAN2 = "VIRIDIANCITY_OLD_MAN2"
|
||||
|
||||
-- Capture the FUNCTION, not the table: attachBase stores the module
|
||||
-- table itself, so once this file's onStep is attached the table's slot
|
||||
-- points back here -- delegating through the table would self-recurse.
|
||||
-- story5's VIRIDIAN_CITY.onStep chains story.lua's sleeping-old-man
|
||||
-- gate and its own gym-lock step (same pattern as yellow_jessie_james).
|
||||
local baseViridianStep = require("data.scripts.story5").VIRIDIAN_CITY.onStep
|
||||
|
||||
-- pokeyellow text/ViridianCity.asm, _ViridianCityOldManHadMyCoffeeNowText
|
||||
-- and _ViridianCityOldManLosingMyTouchText, spelled with the extractor's
|
||||
-- markers (line -> \n, cont -> \v, para -> \f)
|
||||
local function text(game)
|
||||
return {
|
||||
apology = game.data.text._ViridianCityOldManHadMyCoffeeNowText
|
||||
or "Ahh, I've had my\ncoffee now and I\vfeel great!\fSure, you can go\n"
|
||||
.. "through!\fI'm sorry I was\nso rude to you!\fI see you're using\n"
|
||||
.. "a POKéDEX.\fI'll show you how\nto catch POKéMON\vas my apology.",
|
||||
losingMyTouch = game.data.text._ViridianCityOldManLosingMyTouchText
|
||||
or "That didn't work!\nI must be losing\vmy touch.\fI've run out of\n"
|
||||
.. "POKé BALLs too.\fI have to get some\nat POKéMON MART.",
|
||||
}
|
||||
end
|
||||
|
||||
-- The row list for the initial-tutorial branch, keyed by where the
|
||||
-- player stands when the battle ends (ViridianCityPostInitialCatchTraining
|
||||
-- reads wXCoord: (19,9) walks the old man down the corridor, anywhere
|
||||
-- else walks him right 1 after moving the follower Pikachu aside).
|
||||
local function oldMan2Rows(game, ow, npc)
|
||||
local rows = {
|
||||
{ "show_text", "_ViridianCityOldManHadMyCoffeeNowText" },
|
||||
{ "old_man_demo" },
|
||||
{ "set_flag", "EVENT_COMPLETED_CATCH_TRAINING" },
|
||||
{ "show_text", "_ViridianCityOldManLosingMyTouchText" },
|
||||
}
|
||||
if ow.player and ow.player.cellX == 19 then
|
||||
rows[#rows + 1] =
|
||||
{ "walk_npc", npc.def.index,
|
||||
{ "down", "down", "down", "down", "down", "down" } }
|
||||
else
|
||||
-- ViridianCityMovePikachu (scripts/ViridianCity_2.asm): Pikachu
|
||||
-- steps out of the old man's way before he turns right
|
||||
local PikachuFollower = require("src.world.PikachuFollower")
|
||||
local pika = ow and PikachuFollower.current(ow)
|
||||
if pika then
|
||||
rows[#rows + 1] = { "walk_npc", pika.def.index, { "right" } }
|
||||
end
|
||||
rows[#rows + 1] = { "walk_npc", npc.def.index, { "right" } }
|
||||
end
|
||||
rows[#rows + 1] =
|
||||
{ "hide_object", "VIRIDIAN_CITY", OLD_MAN2 }
|
||||
return rows
|
||||
end
|
||||
|
||||
-- The shared talk handler: TEXT_VIRIDIANCITY_OLD_MAN2's text_asm branch
|
||||
-- (ViridianCityOldMan2Text) on EVENT_COMPLETED_CATCH_TRAINING.
|
||||
local function oldMan2Talk(game, ow, npc, done)
|
||||
if game.save.flags and game.save.flags.EVENT_COMPLETED_CATCH_TRAINING then
|
||||
local TextBox = require("src.render.TextBox")
|
||||
game.stack:push(TextBox.new(game, text(game).losingMyTouch, done))
|
||||
return
|
||||
end
|
||||
ow.runner:run(oldMan2Rows(game, ow, npc), { npc = npc, onDone = done })
|
||||
end
|
||||
|
||||
M.VIRIDIAN_CITY = {
|
||||
talk = {
|
||||
TEXT_VIRIDIANCITY_OLD_MAN2 = oldMan2Talk,
|
||||
},
|
||||
|
||||
-- Re-apply the Pokédex swap for a save that already holds the flag but
|
||||
-- was never standing here when it fired (converted .sav imports, same
|
||||
-- shape as story.lua's VIRIDIAN_CITY.onEnter). Yellow shows OLD_MAN2,
|
||||
-- not the Red/Blue OLD_MAN at (17,5), and also puts away a stray
|
||||
-- OLD_MAN a save made by the pre-#617 build left standing.
|
||||
onEnter = function(game, ow)
|
||||
if not (game.save.flags and game.save.flags.EVENT_GOT_POKEDEX) then
|
||||
return
|
||||
end
|
||||
local Commands = require("src.script.Commands")
|
||||
local ctx = { save = game.save, game = game, overworld = ow }
|
||||
Commands.hide_object(ctx, "VIRIDIAN_CITY", "VIRIDIANCITY_OLD_MAN_SLEEPY")
|
||||
Commands.hide_object(ctx, "VIRIDIAN_CITY", "VIRIDIANCITY_OLD_MAN")
|
||||
Commands.show_object(ctx, "VIRIDIAN_CITY", OLD_MAN2)
|
||||
end,
|
||||
|
||||
-- ViridianCityCheckWaitingOldMan: with the Pokédex held and the
|
||||
-- tutorial undone, (19,9) -- the gap east of the old man, the same
|
||||
-- cell the sleeper used to gate -- faces him right, turns the player
|
||||
-- left and starts the OLD_MAN2 flow with no choice.
|
||||
onStep = function(game, ow, x, y)
|
||||
if baseViridianStep and baseViridianStep(game, ow, x, y) then
|
||||
return true
|
||||
end
|
||||
local flags = game.save.flags
|
||||
if not flags.EVENT_GOT_POKEDEX then return false end
|
||||
if flags.EVENT_COMPLETED_CATCH_TRAINING then return false end
|
||||
if x ~= 19 or y ~= 9 then return false end
|
||||
local man
|
||||
for _, npc in ipairs(ow.npcs) do
|
||||
if npc.def and npc.def.name == OLD_MAN2 then man = npc break end
|
||||
end
|
||||
if not man then return false end
|
||||
man.facing = "right"
|
||||
ow.player.facing = "left"
|
||||
oldMan2Talk(game, ow, man, nil)
|
||||
return true
|
||||
end,
|
||||
}
|
||||
|
||||
return M
|
||||
@@ -12,7 +12,7 @@ this port.
|
||||
|
||||
| # | Mechanic | Value | Why the Game Boy had this limit | Where it lives here |
|
||||
|---|---|---|---|---|
|
||||
| 1 | Bag capacity | 20 item slots | `wNumBagItems` save block was a fixed 20-entry id/quantity array in SRAM | `src/inventory/Bag.lua:8` (`Bag.CAPACITY = 20`) |
|
||||
| 1 | Bag capacity | 20 item slots by default | `wNumBagItems` save block was a fixed 20-entry id/quantity array in SRAM | `Data.constants.bagSize`, read by `src/inventory/Bag.lua` (`Bag.capacity`) |
|
||||
| 2 | Party size | 6 Pokémon | `wPartyMon1..6` were 6 fixed save-RAM slots | `src/pokemon/Party.lua:5` (`Party.MAX = 6`) |
|
||||
| 3 | PC storage | 12 boxes × 20 Pokémon | `wBoxDataStart` / Bill's PC allocated a fixed 12×20 SRAM block | `src/pokemon/Boxes.lua:7-8` |
|
||||
| 4 | Moves per Pokémon | 4 | Fixed 4-move-slot field in the party/box Pokémon struct | `src/pokemon/Pokemon.lua:20`, enforced again in `src/battle/BattleState.lua:1941` |
|
||||
@@ -38,6 +38,10 @@ this port.
|
||||
|
||||
## Notes
|
||||
|
||||
- Mods may patch `constants.bagSize` through the public content registry. The
|
||||
native `save.lua` format keeps every existing item when the configured
|
||||
limit changes; exporting to a cartridge `.sav` still writes only the first
|
||||
20 bag slots because the original SRAM layout has no room for more.
|
||||
- PC Box **overflow handling** was deliberately changed even though the
|
||||
20×12 box *shape* was kept faithful: instead of Gen 1's "full box discards
|
||||
or blocks the deposit," this port spills into the next box with room.
|
||||
|
||||
@@ -187,5 +187,18 @@ composited and before touch controls draw. The window-space viewport contains
|
||||
and `dpiY`, so a tool can use the letterbox margins without drawing over the
|
||||
playfield or pushing an updating game state.
|
||||
|
||||
`render.compose` wraps the whole-window composite in `Renderer:endFrame`. It
|
||||
receives `(next, renderer, ctx)`; returning `true` without calling `next` hands
|
||||
the mod full control of the window, while calling `next` runs the engine's
|
||||
normal single-window composite so the mod can decorate around it. `ctx` carries
|
||||
the finished `worldCanvas` and `uiCanvas` with their SGB `zones` / `worldZones`,
|
||||
`worldActive`, the frame metrics (`ww`, `wh`, `pw`, `ph`, `ox`, `oy`, `vpw`,
|
||||
`vph`, `scale`, `Sx`, `Sy`, `dpiX`, `dpiY`), `renderer:blitCanvas(...)` for a
|
||||
palette-correct blit of either canvas into an arbitrary screen rect, and the
|
||||
`secondScreen` bridge (`available()` / `push(imageData, w, h)` / `setEnabled`)
|
||||
for driving a second physical display. This is what lets a mod lay the two
|
||||
passes out as two stacked Game Boy screens, or push one onto a second screen,
|
||||
without the engine knowing the layout.
|
||||
|
||||
Developer mode also arms the mod loader's dev tripwire, which flags mods
|
||||
that reach outside their permission set.
|
||||
|
||||
+30
-2
@@ -315,7 +315,7 @@ the overlay on and lets the mouse act as a finger (`=0` forces it off).
|
||||
|
||||
The launcher's **Touch Controls** button opens a drag editor: move each
|
||||
button freely, **Disable** to hide the overlay permanently (for
|
||||
controllers / emulation handhelds — distinct from the temporary
|
||||
controllers / emulation handhelds -- distinct from the temporary
|
||||
gamepad auto-hide), **Reset** for defaults, **Done** to save into
|
||||
`options.lua` as normalized window fractions so rotation keeps the
|
||||
relative placement. In-game, Options → **TOUCH PAD** toggles the same
|
||||
@@ -380,7 +380,8 @@ semantics - so the two windows read as one app. Six tabs:
|
||||
party dock, so deposit and withdraw live in one place. Empty slots are
|
||||
clickable and create a mon there.
|
||||
- **Items**: money, a searchable item picker (replacing the arrows that
|
||||
cycled one id at a time through ~250 items), the 20-slot bag, PC storage
|
||||
cycled one id at a time through ~250 items), the configurable bag (20 slots
|
||||
by default), PC storage
|
||||
with no slot cap, and the eight badges as toggle chips.
|
||||
- **Events**: flags, defeated trainers, taken items and per-map object
|
||||
toggles, with a real filter field and a two-column paged grid.
|
||||
@@ -432,3 +433,30 @@ own repositories. No index ships with the launcher and none is ever added
|
||||
automatically, so the tab opens on an "Add an index" prompt until you name
|
||||
one; paste an index URL or its `owner/repo` and it is remembered in
|
||||
`options.lua`. More than one index can be added, and the listings merge.
|
||||
|
||||
## Soft reset (all versions)
|
||||
|
||||
Holding A, B, START and SELECT together restarts the game the way flicking
|
||||
a Game Boy's power switch did, dropping straight back to the title screen.
|
||||
It works from anywhere, including mid-battle, which the QUIT entry on the
|
||||
start menu cannot do: the original combo is how stationary and gift
|
||||
Pokemon get their stats rerolled without sitting through a full relaunch.
|
||||
Unsaved progress is discarded, exactly as on hardware.
|
||||
|
||||
As on the original, the four buttons have to stay held for 16 straight
|
||||
polls (better than a quarter of a second) and any direction in the mix
|
||||
cancels it, so it is hard to hit by accident -- including on the on-screen
|
||||
touch controls, where it would take four fingers held on four separate
|
||||
controls.
|
||||
|
||||
## Controls rebinding (CONTROLS screen)
|
||||
|
||||
OPTIONS -> CONTROLS lists every Game Boy button with its current keyboard
|
||||
key and controller button side by side (Z/A). Press A on a row, then press
|
||||
and release the key or pad button you want; the rebind commits on the
|
||||
release. If that input already belongs to another row, the two rows swap,
|
||||
so no button is ever stranded without an input and no input ever serves
|
||||
two buttons. Holding a second key or pad button while the first is still
|
||||
down backs out of the capture without touching a keyboard; Escape still
|
||||
cancels too. SELECT clears one row back to its default, and START resets
|
||||
every binding after a confirmation.
|
||||
|
||||
@@ -476,10 +476,14 @@ function love.mousepressed(x, y, button, istouch)
|
||||
-- meant two choose() calls and two stacked SAF picker activities: the
|
||||
-- player picked their ROM, the top picker closed, and the second was still
|
||||
-- underneath asking for it again, which is the "import the file twice"
|
||||
-- in #553. Filtering on istouch rather than on the OS keeps a real mouse
|
||||
-- (DeX, a Chromebook, a USB mouse) working, which an Android-wide return
|
||||
-- would have broken.
|
||||
if istouch then return end
|
||||
-- in #553. Filtering on istouch keeps a real mouse (DeX, a Chromebook, a
|
||||
-- USB mouse) working, which an Android-wide return would have broken.
|
||||
--
|
||||
-- ANDROID ONLY, and the OS test is load bearing: love.touchpressed above
|
||||
-- returns early on iOS and never forwards, so there the synthesized mouse
|
||||
-- press is the ONLY event the launcher gets. Filtering istouch on both
|
||||
-- killed every tap on iOS outright.
|
||||
if istouch and love.system.getOS() == "Android" then return end
|
||||
return Importer:mousepressed(x, y, button)
|
||||
end
|
||||
if editorMode and EditorApp.mousepressed then
|
||||
|
||||
@@ -50,6 +50,29 @@ chosen file under the app save directory as `picked_rom.gb`,
|
||||
from that folder on Choose / refocus; see `docs/launcher.md`. The APK payload
|
||||
itself remains data-free (no embedded ROM or generated cache).
|
||||
|
||||
### Step bridge (Pokéwalker mod)
|
||||
|
||||
`love.system.syncHealthSteps()` → `GameActivity.syncHealthSteps` (same
|
||||
JNI route as the picker: `common/android.cpp` →
|
||||
`modules/system/System.cpp` → `wrap_System.cpp`). The Java side does a
|
||||
one-shot read of the hardware `TYPE_STEP_COUNTER` sensor (cumulative
|
||||
since boot, counted by the OS whether or not any app runs), anchors the
|
||||
reading in `SharedPreferences` so a walk is never credited twice
|
||||
(a reading below the anchor means the phone rebooted → re-anchor without
|
||||
crediting), and stages the delta as `steps_pending.json` in the save
|
||||
identity dir — the same contract as the iOS `GRHealthBridge`. Nothing in
|
||||
the base game calls it; the consumer is the
|
||||
[Pokéwalker mod](https://github.com/mresnick67/Gen1ReComp-Pokewalker),
|
||||
installed as a mod `.zip` at runtime (its SYNC STEPS option defaults
|
||||
off).
|
||||
|
||||
Android 10+ gates the sensor behind the `ACTIVITY_RECOGNITION` runtime
|
||||
permission (declared in `app/src/main/AndroidManifest.xml`; keep it out
|
||||
of the build script's permission trim). The first
|
||||
`syncHealthSteps()` call shows the system prompt; on grant the sensor
|
||||
read runs immediately (`onRequestPermissionsResult`,
|
||||
`STEP_PERMISSION_REQUEST_CODE`).
|
||||
|
||||
### SDK / NDK
|
||||
|
||||
love-android 11.5a expects:
|
||||
|
||||
@@ -8,6 +8,11 @@
|
||||
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" />
|
||||
<!-- 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
|
||||
STEPS option); scripts/build_android.sh must not strip it. -->
|
||||
<uses-permission android:name="android.permission.ACTIVITY_RECOGNITION" />
|
||||
<!-- OpenGL ES 2.0 -->
|
||||
<uses-feature android:glEsVersion="0x00020000" />
|
||||
<!-- Touchscreen support -->
|
||||
|
||||
@@ -219,6 +219,40 @@ bool showCreateDocument(const char *suggestedName)
|
||||
return result;
|
||||
}
|
||||
|
||||
bool syncHealthSteps()
|
||||
{
|
||||
JNIEnv *env = (JNIEnv*) SDL_AndroidGetJNIEnv();
|
||||
jclass activity = env->FindClass("org/love2d/android/GameActivity");
|
||||
|
||||
jmethodID method = env->GetStaticMethodID(activity, "syncHealthSteps", "()Z");
|
||||
jboolean result = env->CallStaticBooleanMethod(activity, method);
|
||||
|
||||
env->DeleteLocalRef(activity);
|
||||
return result;
|
||||
}
|
||||
|
||||
bool restartApp()
|
||||
{
|
||||
JNIEnv *env = (JNIEnv*) SDL_AndroidGetJNIEnv();
|
||||
jclass activity = env->FindClass("org/love2d/android/GameActivity");
|
||||
|
||||
// Old APK / new liblove skew: fail soft so HostShell.restart can fall
|
||||
// back to a clean quit instead of aborting on a missing method (#575).
|
||||
jmethodID method = env->GetStaticMethodID(activity, "restartApp", "()Z");
|
||||
if (method == nullptr)
|
||||
{
|
||||
env->ExceptionClear();
|
||||
env->DeleteLocalRef(activity);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Does not return on success: the Java side exits the process.
|
||||
jboolean result = env->CallStaticBooleanMethod(activity, method);
|
||||
|
||||
env->DeleteLocalRef(activity);
|
||||
return result;
|
||||
}
|
||||
|
||||
/*
|
||||
* Helper functions for the filesystem module
|
||||
*/
|
||||
@@ -851,4 +885,51 @@ const char *getArg0()
|
||||
} // android
|
||||
} // love
|
||||
|
||||
extern "C" __attribute__((visibility("default")))
|
||||
int love_android_secondary_ready()
|
||||
{
|
||||
JNIEnv *env = (JNIEnv*) SDL_AndroidGetJNIEnv();
|
||||
jclass activity = env->FindClass("org/love2d/android/GameActivity");
|
||||
jmethodID m = env->GetStaticMethodID(activity, "hasSecondaryDisplay", "()Z");
|
||||
jboolean ready = JNI_FALSE;
|
||||
if (m)
|
||||
ready = env->CallStaticBooleanMethod(activity, m);
|
||||
else
|
||||
env->ExceptionClear();
|
||||
env->DeleteLocalRef(activity);
|
||||
return ready ? 1 : 0;
|
||||
}
|
||||
|
||||
extern "C" __attribute__((visibility("default")))
|
||||
void love_android_push_secondary(const void *rgba, int w, int h)
|
||||
{
|
||||
if (!rgba || w <= 0 || h <= 0)
|
||||
return;
|
||||
JNIEnv *env = (JNIEnv*) SDL_AndroidGetJNIEnv();
|
||||
jclass activity = env->FindClass("org/love2d/android/GameActivity");
|
||||
jmethodID m = env->GetStaticMethodID(activity, "updateSecondaryFrame", "(Ljava/nio/ByteBuffer;II)V");
|
||||
if (m)
|
||||
{
|
||||
jobject buf = env->NewDirectByteBuffer((void*) rgba, (jlong) w * (jlong) h * 4);
|
||||
env->CallStaticVoidMethod(activity, m, buf, w, h);
|
||||
env->DeleteLocalRef(buf);
|
||||
}
|
||||
else
|
||||
env->ExceptionClear();
|
||||
env->DeleteLocalRef(activity);
|
||||
}
|
||||
|
||||
extern "C" __attribute__((visibility("default")))
|
||||
void love_android_secondary_enable(int on)
|
||||
{
|
||||
JNIEnv *env = (JNIEnv*) SDL_AndroidGetJNIEnv();
|
||||
jclass activity = env->FindClass("org/love2d/android/GameActivity");
|
||||
jmethodID m = env->GetStaticMethodID(activity, "setSecondaryEnabled", "(Z)V");
|
||||
if (m)
|
||||
env->CallStaticVoidMethod(activity, m, on ? JNI_TRUE : JNI_FALSE);
|
||||
else
|
||||
env->ExceptionClear();
|
||||
env->DeleteLocalRef(activity);
|
||||
}
|
||||
|
||||
#endif // LOVE_ANDROID
|
||||
|
||||
@@ -74,6 +74,22 @@ bool showFilePicker(const char *destFilename = nullptr);
|
||||
**/
|
||||
bool showCreateDocument(const char *suggestedName = nullptr);
|
||||
|
||||
/**
|
||||
* Pokéwalker step bridge: asks GameActivity to read the hardware step
|
||||
* counter and stage steps_pending.json in the save identity dir (see
|
||||
* GameActivity.syncHealthSteps). Returns whether a sync could start.
|
||||
*/
|
||||
bool syncHealthSteps();
|
||||
|
||||
/**
|
||||
* Full process relaunch (GameActivity.restartApp): schedules the app's
|
||||
* launch intent and kills the process, because the in-process
|
||||
* quit("restart") loop double-inits physfs and crashes (#575). On success
|
||||
* the process dies inside the Java call and this never returns; false
|
||||
* means the relaunch could not be scheduled.
|
||||
**/
|
||||
bool restartApp();
|
||||
|
||||
/*
|
||||
* Helper functions for the filesystem module
|
||||
*/
|
||||
|
||||
@@ -212,6 +212,24 @@ bool System::createFile(const char *suggestedName) const
|
||||
#endif
|
||||
}
|
||||
|
||||
bool System::syncHealthSteps() const
|
||||
{
|
||||
#ifdef LOVE_ANDROID
|
||||
return love::android::syncHealthSteps();
|
||||
#else
|
||||
return false;
|
||||
#endif
|
||||
}
|
||||
|
||||
bool System::restartApp() const
|
||||
{
|
||||
#ifdef LOVE_ANDROID
|
||||
return love::android::restartApp();
|
||||
#else
|
||||
return false;
|
||||
#endif
|
||||
}
|
||||
|
||||
bool System::hasBackgroundMusic() const
|
||||
{
|
||||
#if defined(LOVE_ANDROID)
|
||||
|
||||
@@ -127,6 +127,20 @@ public:
|
||||
**/
|
||||
virtual bool createFile(const char *suggestedName = nullptr) const;
|
||||
|
||||
/**
|
||||
* Pokéwalker: stage pending real-world steps (steps_pending.json in the
|
||||
* save dir) from the platform step source. Android-only; false elsewhere.
|
||||
*/
|
||||
virtual bool syncHealthSteps() const;
|
||||
|
||||
/**
|
||||
* Relaunches the whole app with a fresh process (Android only; false
|
||||
* elsewhere). The in-process love.event.quit("restart") double-inits
|
||||
* physfs on Android and crashes, so src/core/HostShell.lua calls this
|
||||
* instead (#575). Does not return on success -- the process exits.
|
||||
**/
|
||||
virtual bool restartApp() const;
|
||||
|
||||
/**
|
||||
* Gets if the user is playing music on background.
|
||||
* Throws an exception on unsupported platforms.
|
||||
|
||||
@@ -109,6 +109,20 @@ int w_createFile(lua_State *L)
|
||||
return 1;
|
||||
}
|
||||
|
||||
int w_syncHealthSteps(lua_State *L)
|
||||
{
|
||||
luax_pushboolean(L, instance()->syncHealthSteps());
|
||||
return 1;
|
||||
}
|
||||
|
||||
int w_restartApp(lua_State *L)
|
||||
{
|
||||
// Does not return on success: GameActivity.restartApp exits the process
|
||||
// after scheduling the relaunch (#575).
|
||||
luax_pushboolean(L, instance()->restartApp());
|
||||
return 1;
|
||||
}
|
||||
|
||||
int w_hasBackgroundMusic(lua_State *L)
|
||||
{
|
||||
lua_pushboolean(L, instance()->hasBackgroundMusic());
|
||||
@@ -126,6 +140,8 @@ static const luaL_Reg functions[] =
|
||||
{ "vibrate", w_vibrate },
|
||||
{ "pickFile", w_pickFile },
|
||||
{ "createFile", w_createFile },
|
||||
{ "syncHealthSteps", w_syncHealthSteps },
|
||||
{ "restartApp", w_restartApp },
|
||||
{ "hasBackgroundMusic", w_hasBackgroundMusic },
|
||||
{ 0, 0 }
|
||||
};
|
||||
|
||||
@@ -37,17 +37,26 @@ import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import android.Manifest;
|
||||
import android.app.AlarmManager;
|
||||
import android.app.AlertDialog;
|
||||
import android.app.PendingIntent;
|
||||
import android.content.Context;
|
||||
import android.content.DialogInterface;
|
||||
import android.content.Intent;
|
||||
import android.content.SharedPreferences;
|
||||
import android.content.pm.ActivityInfo;
|
||||
import android.content.pm.ApplicationInfo;
|
||||
import android.content.res.AssetManager;
|
||||
import android.hardware.Sensor;
|
||||
import android.hardware.SensorEvent;
|
||||
import android.hardware.SensorEventListener;
|
||||
import android.hardware.SensorManager;
|
||||
import android.media.AudioManager;
|
||||
import android.net.Uri;
|
||||
import android.os.Bundle;
|
||||
import android.os.Environment;
|
||||
import android.os.Handler;
|
||||
import android.os.Looper;
|
||||
import android.os.Vibrator;
|
||||
import android.util.Log;
|
||||
import android.util.DisplayMetrics;
|
||||
@@ -67,6 +76,8 @@ public class GameActivity extends SDLActivity {
|
||||
public static final int RECORD_AUDIO_REQUEST_CODE = 3;
|
||||
public static final int FILE_PICKER_REQUEST_CODE = 4;
|
||||
public static final int FILE_CREATE_REQUEST_CODE = 5;
|
||||
public static final int STEP_PERMISSION_REQUEST_CODE = 6;
|
||||
public static final int RESTART_REQUEST_CODE = 7;
|
||||
/** @deprecated Prefer FILE_PICKER_REQUEST_CODE; kept for older call sites. */
|
||||
public static final int ROM_PICKER_REQUEST_CODE = FILE_PICKER_REQUEST_CODE;
|
||||
// Mirrors conf.lua's t.identity ("pokemon-love2d"): where the picked file
|
||||
@@ -83,6 +94,17 @@ public class GameActivity extends SDLActivity {
|
||||
// basename as its body, so RomImporter:focus can say so in the launcher
|
||||
// instead of leaving the player on "No ROM imported" (issue #442).
|
||||
private static final String PICK_ERROR_FILENAME = "pick_error.flag";
|
||||
// Step bridge (love.system.syncHealthSteps): pending-steps delivery
|
||||
// consumed by the Pokéwalker mod, same contract as the iOS
|
||||
// GRHealthBridge. Steps come from the hardware TYPE_STEP_COUNTER
|
||||
// (cumulative since boot, counted by the OS whether or not any app is
|
||||
// running), anchored in SharedPreferences so a walk is never credited
|
||||
// twice.
|
||||
private static final String PENDING_STEPS_FILENAME = "steps_pending.json";
|
||||
private static final String STEP_PREFS = "pokewalker_steps";
|
||||
private static final String STEP_PREF_ANCHOR = "anchor";
|
||||
private static final String STEP_PREF_ANCHOR_WALLTIME = "anchor_walltime";
|
||||
private static final long STEP_MAX_PER_SYNC = 50000;
|
||||
// Destination basename for the in-flight SAF pick (set by showFilePicker).
|
||||
// Saved/restored across instance state: the picker is a separate activity
|
||||
// and Android may destroy this one while it is up (memory pressure, or
|
||||
@@ -312,12 +334,14 @@ public class GameActivity extends SDLActivity {
|
||||
Log.d("GameActivity", "Cancelling vibration");
|
||||
vibrator.cancel();
|
||||
}
|
||||
teardownSecondaryDisplay();
|
||||
super.onPause();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onResume() {
|
||||
super.onResume();
|
||||
setupSecondaryDisplay();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -426,15 +450,23 @@ public class GameActivity extends SDLActivity {
|
||||
* Shows the system document picker (Storage Access Framework) so the
|
||||
* player can pick a ROM / mod / save from anywhere (Downloads, Drive,
|
||||
* etc.) without needing to know where the app's external files folder
|
||||
* is. Requires API 19+ (ACTION_OPEN_DOCUMENT); the picked file (if any)
|
||||
* arrives later in onActivityResult, not synchronously here.
|
||||
* is. The picked file (if any) arrives later in onActivityResult, not
|
||||
* synchronously here.
|
||||
*
|
||||
* API 21+ uses ACTION_OPEN_DOCUMENT; API 16-20 uses an ACTION_GET_CONTENT
|
||||
* chooser instead. Below 19 OPEN_DOCUMENT does not exist, and on 19/20
|
||||
* the stock DocumentsUI is unreliable -- it launches and then hands back
|
||||
* RESULT_CANCELED with no data, which onActivityResult cannot tell apart
|
||||
* from the player cancelling (#584). GET_CONTENT lets any installed file
|
||||
* manager serve the pick, and both intents return the same content:// or
|
||||
* file:// URI shapes, so the result path in onActivityResult stays
|
||||
* picker-agnostic and unchanged.
|
||||
*
|
||||
* @param destFilename basename under the app save identity (e.g.
|
||||
* picked_rom.gb, picked_mod.zip, picked_save.sav)
|
||||
*/
|
||||
@Keep
|
||||
public static boolean showFilePicker(String destFilename) {
|
||||
if (android.os.Build.VERSION.SDK_INT < 19) return false;
|
||||
GameActivity self = (GameActivity) mSingleton;
|
||||
if (self == null) return false;
|
||||
if (destFilename == null || destFilename.length() == 0) {
|
||||
@@ -448,11 +480,26 @@ public class GameActivity extends SDLActivity {
|
||||
}
|
||||
|
||||
self.pendingPickFilename = destFilename;
|
||||
Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT);
|
||||
if (android.os.Build.VERSION.SDK_INT >= 21) {
|
||||
Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT);
|
||||
intent.addCategory(Intent.CATEGORY_OPENABLE);
|
||||
intent.setType("*/*");
|
||||
try {
|
||||
self.startActivityForResult(intent, FILE_PICKER_REQUEST_CODE);
|
||||
return true;
|
||||
} catch (Exception e) {
|
||||
// Some OEM / TV builds ship without DocumentsUI; fall through
|
||||
// to the GET_CONTENT chooser below instead of giving up (#584).
|
||||
Log.d("GameActivity", "could not open document picker: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
Intent intent = new Intent(Intent.ACTION_GET_CONTENT);
|
||||
intent.addCategory(Intent.CATEGORY_OPENABLE);
|
||||
intent.setType("*/*");
|
||||
try {
|
||||
self.startActivityForResult(intent, FILE_PICKER_REQUEST_CODE);
|
||||
self.startActivityForResult(
|
||||
Intent.createChooser(intent, "Choose a file"),
|
||||
FILE_PICKER_REQUEST_CODE);
|
||||
return true;
|
||||
} catch (Exception e) {
|
||||
Log.d("GameActivity", "could not open file picker: " + e.getMessage());
|
||||
@@ -478,14 +525,60 @@ public class GameActivity extends SDLActivity {
|
||||
return showFilePicker(PICKED_SAVE_FILENAME);
|
||||
}
|
||||
|
||||
/**
|
||||
* Relaunches the whole app for love.system.restartApp, used by
|
||||
* src/core/HostShell.lua when a mod toggle needs a cold boot (#575).
|
||||
* love.event.quit("restart") re-runs LOVE's boot inside the same
|
||||
* process, and the second love.filesystem.init throws once physfs
|
||||
* failed to deinit ("already initialized"), killing the app. Instead
|
||||
* we hand our launch intent to AlarmManager and then exit the process:
|
||||
* the alarm lives in system_server, so it survives our death and
|
||||
* cannot race the exit the way a plain startActivity right before
|
||||
* Runtime.exit can on some OEMs, and the dead process guarantees no
|
||||
* native (physfs / SDL / JNI) state leaks into the fresh run.
|
||||
*/
|
||||
@Keep
|
||||
public static boolean restartApp() {
|
||||
GameActivity self = (GameActivity) mSingleton;
|
||||
if (self == null) return false;
|
||||
try {
|
||||
Context context = self.getApplicationContext();
|
||||
Intent intent = context.getPackageManager()
|
||||
.getLaunchIntentForPackage(context.getPackageName());
|
||||
if (intent == null) return false;
|
||||
intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP | Intent.FLAG_ACTIVITY_NEW_TASK);
|
||||
int pendingFlags = PendingIntent.FLAG_CANCEL_CURRENT;
|
||||
if (android.os.Build.VERSION.SDK_INT >= 23) {
|
||||
// Mandatory mutability flag on API 31+; harmless from 23 up.
|
||||
pendingFlags |= PendingIntent.FLAG_IMMUTABLE;
|
||||
}
|
||||
PendingIntent pending = PendingIntent.getActivity(
|
||||
context, RESTART_REQUEST_CODE, intent, pendingFlags);
|
||||
AlarmManager alarm = (AlarmManager) context.getSystemService(Context.ALARM_SERVICE);
|
||||
if (alarm == null) return false;
|
||||
alarm.set(AlarmManager.RTC, System.currentTimeMillis() + 250, pending);
|
||||
} catch (Exception e) {
|
||||
Log.d("GameActivity", "could not schedule restart: " + e.getMessage());
|
||||
return false;
|
||||
}
|
||||
Runtime.getRuntime().exit(0);
|
||||
return true; // unreachable, but keeps the JNI signature honest
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows ACTION_CREATE_DOCUMENT so the player can save a staged export
|
||||
* (pending_export.sav in the app save identity) to Downloads / Drive /
|
||||
* etc. Suggested name is the dialog's default filename.
|
||||
*
|
||||
* CREATE_DOCUMENT does not exist below API 19 and has no pre-SAF
|
||||
* equivalent, so unlike showFilePicker this stays 19+ (#584); the false
|
||||
* return degrades on the Lua side (RomImporter export) to "Exported
|
||||
* inside the app folder", which is the correct pre-KitKat behavior.
|
||||
*/
|
||||
@Keep
|
||||
public static boolean showCreateDocument(String suggestedName) {
|
||||
if (android.os.Build.VERSION.SDK_INT < 19) return false;
|
||||
// (see showFilePicker for why the import side got a pre-19 path)
|
||||
GameActivity self = (GameActivity) mSingleton;
|
||||
if (self == null) return false;
|
||||
if (suggestedName == null || suggestedName.length() == 0) {
|
||||
@@ -532,6 +625,160 @@ public class GameActivity extends SDLActivity {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Step sync, called from Lua as love.system.syncHealthSteps()
|
||||
* (see modules/system/wrap_System.cpp). Asynchronous like the picker:
|
||||
* returns whether a sync could be started; the result lands later as
|
||||
* steps_pending.json in the save identity dir, where the Pokéwalker
|
||||
* mod's poll consumes it.
|
||||
*
|
||||
* Android 10+ gates the step counter behind the ACTIVITY_RECOGNITION
|
||||
* runtime permission; the first call shows the system prompt and a later
|
||||
* sync (the mod retries on save load / option change) delivers.
|
||||
*/
|
||||
@Keep
|
||||
public static boolean syncHealthSteps() {
|
||||
final GameActivity self = (GameActivity) mSingleton;
|
||||
if (self == null) return false;
|
||||
if (android.os.Build.VERSION.SDK_INT >= 29
|
||||
&& ActivityCompat.checkSelfPermission(self,
|
||||
Manifest.permission.ACTIVITY_RECOGNITION)
|
||||
!= PackageManager.PERMISSION_GRANTED) {
|
||||
self.runOnUiThread(new Runnable() {
|
||||
@Override
|
||||
public void run() {
|
||||
ActivityCompat.requestPermissions(self,
|
||||
new String[]{Manifest.permission.ACTIVITY_RECOGNITION},
|
||||
STEP_PERMISSION_REQUEST_CODE);
|
||||
}
|
||||
});
|
||||
return true;
|
||||
}
|
||||
self.startStepSensorRead();
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* One-shot read of the cumulative hardware step counter. The sensor
|
||||
* usually reports its cached value moments after registration; some
|
||||
* devices hold the event until the next physical step, so the listener
|
||||
* is given 20 seconds before being torn down (the next sync retries).
|
||||
*/
|
||||
private void startStepSensorRead() {
|
||||
final SensorManager manager =
|
||||
(SensorManager) getSystemService(Context.SENSOR_SERVICE);
|
||||
if (manager == null) return;
|
||||
Sensor counter = manager.getDefaultSensor(Sensor.TYPE_STEP_COUNTER);
|
||||
if (counter == null) {
|
||||
Log.d("GameActivity", "no step counter sensor on this device");
|
||||
return;
|
||||
}
|
||||
final SensorEventListener listener = new SensorEventListener() {
|
||||
private boolean delivered = false;
|
||||
|
||||
@Override
|
||||
public void onSensorChanged(SensorEvent event) {
|
||||
if (delivered || event.values.length == 0) return;
|
||||
delivered = true;
|
||||
manager.unregisterListener(this);
|
||||
deliverSteps((long) event.values[0]);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onAccuracyChanged(Sensor sensor, int accuracy) {
|
||||
}
|
||||
};
|
||||
if (!manager.registerListener(listener, counter,
|
||||
SensorManager.SENSOR_DELAY_NORMAL)) {
|
||||
Log.d("GameActivity", "step counter listener registration failed");
|
||||
return;
|
||||
}
|
||||
new Handler(Looper.getMainLooper()).postDelayed(new Runnable() {
|
||||
@Override
|
||||
public void run() {
|
||||
// No-op if the listener already delivered and unregistered.
|
||||
manager.unregisterListener(listener);
|
||||
}
|
||||
}, 20000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a cumulative counter reading into pending steps. The counter
|
||||
* resets to zero on reboot: a reading below the stored anchor re-anchors
|
||||
* without crediting (steps walked between the reboot and this sync are
|
||||
* lost, which errs on the honest side).
|
||||
*/
|
||||
private void deliverSteps(long counterNow) {
|
||||
SharedPreferences prefs = getSharedPreferences(STEP_PREFS, MODE_PRIVATE);
|
||||
long anchor = prefs.getLong(STEP_PREF_ANCHOR, -1);
|
||||
long now = System.currentTimeMillis();
|
||||
if (anchor < 0 || counterNow < anchor) {
|
||||
prefs.edit()
|
||||
.putLong(STEP_PREF_ANCHOR, counterNow)
|
||||
.putLong(STEP_PREF_ANCHOR_WALLTIME, now)
|
||||
.apply();
|
||||
Log.d("GameActivity", "step anchor set at " + counterNow);
|
||||
return;
|
||||
}
|
||||
long steps = Math.min(counterNow - anchor, STEP_MAX_PER_SYNC);
|
||||
long fromWalltime = prefs.getLong(STEP_PREF_ANCHOR_WALLTIME, now);
|
||||
if (steps <= 0) return;
|
||||
prefs.edit()
|
||||
.putLong(STEP_PREF_ANCHOR, counterNow)
|
||||
.putLong(STEP_PREF_ANCHOR_WALLTIME, now)
|
||||
.apply();
|
||||
|
||||
File dir = saveIdentityDir();
|
||||
if (!dir.isDirectory() && !dir.mkdirs()) {
|
||||
Log.d("GameActivity", "cannot create save dir for steps: " + dir);
|
||||
return;
|
||||
}
|
||||
File pending = new File(dir, PENDING_STEPS_FILENAME);
|
||||
long total = steps;
|
||||
// Merge with an unconsumed earlier delivery so steps are never lost
|
||||
// (same contract as the iOS bridge).
|
||||
if (pending.isFile()) {
|
||||
try {
|
||||
byte[] raw = new byte[(int) Math.min(pending.length(), 4096)];
|
||||
FileInputStream in = new FileInputStream(pending);
|
||||
int read = in.read(raw);
|
||||
in.close();
|
||||
if (read > 0) {
|
||||
org.json.JSONObject old =
|
||||
new org.json.JSONObject(new String(raw, 0, read, "UTF-8"));
|
||||
total += Math.max(0, old.optLong("steps", 0));
|
||||
}
|
||||
} catch (Exception e) {
|
||||
Log.d("GameActivity", "ignoring unreadable pending steps: " + e);
|
||||
}
|
||||
}
|
||||
try {
|
||||
org.json.JSONObject payload = new org.json.JSONObject();
|
||||
payload.put("steps", total);
|
||||
payload.put("from", isoTime(fromWalltime));
|
||||
payload.put("to", isoTime(now));
|
||||
File tmp = new File(dir, PENDING_STEPS_FILENAME + ".tmp");
|
||||
FileOutputStream out = new FileOutputStream(tmp);
|
||||
out.write(payload.toString().getBytes("UTF-8"));
|
||||
out.close();
|
||||
if (!tmp.renameTo(pending)) {
|
||||
tmp.delete();
|
||||
Log.d("GameActivity", "could not publish pending steps");
|
||||
return;
|
||||
}
|
||||
Log.d("GameActivity", total + " steps pending for the Pokewalker mod");
|
||||
} catch (Exception e) {
|
||||
Log.d("GameActivity", "could not write pending steps: " + e);
|
||||
}
|
||||
}
|
||||
|
||||
private static String isoTime(long millis) {
|
||||
java.text.SimpleDateFormat format =
|
||||
new java.text.SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", java.util.Locale.US);
|
||||
format.setTimeZone(java.util.TimeZone.getTimeZone("UTC"));
|
||||
return format.format(new java.util.Date(millis));
|
||||
}
|
||||
|
||||
private boolean copyFileToUri(File source, Uri destUri) {
|
||||
InputStream in = null;
|
||||
OutputStream out = null;
|
||||
@@ -762,6 +1009,17 @@ public class GameActivity extends SDLActivity {
|
||||
}
|
||||
break;
|
||||
}
|
||||
case STEP_PERMISSION_REQUEST_CODE: {
|
||||
if (grantResults[0] == PackageManager.PERMISSION_GRANTED) {
|
||||
Log.d("GameActivity", "Step permission granted");
|
||||
// Deliver right away so the sync the player just
|
||||
// opted into doesn't wait for the next launch.
|
||||
startStepSensorRead();
|
||||
} else {
|
||||
Log.d("GameActivity", "Did not get step permission.");
|
||||
}
|
||||
break;
|
||||
}
|
||||
default:
|
||||
super.onRequestPermissionsResult(requestCode, permissions, grantResults);
|
||||
}
|
||||
@@ -944,4 +1202,182 @@ public class GameActivity extends SDLActivity {
|
||||
return applicationInfo.sourceDir + "!/lib/" + abi + "/?.so";
|
||||
}
|
||||
}
|
||||
|
||||
// Dual-screen: mirror the engine's bottom-screen canvas onto a secondary
|
||||
// physical display. Driven from the engine through love_android_secondary_*
|
||||
// in src/jni/love/src/common/android.cpp.
|
||||
private static volatile SecondaryPresentation secondaryPresentation;
|
||||
private static volatile boolean secondaryEnabled = false;
|
||||
|
||||
@Keep
|
||||
public static void setSecondaryEnabled(final boolean on) {
|
||||
secondaryEnabled = on;
|
||||
final GameActivity self = (GameActivity) mSingleton;
|
||||
if (self == null) return;
|
||||
self.runOnUiThread(new Runnable() {
|
||||
@Override public void run() {
|
||||
if (on) setupSecondaryDisplay(); else teardownSecondaryDisplay();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private static void setupSecondaryDisplay() {
|
||||
GameActivity self = (GameActivity) mSingleton;
|
||||
if (self == null || !secondaryEnabled || secondaryPresentation != null) return;
|
||||
try {
|
||||
android.hardware.display.DisplayManager dm =
|
||||
(android.hardware.display.DisplayManager) self.getSystemService(Context.DISPLAY_SERVICE);
|
||||
if (dm == null) return;
|
||||
Display chosen = null;
|
||||
for (Display d : dm.getDisplays()) {
|
||||
android.graphics.Point size = new android.graphics.Point();
|
||||
d.getRealSize(size);
|
||||
Log.d("GameActivity", "display id=" + d.getDisplayId() + " name=" + d.getName()
|
||||
+ " size=" + size.x + "x" + size.y);
|
||||
if (chosen == null && d.getDisplayId() != Display.DEFAULT_DISPLAY) {
|
||||
chosen = d;
|
||||
}
|
||||
}
|
||||
if (chosen == null) {
|
||||
Display[] pres =
|
||||
dm.getDisplays(android.hardware.display.DisplayManager.DISPLAY_CATEGORY_PRESENTATION);
|
||||
if (pres != null && pres.length > 0) chosen = pres[0];
|
||||
}
|
||||
if (chosen == null) {
|
||||
Log.d("GameActivity", "no secondary display found");
|
||||
return;
|
||||
}
|
||||
SecondaryPresentation p = new SecondaryPresentation(self, chosen);
|
||||
p.show();
|
||||
secondaryPresentation = p;
|
||||
Log.d("GameActivity", "secondary display presentation started on id=" + chosen.getDisplayId());
|
||||
} catch (Throwable t) {
|
||||
Log.d("GameActivity", "secondary display setup failed: " + t);
|
||||
secondaryPresentation = null;
|
||||
}
|
||||
}
|
||||
|
||||
private static void teardownSecondaryDisplay() {
|
||||
SecondaryPresentation p = secondaryPresentation;
|
||||
secondaryPresentation = null;
|
||||
if (p != null) {
|
||||
try { p.dismiss(); } catch (Throwable t) {}
|
||||
}
|
||||
}
|
||||
|
||||
@Keep
|
||||
public static boolean hasSecondaryDisplay() {
|
||||
return secondaryPresentation != null;
|
||||
}
|
||||
|
||||
@Keep
|
||||
public static void updateSecondaryFrame(java.nio.ByteBuffer buf, int w, int h) {
|
||||
SecondaryPresentation p = secondaryPresentation;
|
||||
if (p != null && buf != null && w > 0 && h > 0) {
|
||||
p.updateFrame(buf, w, h);
|
||||
}
|
||||
}
|
||||
|
||||
private static class SecondaryPresentation extends android.app.Presentation {
|
||||
private final FrameView frameView;
|
||||
|
||||
SecondaryPresentation(Context context, Display display) {
|
||||
super(context, display);
|
||||
frameView = new FrameView(context);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onCreate(Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
android.view.Window w = getWindow();
|
||||
if (w != null) {
|
||||
w.setFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN
|
||||
| WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS,
|
||||
WindowManager.LayoutParams.FLAG_FULLSCREEN
|
||||
| WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS);
|
||||
w.setLayout(WindowManager.LayoutParams.MATCH_PARENT,
|
||||
WindowManager.LayoutParams.MATCH_PARENT);
|
||||
}
|
||||
setContentView(frameView);
|
||||
applyImmersive();
|
||||
frameView.post(new Runnable() {
|
||||
@Override public void run() { applyImmersive(); }
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onWindowFocusChanged(boolean hasFocus) {
|
||||
super.onWindowFocusChanged(hasFocus);
|
||||
if (hasFocus) applyImmersive();
|
||||
}
|
||||
|
||||
private void applyImmersive() {
|
||||
android.view.Window w = getWindow();
|
||||
if (w == null) return;
|
||||
if (android.os.Build.VERSION.SDK_INT >= 30) {
|
||||
w.setDecorFitsSystemWindows(false);
|
||||
android.view.WindowInsetsController c = w.getInsetsController();
|
||||
if (c != null) {
|
||||
c.hide(android.view.WindowInsets.Type.systemBars());
|
||||
c.setSystemBarsBehavior(
|
||||
android.view.WindowInsetsController.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE);
|
||||
}
|
||||
} else {
|
||||
w.getDecorView().setSystemUiVisibility(
|
||||
android.view.View.SYSTEM_UI_FLAG_LAYOUT_STABLE
|
||||
| android.view.View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION
|
||||
| android.view.View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
|
||||
| android.view.View.SYSTEM_UI_FLAG_HIDE_NAVIGATION
|
||||
| android.view.View.SYSTEM_UI_FLAG_FULLSCREEN
|
||||
| android.view.View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY);
|
||||
}
|
||||
}
|
||||
|
||||
void updateFrame(java.nio.ByteBuffer buf, int w, int h) {
|
||||
frameView.updateFrame(buf, w, h);
|
||||
}
|
||||
}
|
||||
|
||||
private static class FrameView extends View {
|
||||
private android.graphics.Bitmap bitmap;
|
||||
private final android.graphics.Rect dst = new android.graphics.Rect();
|
||||
private final android.graphics.Paint paint = new android.graphics.Paint();
|
||||
private final Object lock = new Object();
|
||||
private int fw, fh;
|
||||
|
||||
FrameView(Context context) {
|
||||
super(context);
|
||||
paint.setFilterBitmap(false);
|
||||
paint.setAntiAlias(false);
|
||||
setBackgroundColor(0xFF000000);
|
||||
}
|
||||
|
||||
void updateFrame(java.nio.ByteBuffer buf, int w, int h) {
|
||||
synchronized (lock) {
|
||||
if (bitmap == null || fw != w || fh != h) {
|
||||
if (bitmap != null) bitmap.recycle();
|
||||
bitmap = android.graphics.Bitmap.createBitmap(w, h, android.graphics.Bitmap.Config.ARGB_8888);
|
||||
fw = w; fh = h;
|
||||
}
|
||||
buf.rewind();
|
||||
bitmap.copyPixelsFromBuffer(buf);
|
||||
}
|
||||
postInvalidate();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onDraw(android.graphics.Canvas canvas) {
|
||||
synchronized (lock) {
|
||||
if (bitmap == null || fw == 0 || fh == 0) return;
|
||||
int vw = getWidth(), vh = getHeight();
|
||||
int s = Math.min(vw / fw, vh / fh);
|
||||
if (s < 1) s = 1;
|
||||
int dw = fw * s, dh = fh * s;
|
||||
int dx = (vw - dw) / 2, dy = (vh - dh) / 2;
|
||||
dst.set(dx, dy, dx + dw, dy + dh);
|
||||
canvas.drawColor(0xFF000000);
|
||||
canvas.drawBitmap(bitmap, null, dst, paint);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1 +1 @@
|
||||
11.5
|
||||
12.0
|
||||
|
||||
+17
-38
@@ -1,4 +1,4 @@
|
||||
# iOS build (LÖVE 11.5)
|
||||
# iOS build (LÖVE 12.0)
|
||||
|
||||
> **Native ROM/mod/save import.** The iOS build ships a Swift
|
||||
> document-picker bridge (`native/GRPickerBridge.swift` + `GRBootstrap.m`)
|
||||
@@ -20,20 +20,17 @@
|
||||
> The note below about a missing "UIDocumentPicker handoff" is
|
||||
> resolved by this bridge.
|
||||
|
||||
macOS + Xcode only. Pins the official **LÖVE 11.5** iOS Xcode tree
|
||||
(`love-11.5-ios-source.zip` from [love2d/love releases](https://github.com/love2d/love/releases/tag/11.5)),
|
||||
matching `conf.lua`'s `t.version = "11.5"`.
|
||||
macOS + Xcode only. Fetches the **LÖVE 12.0** source tree and matching Apple
|
||||
dependencies from the official [LÖVE source](https://github.com/love2d/love)
|
||||
and [Apple dependencies](https://github.com/love2d/love-apple-dependencies)
|
||||
repositories. `conf.lua` declares LÖVE 12.0 on iOS and 11.5 elsewhere.
|
||||
|
||||
There is no separate `love2d/love-ios` GitHub repo for 11.5; the release zip
|
||||
**is** the vendored iOS project (Xcode project under
|
||||
`love-src/platform/xcode/love.xcodeproj`, target `love-ios`).
|
||||
|
||||
Pin file: [`LOVE_VERSION`](./LOVE_VERSION) → `11.5`.
|
||||
Pin file: [`LOVE_VERSION`](./LOVE_VERSION) → `12.0`.
|
||||
|
||||
## Quick start (simulator)
|
||||
|
||||
```bash
|
||||
# Fetch LÖVE 11.5 iOS sources (once) + build for Simulator
|
||||
# Fetch LÖVE 12.0 iOS sources and dependencies (once) + build for Simulator
|
||||
scripts/build_ios.sh --fetch
|
||||
```
|
||||
|
||||
@@ -80,10 +77,10 @@ Manual out-of-band steps:
|
||||
|
||||
| Path | Role |
|
||||
|------|------|
|
||||
| `LOVE_VERSION` | Engine pin (`11.5`) |
|
||||
| `LOVE_VERSION` | Engine pin (`12.0`) |
|
||||
| `overlays/love-ios.plist` | Portrait-only Info.plist + display name **Pokemon Red** (copied over the upstream plist every build) |
|
||||
| `love-src/` | Downloaded `love-11.5-ios-source` tree (**gitignored**, do not commit) |
|
||||
| `cache/` | Downloaded zips (**gitignored**) |
|
||||
| `love-src/` | Downloaded LÖVE 12.0 source tree (**gitignored**, do not commit) |
|
||||
| `cache/` | Temporary source and dependency checkout data (**gitignored**) |
|
||||
| `build/` | `xcodebuild` derived data (**gitignored**) |
|
||||
|
||||
Game payload lands at:
|
||||
@@ -94,28 +91,10 @@ and is fused into the built `.app` (LÖVE auto-runs any bundled `*.love`).
|
||||
|
||||
## Apple libraries dependency
|
||||
|
||||
The official `love-11.5-ios-source.zip` already ships prebuilt iOS
|
||||
xcframeworks under `platform/xcode/ios/libraries/` (SDL2, LuaJIT, freetype,
|
||||
ogg, vorbis, theora, modplug).
|
||||
|
||||
If that folder is missing or incomplete (e.g. you cloned sources without
|
||||
libs), download the matching prebuilts and install them:
|
||||
|
||||
```bash
|
||||
curl -fL -o mobile/ios/cache/love-11.5-apple-libraries.zip \
|
||||
https://github.com/love2d/love/releases/download/11.5/love-11.5-apple-libraries.zip
|
||||
unzip -q mobile/ios/cache/love-11.5-apple-libraries.zip -d mobile/ios/cache
|
||||
rm -rf mobile/ios/love-src/platform/xcode/ios/libraries
|
||||
cp -R mobile/ios/cache/love-apple-dependencies/iOS/libraries \
|
||||
mobile/ios/love-src/platform/xcode/ios/libraries
|
||||
```
|
||||
|
||||
`scripts/build_ios.sh` checks for `libraries/SDL2.xcframework` and fails with
|
||||
these instructions if it is absent.
|
||||
|
||||
Upstream also documents
|
||||
[love-apple-dependencies](https://github.com/love2d/love-apple-dependencies)
|
||||
as an alternate source of the same libraries.
|
||||
`scripts/build_ios.sh --fetch` retrieves the matching iOS libraries and the
|
||||
SDL3 framework from
|
||||
[love-apple-dependencies](https://github.com/love2d/love-apple-dependencies).
|
||||
Re-run it if either dependency directory is absent.
|
||||
|
||||
## App identity
|
||||
|
||||
@@ -134,7 +113,7 @@ so refreshing `love-src/` does not lose branding.
|
||||
| Flag | Meaning |
|
||||
|------|---------|
|
||||
| *(default)* | Simulator, Debug, no signing |
|
||||
| `--fetch` | Download/extract `love-11.5-ios-source.zip` if `love-src/` is missing |
|
||||
| `--fetch` | Fetch the LÖVE 12.0 source tree and Apple dependencies if `love-src/` is missing |
|
||||
| `--device` | Build against `iphoneos` instead of `iphonesimulator` |
|
||||
| `--release` | `Release` configuration instead of `Debug` |
|
||||
| `--package-only` | Zip `game.love` + apply plist overlay; skip `xcodebuild` |
|
||||
@@ -147,5 +126,5 @@ Also: `scripts/build.sh ios` delegates here (`--release` is forwarded).
|
||||
- iOS platform installed in Xcode (Settings → Platforms). `xcodebuild -showsdks`
|
||||
should list `iphonesimulator` / `iphoneos`. A partial install can fail IB/xib
|
||||
compiles with `iOS … Platform Not Installed` even when the SDK name appears.
|
||||
- `love-src/` present (`--fetch` or manual unzip of `love-11.5-ios-source.zip`)
|
||||
- iOS libraries under `love-src/platform/xcode/ios/libraries/` (see above)
|
||||
- `love-src/` present (`--fetch`)
|
||||
- iOS libraries under `love-src/platform/xcode/ios/libraries/` and SDL3 under `love-src/platform/xcode/shared/Frameworks/`
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>en</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>Pokemon Red</string>
|
||||
<string>gen1recomp</string>
|
||||
<key>CFBundleDocumentTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
@@ -49,10 +49,6 @@
|
||||
<false/>
|
||||
<key>UILaunchStoryboardName</key>
|
||||
<string>Launch Screen</string>
|
||||
<key>UIRequiredDeviceCapabilities</key>
|
||||
<array>
|
||||
<string>opengles-2</string>
|
||||
</array>
|
||||
<key>UIStatusBarHidden</key>
|
||||
<true/>
|
||||
<key>UIViewControllerBasedStatusBarAppearance</key>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Applies gen1recomp's iOS native-bridge patches to the fetched LÖVE 11.5
|
||||
"""Applies gen1recomp's iOS native-bridge patches to the fetched LÖVE 12.0
|
||||
source tree (mobile/ios/love-src/). Idempotent AND re-appliable: the first
|
||||
run stashes a pristine `.orig` copy of every file it rewrites, and later
|
||||
runs always start over from that copy — so editing the patch content here
|
||||
@@ -39,6 +39,7 @@ WRAP_INCLUDES = """
|
||||
#ifdef LOVE_IOS
|
||||
#include <objc/runtime.h>
|
||||
#include <objc/message.h>
|
||||
#include <string>
|
||||
#include "filesystem/Filesystem.h"
|
||||
#endif
|
||||
""" % MARKER
|
||||
@@ -53,8 +54,12 @@ WRAP_FUNCS = """
|
||||
#ifdef LOVE_IOS
|
||||
static const char *gr_saveDirectory()
|
||||
{
|
||||
static std::string saveDirectory;
|
||||
auto fs = Module::getInstance<love::filesystem::Filesystem>(Module::M_FILESYSTEM);
|
||||
return fs != nullptr ? fs->getSaveDirectory() : "";
|
||||
if (fs == nullptr)
|
||||
return "";
|
||||
saveDirectory = fs->getSaveDirectory();
|
||||
return saveDirectory.c_str();
|
||||
}
|
||||
|
||||
static int gr_callBridge(lua_State *L, const char *className,
|
||||
|
||||
@@ -156,7 +156,7 @@ return function(mod)
|
||||
caughtAreas()[areaKey(self.game, self)] = "DUPES_LOST"
|
||||
mod.save:set("caught_areas", caughtAreas())
|
||||
end
|
||||
Bag.add(self.game.save, ball, 1)
|
||||
Bag.add(self.game.save, ball, 1, self.game.data)
|
||||
self:say(reason == "area" and "This area already\nhas a captured POKéMON!"
|
||||
or "You already have\nthis POKéMON family!")
|
||||
return
|
||||
|
||||
@@ -35,6 +35,7 @@ TARGET="all"
|
||||
NOTARY_PROFILE="notary-profile"
|
||||
NOTARIZE=true
|
||||
IOS_RELEASE=false
|
||||
IOS_IPA=false
|
||||
|
||||
say() { printf '\033[1;32m==>\033[0m %s\n' "$*"; }
|
||||
warn() { printf '\033[1;33mwarn:\033[0m %s\n' "$*" >&2; }
|
||||
@@ -48,6 +49,7 @@ while [ $# -gt 0 ]; do
|
||||
--notary-profile) NOTARY_PROFILE="$2"; shift ;;
|
||||
--no-notarize) NOTARIZE=false ;;
|
||||
--release) IOS_RELEASE=true ;;
|
||||
--ipa) IOS_IPA=true ;;
|
||||
*) fail "unknown argument: $1" ;;
|
||||
esac
|
||||
shift
|
||||
@@ -298,6 +300,9 @@ build_android() {
|
||||
if [ "$VERSION_EXPLICIT" = true ]; then
|
||||
args+=(--version "$VERSION")
|
||||
fi
|
||||
if [ "$IOS_IPA" = true ]; then
|
||||
args+=(--ipa)
|
||||
fi
|
||||
"$ROOT/scripts/build_android.sh" ${args[@]+"${args[@]}"}
|
||||
}
|
||||
|
||||
|
||||
@@ -198,6 +198,10 @@ pack_game_love() {
|
||||
rm -f "$LOVE_FILE"
|
||||
# tools/save-editor ships with the app: the launcher's Edit button on a save
|
||||
# row opens it in-process, so it must be inside the archive (see build.sh).
|
||||
# Deliberately NO fused mods: a mod inside game.love sits in the read-only
|
||||
# APK, so the mod manager's Delete can't remove it and it reappears every
|
||||
# launch. Pokewalker ships as an importable .zip instead, which gives it
|
||||
# a real install/upgrade/delete lifecycle.
|
||||
(cd "$ROOT" && zip -q -9 -r "$LOVE_FILE" \
|
||||
main.lua conf.lua src data assets tools/save-editor \
|
||||
tools/rom_manifest.json tools/rom_manifest_blue.json \
|
||||
@@ -290,10 +294,32 @@ require_android_sdk() {
|
||||
# --------------------------------------------------------------- gradle
|
||||
run_gradle() {
|
||||
local task="assembleEmbedNoRecordDebug"
|
||||
say "building APK ($task)"
|
||||
local build_dir="$ANDROID_DIR"
|
||||
|
||||
# ndk-build is GNU make underneath and cannot cope with spaces anywhere in
|
||||
# the project path ("Your APP_BUILD_SCRIPT points to an unknown file").
|
||||
# When this checkout lives at a spaced path (e.g. "~/xCode Projects/..."),
|
||||
# shadow the android tree to a space-free location and build there; the
|
||||
# shadow persists across runs so gradle/ndk builds stay incremental.
|
||||
case "$ANDROID_DIR" in
|
||||
*" "*)
|
||||
build_dir="${TMPDIR:-/tmp}/gen1recomp-android-shadow"
|
||||
say "path contains spaces (ndk-build cannot handle them);"
|
||||
say "shadow-building in: $build_dir"
|
||||
mkdir -p "$build_dir"
|
||||
rsync -a --delete \
|
||||
--exclude=".gradle" --exclude="app/build" --exclude="love/build" \
|
||||
--exclude="local.properties" \
|
||||
"$ANDROID_DIR/" "$build_dir/"
|
||||
if [ -f "$ANDROID_DIR/local.properties" ]; then
|
||||
cp "$ANDROID_DIR/local.properties" "$build_dir/local.properties"
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
|
||||
say "building APK ($task)"
|
||||
if ! (
|
||||
cd "$ANDROID_DIR"
|
||||
cd "$build_dir"
|
||||
./gradlew --no-daemon "$task"
|
||||
); then
|
||||
fail "gradle $task failed.
|
||||
@@ -302,7 +328,7 @@ run_gradle() {
|
||||
You can still iterate on the .love payload with: scripts/build_android.sh --package-only"
|
||||
fi
|
||||
|
||||
local out_dir="$ANDROID_DIR/app/build/outputs/apk/embedNoRecord/debug"
|
||||
local out_dir="$build_dir/app/build/outputs/apk/embedNoRecord/debug"
|
||||
if [ -d "$out_dir" ]; then
|
||||
say "APK output:"
|
||||
find "$out_dir" -name '*.apk' -exec ls -lh {} \;
|
||||
|
||||
+81
-55
@@ -1,6 +1,6 @@
|
||||
#!/usr/bin/env bash
|
||||
# Packages the LÖVE2D Pokémon Red port into an iOS app via LÖVE 11.5's
|
||||
# official iOS Xcode project (love-11.5-ios-source.zip).
|
||||
# Packages the LÖVE2D Pokémon Red port into an iOS app via LÖVE 12.0's
|
||||
# iOS Xcode project.
|
||||
#
|
||||
# Usage: scripts/build_ios.sh [--fetch] [--device] [--release] [--install]
|
||||
# [--version X.Y.Z] [--package-only]
|
||||
@@ -12,13 +12,12 @@
|
||||
# first connected iPhone/iPad (unlock it first)
|
||||
# --release Release configuration
|
||||
# --version X.Y.Z stamp MARKETING_VERSION / CURRENT_PROJECT_VERSION
|
||||
# --fetch Download love-11.5-ios-source.zip into mobile/ios/love-src/
|
||||
# --fetch Fetch LÖVE 12.0 sources and Apple dependencies into mobile/ios/love-src/
|
||||
# --package-only Zip game.love + apply plist overlay; skip xcodebuild
|
||||
#
|
||||
# Prerequisites:
|
||||
# - macOS + Xcode (xcodebuild)
|
||||
# - mobile/ios/love-src/ (see --fetch / mobile/ios/README.md)
|
||||
# - prebuilt iOS libraries under love-src/platform/xcode/ios/libraries/
|
||||
#
|
||||
# Output: dist/ios/<Config>-<sdk>/gen1recomp.app (convenience copy)
|
||||
# dist/ios/gen1recomp.ipa (device builds only)
|
||||
@@ -50,21 +49,22 @@ DISPLAY_NAME="gen1recomp"
|
||||
# capabilities like HealthKit are involved), so a per-team default
|
||||
# lets anyone build without colliding with someone else's app
|
||||
# 4. simulator: the project default (no App ID registration involved)
|
||||
BUNDLE_ID="${GEN1_BUNDLE_ID:-}"
|
||||
BUNDLE_ID="${GEN1_BUNDLE_ID:-com.theboisclub.gen1recomp}"
|
||||
if [ -z "$BUNDLE_ID" ] && [ -f "$IOS_DIR/bundle_id.local" ]; then
|
||||
BUNDLE_ID="$(tr -d '[:space:]' < "$IOS_DIR/bundle_id.local")"
|
||||
fi
|
||||
LOVE_VERSION="$(tr -d '[:space:]' < "$IOS_DIR/LOVE_VERSION" 2>/dev/null || echo 11.5)"
|
||||
IOS_SOURCE_ZIP="love-${LOVE_VERSION}-ios-source.zip"
|
||||
APPLE_LIBS_ZIP="love-${LOVE_VERSION}-apple-libraries.zip"
|
||||
IOS_SOURCE_URL="https://github.com/love2d/love/releases/download/${LOVE_VERSION}/${IOS_SOURCE_ZIP}"
|
||||
APPLE_LIBS_URL="https://github.com/love2d/love/releases/download/${LOVE_VERSION}/${APPLE_LIBS_ZIP}"
|
||||
LOVE_VERSION="$(tr -d '[:space:]' < "$IOS_DIR/LOVE_VERSION" 2>/dev/null || echo 12.0)"
|
||||
LOVE_SOURCE_REF="${LOVE_SOURCE_REF:-main}"
|
||||
APPLE_DEPENDENCIES_REF="${APPLE_DEPENDENCIES_REF:-main}"
|
||||
LOVE_SOURCE_REPO="https://github.com/love2d/love.git"
|
||||
APPLE_DEPENDENCIES_REPO="https://github.com/love2d/love-apple-dependencies.git"
|
||||
|
||||
FETCH=false
|
||||
DEVICE=false
|
||||
RELEASE=false
|
||||
PACKAGE_ONLY=false
|
||||
INSTALL=false
|
||||
CREATE_IPA=false
|
||||
# Last resort for an incomplete source export, mirroring build_android.sh.
|
||||
MANIFEST_BASE_URL="${MANIFEST_BASE_URL:-https://raw.githubusercontent.com/bryanthaboi/gen1recomp/main}"
|
||||
MANIFESTS=""
|
||||
@@ -82,6 +82,7 @@ while [ $# -gt 0 ]; do
|
||||
--release) RELEASE=true ;;
|
||||
--package-only) PACKAGE_ONLY=true ;;
|
||||
--install) INSTALL=true ;;
|
||||
--ipa) CREATE_IPA=true ;;
|
||||
--version) VERSION="$2"; shift ;;
|
||||
-h|--help)
|
||||
sed -n '2,24p' "$0"
|
||||
@@ -92,6 +93,10 @@ while [ $# -gt 0 ]; do
|
||||
shift
|
||||
done
|
||||
|
||||
if $CREATE_IPA; then
|
||||
DEVICE=true
|
||||
fi
|
||||
|
||||
VERSION_CODE=""
|
||||
if [ -n "$VERSION" ]; then
|
||||
if ! printf '%s' "$VERSION" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$'; then
|
||||
@@ -154,25 +159,19 @@ fi
|
||||
# --------------------------------------------------------------- fetch love-src
|
||||
fetch_love_ios() {
|
||||
mkdir -p "$CACHE"
|
||||
local zip_path="$CACHE/$IOS_SOURCE_ZIP"
|
||||
if [ ! -f "$zip_path" ]; then
|
||||
say "downloading $IOS_SOURCE_ZIP (LÖVE $LOVE_VERSION iOS sources)"
|
||||
curl -fL --progress-bar "$IOS_SOURCE_URL" -o "$zip_path" \
|
||||
|| fail "download failed: $IOS_SOURCE_URL"
|
||||
else
|
||||
say "using cached $zip_path"
|
||||
fi
|
||||
|
||||
say "extracting into $LOVE_SRC"
|
||||
rm -rf "$LOVE_SRC"
|
||||
local tmp
|
||||
tmp="$(mktemp -d "$CACHE/extract.XXXXXX")"
|
||||
unzip -q "$zip_path" -d "$tmp"
|
||||
# Zip root is love-<version>-ios-source/
|
||||
local extracted
|
||||
extracted="$(find "$tmp" -maxdepth 1 -mindepth 1 -type d ! -name '__MACOSX' | head -1)"
|
||||
[ -n "$extracted" ] || fail "unexpected layout inside $IOS_SOURCE_ZIP"
|
||||
mv "$extracted" "$LOVE_SRC"
|
||||
say "fetching LÖVE $LOVE_VERSION sources ($LOVE_SOURCE_REF)"
|
||||
git clone --depth 1 --branch "$LOVE_SOURCE_REF" "$LOVE_SOURCE_REPO" "$tmp/love" \
|
||||
|| fail "failed to fetch LÖVE sources from $LOVE_SOURCE_REPO"
|
||||
say "fetching Apple dependencies ($APPLE_DEPENDENCIES_REF)"
|
||||
git clone --depth 1 --branch "$APPLE_DEPENDENCIES_REF" "$APPLE_DEPENDENCIES_REPO" "$tmp/dependencies" \
|
||||
|| fail "failed to fetch Apple dependencies from $APPLE_DEPENDENCIES_REPO"
|
||||
rm -rf "$LOVE_SRC"
|
||||
mv "$tmp/love" "$LOVE_SRC"
|
||||
mkdir -p "$LIBS_DIR" "$XCODE_DIR/shared"
|
||||
cp -R "$tmp/dependencies/iOS/libraries/." "$LIBS_DIR"
|
||||
cp -R "$tmp/dependencies/shared/." "$XCODE_DIR/shared"
|
||||
rm -rf "$tmp"
|
||||
say "love-src ready (LÖVE $LOVE_VERSION)"
|
||||
}
|
||||
@@ -182,18 +181,12 @@ if [ ! -d "$XCODE_DIR/love.xcodeproj" ]; then
|
||||
fetch_love_ios
|
||||
else
|
||||
fail "LÖVE $LOVE_VERSION iOS sources not found at mobile/ios/love-src/.
|
||||
Fetch them (documented download of love-${LOVE_VERSION}-ios-source.zip):
|
||||
Fetch them:
|
||||
scripts/build_ios.sh --fetch
|
||||
Or manually:
|
||||
mkdir -p mobile/ios/cache
|
||||
curl -fL -o mobile/ios/cache/$IOS_SOURCE_ZIP \\
|
||||
$IOS_SOURCE_URL
|
||||
unzip -q mobile/ios/cache/$IOS_SOURCE_ZIP -d mobile/ios/cache
|
||||
mv mobile/ios/cache/love-${LOVE_VERSION}-ios-source mobile/ios/love-src
|
||||
See mobile/ios/README.md."
|
||||
fi
|
||||
elif $FETCH; then
|
||||
say "love-src already present; skipping download (delete mobile/ios/love-src to refresh)"
|
||||
say "love-src already present; skipping fetch (delete mobile/ios/love-src to refresh)"
|
||||
fi
|
||||
|
||||
[ -d "$XCODE_DIR/love.xcodeproj" ] \
|
||||
@@ -201,25 +194,16 @@ fi
|
||||
|
||||
# --------------------------------------------------------------- apple libraries
|
||||
require_ios_libraries() {
|
||||
if [ -d "$LIBS_DIR/SDL2.xcframework" ]; then
|
||||
if [ -d "$LIBS_DIR/SDL2.xcframework" ] && [ -d "$XCODE_DIR/shared/Frameworks/SDL3.xcframework" ]; then
|
||||
return 0
|
||||
fi
|
||||
fail "prebuilt iOS libraries missing at:
|
||||
$LIBS_DIR
|
||||
love-ios expects SDL2.xcframework (and friends) there.
|
||||
and shared/Frameworks.
|
||||
|
||||
The official love-${LOVE_VERSION}-ios-source.zip normally includes them.
|
||||
If they are absent, install love-${LOVE_VERSION}-apple-libraries.zip:
|
||||
Re-fetch the LÖVE $LOVE_VERSION source tree and its Apple dependencies:
|
||||
|
||||
mkdir -p mobile/ios/cache
|
||||
curl -fL -o mobile/ios/cache/$APPLE_LIBS_ZIP \\
|
||||
$APPLE_LIBS_URL
|
||||
unzip -q mobile/ios/cache/$APPLE_LIBS_ZIP -d mobile/ios/cache
|
||||
rm -rf mobile/ios/love-src/platform/xcode/ios/libraries
|
||||
cp -R mobile/ios/cache/love-apple-dependencies/iOS/libraries \\
|
||||
mobile/ios/love-src/platform/xcode/ios/libraries
|
||||
|
||||
See mobile/ios/README.md (Apple libraries dependency)."
|
||||
scripts/build_ios.sh --fetch"
|
||||
}
|
||||
|
||||
require_ios_libraries
|
||||
@@ -232,6 +216,30 @@ apply_ios_branding() {
|
||||
cp "$OVERLAY_PLIST" "$dest"
|
||||
}
|
||||
|
||||
apply_ios_icon() {
|
||||
local source="$ROOT/assets/logo/logo.png"
|
||||
local target="$XCODE_DIR/Images.xcassets/iOS AppIcon.appiconset"
|
||||
[ -f "$source" ] || fail "missing iOS icon source: $source"
|
||||
[ -d "$target" ] || fail "missing iOS app icon set: $target"
|
||||
local entry name size
|
||||
while IFS=: read -r name size; do
|
||||
sips -z "$size" "$size" "$source" --out "$target/$name" >/dev/null
|
||||
done <<'EOF'
|
||||
icon-1024pt@1x.png:1024
|
||||
icon-29pt@1x.png:29
|
||||
icon-29pt@2x.png:58
|
||||
icon-29pt@3x.png:87
|
||||
icon-40pt@1x.png:40
|
||||
icon-40pt@2x.png:80
|
||||
icon-40pt@3x.png:120
|
||||
icon-60pt@2x.png:120
|
||||
icon-60pt@3x.png:180
|
||||
icon-76pt@1x.png:76
|
||||
icon-76pt@2x.png:152
|
||||
icon-83.5pt@2x.png:167
|
||||
EOF
|
||||
}
|
||||
|
||||
# --------------------------------------------------------------- game.love
|
||||
# Every version's import manifest has to ship or that game's ROM import fails in
|
||||
# the built app: decodeManifest (src/import/RomImporter.lua) errors outright when
|
||||
@@ -326,8 +334,9 @@ pack_game_love() {
|
||||
# in the built app while dev, which reads the source tree, stayed green.
|
||||
archive_entries="$(unzip -Z1 "$LOVE_FILE")"
|
||||
# shellcheck disable=SC2086 # MANIFESTS is a deliberate word list
|
||||
for required in tools/save-editor/App.lua tools/save-editor/Kit.lua \
|
||||
tools/save-editor/panels/Party.lua $MANIFESTS; do
|
||||
for required in src/update/Boot.lua tools/save-editor/App.lua \
|
||||
tools/save-editor/Kit.lua tools/save-editor/panels/Party.lua \
|
||||
$MANIFESTS; do
|
||||
printf '%s\n' "$archive_entries" | grep -qx "$required" \
|
||||
|| fail "game.love is missing $required"
|
||||
done
|
||||
@@ -534,11 +543,27 @@ run_xcodebuild() {
|
||||
|
||||
say "xcodebuild love-ios ($config / $sdk)"
|
||||
set +e
|
||||
(
|
||||
cd "$XCODE_DIR"
|
||||
xcodebuild "${args[@]}"
|
||||
)
|
||||
local xc_status=$?
|
||||
local xc_status
|
||||
if command -v xcbeautify >/dev/null 2>&1; then
|
||||
(
|
||||
cd "$XCODE_DIR"
|
||||
xcodebuild "${args[@]}"
|
||||
) 2>&1 | xcbeautify
|
||||
local pipeline_status=("${PIPESTATUS[@]}")
|
||||
local xcode_status=${pipeline_status[0]}
|
||||
local beautify_status=${pipeline_status[1]}
|
||||
if [ "$xcode_status" -ne 0 ]; then
|
||||
xc_status=$xcode_status
|
||||
else
|
||||
xc_status=$beautify_status
|
||||
fi
|
||||
else
|
||||
(
|
||||
cd "$XCODE_DIR"
|
||||
xcodebuild "${args[@]}"
|
||||
)
|
||||
xc_status=$?
|
||||
fi
|
||||
set -e
|
||||
if [ "$xc_status" -ne 0 ]; then
|
||||
fail "xcodebuild failed (exit $xc_status).
|
||||
@@ -640,6 +665,7 @@ install_to_device() {
|
||||
|
||||
# --------------------------------------------------------------- main
|
||||
apply_ios_branding
|
||||
apply_ios_icon
|
||||
say "applying iOS native bridge patches (picker/Files support)"
|
||||
python3 "$IOS_DIR/patch_love_src.py" || fail "patch_love_src.py failed"
|
||||
ensure_manifests
|
||||
|
||||
+204
-43
@@ -291,10 +291,17 @@ function BattleState:picImage(img)
|
||||
or PaletteFX.mode == "classic"
|
||||
if self.grayPics or mono then return grayImage(img) end
|
||||
-- SET_PAL_BATTLE_BLACK covers every battle palette slot, so the pics go
|
||||
-- dark with the HP bars while the blackout text is up (#292). Below the
|
||||
-- mono check on purpose: the forced-mono modes re-threshold the whole
|
||||
-- frame downstream, and the DMG had no SGB darkening to begin with.
|
||||
if self.blackedOut then return blackImage(self.data, img) end
|
||||
-- dark with the HP bars while the blackout text is up (#292). The intro
|
||||
-- silhouette slide (SlidePlayerAndEnemySilhouettesOnScreen) darkens the
|
||||
-- same way: the original slides both pics in under the %11100100
|
||||
-- silhouette palette and only runs SET_PAL_BATTLE once they have landed,
|
||||
-- so a still-sliding pic reads as a black silhouette, exactly like the
|
||||
-- evolution movie's PAL_BLACK (#577). Below the mono check on purpose:
|
||||
-- the forced-mono modes re-threshold the whole frame downstream, and the
|
||||
-- DMG had no SGB darkening to begin with.
|
||||
if self.blackedOut or (self.introSlide or 0) > 0 then
|
||||
return blackImage(self.data, img)
|
||||
end
|
||||
return fadeImage(img, self:activeBgp())
|
||||
end
|
||||
|
||||
@@ -645,17 +652,63 @@ function BattleState.newTrainer(game, oppClass, partyIndex)
|
||||
return self
|
||||
end
|
||||
|
||||
-- The disguise itself. InitWildBattle .isGhost (engine/battle/core.asm)
|
||||
-- swaps only the pic and the nick -- wEnemyMonSpecies2 still holds the real
|
||||
-- species, so the SGB palette stays the disguised mon's. ghostReal keeps
|
||||
-- what LoadEnemyMonData puts back when the scope unveils.
|
||||
local function disguiseAsGhost(self)
|
||||
self.ghostReal = { name = self.enemy.name, sprite = self.enemy.sprite }
|
||||
self.enemy.name = "GHOST"
|
||||
self.enemy.sprite = getImage("assets/generated/battle/front/ghost.png",
|
||||
monPalette(self.data, self.enemy.mon.species))
|
||||
self.introText = Strings("The GHOST\nappeared!")
|
||||
end
|
||||
|
||||
-- Pokémon Tower ghosts (engine/battle/core.asm): without the Silph Scope
|
||||
-- the enemy is "GHOST", you're too scared to attack, and balls fail.
|
||||
function BattleState:makeGhost()
|
||||
self.ghost = true
|
||||
self.enemy.name = "GHOST"
|
||||
-- the ghost keeps the disguised mon's SGB palette: InitWildBattle
|
||||
-- swaps only the pic, wEnemyMonSpecies2 still holds the real species
|
||||
-- (engine/battle/core.asm InitWildBattle .isGhost)
|
||||
self.enemy.sprite = getImage("assets/generated/battle/front/ghost.png",
|
||||
monPalette(self.data, self.enemy.mon.species))
|
||||
self.introText = Strings("The GHOST\nappeared!")
|
||||
disguiseAsGhost(self)
|
||||
end
|
||||
|
||||
-- The RESTLESS SOUL with the SILPH SCOPE in the bag. Mechanically this is
|
||||
-- not a ghost battle at all -- IsGhostBattle (core.asm) returns false the
|
||||
-- moment the scope is in the bag, so the mon attacks, is attacked and rolls
|
||||
-- for flight normally -- but InitWildBattle still enters disguised: its
|
||||
-- .isGhost branch fires on wCurOpponent == RESTLESS_SOUL as well. Taking
|
||||
-- the disguise off is PrintBeginningBattleText .isMarowak's job
|
||||
-- (engine/battle/common_text.asm), which enter() queues through
|
||||
-- queueScopeReveal (#492).
|
||||
function BattleState:makeUnveiledGhost()
|
||||
self.scopeReveal = true
|
||||
disguiseAsGhost(self)
|
||||
end
|
||||
|
||||
-- MarowakAnim (engine/battle/ghost_marowak_anim.asm) counted in frames: the
|
||||
-- ghost pic is copied into OAM and flashed 8 times by xor-ing rOBP1 with
|
||||
-- $80 (10 frames a side), the BG pic underneath is swapped to the RESTLESS
|
||||
-- SOUL, rOBP1 is shifted left two bits every 10 frames until the ghost has
|
||||
-- faded into the paper (3 shifts), and the Marowak copy fades back in the
|
||||
-- same way (4 shifts). Alpha over the paper is what those OBP shifts look
|
||||
-- like on screen.
|
||||
BattleState.GHOST_FLASH_FRAMES = 80
|
||||
BattleState.GHOST_FADE_OUT_FRAMES = 30
|
||||
BattleState.GHOST_FADE_IN_FRAMES = 40
|
||||
BattleState.GHOST_REVEAL_FRAMES = 150
|
||||
|
||||
-- The rows .isMarowak prints over the disguise, after the "GHOST appeared!"
|
||||
-- box enter() has already queued: the unveil line, MarowakAnim, then "Wild
|
||||
-- MAROWAK appeared!" under the restored name. The fall-through into
|
||||
-- .playSFX at the end is the same SFX_TRAINER_APPEARED the port does not
|
||||
-- play for trainer intros either, so it is left out here too.
|
||||
function BattleState:queueScopeReveal()
|
||||
local unveiled = self.data.text and self.data.text._UnveiledGhostText
|
||||
self:say(unveiled
|
||||
or Strings("SILPH SCOPE\nunveiled the\vGHOST's identity!"))
|
||||
self:act(function() self.ghostReveal = { t = 0 } end)
|
||||
table.insert(self.queue, { wait = BattleState.GHOST_REVEAL_FRAMES })
|
||||
self:say(Strings("Wild %s\nappeared!",
|
||||
self.ghostReal and self.ghostReal.name or self.enemy.name))
|
||||
end
|
||||
|
||||
-- The old man's catch tutorial (BATTLE_TYPE_OLD_MAN,
|
||||
@@ -669,6 +722,12 @@ end
|
||||
function BattleState:makeOldManDemo(name)
|
||||
self.demo = true
|
||||
self.demoName = name or "OLD MAN"
|
||||
-- LoadPlayerBackPic and DisplayBattleMenu split on the same wBattleType:
|
||||
-- BATTLE_TYPE_OLD_MAN gets .oldManName + OldManPicBack, BATTLE_TYPE_PIKACHU
|
||||
-- gets .profOakName + ProfOakPicBack (pokeyellow core.asm). The thrower
|
||||
-- name the caller passes IS that distinction here, so it picks the pic too
|
||||
-- -- data/scripts/story2.lua is the only site that names PROF.OAK (#557).
|
||||
self.oakDemo = name == "PROF.OAK"
|
||||
-- Yellow's Pallet intro runs this before the player owns any mon
|
||||
-- (BATTLE_TYPE_PIKACHU precedes the lab gift), so newWild flagged the
|
||||
-- battle dead for lack of a party. The demo never sends out, draws, or
|
||||
@@ -1189,8 +1248,11 @@ function BattleState:enter()
|
||||
-- without a transition (link battles, scripted pushes)
|
||||
Music.playBattle(self.data, self.musicKind)
|
||||
-- intro presentation (SlidePlayerAndEnemySilhouettesOnScreen): both
|
||||
-- sides slide in; the trainer pics stay up until the send-outs
|
||||
self.introSlide = 40
|
||||
-- sides slide in as black silhouettes. The original scrolls SCX from
|
||||
-- $90 to 0 two pixels per frame (72 frames); the port covers the full
|
||||
-- 160px screen width, so 2px/frame is an 80-frame slide (slide offset is
|
||||
-- introSlide*2 below). The trainer pics stay up until the send-outs.
|
||||
self.introSlide = 80
|
||||
self.showEnemyTrainer = self.kind == "trainer" and self.trainerPic ~= nil
|
||||
-- DrawAllPokeballs (common_text.asm:27) puts the party ball rows AND the
|
||||
-- HUD corner/underline tiles under them (PlacePlayerHUDTiles /
|
||||
@@ -1214,7 +1276,8 @@ function BattleState:enter()
|
||||
-- fade, the ground-padding measurement and battle_sprite_scales.
|
||||
local backPath, backTrueColor =
|
||||
require("src.pokemon.Sprites").playerPath(self.data, "back",
|
||||
{ kind = "battle", demo = self.demo, battle = self })
|
||||
{ kind = "battle", demo = self.demo, oakDemo = self.oakDemo,
|
||||
battle = self })
|
||||
self.playerBackPic = getImage(backPath,
|
||||
namedPalette(self.data, "MEWMON"), backTrueColor)
|
||||
self.showPlayerBack = self.playerBackPic ~= nil
|
||||
@@ -1229,11 +1292,16 @@ function BattleState:enter()
|
||||
-- battle calls PlayCry BEFORE PrintText WildMonAppearedText, so the cry
|
||||
-- sounds with the "Wild X appeared!" box instead of waiting on the A
|
||||
-- press that clears its `prompt` (#303). The Silph-Scope-less tower
|
||||
-- ghost gets no cry at all (common_text.asm:43-48).
|
||||
if self.kind ~= "trainer" and self.kind ~= "link" and not self.ghost then
|
||||
-- ghost gets no cry at all (common_text.asm:43-48), and neither does the
|
||||
-- unveiled MAROWAK: .isMarowak never reaches PlayCry (#492).
|
||||
if self.kind ~= "trainer" and self.kind ~= "link"
|
||||
and not self.ghost and not self.scopeReveal then
|
||||
queueEnemyCry()
|
||||
end
|
||||
self:say(self.introText)
|
||||
-- the unveil rides on that same box, before _InitBattleCommon clears the
|
||||
-- intro chrome below (#492)
|
||||
if self.scopeReveal then self:queueScopeReveal() end
|
||||
-- _InitBattleCommon (core.asm:6755-6762): the instant the intro text is
|
||||
-- dismissed both HUD blocks are cleared and ClearSprites drops the
|
||||
-- pokeball OAM, so the intro chrome never returns for the rest of the
|
||||
@@ -2663,6 +2731,35 @@ function BattleState:updateFx()
|
||||
end
|
||||
end
|
||||
end
|
||||
-- The Silph Scope unveil (MarowakAnim; queueScopeReveal parks a wait row
|
||||
-- of GHOST_REVEAL_FRAMES over it): the ghost flashes, fades out, the pic
|
||||
-- and the nick swap back the way LoadEnemyMonData restores them, and the
|
||||
-- real mon fades in (#492).
|
||||
local gr = self.ghostReveal
|
||||
if gr and self.enemy then
|
||||
gr.t = gr.t + 1
|
||||
local pf = self:picFxFor(self.enemy)
|
||||
local flashEnd = BattleState.GHOST_FLASH_FRAMES
|
||||
local outEnd = flashEnd + BattleState.GHOST_FADE_OUT_FRAMES
|
||||
if gr.t <= flashEnd then
|
||||
pf.fade = (math.floor((gr.t - 1) / 10) % 2 == 1) and 0.5 or 1
|
||||
elseif gr.t <= outEnd then
|
||||
pf.fade = 1 - math.ceil((gr.t - flashEnd) / 10) / 3
|
||||
else
|
||||
if not gr.swapped then
|
||||
gr.swapped = true
|
||||
local real = self.ghostReal
|
||||
if real then
|
||||
self.enemy.name = real.name or self.enemy.name
|
||||
self.enemy.sprite = real.sprite or self.enemy.sprite
|
||||
end
|
||||
end
|
||||
pf.fade = math.min(1, math.ceil((gr.t - outEnd) / 10) / 4)
|
||||
end
|
||||
if gr.t >= BattleState.GHOST_REVEAL_FRAMES then
|
||||
self.ghostReveal, self.scopeReveal, pf.fade = nil, nil, nil
|
||||
end
|
||||
end
|
||||
-- the send-out grow-in (AnimateSendingOutMon): 3+4+5 frames, then
|
||||
-- the pic draws at full size again
|
||||
if self.growIn then
|
||||
@@ -2677,10 +2774,24 @@ function BattleState:updateFx()
|
||||
-- so a sounding siren rides out the next hit's HP drain instead of
|
||||
-- dropping out mid-announcement (#293)
|
||||
self.lowHealthAlarmOn = self:lowHealthAlarmActive()
|
||||
if self.lowHealthAlarmOn then
|
||||
Sound.startLoop(self.data, "Low_Health_Alarm")
|
||||
-- battle.low_health_alarm: on/off toggle for the siren loop, ctx.on
|
||||
-- mirrors self.lowHealthAlarmOn. Vanilla just starts/stops the loop
|
||||
-- each frame; a mod can wrap this to reshape the toggle (e.g. force
|
||||
-- ctx.on false after some budget) before letting vanilla act on it.
|
||||
if Runtime.wantsHook("battle.low_health_alarm") then
|
||||
Runtime.call("battle.low_health_alarm", function(ctx)
|
||||
if ctx.on then
|
||||
Sound.startLoop(ctx.battle.data, "Low_Health_Alarm")
|
||||
else
|
||||
Sound.stopLoop("Low_Health_Alarm")
|
||||
end
|
||||
end, { on = self.lowHealthAlarmOn, battle = self })
|
||||
else
|
||||
Sound.stopLoop("Low_Health_Alarm")
|
||||
if self.lowHealthAlarmOn then
|
||||
Sound.startLoop(self.data, "Low_Health_Alarm")
|
||||
else
|
||||
Sound.stopLoop("Low_Health_Alarm")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -3216,7 +3327,10 @@ function BattleState:onFaint(battler)
|
||||
end
|
||||
end
|
||||
|
||||
function BattleState:enemyMonFainted()
|
||||
-- Exp for the defeated enemy, shared by the faint path (enemyMonFainted)
|
||||
-- and, when a mod's battle.catch_exp hook says so, the catch path
|
||||
-- (storeCaughtMon).
|
||||
function BattleState:awardExp()
|
||||
-- exp is split among the mons that fought this enemy
|
||||
-- (engine/battle/experience.asm); traded mons earn x1.5; each
|
||||
-- participant gets the full stat exp
|
||||
@@ -3294,27 +3408,44 @@ function BattleState:enemyMonFainted()
|
||||
end
|
||||
end
|
||||
end
|
||||
-- with EXP.ALL, participants split half the exp and the other half
|
||||
-- is divided among the whole party (engine/battle/experience.asm)
|
||||
local expAll = (self.game.save.inventory.EXP_ALL or 0) > 0
|
||||
for _, mon in ipairs(alive) do
|
||||
applyShare(mon, participants * (expAll and 2 or 1), true)
|
||||
end
|
||||
if expAll then
|
||||
-- the second GainExperience pass sets the gain flags for the WHOLE
|
||||
-- party, so DivideExpDataByNumMonsGainingExp divides the already
|
||||
-- halved-and-participant-divided exp again by the party count, and
|
||||
-- .partyMonLoop still skips fainted mons (core.asm:818-858 +
|
||||
-- experience.asm:9-13); each mon gets its own GainedText with the
|
||||
-- "with EXP.ALL," tail (wBoostExpByExpAll) -- pokered prints no
|
||||
-- summary line
|
||||
for _, mon in ipairs(self.game.save.party) do
|
||||
if mon.hp > 0 then
|
||||
applyShare(mon, math.max(1, participants) * #self.game.save.party * 2, "expAll")
|
||||
-- battle.exp_award: the participant/EXP.ALL split, factored out so a
|
||||
-- mod can replace it wholesale (e.g. a flat undivided share to every
|
||||
-- non-fainted party mon) without re-deriving participants/alive.
|
||||
-- ctx.applyShare(mon, split, announce) is the same helper vanilla uses.
|
||||
local function vanillaExpAward(ctx)
|
||||
-- with EXP.ALL, participants split half the exp and the other half
|
||||
-- is divided among the whole party (engine/battle/experience.asm)
|
||||
local expAll = (self.game.save.inventory.EXP_ALL or 0) > 0
|
||||
for _, mon in ipairs(ctx.alive) do
|
||||
ctx.applyShare(mon, ctx.participants * (expAll and 2 or 1), true)
|
||||
end
|
||||
if expAll then
|
||||
-- the second GainExperience pass sets the gain flags for the WHOLE
|
||||
-- party, so DivideExpDataByNumMonsGainingExp divides the already
|
||||
-- halved-and-participant-divided exp again by the party count, and
|
||||
-- .partyMonLoop still skips fainted mons (core.asm:818-858 +
|
||||
-- experience.asm:9-13); each mon gets its own GainedText with the
|
||||
-- "with EXP.ALL," tail (wBoostExpByExpAll) -- pokered prints no
|
||||
-- summary line
|
||||
for _, mon in ipairs(self.game.save.party) do
|
||||
if mon.hp > 0 then
|
||||
ctx.applyShare(mon, math.max(1, ctx.participants) * #self.game.save.party * 2, "expAll")
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
local awardCtx = { battle = self, participants = participants, alive = alive,
|
||||
applyShare = applyShare }
|
||||
if Runtime.wantsHook("battle.exp_award") then
|
||||
Runtime.call("battle.exp_award", vanillaExpAward, awardCtx)
|
||||
else
|
||||
vanillaExpAward(awardCtx)
|
||||
end
|
||||
self.participants = {}
|
||||
end
|
||||
|
||||
function BattleState:enemyMonFainted()
|
||||
self:awardExp()
|
||||
|
||||
if self.kind == "trainer" then
|
||||
-- EnemySendOutFirstMon / AnyEnemyPokemonAliveCheck (core.asm): scan
|
||||
@@ -3353,10 +3484,12 @@ function BattleState:enemyMonFainted()
|
||||
-- SwitchPlayerMon runs AFTER TrainerSentOutText (core.asm:1436-1443)
|
||||
local shiftSwitchMon = nil
|
||||
if style ~= "set" and partyCount > 1 and self.player.mon.hp > 0 then
|
||||
-- _TrainerAboutToUseText: "X is" / "about to use" then cont nick,
|
||||
-- then para "Will PLAYER" / "change POKéMON?" with YES/NO.
|
||||
self:say(Strings("%s is\nabout to use", self.trainer.name))
|
||||
self:say(Strings("%s!", nextName))
|
||||
-- _TrainerAboutToUseText (data/text/text_2.asm): "X is" / "about to
|
||||
-- use" then a CONT to the nick, not a fresh box -- the box scrolls
|
||||
-- "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.
|
||||
self:say(Strings("%s is\nabout to use\v%s!", self.trainer.name, nextName))
|
||||
self:sayChoice(
|
||||
Strings("Will %s\nchange POKéMON?", self.game.save.player.name),
|
||||
function(yes)
|
||||
@@ -3474,8 +3607,18 @@ function BattleState:enemyMonFainted()
|
||||
-- PrintEndBattleText prints one text box; a `para` (\f) inside it
|
||||
-- starts a fresh page, which is a message row of its own here (five
|
||||
-- EndBattleTexts carry one, e.g. _Route9Youngster1EndBattleText)
|
||||
-- TrainerEndBattleText (home/trainers.asm) prints _TrainerNameText
|
||||
-- (wNameBuffer then ": ", data/text/text_1.asm) and only then the
|
||||
-- saved pointer, so the loss line opens with the trainer class tag on
|
||||
-- its first line (RIVAL1/2/3 tag with the rival's name:
|
||||
-- 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
|
||||
for page in (self.endBattleText .. "\f"):gmatch("(.-)\f") do
|
||||
if page ~= "" then self:sayNext(page) end
|
||||
if page ~= "" then
|
||||
self:sayNext(tag and (tag .. ": " .. page) or page)
|
||||
tag = nil
|
||||
end
|
||||
end
|
||||
end
|
||||
self:sayNext(Strings("%s got ¥%d\nfor winning!", self.game.save.player.name, prize))
|
||||
@@ -3875,6 +4018,12 @@ function BattleState:storeCaughtMon()
|
||||
-- (item_effects.asm:472-501), regenerating its move list from the
|
||||
-- base data -- a Mimic'd slot never leaves the battle with it
|
||||
self:restoreMimicked(self.enemy)
|
||||
-- battle.catch_exp: vanilla catches never grant exp; a mod can flip
|
||||
-- this to true to pay out the same award a faint would have.
|
||||
if Runtime.wantsHook("battle.catch_exp")
|
||||
and Runtime.call("battle.catch_exp", function() return false end, { battle = self }) then
|
||||
self:awardExp()
|
||||
end
|
||||
local game = self.game
|
||||
local dex = game.save.pokedex
|
||||
local species = self.enemy.mon.species
|
||||
@@ -4304,6 +4453,18 @@ function BattleState:drawBattlerPic(battler, x, y, scale)
|
||||
end
|
||||
if battler.fainted then return end
|
||||
|
||||
-- MarowakAnim's rOBP1 fades (updateFx): alpha over the paper is what those
|
||||
-- palette shifts look like, and the test has to sit ahead of the plain
|
||||
-- fast path below (#492)
|
||||
local fadePf = self.picFx and self.picFx[battler]
|
||||
if fadePf and fadePf.fade then
|
||||
local cr, cg, cb, ca = love.graphics.getColor()
|
||||
love.graphics.setColor(cr, cg, cb, ca * fadePf.fade)
|
||||
love.graphics.draw(img, x, y, 0, scale, scale)
|
||||
love.graphics.setColor(cr, cg, cb, ca)
|
||||
return
|
||||
end
|
||||
|
||||
local pf = self.picFx and self.picFx[battler]
|
||||
if not pf or (not pf.kind and not pf.hidden and not pf.minimized
|
||||
and (pf.ox or 0) == 0 and (pf.oy or 0) == 0) then
|
||||
@@ -5069,7 +5230,7 @@ function BattleState:drawClassic()
|
||||
if sx == 0 and sy == 0 and fx and fx.shake and fx.shake > 0 then
|
||||
sx = self.frame % 4 < 2 and 2 or -2
|
||||
end
|
||||
local slide = (self.introSlide or 0) * 4 -- intro slide-in offset
|
||||
local slide = (self.introSlide or 0) * 2 -- intro slide-in offset (2px/frame)
|
||||
|
||||
if self:colorMode() then
|
||||
-- SGB pipeline: gray BG canvas -> (wavy) -> zone recolor with the
|
||||
|
||||
@@ -183,6 +183,21 @@ local function drawCommandMenu(battle)
|
||||
Font.drawBox(0, 13, 20, 5)
|
||||
Font.drawBox(20, 13, 18, 5)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
-- The old-man / PROF.OAK catch demo has no party, so makeOldManDemo parks
|
||||
-- the WILD mon in battle.player as a placeholder (BattleState:1209). The
|
||||
-- classic layout's demo branch never names anyone (DisplayBattleMenu,
|
||||
-- core.asm:2038-2049, just draws the menu), so naming it here printed
|
||||
-- "What will PIKACHU do?" over Oak's scripted throw (#557). Leave the
|
||||
-- prompt side blank and run the same scripted hand the classic does.
|
||||
if battle.demo then
|
||||
Font.draw(Strings("FIGHT"), 176, 112)
|
||||
Font.drawCode(0xE1, 240, 112); Font.drawCode(0xE2, 248, 112)
|
||||
Font.draw(Strings("ITEM"), 176, 128)
|
||||
Font.draw(Strings("RUN"), 240, 128)
|
||||
-- next to FIGHT for the first 80 frames, then ITEM
|
||||
Font.drawCode(0xED, 168, (battle.demoTimer or 0) <= 80 and 112 or 128)
|
||||
return
|
||||
end
|
||||
Font.draw(Strings("What will"), 8, 112)
|
||||
local who = battle.player and battle.player.name or ""
|
||||
Font.draw(fitName(who, 112) .. Strings(" do?"), 8, 128)
|
||||
@@ -305,22 +320,40 @@ function WideBattle.draw(battle)
|
||||
if sx == 0 and sy == 0 and fx and fx.shake and fx.shake > 0 then
|
||||
sx = battle.frame % 4 < 2 and 2 or -2
|
||||
end
|
||||
local slide = (battle.introSlide or 0) * 4
|
||||
local slide = (battle.introSlide or 0) * 2
|
||||
|
||||
-- Each side keeps its original sprite pixels and placement math: the two
|
||||
-- 160x144 OAM regions are translated apart and clipped into the wider
|
||||
-- battlefield rather than either monster being scaled. wideRegion tells
|
||||
-- drawBattlerPic its own side window is already the clip.
|
||||
-- drawBattlerPic its own side window is already the clip. A shake moves
|
||||
-- each region's clip with its contents, so a pic pushed toward a region
|
||||
-- edge is not sheared off it (a vertical shake used to clip the player's
|
||||
-- feet at FIELD_BOTTOM).
|
||||
battle.wideRegion = true
|
||||
inRegion(0, 32, 160, WideBattle.FIELD_BOTTOM - 32, 20 + sx, 8 + sy,
|
||||
inRegion(sx, 32 + sy, 160, WideBattle.FIELD_BOTTOM - 32, 20 + sx, 8 + sy,
|
||||
function() battle:drawPicsLayer(slide, 0, 0, "player", true) end)
|
||||
inRegion(160, 0, 144, WideBattle.FIELD_BOTTOM, 136 + sx, sy,
|
||||
inRegion(160 + sx, sy, 144, WideBattle.FIELD_BOTTOM, 136 + sx, sy,
|
||||
function() battle:drawPicsLayer(slide, 0, 0, "enemy", true) end)
|
||||
battle.wideRegion = nil
|
||||
|
||||
drawHUDs(battle, slide)
|
||||
-- A battle sets rWY to 0 (engine/battle/core.asm), so the window the
|
||||
-- shakes move IS the whole screen: PredefShakeScreenHorizontally,
|
||||
-- PredefShakeScreenVertically and AnimationShakeScreenHorizontallySlow
|
||||
-- displace the HUDs and the message window along with the pics, exactly as
|
||||
-- drawClassic offsets its whole BG canvas. Shaking only the two pic
|
||||
-- regions left the foe gliding sideways across a nailed-down screen on
|
||||
-- every applying-attack shake -- TAIL WHIP and every other status move
|
||||
-- (#562). The OAM anim layer stays put, as it does in drawClassic.
|
||||
local function shaken(fn)
|
||||
if sx == 0 and sy == 0 then return fn() end
|
||||
g.push()
|
||||
g.translate(sx, sy)
|
||||
fn()
|
||||
g.pop()
|
||||
end
|
||||
shaken(function() drawHUDs(battle, slide) end)
|
||||
drawAnimationLayer(battle)
|
||||
drawTextArea(battle)
|
||||
shaken(function() drawTextArea(battle) end)
|
||||
|
||||
if fx and fx.flash and fx.flash > 0 and battle.frame % 4 < 2 then
|
||||
g.setColor(1, 1, 1, 0.85)
|
||||
|
||||
+12
-5
@@ -14,12 +14,12 @@ local MODULES = {
|
||||
-- Optional for compatibility with developer and stale caches.
|
||||
local OPTIONAL = { "audio", "palettes", "icons" }
|
||||
|
||||
-- The rules the engine still carries as literals. The constants registry
|
||||
-- deep-merges over these, so a value has to exist before a mod can patch
|
||||
-- it; each one is the number the engine hard-codes today, so seeding them
|
||||
-- changes nothing on a mod-free boot.
|
||||
-- Vanilla defaults for rules exposed through the constants registry. A
|
||||
-- value has to exist before a mod can patch it; each one matches the
|
||||
-- engine's no-mod behavior, so seeding them changes nothing on a vanilla
|
||||
-- boot.
|
||||
local CONSTANT_DEFAULTS = {
|
||||
bagSize = 20, -- BAG_ITEM_CAPACITY (src/inventory/Bag.lua)
|
||||
bagSize = 20, -- BAG_ITEM_CAPACITY (Bag.capacity fallback)
|
||||
partyMax = 6, -- PARTY_LENGTH (src/pokemon/Party.lua)
|
||||
boxCount = 12, boxSize = 20, -- Bill's PC (src/pokemon/Boxes.lua)
|
||||
moveMax = 4,
|
||||
@@ -77,6 +77,13 @@ end
|
||||
function Data:applyVersionedFieldData()
|
||||
if require("src.core.GameVersion").isYellow() then
|
||||
self.field.trades = copy(YELLOW_TRADES)
|
||||
-- The old man's catch demo is a RATTATA in Yellow
|
||||
-- (scripts/ViridianCity.asm ViridianCityOldManStartCatchTrainingScript
|
||||
-- .SetupBattle: ld a, RATTATA / ld [wCurOpponent], a) but the Yellow
|
||||
-- manifest inherited Red's WEEDLE field.oldManBattle (#617), so old
|
||||
-- Yellow caches carry the wrong demo species too. The fixed import
|
||||
-- manifest below stamps RATTATA for fresh imports.
|
||||
self.field.oldManBattle = { species = "RATTATA", level = 5 }
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -178,6 +178,22 @@ function Game:step(dt)
|
||||
-- this logic tick, not one tick later. With no wrapper this is a no-op.
|
||||
ModRuntime.call("input.step", function() end, self, dt)
|
||||
self.input:step()
|
||||
-- A+B+SELECT+START held for 16 steps: SoftReset (home/init.asm) stops the
|
||||
-- audio, whites the palettes out and falls through into Init, i.e. the
|
||||
-- power-on boot, so everything unsaved is gone and the title sequence
|
||||
-- comes back -- exactly what returnToTitle does for the START menu's QUIT.
|
||||
-- The check lives here, above stack:update, so it fires from any state:
|
||||
-- overworld, battle, a menu or a cutscene, the way _Joypad's does (#563).
|
||||
-- Input:reset clears the four still-physically-held buttons so the title
|
||||
-- screen does not read A as a menu choice on its first frame.
|
||||
-- a tool mod (or a harness) may hand Game a stand-in input with no
|
||||
-- chord bookkeeping; those simply never soft reset
|
||||
if self.input.softResetStep and self.input:softResetStep() then
|
||||
Input:reset()
|
||||
TouchControls:reset()
|
||||
self:returnToTitle()
|
||||
return
|
||||
end
|
||||
-- serviced unconditionally: a link battle's ENet transport must not
|
||||
-- stall just because PartyMenu/ChoiceBox/NamingScreen is temporarily
|
||||
-- on top of BattleState (see LinkBattle.new)
|
||||
@@ -475,8 +491,16 @@ function Game:restartWithMods()
|
||||
require("src.core.HostShell").restart()
|
||||
end
|
||||
|
||||
-- Releases reach Input even while a top state captures raw input: a
|
||||
-- swallowed key-up would strand a held-state flag for a key Input saw go
|
||||
-- down before the capture armed (the stuck-flag hazard Input:reset
|
||||
-- exists for). The top state only OBSERVES the release afterwards,
|
||||
-- unlike onKeyPressed above which owns the press, so BindingsMenu can
|
||||
-- commit a capture on the key-up (#589).
|
||||
function Game:keyreleased(key)
|
||||
Input:keyreleased(key)
|
||||
local top = self.stack and self.stack:top()
|
||||
if top and top.onKeyReleased then top:onKeyReleased(key) end
|
||||
end
|
||||
|
||||
function Game:gamepadpressed(joystick, button)
|
||||
@@ -493,7 +517,10 @@ function Game:gamepadpressed(joystick, button)
|
||||
end
|
||||
|
||||
function Game:gamepadreleased(joystick, button)
|
||||
-- same observe-after-Input contract as Game:keyreleased (#589)
|
||||
Input:gamepadreleased(joystick, button)
|
||||
local top = self.stack and self.stack:top()
|
||||
if top and top.onGamepadReleased then top:onGamepadReleased(button) end
|
||||
end
|
||||
|
||||
function Game:gamepadaxis(joystick, axis, value)
|
||||
|
||||
@@ -26,9 +26,30 @@ end
|
||||
-- ("Failed to initialize filesystem: already initialized") and the relaunch
|
||||
-- crashes. So on an AppImage we relaunch the executable; the fresh process's
|
||||
-- Boot step mounts any downloaded update exactly as a manual relaunch would.
|
||||
-- Android hits the same wall (#575): the vendored love.cpp loops runlove()
|
||||
-- in-process on "restart", and PHYSFS_deinit in the old Filesystem module's
|
||||
-- destructor fails ("files still open") whenever any physfs handle survives
|
||||
-- lua_close, so the second PHYSFS_init throws the same "already initialized"
|
||||
-- and the app dies. There we relaunch through the GameActivity.restartApp
|
||||
-- JNI bridge (love.system.restartApp), which schedules our launch intent
|
||||
-- and kills the process so no native state can leak into the fresh run.
|
||||
-- On every other platform the in-process restart works, so keep it.
|
||||
function HostShell.restart()
|
||||
if not (love and love.event and love.event.quit) then return end
|
||||
|
||||
local osName = love.system and love.system.getOS and love.system.getOS()
|
||||
if osName == "Android" then
|
||||
-- restartApp kills the process on success, so a true return is never
|
||||
-- observed; false means the bridge could not schedule the relaunch.
|
||||
-- An older APK whose liblove predates the bridge (love.system.restartApp
|
||||
-- is nil) has no crash-free in-process restart, so quit to the OS
|
||||
-- cleanly and let the player relaunch by hand -- worse than restarting,
|
||||
-- but better than the guaranteed crash of quit("restart") (#575).
|
||||
if love.system.restartApp and love.system.restartApp() then return end
|
||||
love.event.quit()
|
||||
return
|
||||
end
|
||||
|
||||
local appimage = os.getenv("APPIMAGE")
|
||||
if not appimage then
|
||||
love.event.quit("restart")
|
||||
|
||||
@@ -263,8 +263,56 @@ function Input:isDown(btn)
|
||||
return self.state[btn] or false
|
||||
end
|
||||
|
||||
-- True when the on-screen overlay is one of the live sources holding this
|
||||
-- button (see overlayPressed above). Player:turnWindow widens the
|
||||
-- turn-in-place tap window on touch, where a press and release can never be
|
||||
-- as short as a physical d-pad's (#415).
|
||||
function Input:isTouchDown(btn)
|
||||
local sources = self.sources[btn]
|
||||
return (sources and sources["touch:" .. btn]) and true or false
|
||||
end
|
||||
|
||||
function Input:wasPressed(btn)
|
||||
return self.pressed[btn] or false
|
||||
end
|
||||
|
||||
-- Soft reset (#563). _Joypad (engine/joypad.asm) tests the RAW joypad read
|
||||
-- with `cp PAD_BUTTONS` -- an equality, not a mask -- so the combo counts
|
||||
-- only while A, B, SELECT and START are the only buttons down; any d-pad
|
||||
-- direction in the mix cancels it. That test sits ahead of the wJoyIgnore
|
||||
-- and BIT_DISABLE_JOYPAD masking below it, which is why the reset still
|
||||
-- works mid-battle and mid-cutscene where ordinary input is thrown away.
|
||||
-- TrySoftReset then burns one DelayFrame per pass and decrements hSoftReset,
|
||||
-- seeded with 16 by Init (home/init.asm), so the combo has to survive 16
|
||||
-- consecutive polls. That hold is also what keeps the on-screen overlay
|
||||
-- safe: it already takes four separate fingers on four separate controls,
|
||||
-- and they all have to stay put for better than a quarter of a second.
|
||||
local SOFT_RESET_FRAMES = 16
|
||||
|
||||
function Input:softResetHeld()
|
||||
if not (self.state.a and self.state.b
|
||||
and self.state.start and self.state.select) then
|
||||
return false
|
||||
end
|
||||
return not (self.state.up or self.state.down
|
||||
or self.state.left or self.state.right)
|
||||
end
|
||||
|
||||
-- Ticked once per fixed step by Game:step; true on the step the countdown
|
||||
-- runs out. hSoftReset is never re-seeded on release in the original, so
|
||||
-- its count leaks across a whole session; a port that copied that would
|
||||
-- eventually reset on a stray four-button press, so the counter re-arms as
|
||||
-- soon as the combo drops.
|
||||
function Input:softResetStep()
|
||||
if not self:softResetHeld() then
|
||||
self.softResetFrames = nil
|
||||
return false
|
||||
end
|
||||
local left = (self.softResetFrames or SOFT_RESET_FRAMES) - 1
|
||||
self.softResetFrames = left
|
||||
if left > 0 then return false end
|
||||
self.softResetFrames = nil
|
||||
return true
|
||||
end
|
||||
|
||||
return Input
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
-- Usable window rect for mobile chrome (notch / Dynamic Island / home
|
||||
-- indicator / Android display cutouts). Wraps love.window.getSafeArea when
|
||||
-- the engine provides it; otherwise the full graphics window.
|
||||
--
|
||||
-- Desktop and headless stubs return the full window, so callers can always
|
||||
-- layout against this rect without platform branches. Interactive chrome
|
||||
-- (touch overlay, launcher) should prefer this over getDimensions; the game
|
||||
-- canvas may still letterbox into the full framebuffer for immersion.
|
||||
|
||||
local SafeArea = {}
|
||||
|
||||
function SafeArea.rect()
|
||||
local ww, wh = 0, 0
|
||||
if love and love.graphics and love.graphics.getDimensions then
|
||||
ww, wh = love.graphics.getDimensions()
|
||||
end
|
||||
if ww <= 0 then ww = 1 end
|
||||
if wh <= 0 then wh = 1 end
|
||||
|
||||
if not (love and love.window and love.window.getSafeArea) then
|
||||
return 0, 0, ww, wh
|
||||
end
|
||||
|
||||
local x, y, w, h = love.window.getSafeArea()
|
||||
if type(x) ~= "number" or type(y) ~= "number"
|
||||
or type(w) ~= "number" or type(h) ~= "number"
|
||||
or w <= 0 or h <= 0 then
|
||||
return 0, 0, ww, wh
|
||||
end
|
||||
|
||||
-- Clamp to the drawable window so a bad / mid-rotation backend cannot
|
||||
-- push layout outside the surface.
|
||||
x = math.max(0, math.min(x, ww))
|
||||
y = math.max(0, math.min(y, wh))
|
||||
w = math.max(1, math.min(w, ww - x))
|
||||
h = math.max(1, math.min(h, wh - y))
|
||||
return x, y, w, h
|
||||
end
|
||||
|
||||
return SafeArea
|
||||
@@ -1058,7 +1058,7 @@ local function reclaim(save, data, report)
|
||||
if type(entry) == "table" and known(data.items, entry.id) then
|
||||
table.remove(orphaned.items, i)
|
||||
if entry.from == "pcItems" or type(save.inventory) ~= "table"
|
||||
or not Bag.add(save, entry.id, entry.count or 1) then
|
||||
or not Bag.add(save, entry.id, entry.count or 1, data) then
|
||||
save.pcItems = save.pcItems or {}
|
||||
save.pcItems[entry.id] = (save.pcItems[entry.id] or 0) + (entry.count or 1)
|
||||
end
|
||||
|
||||
+42
-26
@@ -23,6 +23,7 @@
|
||||
-- and a player rebind can never detach the overlay.
|
||||
|
||||
local Input = require("src.core.Input")
|
||||
local SafeArea = require("src.core.SafeArea")
|
||||
|
||||
local TouchControls = {}
|
||||
|
||||
@@ -92,20 +93,23 @@ function TouchControls.normalizeConfig(tc)
|
||||
return out
|
||||
end
|
||||
|
||||
-- Pure default layout in LOVE units for a given window size. Shared by
|
||||
-- layout() and the editor's Reset path so defaults stay in one place.
|
||||
function TouchControls.defaultLayout(ww, wh)
|
||||
-- Pure default layout in LOVE units for a usable rect of size ww x wh at
|
||||
-- origin (ox, oy). Shared by layout() and the editor's Reset path so
|
||||
-- defaults stay in one place. ox/oy default to 0 for the headless tests
|
||||
-- and for callers that already pass a full-window size.
|
||||
function TouchControls.defaultLayout(ww, wh, ox, oy)
|
||||
ox, oy = ox or 0, oy or 0
|
||||
local short = math.min(ww, wh)
|
||||
local dpadW = math.min(180, short * 0.34)
|
||||
local abW = dpadW * 0.46
|
||||
local ssW = dpadW * 0.30
|
||||
local margin = dpadW * 0.12
|
||||
return {
|
||||
dpad = { cx = margin + dpadW / 2, cy = wh - margin - dpadW / 2, w = dpadW },
|
||||
a = { cx = ww - margin - abW * 0.55, cy = wh - margin - abW * 1.75, w = abW },
|
||||
b = { cx = ww - margin - abW * 1.60, cy = wh - margin - abW * 0.55, w = abW },
|
||||
start = { cx = ww / 2 + ssW * 0.60, cy = wh - margin - ssW * 0.95, w = ssW },
|
||||
select = { cx = ww / 2 - ssW * 0.60, cy = wh - margin - ssW * 0.95, w = ssW },
|
||||
dpad = { cx = ox + margin + dpadW / 2, cy = oy + wh - margin - dpadW / 2, w = dpadW },
|
||||
a = { cx = ox + ww - margin - abW * 0.55, cy = oy + wh - margin - abW * 1.75, w = abW },
|
||||
b = { cx = ox + ww - margin - abW * 1.60, cy = oy + wh - margin - abW * 0.55, w = abW },
|
||||
start = { cx = ox + ww / 2 + ssW * 0.60, cy = oy + wh - margin - ssW * 0.95, w = ssW },
|
||||
select = { cx = ox + ww / 2 - ssW * 0.60, cy = oy + wh - margin - ssW * 0.95, w = ssW },
|
||||
}
|
||||
end
|
||||
|
||||
@@ -155,6 +159,7 @@ function TouchControls:applyOptions(opts)
|
||||
self.enabled = cfg.enabled
|
||||
self.positions = cfg.positions
|
||||
self.layoutW, self.layoutH = nil, nil
|
||||
self.layoutOx, self.layoutOy = nil, nil
|
||||
if not self.enabled then
|
||||
self.controllerHidden = false
|
||||
self:reset()
|
||||
@@ -185,30 +190,37 @@ function TouchControls:visible()
|
||||
and not self.controllerHidden
|
||||
end
|
||||
|
||||
local function clampZone(zone, ww, wh)
|
||||
-- Keep a control fully inside the usable rect [x0,y0]..[x1,y1].
|
||||
local function clampZone(zone, x0, y0, x1, y1)
|
||||
local half = zone.w * 0.5
|
||||
zone.cx = math.max(half, math.min(ww - half, zone.cx))
|
||||
zone.cy = math.max(half, math.min(wh - half, zone.cy))
|
||||
zone.cx = math.max(x0 + half, math.min(x1 - half, zone.cx))
|
||||
zone.cy = math.max(y0 + half, math.min(y1 - half, zone.cy))
|
||||
end
|
||||
|
||||
-- Layout in LOVE units (density-independent on mobile), recomputed when
|
||||
-- the window size changes (rotation, resize). Default: d-pad bottom-left,
|
||||
-- B/A bottom-right with A above B (the Game Boy diagonal), START/SELECT
|
||||
-- flanking the bottom center. Custom positions (normalized 0..1) override
|
||||
-- centers while sizes stay derived from the short edge.
|
||||
-- the window or safe area changes (rotation, resize, notch insets).
|
||||
-- Default: d-pad bottom-left, B/A bottom-right with A above B (the Game Boy
|
||||
-- diagonal), START/SELECT flanking the bottom center -- all inside the
|
||||
-- device safe area so thumbs clear the home indicator / cutouts.
|
||||
-- Custom positions (normalized 0..1 within the safe rect) override centers
|
||||
-- while sizes stay derived from the short edge.
|
||||
function TouchControls:layout()
|
||||
local ww, wh = love.graphics.getDimensions()
|
||||
if self.layoutW == ww and self.layoutH == wh and self.L then return self.L end
|
||||
self.layoutW, self.layoutH = ww, wh
|
||||
self.L = TouchControls.defaultLayout(ww, wh)
|
||||
local ox, oy, sw, sh = SafeArea.rect()
|
||||
if self.layoutW == sw and self.layoutH == sh
|
||||
and self.layoutOx == ox and self.layoutOy == oy and self.L then
|
||||
return self.L
|
||||
end
|
||||
self.layoutW, self.layoutH = sw, sh
|
||||
self.layoutOx, self.layoutOy = ox, oy
|
||||
self.L = TouchControls.defaultLayout(sw, sh, ox, oy)
|
||||
if self.positions then
|
||||
for _, name in ipairs(CONTROLS) do
|
||||
local p = self.positions[name]
|
||||
local zone = self.L[name]
|
||||
if p and zone then
|
||||
zone.cx = p.x * ww
|
||||
zone.cy = p.y * wh
|
||||
clampZone(zone, ww, wh)
|
||||
zone.cx = ox + p.x * sw
|
||||
zone.cy = oy + p.y * sh
|
||||
clampZone(zone, ox, oy, ox + sw, oy + sh)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -222,21 +234,25 @@ function TouchControls:layout()
|
||||
end
|
||||
|
||||
-- Move one control to a screen-space point and persist its normalized
|
||||
-- position. Used by the layout editor while dragging.
|
||||
-- position within the safe rect. Used by the layout editor while dragging.
|
||||
function TouchControls:setControlCenter(name, cx, cy)
|
||||
local ww, wh = love.graphics.getDimensions()
|
||||
local ox, oy, sw, sh = SafeArea.rect()
|
||||
local L = self:layout()
|
||||
local zone = L[name]
|
||||
if not zone then return end
|
||||
zone.cx, zone.cy = cx, cy
|
||||
clampZone(zone, ww, wh)
|
||||
clampZone(zone, ox, oy, ox + sw, oy + sh)
|
||||
self.positions = self.positions or {}
|
||||
self.positions[name] = { x = zone.cx / ww, y = zone.cy / wh }
|
||||
self.positions[name] = {
|
||||
x = sw > 0 and (zone.cx - ox) / sw or 0,
|
||||
y = sh > 0 and (zone.cy - oy) / sh or 0,
|
||||
}
|
||||
end
|
||||
|
||||
function TouchControls:clearPositions()
|
||||
self.positions = nil
|
||||
self.layoutW, self.layoutH = nil, nil
|
||||
self.layoutOx, self.layoutOy = nil, nil
|
||||
end
|
||||
|
||||
local function inCircle(zone, x, y, slop)
|
||||
|
||||
+1
-1
@@ -214,7 +214,7 @@ function VERBS.give(self, rest)
|
||||
end
|
||||
elseif game.data.items and game.data.items[id] then
|
||||
local n = tonumber(count) or 1
|
||||
if require("src.inventory.Bag").add(save, id, n) then
|
||||
if require("src.inventory.Bag").add(save, id, n, game.data) then
|
||||
self:print(("%s x%d added"):format(id, n))
|
||||
else
|
||||
self:print("bag full")
|
||||
|
||||
@@ -1179,6 +1179,13 @@ function RomExtractor:extractPokemon()
|
||||
completed = completed + 1
|
||||
self:tick("Pokemon", completed, #self.manifest.dexOrder + 10)
|
||||
end
|
||||
-- Yellow-only third back pic: LoadPlayerBackPic (engine/battle/core.asm)
|
||||
-- picks OldManPicBack for BATTLE_TYPE_OLD_MAN but ProfOakPicBack for
|
||||
-- BATTLE_TYPE_PIKACHU, the Pallet Town catch scene (#557). Outside the
|
||||
-- ticked loop so the progress denominator stays as it was.
|
||||
if self.symbols["ProfOakPicBack"] then
|
||||
self:writeCompressedPic("ProfOakPicBack", "battle/profoakb.png")
|
||||
end
|
||||
local balls = self:symbol("PokeballTileGraphics")
|
||||
self:write2bpp(self.rom:bytes(balls.bank, balls.address, 64),
|
||||
32, 8, "battle/balls.png", true)
|
||||
@@ -1826,6 +1833,35 @@ function RomExtractor:extractField()
|
||||
end
|
||||
end
|
||||
|
||||
-- Yellow-only: TalkToPikachu's framed portrait, one 5x5 base frame per
|
||||
-- PikaPicAnimScript -- each script's FIRST pikapic_loadgfx in
|
||||
-- data/pikachu/pikachu_pic_animation.asm, the pic PikaPicAnimBGFrames_4
|
||||
-- (PikaAnimTilemap_1, column order) paints. Scripts 18/22/23/24 have no
|
||||
-- compressed base and take PikaPicAnimBGFrames_5 -> PikaAnimTilemap_9,
|
||||
-- which is ROW order over a raw 25-tile sheet, hence no columns flag.
|
||||
-- Script 26 shares script 11's base. The pikaframe overlays each script
|
||||
-- draws ON TOP of the base are a second full pose out of the same blob and
|
||||
-- stay unripped: PikachuFollower.picLift stands in for their motion
|
||||
-- (#561, still on #407's stand-in).
|
||||
local PIKAPIC_BASE = {
|
||||
"Pic_e4000", "Pic_e411c", "Pic_e4272", "Pic_e4383", "Pic_e458b",
|
||||
"Pic_e467b", "Pic_e476e", "Pic_e49d1", "Pic_e4b39", "Pic_e4c3e",
|
||||
"Pic_e5000", "Pic_e523f", "Pic_e548e", "Pic_e56d1", "Pic_e5924",
|
||||
"Pic_e5b7d", "Pic_e5ddd", "GFX_e6020", "Pic_e6340", "Pic_e6587",
|
||||
"Pic_e67d6", "GFX_e6e6f", "GFX_e718f", "GFX_e74af", "Pic_e77cf",
|
||||
"Pic_e5000", "Pic_f0abf", "Pic_f0cf4",
|
||||
}
|
||||
if self.symbols[PIKAPIC_BASE[1]] then
|
||||
for script, label in ipairs(PIKAPIC_BASE) do
|
||||
local path = "pikachu/pikapic_" .. script .. ".png"
|
||||
if label:sub(1, 4) == "GFX_" then
|
||||
self:raw2bpp(label, 40, 40, path, { matte = true })
|
||||
else
|
||||
self:writeCompressedPic(label, path)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
self:raw1bpp("LedgeHoppingShadow", 8, 8,
|
||||
"fx/shadow.png", true); tick()
|
||||
for _, spec in ipairs({
|
||||
|
||||
+207
-66
@@ -1,6 +1,7 @@
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
local Strings = require("src.core.Strings")
|
||||
local HostShell = require("src.core.HostShell")
|
||||
local SafeArea = require("src.core.SafeArea")
|
||||
|
||||
local RomImporter = {}
|
||||
RomImporter.__index = RomImporter
|
||||
@@ -60,11 +61,22 @@ local REQUIRED_FILES = {
|
||||
-- them re-imports on its own, without dragging the other versions through a
|
||||
-- CACHE_FORMAT bump.
|
||||
local VERSION_REQUIRED_FILES = {
|
||||
yellow = { "assets/generated/battle/trainers/jessie_james.png" }, -- #439
|
||||
yellow = {
|
||||
"assets/generated/battle/trainers/jessie_james.png", -- #439
|
||||
-- Oak's own back pic and the pikapic base frames only exist in caches
|
||||
-- built after their manifest symbols landed, so an older Yellow cache
|
||||
-- has to re-import to stop falling back to the old man's back pic and
|
||||
-- to the battle front pic (#557, #561). Both are gated on manifest
|
||||
-- symbols in RomExtractor, so these markers must only ever list files
|
||||
-- tools/rom_manifest_yellow.json can actually produce -- otherwise the
|
||||
-- cache reads as incomplete and re-importing cannot clear it.
|
||||
"assets/generated/battle/profoakb.png",
|
||||
"assets/generated/pikachu/pikapic_1.png",
|
||||
},
|
||||
}
|
||||
|
||||
-- "Split-screen ROM selector" first-run palette (matches FirstRun.dc.html from
|
||||
-- the Claude Design project): a dark neon arcade panel, one column per game.
|
||||
-- "Split-screen ROM selector" first-run palette (matches the FirstRun mockup):
|
||||
-- a dark neon arcade panel, one column per game.
|
||||
-- Red, Blue, and Yellow share the same importer flow once listed in
|
||||
-- GameVersion.VERSIONS. Values are 0-255 RGB; alpha is applied per draw.
|
||||
local PAL = {
|
||||
@@ -636,6 +648,8 @@ function RomImporter.new(onComplete, opts)
|
||||
-- Android SAF create-document: which game's SAVE FILES card should show
|
||||
-- "Save exported." when export_done.flag appears on focus.
|
||||
androidPendingExportVersion = nil,
|
||||
iosPendingKind = nil,
|
||||
iosPendingVersion = nil,
|
||||
-- Virtual pointer for handhelds / gamepads (Anbernic stock OS has no
|
||||
-- mouse). D-pad + left stick move it; A clicks; shoulders cycle tabs;
|
||||
-- right stick scrolls the save-slot / mods lists.
|
||||
@@ -817,7 +831,11 @@ local function resetPointerCursor(self)
|
||||
if not (love.mouse.isCursorSupported and love.mouse.isCursorSupported()) then
|
||||
return
|
||||
end
|
||||
self.arrowCursor = self.arrowCursor or love.mouse.getSystemCursor("arrow")
|
||||
if not self.arrowCursor then
|
||||
local ok, cursor = pcall(love.mouse.getSystemCursor, "arrow")
|
||||
if not ok then return end
|
||||
self.arrowCursor = cursor
|
||||
end
|
||||
love.mouse.setCursor(self.arrowCursor)
|
||||
end
|
||||
|
||||
@@ -1003,6 +1021,14 @@ end
|
||||
-- which focus/Choose consumes on return.
|
||||
function RomImporter:chooseMod()
|
||||
if self.workState == "working" then return end
|
||||
if self.ios and love.system.getPickedFile then
|
||||
self.iosPendingKind = "mod"
|
||||
if not pickFile("mod") then
|
||||
self.iosPendingKind = nil
|
||||
self.modNotice = { ok = false, text = "Could not open the file picker." }
|
||||
end
|
||||
return
|
||||
end
|
||||
if self.android then
|
||||
local name = findPendingMod(true, self.pickSkip)
|
||||
if name then
|
||||
@@ -1017,7 +1043,6 @@ function RomImporter:chooseMod()
|
||||
else
|
||||
self.pickPending = true
|
||||
self.pickTimer = 0
|
||||
self.pickElapsed = 0
|
||||
end
|
||||
return
|
||||
end
|
||||
@@ -1065,6 +1090,16 @@ end
|
||||
-- Android mirrors ROM / mod import via love.system.pickFile("sav").
|
||||
function RomImporter:chooseSaveImport(version)
|
||||
if self.workState == "working" then return end
|
||||
if self.ios and love.system.getPickedFile then
|
||||
self.iosPendingKind = "sav"
|
||||
self.iosPendingVersion = version
|
||||
if not pickFile("sav") then
|
||||
self.iosPendingKind = nil
|
||||
self.iosPendingVersion = nil
|
||||
self.saveNotice[version] = { ok = false, text = "Could not open the file picker." }
|
||||
end
|
||||
return
|
||||
end
|
||||
if self.android then
|
||||
local name = findPendingSav(true, self.pickSkip)
|
||||
if name then
|
||||
@@ -1082,7 +1117,6 @@ function RomImporter:chooseSaveImport(version)
|
||||
else
|
||||
self.pickPending = true
|
||||
self.pickTimer = 0
|
||||
self.pickElapsed = 0
|
||||
end
|
||||
return
|
||||
end
|
||||
@@ -1118,10 +1152,9 @@ function RomImporter:exportSave(version)
|
||||
return
|
||||
end
|
||||
self.androidPendingExportVersion = version
|
||||
if love.system.createFile and love.system.createFile(suggested) then
|
||||
if love.system.createFile and love.system.createFile(suggested, love.filesystem.getSaveDirectory()) then
|
||||
self.pickPending = true
|
||||
self.pickTimer = 0
|
||||
self.pickElapsed = 0
|
||||
self.saveNotice[version] = { ok = true,
|
||||
text = "Pick where to save " .. suggested .. "..." }
|
||||
else
|
||||
@@ -1156,6 +1189,14 @@ end
|
||||
function RomImporter:choose(version)
|
||||
if self.workState == "working" then return end
|
||||
self.chooseVersion = version or "red"
|
||||
if self.ios and love.system.getPickedFile then
|
||||
self.iosPendingKind = "rom"
|
||||
if not pickFile("rom") then
|
||||
self.iosPendingKind = nil
|
||||
self:setError("Could not open the file picker.")
|
||||
end
|
||||
return
|
||||
end
|
||||
if self.android then
|
||||
-- Prefer a not-yet-imported .gb/.gbc already in the save dir (USB copy, or
|
||||
-- a fresh SAF pick). Never reuse an already-imported cart's file -- that
|
||||
@@ -1177,7 +1218,6 @@ function RomImporter:choose(version)
|
||||
else
|
||||
self.pickPending = true
|
||||
self.pickTimer = 0
|
||||
self.pickElapsed = 0
|
||||
end
|
||||
return
|
||||
end
|
||||
@@ -1225,18 +1265,18 @@ end
|
||||
-- for the next tap to find, which is what made users import twice and
|
||||
-- what made it look random: it depends on memory pressure (#553).
|
||||
--
|
||||
-- Disarms after PICK_TIMEOUT so a cancelled picker (which delivers nothing,
|
||||
-- ever) does not leave this scanning the save directory for the whole session.
|
||||
local PICK_TIMEOUT = 120
|
||||
-- Deliberately NO timeout. A version of this disarmed the poll after 120s so a
|
||||
-- cancelled picker would stop scanning, which was wrong on iOS: the picker there
|
||||
-- is an in-process modal sheet, so update() keeps running while it is open and
|
||||
-- the window burned down while the player was still browsing Files. The pick
|
||||
-- then landed with nothing armed to consume it, and because every path here is
|
||||
-- silent on success the import just did not happen, with no error shown. A
|
||||
-- half-second directory listing on a menu screen is far cheaper than an import
|
||||
-- that vanishes, so the poll stays armed until something is actually consumed.
|
||||
|
||||
function RomImporter:_pollPickedFiles(dt)
|
||||
if not self.pickPending then return end
|
||||
if self.workState == "working" then return end
|
||||
self.pickElapsed = (self.pickElapsed or 0) + dt
|
||||
if self.pickElapsed > PICK_TIMEOUT then
|
||||
self.pickPending, self.pickElapsed = nil, nil
|
||||
return
|
||||
end
|
||||
self.pickTimer = (self.pickTimer or 0) + dt
|
||||
if self.pickTimer < 0.5 then return end
|
||||
self.pickTimer = 0
|
||||
@@ -1246,7 +1286,7 @@ function RomImporter:_pollPickedFiles(dt)
|
||||
local pickError = love.filesystem.read("pick_error.txt")
|
||||
if pickError then
|
||||
love.filesystem.remove("pick_error.txt")
|
||||
self.pickPending, self.pickElapsed = nil, nil
|
||||
self.pickPending = nil
|
||||
self.modNotice = { ok = false, text = pickError }
|
||||
self.notice = { version = self.chooseVersion or "red",
|
||||
status = "File import failed:", detail = pickError }
|
||||
@@ -1263,7 +1303,7 @@ function RomImporter:_pollPickedFiles(dt)
|
||||
end
|
||||
end
|
||||
if found then
|
||||
self.pickPending, self.pickElapsed = nil, nil
|
||||
self.pickPending = nil
|
||||
self:focus(true)
|
||||
end
|
||||
end
|
||||
@@ -1271,6 +1311,37 @@ end
|
||||
function RomImporter:update(dt)
|
||||
self.pulse = self.pulse + dt
|
||||
self:_updatePadCursor(dt)
|
||||
if self.ios and love.system.getPickedFile and self.workState ~= "working" then
|
||||
local path = love.system.getPickedFile()
|
||||
if path then
|
||||
local kind = self.iosPendingKind or "rom"
|
||||
local version = self.iosPendingVersion
|
||||
self.iosPendingKind = nil
|
||||
self.iosPendingVersion = nil
|
||||
if kind == "mod" then
|
||||
self:_installMod(path)
|
||||
elseif kind == "sav" then
|
||||
self:_importSave(version or self:_savedropTarget(), path)
|
||||
else
|
||||
self:startPath(path)
|
||||
end
|
||||
elseif love.system.getPickError then
|
||||
local errorText = love.system.getPickError()
|
||||
if errorText then
|
||||
local kind = self.iosPendingKind or "rom"
|
||||
local version = self.iosPendingVersion or self:_savedropTarget()
|
||||
self.iosPendingKind = nil
|
||||
self.iosPendingVersion = nil
|
||||
if kind == "mod" then
|
||||
self.modNotice = { ok = false, text = errorText }
|
||||
elseif kind == "sav" then
|
||||
self.saveNotice[version] = { ok = false, text = errorText }
|
||||
else
|
||||
self:setError(errorText)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
self:_pollPickedFiles(dt)
|
||||
if self.workState ~= "working" or not self.worker then return end
|
||||
local started = love.timer.getTime()
|
||||
@@ -1295,10 +1366,10 @@ local PAD_DPAD_SPEED = 420
|
||||
|
||||
function RomImporter:_activatePadCursor()
|
||||
if self._padCursorActive then return end
|
||||
local w, h = love.graphics.getDimensions()
|
||||
local ox, oy, w, h = SafeArea.rect()
|
||||
if not self._padInited then
|
||||
self._padCursor.x = w * 0.5
|
||||
self._padCursor.y = h * 0.45
|
||||
self._padCursor.x = ox + w * 0.5
|
||||
self._padCursor.y = oy + h * 0.45
|
||||
self._padInited = true
|
||||
end
|
||||
self._padCursorActive = true
|
||||
@@ -1315,6 +1386,7 @@ function RomImporter:_cycleTab(delta)
|
||||
self._slotPress = nil
|
||||
self._modPress = nil
|
||||
self._findSearchFocus = false
|
||||
self:_disarmTextInput()
|
||||
end
|
||||
|
||||
function RomImporter:_updatePadCursor(dt)
|
||||
@@ -1344,11 +1416,11 @@ function RomImporter:_updatePadCursor(dt)
|
||||
if mag > 1 then dx, dy = dx / mag, dy / mag end
|
||||
local speed = (math.abs(ax) > PAD_DEAD or math.abs(ay) > PAD_DEAD)
|
||||
and PAD_SPEED or PAD_DPAD_SPEED
|
||||
local w, h = love.graphics.getDimensions()
|
||||
local ox, oy, w, h = SafeArea.rect()
|
||||
local nx = self._padCursor.x + dx * speed * dt
|
||||
local ny = self._padCursor.y + dy * speed * dt
|
||||
self._padCursor.x = math.max(0, math.min(w, nx))
|
||||
self._padCursor.y = math.max(0, math.min(h, ny))
|
||||
self._padCursor.x = math.max(ox, math.min(ox + w, nx))
|
||||
self._padCursor.y = math.max(oy, math.min(oy + h, ny))
|
||||
end
|
||||
|
||||
-- Right stick scrolls the active list (save slots or mods), or the whole page
|
||||
@@ -1726,7 +1798,10 @@ function RomImporter:_resetFrameRects()
|
||||
end
|
||||
|
||||
function RomImporter:draw()
|
||||
local width, height = love.graphics.getDimensions()
|
||||
-- Full window for immersive backdrop; safe rect for interactive chrome so
|
||||
-- notch / Dynamic Island / home indicator / Android cutouts are respected.
|
||||
local fullW, fullH = love.graphics.getDimensions()
|
||||
local ox, oy, width, height = SafeArea.rect()
|
||||
local s = clamp(height / 768, 0.7, 1.6)
|
||||
local pulse = self.pulse
|
||||
self._s = s
|
||||
@@ -1745,8 +1820,9 @@ function RomImporter:draw()
|
||||
self._anyHover = false
|
||||
self:_resetFrameRects()
|
||||
|
||||
-- Fonts + size-dependent scenery, rebuilt only when the window size changes.
|
||||
local fontKey = ("%dx%d"):format(width, height)
|
||||
-- Fonts + size-dependent scenery, rebuilt only when the window / safe
|
||||
-- area changes (rotation, resize, inset changes).
|
||||
local fontKey = ("%dx%d@%d,%d"):format(fullW, fullH, ox, oy)
|
||||
if self.fontKey ~= fontKey then
|
||||
self.fontKey = fontKey
|
||||
local function f(px) return love.graphics.newFont(math.max(8, math.floor(px + 0.5))) end
|
||||
@@ -1770,10 +1846,10 @@ function RomImporter:draw()
|
||||
-- Background: a radial gradient (bright navy at top-centre -> near black).
|
||||
-- A triangle fan from the top-centre gives the radial falloff; the screen
|
||||
-- is cleared to the outer colour first so the corners it does not reach
|
||||
-- match seamlessly.
|
||||
-- match seamlessly. Sized to the full window so unsafe edges stay filled.
|
||||
do
|
||||
local cx, cy = width / 2, 0
|
||||
local rx, ry = width * 1.3, height * 1.08
|
||||
local cx, cy = fullW / 2, 0
|
||||
local rx, ry = fullW * 1.3, fullH * 1.08
|
||||
local n = 72
|
||||
local verts = { { cx, cy, 0, 0,
|
||||
PAL.bgTop[1] / 255, PAL.bgTop[2] / 255, PAL.bgTop[3] / 255, 1 } }
|
||||
@@ -1787,8 +1863,8 @@ function RomImporter:draw()
|
||||
|
||||
-- CRT vignette: a gentle edge darkening, centred slightly above the middle.
|
||||
do
|
||||
local cx, cy = width / 2, height * 0.45
|
||||
local rx, ry = width * 0.78, height * 0.78
|
||||
local cx, cy = fullW / 2, fullH * 0.45
|
||||
local rx, ry = fullW * 0.78, fullH * 0.78
|
||||
local n = 72
|
||||
local verts = { { cx, cy, 0, 0, 0, 0, 0, 0 } }
|
||||
for i = 0, n do
|
||||
@@ -1810,7 +1886,7 @@ function RomImporter:draw()
|
||||
self.scanlineImage:setWrap("repeat", "repeat")
|
||||
self.scanlineImage:setFilter("nearest", "nearest")
|
||||
end
|
||||
self.scanlineQuad = love.graphics.newQuad(0, 0, width, height, 1, 3)
|
||||
self.scanlineQuad = love.graphics.newQuad(0, 0, fullW, fullH, 1, 3)
|
||||
end
|
||||
|
||||
-- Invert shader: the Boi's Club Games mark is dark ink; on this dark panel it
|
||||
@@ -1836,21 +1912,23 @@ function RomImporter:draw()
|
||||
}
|
||||
]])
|
||||
|
||||
-- background
|
||||
-- background (full window — unsafe edges stay painted)
|
||||
col(PAL.bgBot)
|
||||
love.graphics.rectangle("fill", 0, 0, width, height)
|
||||
love.graphics.rectangle("fill", 0, 0, fullW, fullH)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
love.graphics.draw(self.bgMesh)
|
||||
|
||||
-- Centered content container (max ~1440 scaled units on very wide windows)
|
||||
-- with a responsive side gutter; every column below derives from these.
|
||||
-- Origin is the safe-area top-left so chrome clears device insets.
|
||||
local appW = math.min(width, 1440 * s)
|
||||
local appX = (width - appW) / 2
|
||||
local appX = ox + (width - appW) / 2
|
||||
local padH = clamp(appW * 0.03, 12 * s, 26 * s)
|
||||
local third = appW / 3
|
||||
|
||||
-- tricolor strip (Red | Blue | Yellow), 6px tall, with a soft downward bloom
|
||||
local stripH = math.max(4, 6 * s)
|
||||
local stripY = oy
|
||||
local segs = {
|
||||
{ PAL.red, appX, third },
|
||||
{ PAL.blue, appX + third, third },
|
||||
@@ -1858,11 +1936,11 @@ function RomImporter:draw()
|
||||
}
|
||||
love.graphics.setBlendMode("add")
|
||||
for _, seg in ipairs(segs) do
|
||||
fillGrad(seg[2], stripH, seg[3], stripH * 3.6, seg[1], seg[1], 0.30, 0.0)
|
||||
fillGrad(seg[2], stripY + stripH, seg[3], stripH * 3.6, seg[1], seg[1], 0.30, 0.0)
|
||||
end
|
||||
love.graphics.setBlendMode("alpha")
|
||||
for _, seg in ipairs(segs) do
|
||||
col(seg[1]); love.graphics.rectangle("fill", seg[2], 0, seg[3], stripH)
|
||||
col(seg[1]); love.graphics.rectangle("fill", seg[2], stripY, seg[3], stripH)
|
||||
end
|
||||
|
||||
-- Footer (Boi's Club Games logo + trust warning), measured first so the
|
||||
@@ -1884,7 +1962,7 @@ function RomImporter:draw()
|
||||
math.min(330 * s, appW - 32 * s))
|
||||
local logoScale = math.min(logoTargetW / logoW, height * 0.15 / logoH)
|
||||
local logoDW, logoDH = logoW * logoScale, logoH * logoScale
|
||||
local logoY = stripH + 14 * s
|
||||
local logoY = stripY + stripH + 14 * s
|
||||
|
||||
-- Tab bar: R/B/Y/divider/MODS chips (label + underline on the active one),
|
||||
-- with "N of 3 ready" right-aligned.
|
||||
@@ -1914,7 +1992,7 @@ function RomImporter:draw()
|
||||
local bannerBand = bannerActive and (bannerH + 20 * s) or 6 * s
|
||||
local cX = appX + padH
|
||||
local cW = appW - 2 * padH
|
||||
local contentBottom = height - footerH - bannerBand
|
||||
local contentBottom = oy + height - footerH - bannerBand
|
||||
local cH = math.max(0, contentBottom - contentTop)
|
||||
|
||||
-- Page scroll. Everything under the tab bar -- panel, updater banner and
|
||||
@@ -1925,14 +2003,14 @@ function RomImporter:draw()
|
||||
-- the previous frame's measurement, the same one-frame settle the slot and
|
||||
-- mod lists already rely on. While the page fits, `paged` is false and every
|
||||
-- measurement below is what it always was.
|
||||
local viewportH = math.max(0, height - contentTop)
|
||||
local viewportH = math.max(0, oy + height - contentTop)
|
||||
self._panelNaturalH = self._panelNaturalH or {}
|
||||
local naturalH = (self._panelNaturalH[self.tab] or 0) + bannerBand + footerH
|
||||
local paged, pageScroll, maxPage =
|
||||
RomImporter.pageScrollFor(naturalH, viewportH, self.pageScroll)
|
||||
self.pageScroll, self._pageMax = pageScroll, maxPage
|
||||
-- read by the hit tests; a scrolled control is live only inside the viewport
|
||||
pageBand = paged and { contentTop, height } or nil
|
||||
pageBand = paged and { contentTop, oy + height } or nil
|
||||
|
||||
-- tab bar (rebuilds self.tabRects). Pinned: it is the launcher's navigation,
|
||||
-- and it sits above the scrolling viewport.
|
||||
@@ -2103,10 +2181,10 @@ function RomImporter:draw()
|
||||
|
||||
-- logo, over the split, with a gentle bob + gold glow + sweeping shine
|
||||
local bob = math.sin(pulse * (2 * math.pi / 4)) * 6 * s
|
||||
local lx, ly = (width - logoDW) / 2, logoY + bob
|
||||
local lx, ly = ox + (width - logoDW) / 2, logoY + bob
|
||||
love.graphics.setBlendMode("add")
|
||||
love.graphics.setColor(1, 0.85, 0.2, 0.16 + 0.12 * (0.5 + 0.5 * math.sin(pulse * 1.6)))
|
||||
love.graphics.draw(self.logo, (width - logoDW * 1.05) / 2, ly - logoDH * 0.025, 0,
|
||||
love.graphics.draw(self.logo, ox + (width - logoDW * 1.05) / 2, ly - logoDH * 0.025, 0,
|
||||
logoScale * 1.05, logoScale * 1.05)
|
||||
love.graphics.setBlendMode("alpha")
|
||||
local shineW = 0.16
|
||||
@@ -2139,11 +2217,11 @@ function RomImporter:draw()
|
||||
-- save-slot rename modal (#205), drawn over everything
|
||||
if self._rename then
|
||||
col(PAL.bgBot, 0.72)
|
||||
love.graphics.rectangle("fill", 0, 0, width, height)
|
||||
love.graphics.rectangle("fill", 0, 0, fullW, fullH)
|
||||
local dw = math.min(appW - 32 * s, 420 * s)
|
||||
local dh = 128 * s
|
||||
local dx = appX + (appW - dw) / 2
|
||||
local dy = (height - dh) / 2
|
||||
local dy = oy + (height - dh) / 2
|
||||
local rr = 12 * s
|
||||
neonGlow(dx, dy, dw, dh, rr, PAL.green, 0.4)
|
||||
fillGradRounded(dx, dy, dw, dh, rr, PAL.slotBg, PAL.slotBg, 0.85, 0.85)
|
||||
@@ -2185,11 +2263,11 @@ function RomImporter:draw()
|
||||
-- it is typed.
|
||||
if self._indexPrompt then
|
||||
col(PAL.bgBot, 0.72)
|
||||
love.graphics.rectangle("fill", 0, 0, width, height)
|
||||
love.graphics.rectangle("fill", 0, 0, fullW, fullH)
|
||||
local dw = math.min(appW - 32 * s, 520 * s)
|
||||
local dh = 168 * s
|
||||
local dh = 176 * s
|
||||
local dx = appX + (appW - dw) / 2
|
||||
local dy = (height - dh) / 2
|
||||
local dy = oy + (height - dh) / 2
|
||||
local rr = 12 * s
|
||||
neonGlow(dx, dy, dw, dh, rr, PAL.modDot, 0.4)
|
||||
fillGradRounded(dx, dy, dw, dh, rr, PAL.slotBg, PAL.slotBg, 0.9, 0.9)
|
||||
@@ -2231,6 +2309,14 @@ function RomImporter:draw()
|
||||
fy + 7 * s, math.max(1, 1.5 * s), fh - 14 * s)
|
||||
end
|
||||
|
||||
-- PASTE under the field: a touch screen has no ctrl+V, and an index URL
|
||||
-- is not something anyone retypes on a soft keyboard (#578). This rect
|
||||
-- is the one click mousepressed honors while the prompt is up; pinned so
|
||||
-- page-scroll banding never eats the tap.
|
||||
self._indexPasteRect = self:_chipButton(fx + fw - 84 * s, fy + fh + 8 * s,
|
||||
Strings("Paste"), { w = 84 * s, h = 28 * s, kind = "accent" })
|
||||
self._indexPasteRect.pinned = true
|
||||
|
||||
love.graphics.setFont(self.hintFont)
|
||||
col(PAL.warning)
|
||||
printfB(Strings("Enter to add - Esc to cancel"),
|
||||
@@ -2241,7 +2327,7 @@ function RomImporter:draw()
|
||||
if self._modConfirm or self._modVersions or self._modReleaseNotes
|
||||
or self._findDetails then
|
||||
col(PAL.bgBot, 0.72)
|
||||
love.graphics.rectangle("fill", 0, 0, width, height)
|
||||
love.graphics.rectangle("fill", 0, 0, fullW, fullH)
|
||||
end
|
||||
if self._modConfirm then
|
||||
local c = self._modConfirm
|
||||
@@ -2249,7 +2335,7 @@ function RomImporter:draw()
|
||||
local lineH = self.hintFont:getHeight() + 4 * s
|
||||
local dh = 36 * s + (#c.lines) * lineH + 56 * s
|
||||
local dx = appX + (appW - dw) / 2
|
||||
local dy = (height - dh) / 2
|
||||
local dy = oy + (height - dh) / 2
|
||||
local rr = 12 * s
|
||||
fillGradRounded(dx, dy, dw, dh, rr, PAL.slotBg, PAL.slotBg, 0.92, 0.92)
|
||||
love.graphics.setLineWidth(math.max(1, 1.2 * s))
|
||||
@@ -2291,7 +2377,7 @@ function RomImporter:draw()
|
||||
local dw = math.min(appW - 32 * s, 480 * s)
|
||||
local dh = math.min(height - 48 * s, 360 * s)
|
||||
local dx = appX + (appW - dw) / 2
|
||||
local dy = (height - dh) / 2
|
||||
local dy = oy + (height - dh) / 2
|
||||
local rr = 12 * s
|
||||
fillGradRounded(dx, dy, dw, dh, rr, PAL.slotBg, PAL.slotBg, 0.92, 0.92)
|
||||
love.graphics.setLineWidth(math.max(1, 1.2 * s))
|
||||
@@ -2336,7 +2422,7 @@ function RomImporter:draw()
|
||||
local dw = math.min(appW - 32 * s, 520 * s)
|
||||
local dh = math.min(height - 48 * s, 420 * s)
|
||||
local dx = appX + (appW - dw) / 2
|
||||
local dy = (height - dh) / 2
|
||||
local dy = oy + (height - dh) / 2
|
||||
local rr = 12 * s
|
||||
fillGradRounded(dx, dy, dw, dh, rr, PAL.slotBg, PAL.slotBg, 0.94, 0.94)
|
||||
love.graphics.setLineWidth(math.max(1, 1.2 * s))
|
||||
@@ -2393,7 +2479,7 @@ function RomImporter:draw()
|
||||
listH = listN * rowH
|
||||
dh = headerH + listH + footerH
|
||||
local dx = appX + (appW - dw) / 2
|
||||
local dy = (height - dh) / 2
|
||||
local dy = oy + (height - dh) / 2
|
||||
local rr = 12 * s
|
||||
fillGradRounded(dx, dy, dw, dh, rr, PAL.slotBg, PAL.slotBg, 0.96, 0.96)
|
||||
love.graphics.setLineWidth(math.max(1, 1.2 * s))
|
||||
@@ -2402,7 +2488,7 @@ function RomImporter:draw()
|
||||
|
||||
love.graphics.setFont(self.slotNameFont)
|
||||
col(PAL.white)
|
||||
love.graphics.printf("Other versions — " .. tostring(v.name),
|
||||
love.graphics.printf("Other versions: " .. tostring(v.name),
|
||||
dx + pad, dy + 10 * s, dw - pad * 2, "left")
|
||||
love.graphics.setFont(self.hintFont)
|
||||
local info = self:_modUpdateInfo(v.id)
|
||||
@@ -2509,8 +2595,11 @@ function RomImporter:draw()
|
||||
if self._hoverEnabled and not self._padCursorActive
|
||||
and love.mouse.isCursorSupported and love.mouse.isCursorSupported() then
|
||||
if self._anyHover then
|
||||
self.handCursor = self.handCursor or love.mouse.getSystemCursor("hand")
|
||||
love.mouse.setCursor(self.handCursor)
|
||||
if not self.handCursor then
|
||||
local ok, cursor = pcall(love.mouse.getSystemCursor, "hand")
|
||||
if ok then self.handCursor = cursor end
|
||||
end
|
||||
if self.handCursor then love.mouse.setCursor(self.handCursor) end
|
||||
else
|
||||
resetPointerCursor(self)
|
||||
end
|
||||
@@ -2569,7 +2658,14 @@ end
|
||||
|
||||
function RomImporter:mousepressed(x, y, button)
|
||||
if self._rename then return end -- the rename modal swallows all clicks
|
||||
if self._indexPrompt then return end -- and so does the add-index prompt
|
||||
-- The add-index prompt swallows clicks too, except its PASTE button: a
|
||||
-- touch screen has no ctrl+V, so the button is the only paste path (#578).
|
||||
if self._indexPrompt then
|
||||
if button == 1 and inside(self._indexPasteRect, x, y) then
|
||||
self:_pasteIndexUrl()
|
||||
end
|
||||
return
|
||||
end
|
||||
-- Mod confirm / versions / release-notes modals swallow clicks too.
|
||||
if self._modConfirm then
|
||||
if button ~= 1 then return end
|
||||
@@ -2676,6 +2772,7 @@ function RomImporter:mousepressed(x, y, button)
|
||||
self._modPress = nil -- and any half-started mod toggle press
|
||||
self._pagePress = nil -- and any half-started page pan
|
||||
self._findSearchFocus = false -- and the search caret, now off screen
|
||||
self:_disarmTextInput()
|
||||
-- Each tab is its own column of a different length; carrying one tab's
|
||||
-- offset into another lands somewhere arbitrary.
|
||||
self.pageScroll = 0
|
||||
@@ -2797,11 +2894,14 @@ function RomImporter:mousepressed(x, y, button)
|
||||
end
|
||||
if inside(self.findRefreshRect, x, y) then
|
||||
self._findSearchFocus = false
|
||||
self:_disarmTextInput()
|
||||
self:_refreshFind(true)
|
||||
return
|
||||
end
|
||||
if inside(self.findSearchRect, x, y) then
|
||||
self._findSearchFocus = true; return
|
||||
self._findSearchFocus = true
|
||||
self:_armTextInput()
|
||||
return
|
||||
end
|
||||
for _, r in ipairs(self.findSourceRemoveRects or {}) do
|
||||
if inside(r, x, y) then self:_removeIndex(r.id); return end
|
||||
@@ -2829,7 +2929,10 @@ function RomImporter:mousepressed(x, y, button)
|
||||
end
|
||||
-- A press anywhere else on the tab drops the search caret, so the field does
|
||||
-- not silently keep eating keystrokes once the player has moved on.
|
||||
if self.tab == "find" then self._findSearchFocus = false end
|
||||
if self.tab == "find" and self._findSearchFocus then
|
||||
self._findSearchFocus = false
|
||||
self:_disarmTextInput()
|
||||
end
|
||||
-- Nothing was hit. On a scrolling page that is a press on empty background,
|
||||
-- which is the natural place to grab and pan from.
|
||||
if armDrag and (self._pageMax or 0) > 0 then
|
||||
@@ -2845,6 +2948,7 @@ function RomImporter:keypressed(key)
|
||||
self:_commitRename()
|
||||
elseif key == "escape" then
|
||||
self._rename = nil
|
||||
self:_disarmTextInput()
|
||||
end
|
||||
return
|
||||
end
|
||||
@@ -2855,13 +2959,11 @@ function RomImporter:keypressed(key)
|
||||
self:_commitAddIndex()
|
||||
elseif key == "escape" then
|
||||
self._indexPrompt = nil
|
||||
self:_disarmTextInput()
|
||||
elseif key == "v" and (love.keyboard.isDown("lctrl", "rctrl", "lgui", "rgui")) then
|
||||
-- an index URL is long and comes from a browser: typing it out by hand
|
||||
-- is the difference between adding one and giving up
|
||||
local ok, text = pcall(love.system.getClipboardText)
|
||||
if ok and type(text) == "string" then
|
||||
self._indexPrompt.text = self._indexPrompt.text .. text:gsub("%s", "")
|
||||
end
|
||||
self:_pasteIndexUrl()
|
||||
end
|
||||
return
|
||||
end
|
||||
@@ -2885,6 +2987,7 @@ function RomImporter:keypressed(key)
|
||||
self.findScroll = 0
|
||||
elseif key == "escape" or key == "return" or key == "kpenter" then
|
||||
self._findSearchFocus = false
|
||||
self:_disarmTextInput()
|
||||
end
|
||||
return
|
||||
end
|
||||
@@ -3438,6 +3541,27 @@ local MAX_SLOT_LABEL = 24
|
||||
local MAX_INDEX_URL = 200
|
||||
local MAX_FIND_QUERY = 48
|
||||
|
||||
-- Mobile LOVE only delivers love.textinput while setTextInput(true) is armed,
|
||||
-- and arming it is also what raises the soft keyboard, so a cabled USB
|
||||
-- keyboard is just as dead without it (#578). Every site that opens one of
|
||||
-- the launcher's three text fields (_rename, _indexPrompt, _findSearchFocus)
|
||||
-- arms through here, and every site that closes one disarms. Desktop has
|
||||
-- text input on by default and the save editor hosted from this launcher
|
||||
-- depends on it staying on (tools/save-editor/Kit.lua, #529), so disarm only
|
||||
-- lowers on mobile -- setTextInput is global SDL state, not per-widget.
|
||||
function RomImporter:_armTextInput()
|
||||
if love.keyboard and love.keyboard.setTextInput then
|
||||
pcall(love.keyboard.setTextInput, true)
|
||||
end
|
||||
end
|
||||
|
||||
function RomImporter:_disarmTextInput()
|
||||
if not self.android then return end
|
||||
if love.keyboard and love.keyboard.setTextInput then
|
||||
pcall(love.keyboard.setTextInput, false)
|
||||
end
|
||||
end
|
||||
|
||||
function RomImporter:_beginRename(version, id)
|
||||
local label
|
||||
for _, slot in ipairs(self.slots[version] or {}) do
|
||||
@@ -3445,12 +3569,14 @@ function RomImporter:_beginRename(version, id)
|
||||
end
|
||||
self._rename = { version = version, id = id, text = label or "" }
|
||||
self._slotPress = nil -- cancel any armed click/drag on the list
|
||||
self:_armTextInput()
|
||||
end
|
||||
|
||||
function RomImporter:_commitRename()
|
||||
local r = self._rename
|
||||
if not r then return end
|
||||
self._rename = nil
|
||||
self:_disarmTextInput()
|
||||
require("src.core.SaveData").renameSlot(r.version, r.id, r.text)
|
||||
self:_refreshSlots(r.version)
|
||||
end
|
||||
@@ -3472,6 +3598,19 @@ function RomImporter:textinput(text)
|
||||
self._rename.text = utf8Cap(self._rename.text .. text, MAX_SLOT_LABEL)
|
||||
end
|
||||
|
||||
-- Clipboard into the index prompt, shared by ctrl/cmd+V and the prompt's
|
||||
-- on-screen PASTE button (#578). Same rule as typed input: URLs never
|
||||
-- contain a literal space, and a pasted one usually arrives with a stray
|
||||
-- newline attached.
|
||||
function RomImporter:_pasteIndexUrl()
|
||||
if not self._indexPrompt then return end
|
||||
local ok, text = pcall(love.system.getClipboardText)
|
||||
if ok and type(text) == "string" then
|
||||
self._indexPrompt.text =
|
||||
utf8Cap(self._indexPrompt.text .. text:gsub("%s", ""), MAX_INDEX_URL)
|
||||
end
|
||||
end
|
||||
|
||||
-- "+ New save slot": register an empty slot, make it active, relist, and pin the
|
||||
-- scroll to the bottom (clamped next draw) so the new row is on screen.
|
||||
function RomImporter:_newSlot(version)
|
||||
@@ -3623,7 +3762,7 @@ function RomImporter:_drawSaveSlotPanel(version, x, y, w, h, paged)
|
||||
local metaH = self.labelFont:getHeight()
|
||||
local rowPadV = 10 * s
|
||||
local chipBtnH = self.hintFont:getHeight() + 10 * s
|
||||
-- LOADED sits top-right; Edit/Delete sit bottom-right — row must fit both
|
||||
-- LOADED sits top-right; Edit/Delete sit bottom-right -- row must fit both
|
||||
-- without stacking on the same y (chip buttons are taller than the old text).
|
||||
local loadedH = self.warningFont:getHeight() + 6 * s
|
||||
local rowH = math.max(rowPadV * 2 + nameH + 4 * s + metaH,
|
||||
@@ -4515,11 +4654,13 @@ end
|
||||
-- would make the launcher's choice look like an endorsement.
|
||||
function RomImporter:_promptAddIndex()
|
||||
self._indexPrompt = { text = "" }
|
||||
self:_armTextInput()
|
||||
end
|
||||
|
||||
function RomImporter:_commitAddIndex()
|
||||
local prompt = self._indexPrompt
|
||||
self._indexPrompt = nil
|
||||
self:_disarmTextInput()
|
||||
if not prompt then return end
|
||||
local ModIndex = require("src.mods.ModIndex")
|
||||
local row, err = ModIndex.addSource(prompt.text or "")
|
||||
|
||||
+23
-7
@@ -1,11 +1,26 @@
|
||||
-- The 20-slot bag (BAG_ITEM_CAPACITY, constants/menu_constants.asm):
|
||||
-- a distinct item id occupies one slot regardless of quantity; badges
|
||||
-- live in the inventory table but are not bag items. save.bagOrder
|
||||
-- keeps acquisition order like wBagItems (SELECT can reorder it).
|
||||
-- The bag defaults to 20 slots (BAG_ITEM_CAPACITY,
|
||||
-- constants/menu_constants.asm), but mods may replace that limit through
|
||||
-- Data.constants.bagSize. A distinct item id occupies one slot regardless
|
||||
-- of quantity; badges live in the inventory table but are not bag items.
|
||||
-- save.bagOrder keeps acquisition order like wBagItems (SELECT can reorder
|
||||
-- it).
|
||||
|
||||
local Bag = {}
|
||||
|
||||
Bag.CAPACITY = 20
|
||||
local DEFAULT_CAPACITY = 20
|
||||
|
||||
-- `data` is injectable for the save editor and headless mod tests. Normal
|
||||
-- gameplay may omit it because the loader merges mods into the Data
|
||||
-- singleton before any item can be added. The fallback keeps old/stale
|
||||
-- generated caches and isolated callers at the vanilla limit.
|
||||
function Bag.capacity(data)
|
||||
data = data or require("src.core.Data")
|
||||
local configured = data and data.constants and data.constants.bagSize
|
||||
if type(configured) == "number" and configured >= 1 then
|
||||
return math.floor(configured)
|
||||
end
|
||||
return DEFAULT_CAPACITY
|
||||
end
|
||||
|
||||
local function isBadge(id)
|
||||
return id:find("BADGE", 1, true) ~= nil
|
||||
@@ -55,9 +70,10 @@ end
|
||||
-- Add qty of an item; returns false (and adds nothing) when a new slot
|
||||
-- is needed and the bag is full, or when the stack would pass 99
|
||||
-- (AddItemToInventory's per-slot quantity cap).
|
||||
function Bag.add(save, id, qty)
|
||||
function Bag.add(save, id, qty, data)
|
||||
local inv = save.inventory
|
||||
if not inv[id] and not isBadge(id) and Bag.slots(save) >= Bag.CAPACITY then
|
||||
if not inv[id] and not isBadge(id)
|
||||
and Bag.slots(save) >= Bag.capacity(data) then
|
||||
return false
|
||||
end
|
||||
if not isBadge(id) and (inv[id] or 0) + (qty or 1) > 99 then
|
||||
|
||||
@@ -86,8 +86,58 @@ local function mergeConflictLists(conflicts, incompatible)
|
||||
return out
|
||||
end
|
||||
|
||||
-- Drop bytes that are not valid UTF-8 (malformed sequences, overlongs,
|
||||
-- surrogates, > U+10FFFF) and a leading BOM. LÖVE's text renderer raises
|
||||
-- "Invalid UTF-8" from love.graphics.print/printf, so any manifest string a
|
||||
-- panel may draw must be scrubbed here -- the one place every mod manifest
|
||||
-- passes through -- or a single mangled description crashes the whole MODS
|
||||
-- panel instead of misrendering one card.
|
||||
local function scrubUtf8(s)
|
||||
if type(s) ~= "string" then return s end
|
||||
s = s:gsub("^\239\187\191", "")
|
||||
local out, i, n = {}, 1, #s
|
||||
while i <= n do
|
||||
local b = s:byte(i)
|
||||
local len
|
||||
if b < 0x80 then len = 1
|
||||
elseif b >= 0xC2 and b <= 0xDF then len = 2
|
||||
elseif b >= 0xE0 and b <= 0xEF then len = 3
|
||||
elseif b >= 0xF0 and b <= 0xF4 then len = 4
|
||||
end
|
||||
local ok = len ~= nil and i + len - 1 <= n
|
||||
if ok and len > 1 then
|
||||
for j = i + 1, i + len - 1 do
|
||||
local c = s:byte(j)
|
||||
if c < 0x80 or c > 0xBF then ok = false; break end
|
||||
end
|
||||
if ok then
|
||||
-- boundary lead bytes narrow their second byte: no overlongs
|
||||
-- (E0/F0), no surrogates (ED), nothing past U+10FFFF (F4)
|
||||
local b2 = s:byte(i + 1)
|
||||
if (b == 0xE0 and b2 < 0xA0) or (b == 0xED and b2 > 0x9F)
|
||||
or (b == 0xF0 and b2 < 0x90) or (b == 0xF4 and b2 > 0x8F) then
|
||||
ok = false
|
||||
end
|
||||
end
|
||||
end
|
||||
if ok then
|
||||
out[#out + 1] = s:sub(i, i + len - 1)
|
||||
i = i + len
|
||||
else
|
||||
i = i + 1
|
||||
end
|
||||
end
|
||||
return table.concat(out)
|
||||
end
|
||||
|
||||
function Manifest.validate(raw, path)
|
||||
assert(type(raw) == "table", "manifest must be an object")
|
||||
-- scrubbed in place so every later reader agrees, including the launcher's
|
||||
-- badge derivation, which reads raw.category rather than the validated copy
|
||||
raw.name = scrubUtf8(raw.name)
|
||||
raw.version = scrubUtf8(raw.version)
|
||||
raw.description = scrubUtf8(raw.description)
|
||||
raw.category = scrubUtf8(raw.category)
|
||||
assert(type(raw.id) == "string" and raw.id:match("^[%w_%-]+$"),
|
||||
"manifest id must contain only letters, numbers, _ or -")
|
||||
assert(type(raw.name) == "string" and raw.name ~= "", "manifest name is required")
|
||||
|
||||
@@ -1186,11 +1186,13 @@ R.field = {
|
||||
nest = f.opt(f.any) },
|
||||
flyOrder = f.list(f.str),
|
||||
-- the player's own trainer art (FieldDefaults.PLAYER_PICS): the battle
|
||||
-- back pic, the catch tutorial's old man, and the front pic the intro,
|
||||
-- trainer card and Hall of Fame share. Every key is optional so a
|
||||
-- conversion can replace one pic and inherit the rest.
|
||||
-- back pic, the catch tutorial's old man, Yellow's PROF.OAK variant of
|
||||
-- it (#557), and the front pic the intro, trainer card and Hall of Fame
|
||||
-- share. Every key is optional so a conversion can replace one pic and
|
||||
-- inherit the rest.
|
||||
playerPics = f.rec{
|
||||
back = f.opt(f.str), demoBack = f.opt(f.str), front = f.opt(f.str) },
|
||||
back = f.opt(f.str), demoBack = f.opt(f.str),
|
||||
oakBack = f.opt(f.str), front = f.opt(f.str) },
|
||||
-- the new-game and boot config a total conversion replaces
|
||||
boot = f.rec{
|
||||
startMap = f.opt(f.str), startX = f.opt(f.int(0)), startY = f.opt(f.int(0)),
|
||||
|
||||
+13
-1
@@ -46,20 +46,32 @@ end
|
||||
-- opts.kind: "battle" | "intro" | "trainer_card" | "hof"
|
||||
-- opts.demo: the catch tutorial, where the old man fights in the player's
|
||||
-- place and stands in for the back pic
|
||||
-- opts.oakDemo: the Yellow variant of that demo (BATTLE_TYPE_PIKACHU), where
|
||||
-- PROF.OAK fights in the player's place behind his own back pic
|
||||
-- opts.battle: the live battle, for kind == "battle"
|
||||
-- Returns path, trueColor.
|
||||
function Sprites.playerPath(data, side, opts)
|
||||
opts = opts or {}
|
||||
side = side == "back" and "back" or "front"
|
||||
-- one key per pic, so a conversion can replace the back and inherit the
|
||||
-- rest; fieldValue folds data.field over FieldDefaults per key
|
||||
-- rest; fieldValue folds data.field over FieldDefaults per key. The two
|
||||
-- demo keys mirror LoadPlayerBackPic's wBattleType branch (#557).
|
||||
local key = side == "front" and "front"
|
||||
or (opts.oakDemo and "oakBack")
|
||||
or (opts.demo and "demoBack" or "back")
|
||||
local path = FieldDefaults.fieldValue(data, "playerPics", key)
|
||||
-- ProfOakPicBack is a Yellow-only rip, so a cache imported before it
|
||||
-- existed has no file there; fall back to the old man rather than hand a
|
||||
-- missing path to getImage (#557)
|
||||
if key == "oakBack" and path
|
||||
and not require("src.render.Assets").exists(path) then
|
||||
path = FieldDefaults.fieldValue(data, "playerPics", "demoBack")
|
||||
end
|
||||
local ctx = {
|
||||
side = side,
|
||||
kind = opts.kind or "battle",
|
||||
demo = opts.demo and true or false,
|
||||
oakDemo = opts.oakDemo and true or false,
|
||||
battle = opts.battle,
|
||||
trueColor = false,
|
||||
data = data,
|
||||
|
||||
@@ -120,7 +120,7 @@ end
|
||||
-- shape (love.graphics.newCanvas), so accept either and nothing else.
|
||||
local function isCanvas(v)
|
||||
if type(v) == "userdata" then
|
||||
return type(v.typeOf) == "function" and v:typeOf("Canvas") == true
|
||||
return type(v.getWidth) == "function" and type(v.getHeight) == "function"
|
||||
end
|
||||
if type(v) == "table" then
|
||||
return type(v.getWidth) == "function" and type(v.getHeight) == "function"
|
||||
|
||||
+76
-32
@@ -32,7 +32,7 @@ Renderer.MAX_UI_HEIGHT = 576
|
||||
-- would otherwise take the frame down with it.
|
||||
local function isCanvas(v)
|
||||
if type(v) == "userdata" then
|
||||
return type(v.typeOf) == "function" and v:typeOf("Canvas") == true
|
||||
return type(v.getWidth) == "function" and type(v.getHeight) == "function"
|
||||
end
|
||||
if type(v) == "table" then
|
||||
return type(v.getWidth) == "function" and type(v.getHeight) == "function"
|
||||
@@ -474,6 +474,41 @@ local function withTrueColor(zoneList, pass)
|
||||
return merged
|
||||
end
|
||||
|
||||
-- Palette-correct blit of `canvas` at (sx, sy) LOVE-unit scales into origin
|
||||
-- (bx, by), scissored to the (boxX, boxY, boxW, boxH) screen rect. zoneSx/
|
||||
-- zoneSy convert zone coords (canvas-space) into screen units. Public so a
|
||||
-- render.compose mod can composite the world/UI canvases into its own layout.
|
||||
function Renderer:blitCanvas(canvas, sx, sy, zoneList, zoneSx, zoneSy,
|
||||
bx, by, boxX, boxY, boxW, boxH, dpiX, dpiY)
|
||||
local shader = zoneList and zoneList[1] and PaletteFX.shader() or nil
|
||||
if not shader then
|
||||
love.graphics.setScissor(boxX, boxY, boxW, boxH)
|
||||
love.graphics.draw(canvas, bx, by, 0, sx, sy)
|
||||
love.graphics.setScissor()
|
||||
return
|
||||
end
|
||||
love.graphics.setShader(shader)
|
||||
-- a colors == false zone is the trueColor opt-out: its rect draws with
|
||||
-- no shader at all. Nothing sets one without a mod, so a vanilla zone
|
||||
-- list never toggles and issues exactly the calls it always did.
|
||||
local bare = false
|
||||
for _, z in ipairs(zoneList) do
|
||||
local plain = z.colors == false
|
||||
if plain ~= bare then
|
||||
bare = plain
|
||||
love.graphics.setShader(not plain and shader or nil)
|
||||
end
|
||||
if not plain then PaletteFX.sendColors(shader, z.colors) end
|
||||
if scissorClamped(bx + z.x * zoneSx, by + z.y * zoneSy,
|
||||
z.w * zoneSx, z.h * zoneSy,
|
||||
boxX, boxY, boxW, boxH, dpiX, dpiY) then
|
||||
love.graphics.draw(canvas, bx, by, 0, sx, sy)
|
||||
end
|
||||
end
|
||||
love.graphics.setScissor()
|
||||
love.graphics.setShader()
|
||||
end
|
||||
|
||||
-- zones: optional list of SGB palette regions (see PaletteFX) in
|
||||
-- 160x144 UI space, applied to the UI pass. worldZones: optional
|
||||
-- regions in world-canvas pixels (overworld survey zoom colors each
|
||||
@@ -506,6 +541,36 @@ function Renderer:endFrame(zones, worldZones)
|
||||
zones = withTrueColor(zones, "ui")
|
||||
worldZones = withTrueColor(worldZones, "world")
|
||||
|
||||
-- render.compose: hand a mod the finished world + UI canvases (and their
|
||||
-- SGB zones), the frame metrics, Renderer:blitCanvas and the SecondScreen
|
||||
-- bridge, letting it lay the two passes out however it likes -- e.g. as two
|
||||
-- stacked Game Boy screens, or driving one onto a second physical display.
|
||||
-- The mod returns true to take over the whole window; anything else (or no
|
||||
-- mod wrapping the hook) falls through to the normal composite below.
|
||||
if Runtime.wantsHook("render.compose") then
|
||||
local ctx = {
|
||||
renderer = self,
|
||||
worldCanvas = self.worldCanvas, uiCanvas = self.canvas,
|
||||
worldOverride = self.worldOverride,
|
||||
worldActive = self.worldActive and true or false,
|
||||
zones = zones, worldZones = worldZones,
|
||||
ww = ww, wh = wh, pw = pw, ph = ph, ox = ox, oy = oy,
|
||||
vpw = vpw, vph = vph, uiw = uiw, uih = uih,
|
||||
scale = Sp, Sx = Sx, Sy = Sy, dpiX = dpiX, dpiY = dpiY,
|
||||
secondScreen = require("src.render.SecondScreen"),
|
||||
}
|
||||
if Runtime.call("render.compose", function() return false end, self, ctx) == true then
|
||||
self.worldActive = false
|
||||
self.uprightActive = false
|
||||
self.worldOverride = nil
|
||||
PaletteFX.setPass(nil)
|
||||
return {
|
||||
width = ww, height = wh, gameX = ox, gameY = oy,
|
||||
gameWidth = vpw, gameHeight = vph, scale = Sp, dpiX = dpiX, dpiY = dpiY,
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
-- A post-process pipeline needs the whole composite in a canvas for the
|
||||
-- same reason GBC FX does, so either one alone is enough to take the
|
||||
-- present path; with neither, the frame draws straight to the screen
|
||||
@@ -560,38 +625,12 @@ function Renderer:endFrame(zones, worldZones)
|
||||
})
|
||||
end
|
||||
|
||||
-- blit `canvas` at (sx, sy) LOVE-unit scales into origin (bx, by),
|
||||
-- scissored to the (boxX, boxY, boxW, boxH) screen rect. zoneSx/zoneSy
|
||||
-- convert zone coords (canvas-space) into screen units.
|
||||
-- see Renderer:blitCanvas; bound here to the frame's dpi so the composite
|
||||
-- call sites below stay unchanged.
|
||||
local function blit(canvas, sx, sy, zoneList, zoneSx, zoneSy,
|
||||
bx, by, boxX, boxY, boxW, boxH)
|
||||
local shader = zoneList and zoneList[1] and PaletteFX.shader() or nil
|
||||
if not shader then
|
||||
love.graphics.setScissor(boxX, boxY, boxW, boxH)
|
||||
love.graphics.draw(canvas, bx, by, 0, sx, sy)
|
||||
love.graphics.setScissor()
|
||||
return
|
||||
end
|
||||
love.graphics.setShader(shader)
|
||||
-- a colors == false zone is the trueColor opt-out: its rect draws with
|
||||
-- no shader at all. Nothing sets one without a mod, so a vanilla zone
|
||||
-- list never toggles and issues exactly the calls it always did.
|
||||
local bare = false
|
||||
for _, z in ipairs(zoneList) do
|
||||
local plain = z.colors == false
|
||||
if plain ~= bare then
|
||||
bare = plain
|
||||
love.graphics.setShader(not plain and shader or nil)
|
||||
end
|
||||
if not plain then PaletteFX.sendColors(shader, z.colors) end
|
||||
if scissorClamped(bx + z.x * zoneSx, by + z.y * zoneSy,
|
||||
z.w * zoneSx, z.h * zoneSy,
|
||||
boxX, boxY, boxW, boxH, dpiX, dpiY) then
|
||||
love.graphics.draw(canvas, bx, by, 0, sx, sy)
|
||||
end
|
||||
end
|
||||
love.graphics.setScissor()
|
||||
love.graphics.setShader()
|
||||
return self:blitCanvas(canvas, sx, sy, zoneList, zoneSx, zoneSy,
|
||||
bx, by, boxX, boxY, boxW, boxH, dpiX, dpiY)
|
||||
end
|
||||
|
||||
if self.worldOverride then
|
||||
@@ -602,7 +641,12 @@ function Renderer:endFrame(zones, worldZones)
|
||||
-- runs, so dialogs, menus and the HUD sit on top as usual.
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
love.graphics.setScissor(0, 0, ww, wh)
|
||||
love.graphics.draw(self.worldOverride, 0, 0, 0, 1 / dpiX, 1 / dpiY)
|
||||
local loveMajor = love.getVersion()
|
||||
if love.system and love.system.getOS and love.system.getOS() == "iOS" and loveMajor >= 12 then
|
||||
love.graphics.draw(self.worldOverride, 0, wh, 0, 1 / dpiX, -1 / dpiY)
|
||||
else
|
||||
love.graphics.draw(self.worldOverride, 0, 0, 0, 1 / dpiX, 1 / dpiY)
|
||||
end
|
||||
love.graphics.setScissor()
|
||||
-- the screen-space overlays the flat path draws over its composite
|
||||
local fade = self.worldFadeAlpha
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
-- Bridge to native secondary-display output (Android Presentation). The C
|
||||
-- functions live in mobile/android/love/src/jni/love/src/common/android.cpp.
|
||||
-- Everything is guarded: off Android, or if the symbols cannot be resolved,
|
||||
-- this stays inert and the renderer keeps the in-window stacked layout.
|
||||
|
||||
local SecondScreen = {}
|
||||
local C = nil
|
||||
|
||||
local function log(msg)
|
||||
pcall(function() require("src.core.Logger").info("SecondScreen: %s", msg) end)
|
||||
end
|
||||
|
||||
do
|
||||
local ok, ffi = pcall(require, "ffi")
|
||||
if not (ok and ffi) then
|
||||
log("ffi unavailable (not LuaJIT); second display disabled")
|
||||
else
|
||||
pcall(ffi.cdef, [[
|
||||
int love_android_secondary_ready();
|
||||
void love_android_push_secondary(const void *rgba, int w, int h);
|
||||
void love_android_secondary_enable(int on);
|
||||
]])
|
||||
local okLib, lib = pcall(ffi.load, "love")
|
||||
if okLib and lib and pcall(function() return lib.love_android_secondary_ready end) then
|
||||
C = lib
|
||||
log("bridge linked via ffi.load('love')")
|
||||
elseif pcall(function() return ffi.C.love_android_secondary_ready end) then
|
||||
C = ffi.C
|
||||
log("bridge linked via default namespace")
|
||||
else
|
||||
log(("bridge symbols not found (ffi.load ok=%s); second display disabled")
|
||||
:format(tostring(okLib)))
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
function SecondScreen.usable()
|
||||
return C ~= nil
|
||||
end
|
||||
|
||||
function SecondScreen.available()
|
||||
if not C then return false end
|
||||
local ok, r = pcall(C.love_android_secondary_ready)
|
||||
return ok and r ~= 0
|
||||
end
|
||||
|
||||
function SecondScreen.push(imageData, w, h)
|
||||
if not C or not imageData then return false end
|
||||
return pcall(function()
|
||||
C.love_android_push_secondary(imageData:getFFIPointer(), w, h)
|
||||
end)
|
||||
end
|
||||
|
||||
function SecondScreen.setEnabled(on)
|
||||
if not C then return end
|
||||
pcall(function() C.love_android_secondary_enable(on and 1 or 0) end)
|
||||
end
|
||||
|
||||
return SecondScreen
|
||||
@@ -210,11 +210,12 @@ end
|
||||
-- text (label or literal; {RAM:wStringBuffer} becomes the item name);
|
||||
-- pass false when the script shows its own received-text row.
|
||||
function Commands.give_item(ctx, itemId, count, gotText)
|
||||
-- the 20-slot bag can refuse (BAG_ITEM_CAPACITY): say so and halt
|
||||
-- the bag can refuse at its configured capacity (20 in vanilla): halt
|
||||
-- the script, so later set_flag rows don't burn the gift -- make
|
||||
-- room and talk again, like the original (pokered's `jr nc, .bag_full`
|
||||
-- skips the received text entirely when AddItemToInventory refuses)
|
||||
if not require("src.inventory.Bag").add(ctx.save, itemId, count or 1) then
|
||||
if not require("src.inventory.Bag").add(
|
||||
ctx.save, itemId, count or 1, ctx.game.data) then
|
||||
Commands.show_text(ctx, ctx.game.data.text
|
||||
and ctx.game.data.text._BagFullText or Strings("You can't carry\nany more items!"))
|
||||
return math.huge
|
||||
|
||||
+151
-27
@@ -6,6 +6,7 @@
|
||||
|
||||
local Font = require("src.render.Font")
|
||||
local ListMenu = require("src.ui.ListMenu")
|
||||
local ChoiceBox = require("src.ui.ChoiceBox")
|
||||
local Input = require("src.core.Input")
|
||||
local Strings = require("src.core.Strings")
|
||||
|
||||
@@ -13,16 +14,18 @@ local BindingsMenu = setmetatable({}, { __index = ListMenu })
|
||||
BindingsMenu.__index = BindingsMenu
|
||||
|
||||
-- Input.lua's map, primary key first where several keys share a button.
|
||||
-- `pad` is the default SDL gamecontroller button (see Input.lua); shown
|
||||
-- on the SELECT row so controller Back/View is discoverable (#73).
|
||||
-- `pad` mirrors DEFAULT_GAMEPAD_BINDINGS in src/core/Input.lua row for
|
||||
-- row; keep the two in sync. Every row shows its key and pad so the
|
||||
-- controller side is discoverable (#73, #589), and the swap in
|
||||
-- storeBinding leans on each row holding a value in both slots.
|
||||
local BUTTONS = {
|
||||
{ id = "up", label = "UP", key = "up" },
|
||||
{ id = "down", label = "DOWN", key = "down" },
|
||||
{ id = "left", label = "LEFT", key = "left" },
|
||||
{ id = "right", label = "RIGHT", key = "right" },
|
||||
{ id = "a", label = "A", key = "z" },
|
||||
{ id = "b", label = "B", key = "x" },
|
||||
{ id = "start", label = "START", key = "escape" },
|
||||
{ id = "up", label = "UP", key = "up", pad = "dpup" },
|
||||
{ id = "down", label = "DOWN", key = "down", pad = "dpdown" },
|
||||
{ id = "left", label = "LEFT", key = "left", pad = "dpleft" },
|
||||
{ id = "right", label = "RIGHT", key = "right", pad = "dpright" },
|
||||
{ id = "a", label = "A", key = "z", pad = "a" },
|
||||
{ id = "b", label = "B", key = "x", pad = "b" },
|
||||
{ id = "start", label = "START", key = "escape", pad = "start" },
|
||||
{ id = "select", label = "SELECT", key = "tab", pad = "back" },
|
||||
}
|
||||
|
||||
@@ -41,14 +44,33 @@ local function boundPad(overlay, def)
|
||||
return def.pad
|
||||
end
|
||||
|
||||
-- Key column for every row. SELECT also appends "/PAD" (default BACK)
|
||||
-- so controller Select/View is visible without opening a second legend.
|
||||
-- The right column is KEY/PAD (e.g. "Z/A"). The row is 20 tiles and the
|
||||
-- widest label ("SELECT") ends at x=64, so each half is clamped to 5
|
||||
-- glyphs: 5+1+5 right-aligned at x=152 starts no further left than x=64.
|
||||
-- SDL names longer than 5 get a fixed short form before the clamp.
|
||||
local KEY_SHORT = {
|
||||
escape = "ESC", backspace = "BKSP", ["return"] = "ENTER",
|
||||
kpenter = "ENTER", space = "SPACE",
|
||||
}
|
||||
local PAD_SHORT = {
|
||||
dpup = "D-UP", dpdown = "D-DN", dpleft = "D-LT", dpright = "D-RT",
|
||||
leftshoulder = "LB", rightshoulder = "RB",
|
||||
leftstick = "LS", rightstick = "RS", guide = "GUIDE",
|
||||
}
|
||||
local function shortName(name, shorts)
|
||||
local s = shorts[name]
|
||||
if s then return s end
|
||||
s = name:upper()
|
||||
return #s > 5 and s:sub(1, 5) or s
|
||||
end
|
||||
|
||||
-- Right column for every row: effective key and pad together, so a
|
||||
-- controller player can read the whole map without a second legend (#589).
|
||||
local function boundRight(overlay, def)
|
||||
local key = boundKey(overlay, def)
|
||||
if def.id ~= "select" then return key:upper() end
|
||||
local key = shortName(boundKey(overlay, def), KEY_SHORT)
|
||||
local pad = boundPad(overlay, def)
|
||||
if pad then return (key .. "/" .. pad):upper() end
|
||||
return key:upper()
|
||||
if pad then return key .. "/" .. shortName(pad, PAD_SHORT) end
|
||||
return key
|
||||
end
|
||||
|
||||
function BindingsMenu.new(game)
|
||||
@@ -61,9 +83,17 @@ function BindingsMenu.new(game)
|
||||
items[i] = { label = Strings(def.label),
|
||||
right = boundRight(overlay, def), button = def }
|
||||
end
|
||||
local self = setmetatable(ListMenu.new(game, "CONTROLS", items, {}),
|
||||
BindingsMenu)
|
||||
local self = setmetatable(ListMenu.new(game, "CONTROLS", items, {
|
||||
-- 6 rows leaves the bottom two lines free for the hint; a clear or
|
||||
-- reset nobody can see on screen may as well not exist (#589)
|
||||
rows = 6,
|
||||
footer = Strings("SELECT:CLEAR ROW\nSTART:RESET ALL"),
|
||||
}), BindingsMenu)
|
||||
self.onChoose = function(item) self:beginCapture(item) end
|
||||
-- SELECT deletes one row's rebind: dropping the overlay entry is enough
|
||||
-- because Input:applyBindings rebuilds the whole map from the defaults
|
||||
-- on every call (#589)
|
||||
self.onSelectKey = function(item) self:clearBinding(item) end
|
||||
-- A rebind reaches Input only when this screen closes (#510). The menu
|
||||
-- steers by the live map, so applying "B = Z" the instant it was captured
|
||||
-- turned the player's next confirm press into a cancel and shut the
|
||||
@@ -82,12 +112,28 @@ function BindingsMenu:commitBindings()
|
||||
if opts then Input:applyBindings(opts.bindings) end
|
||||
end
|
||||
|
||||
-- the capture handlers are per-instance slots, so Game's raw-input
|
||||
-- routing only ever sees this screen while a capture is armed
|
||||
-- The capture handlers are per-instance slots, so Game's raw-input
|
||||
-- routing only ever sees this screen while a capture is armed. A capture
|
||||
-- no longer commits on the press: it commits when that press is RELEASED,
|
||||
-- and a second key or pad button going down while the first is still held
|
||||
-- cancels instead. That gives a bare controller a way to back out of an
|
||||
-- armed row, where Escape cannot help (#589).
|
||||
function BindingsMenu:beginCapture(item)
|
||||
self.capture = item
|
||||
self.pending = nil
|
||||
self.onKeyPressed = BindingsMenu.captureKey
|
||||
self.onGamepadPressed = BindingsMenu.capturePad
|
||||
self.onKeyReleased = BindingsMenu.captureKeyRelease
|
||||
self.onGamepadReleased = BindingsMenu.capturePadRelease
|
||||
end
|
||||
|
||||
function BindingsMenu:endCapture()
|
||||
self.capture = nil
|
||||
self.pending = nil
|
||||
self.onKeyPressed = nil
|
||||
self.onGamepadPressed = nil
|
||||
self.onKeyReleased = nil
|
||||
self.onGamepadReleased = nil
|
||||
end
|
||||
|
||||
-- Escape is the capture's way out, so it is never captured: every other
|
||||
@@ -95,23 +141,64 @@ end
|
||||
-- to bind something (#510). Escape stays START in Input's default map,
|
||||
-- which no rebind removes, so reserving it costs the player nothing.
|
||||
function BindingsMenu:captureKey(key)
|
||||
if key == "escape" then return self:storeBinding("key", nil) end
|
||||
self:storeBinding("key", key)
|
||||
if key == "escape" or self.pending then return self:endCapture() end
|
||||
self.pending = { slot = "key", value = key }
|
||||
end
|
||||
|
||||
function BindingsMenu:capturePad(button)
|
||||
self:storeBinding("pad", button)
|
||||
if self.pending then return self:endCapture() end
|
||||
self.pending = { slot = "pad", value = button }
|
||||
end
|
||||
|
||||
-- Game forwards every release to Input BEFORE these hooks (see
|
||||
-- Game:keyreleased): the capture observes releases, it never owns them,
|
||||
-- so Input's held-state stays honest for keys it saw go down before the
|
||||
-- capture armed. A release that does not match the pending input (the
|
||||
-- press that armed the row, a cancelled capture's stragglers) is noise.
|
||||
function BindingsMenu:captureKeyRelease(key)
|
||||
local p = self.pending
|
||||
if p and p.slot == "key" and p.value == key then
|
||||
self:storeBinding("key", key)
|
||||
end
|
||||
end
|
||||
|
||||
function BindingsMenu:capturePadRelease(button)
|
||||
local p = self.pending
|
||||
if p and p.slot == "pad" and p.value == button then
|
||||
self:storeBinding("pad", button)
|
||||
end
|
||||
end
|
||||
|
||||
function BindingsMenu:storeBinding(slot, value)
|
||||
local item = self.capture
|
||||
self.capture = nil
|
||||
self.onKeyPressed = nil
|
||||
self.onGamepadPressed = nil
|
||||
self:endCapture()
|
||||
local game = self.game
|
||||
if not (item and value and game.save and game.save.options) then return end
|
||||
local opts = game.save.options
|
||||
opts.bindings = opts.bindings or {}
|
||||
-- Swap, never steal (#589): when the captured input is another row's
|
||||
-- effective binding in this slot, that row inherits this row's previous
|
||||
-- binding. Every BUTTONS row has a default in both slots, so `prev`
|
||||
-- always exists: no row goes empty and no input serves two rows.
|
||||
-- Default key ALIASES (W beside Up, Space beside Z; DEFAULT_BINDINGS in
|
||||
-- Input.lua) are not effective bindings, so capturing one costs the
|
||||
-- other row a spare alias, never its shown key.
|
||||
local effective = (slot == "key") and boundKey or boundPad
|
||||
local prev = effective(opts.bindings, item.button)
|
||||
if value ~= prev then
|
||||
for _, other in ipairs(self.items) do
|
||||
if other ~= item and effective(opts.bindings, other.button) == value then
|
||||
local ob = opts.bindings[other.button.id]
|
||||
if type(ob) ~= "table" then
|
||||
ob = { key = type(ob) == "string" and ob or nil }
|
||||
end
|
||||
ob[slot] = prev
|
||||
opts.bindings[other.button.id] = ob
|
||||
other.right = boundRight(opts.bindings, other.button)
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
local b = opts.bindings[item.button.id]
|
||||
if type(b) ~= "table" then
|
||||
-- keep a direct-edited plain key string when only the pad changes
|
||||
@@ -123,18 +210,55 @@ function BindingsMenu:storeBinding(slot, value)
|
||||
if game.writeOptions then game:writeOptions() end
|
||||
end
|
||||
|
||||
-- SELECT: forget one row's rebind and fall back to the defaults. #510's
|
||||
-- deferral still holds: only options change here, the live map catches up
|
||||
-- in commitBindings on close.
|
||||
function BindingsMenu:clearBinding(item)
|
||||
local game = self.game
|
||||
local opts = game and game.save and game.save.options
|
||||
if not (opts and opts.bindings and opts.bindings[item.button.id]) then
|
||||
return
|
||||
end
|
||||
opts.bindings[item.button.id] = nil
|
||||
item.right = boundRight(opts.bindings, item.button)
|
||||
if game.writeOptions then game:writeOptions() end
|
||||
end
|
||||
|
||||
-- START: confirm, then drop the whole overlay (#589). The footer doubles
|
||||
-- as the prompt while the YES/NO box is up, the same bottom-line pattern
|
||||
-- the mart and PC screens use.
|
||||
function BindingsMenu:confirmReset()
|
||||
local game = self.game
|
||||
local hint = self.footer
|
||||
self.footer = Strings("RESET ALL BINDINGS?")
|
||||
game.stack:push(ChoiceBox.new(game, function(yes)
|
||||
self.footer = hint
|
||||
if not yes then return end
|
||||
local opts = game.save and game.save.options
|
||||
if opts then opts.bindings = nil end
|
||||
for _, it in ipairs(self.items) do
|
||||
it.right = boundRight(nil, it.button)
|
||||
end
|
||||
if game.writeOptions then game:writeOptions() end
|
||||
end, { defaultNo = true }))
|
||||
end
|
||||
|
||||
function BindingsMenu:update(dt)
|
||||
if self.capture then return end -- the raw capture owns the input
|
||||
if self.game.input:wasPressed("start") then
|
||||
return self:confirmReset()
|
||||
end
|
||||
ListMenu.update(self, dt)
|
||||
end
|
||||
|
||||
function BindingsMenu:draw()
|
||||
ListMenu.draw(self)
|
||||
if self.capture then
|
||||
Font.drawBox(1, 6, 18, 5)
|
||||
Font.drawBox(1, 6, 18, 6)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
Font.draw(Strings("PRESS A BUTTON"), 24, 60)
|
||||
Font.draw(Strings("ESC TO CANCEL"), 24, 72)
|
||||
Font.draw(Strings("RELEASE TO SET"), 24, 72)
|
||||
Font.draw(Strings("ESC/2ND CANCELS"), 24, 84)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -66,6 +66,7 @@ local function withdraw(game)
|
||||
end
|
||||
game.stack:push(ListMenu.new(game,
|
||||
Strings("BOX %d (WITHDRAW)", game.save.currentBox), items, {
|
||||
noSound = true, -- PCMainMenu holds BIT_NO_MENU_BUTTON_SOUND (#570)
|
||||
onChoose = function(item, list)
|
||||
local mon = box[item.value]
|
||||
if not mon then return end
|
||||
@@ -112,6 +113,7 @@ local function deposit(game)
|
||||
table.insert(items, { label = monLabel(game, mon), value = i })
|
||||
end
|
||||
game.stack:push(ListMenu.new(game, "PARTY (DEPOSIT)", items, {
|
||||
noSound = true, -- PCMainMenu holds BIT_NO_MENU_BUTTON_SOUND (#570)
|
||||
onChoose = function(item, list)
|
||||
local mon = game.save.party[item.value]
|
||||
if not mon then return end
|
||||
@@ -157,6 +159,7 @@ local function release(game)
|
||||
end
|
||||
game.stack:push(ListMenu.new(game,
|
||||
Strings("BOX %d (RELEASE)", game.save.currentBox), items, {
|
||||
noSound = true, -- PCMainMenu holds BIT_NO_MENU_BUTTON_SOUND (#570)
|
||||
onChoose = function(_, list)
|
||||
local mon = box[list.index]
|
||||
if not mon then return end
|
||||
@@ -189,6 +192,7 @@ local function changeBox(game)
|
||||
})
|
||||
end
|
||||
game.stack:push(ListMenu.new(game, "CHANGE BOX", items, {
|
||||
noSound = true, -- PCMainMenu holds BIT_NO_MENU_BUTTON_SOUND (#570)
|
||||
onChoose = function(item, list)
|
||||
-- the original asks BEFORE switching ("When you change a #MON
|
||||
-- BOX, data will be saved. OK?"); declining aborts the change
|
||||
|
||||
@@ -78,6 +78,10 @@ function ListMenu.new(game, title, items, opts)
|
||||
-- bottom text box -- same row budget as the mart, without the money box.
|
||||
self.messageBox = opts.messageBox
|
||||
self.money = opts.money -- () -> current money for the box
|
||||
-- BIT_NO_MENU_BUTTON_SOUND (wMiscFlags): both PC sessions hold the flag
|
||||
-- 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)
|
||||
return self
|
||||
end
|
||||
@@ -101,6 +105,17 @@ local function syncScroll(self)
|
||||
if self.index - self.scroll < 1 then self.scroll = self.index - 1 end
|
||||
end
|
||||
|
||||
-- HandleMenuInput_ (home/window.asm) replays SFX_PRESS_AB for any watched
|
||||
-- A or B press; DisplayListMenuID's mask is PAD_A | PAD_B | PAD_SELECT
|
||||
-- (home/list_menu.asm), so the SELECT swap key stays silent (#570)
|
||||
local function beep(self)
|
||||
-- game.data is nil under the UI harnesses that drive a list with a stub
|
||||
-- game (tests/engine/rebind_capture_bug510.lua), where there is no audio
|
||||
-- cache to play out of
|
||||
if self.noSound or not (self.game and self.game.data) then return end
|
||||
require("src.core.Sound").play(self.game.data, "Press_AB")
|
||||
end
|
||||
|
||||
-- edge press or key-repeat tick for a held direction
|
||||
local function navPressed(self, dir)
|
||||
if dir == "up" then
|
||||
@@ -126,6 +141,7 @@ function ListMenu:update(dt)
|
||||
local input = self.game.input
|
||||
if #self.items == 0 then
|
||||
if input:wasPressed("a") or input:wasPressed("b") then
|
||||
beep(self)
|
||||
self.game.stack:pop()
|
||||
if self.onCancel then self.onCancel() end
|
||||
end
|
||||
@@ -148,10 +164,12 @@ function ListMenu:update(dt)
|
||||
elseif self.onSelectKey and input:wasPressed("select") then
|
||||
self.onSelectKey(self.items[self.index], self)
|
||||
elseif input:wasPressed("b") then
|
||||
beep(self)
|
||||
self.game.stack:pop()
|
||||
if self.onCancel then self.onCancel() end
|
||||
return
|
||||
elseif input:wasPressed("a") then
|
||||
beep(self)
|
||||
local item = self.items[self.index]
|
||||
if self.onChoose then
|
||||
self.onChoose(item, self)
|
||||
|
||||
+18
-4
@@ -108,20 +108,34 @@ function Menu:draw()
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
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
|
||||
-- row and let any slack fall as a blank row under the top edge --
|
||||
-- draw_start_menu.asm (TextBoxBorder 10,0, then hlcoord 12,2 /
|
||||
-- wTopMenuItemY 2), players_pc.asm and bills_pc.asm all do the same. The
|
||||
-- bottom border is ty + th - 1, so the last choice sits on ty + th - 2 and
|
||||
-- row r counts back up from there. Anchoring from the top instead only
|
||||
-- agrees when th is exactly visible * rowStep + 2, which is Menu.new's
|
||||
-- default but NOT what a caller sizing its own box passes: BagMenu's
|
||||
-- USE/TOSS is th = 5 for two choices (#284, matching text_boxes.asm's
|
||||
-- USE_TOSS_MENU_TEMPLATE rows 10..14), and a top anchor pushed TOSS onto
|
||||
-- the bottom border (#564, #572).
|
||||
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 + row * self.rowStep - (self.rowStep - 1)) * 8)
|
||||
(self.ty + self.th - 2 - (visible - row) * self.rowStep) * 8)
|
||||
end
|
||||
local cursorRow = self.index - self.scroll
|
||||
Font.drawCode(Theme.cursor, (self.tx + 1) * 8,
|
||||
(self.ty + cursorRow * self.rowStep - (self.rowStep - 1)) * 8)
|
||||
(self.ty + self.th - 2 - (visible - cursorRow) * self.rowStep) * 8)
|
||||
-- moreArrow ($EE): the same "more below" glyph OptionRows/ManagerState
|
||||
-- use, sat on the bottom border like TextBox's page-advance cursor
|
||||
-- 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
|
||||
-- row, which the last choice now occupies, and Menu.new widens the box to
|
||||
-- widest + 3 so tx + tw - 2 is exactly that label's final glyph (#564).
|
||||
if self.maxVisible and self.scroll + self.maxVisible < #self.items then
|
||||
Font.drawCode(Theme.moreArrow, (self.tx + self.tw - 2) * 8,
|
||||
(self.ty + self.th - 2) * 8)
|
||||
(self.ty + self.th - 1) * 8)
|
||||
end
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
end
|
||||
|
||||
@@ -448,10 +448,21 @@ function OptionsMenu:update(dt)
|
||||
elseif row and row.step then
|
||||
changed = row.step(self.game, dir) and true or false
|
||||
elseif input:wasPressed("a") then -- CANCEL
|
||||
-- DisplayOptionMenu .exitMenu (engine/menus/main_menu.asm) is the
|
||||
-- only spot in this menu that plays SFX_PRESS_AB: A on a setting row
|
||||
-- and the Left/Right toggles stay silent (#570). game.data is nil
|
||||
-- under the stub games the UI harnesses drive.
|
||||
if self.game.data then
|
||||
require("src.core.Sound").play(self.game.data, "Press_AB")
|
||||
end
|
||||
self.game.stack:pop()
|
||||
if self.onCancel then self.onCancel() end
|
||||
end
|
||||
elseif input:wasPressed("b") or input:wasPressed("start") then
|
||||
-- .exitMenu again: B and START leave the same way, sound and all
|
||||
if self.game.data then
|
||||
require("src.core.Sound").play(self.game.data, "Press_AB")
|
||||
end
|
||||
self.game.stack:pop()
|
||||
if self.onCancel then self.onCancel() end
|
||||
end
|
||||
|
||||
@@ -310,6 +310,14 @@ function PartyMenu:update(dt)
|
||||
local input = self.game.input
|
||||
local party = self.party or self.game.save.party
|
||||
|
||||
-- HandlePartyMenuInput (home/pokemon.asm) and the field-move submenu
|
||||
-- (engine/menus/start_sub_menus.asm .chosePokemon) both run through
|
||||
-- HandleMenuInput_, which beeps SFX_PRESS_AB on any A or B press (#570).
|
||||
-- game.data is nil under the stub games the UI harnesses drive.
|
||||
if self.game.data and (input:wasPressed("a") or input:wasPressed("b")) then
|
||||
require("src.core.Sound").play(self.game.data, "Press_AB")
|
||||
end
|
||||
|
||||
if self.submenu then
|
||||
local n = #self.subItems
|
||||
if input:wasPressed("up") then
|
||||
|
||||
+4
-1
@@ -73,10 +73,11 @@ local function withdraw(game)
|
||||
local pc = game.save.pcItems
|
||||
game.stack:push(ListMenu.new(game, "WITHDRAW ITEM", buildItems(game, pc), {
|
||||
messageBox = true,
|
||||
noSound = true, -- PlayerPCMenu holds BIT_NO_MENU_BUTTON_SOUND (#570)
|
||||
onChoose = function(item, list)
|
||||
askQuantity(game, list, pc[item.value] or 1, item.value, function(qty)
|
||||
local Bag = require("src.inventory.Bag")
|
||||
if not Bag.add(game.save, item.value, qty) then
|
||||
if not Bag.add(game.save, item.value, qty, game.data) then
|
||||
list.footer = Strings("You can't carry\nany more items.")
|
||||
return
|
||||
end
|
||||
@@ -110,6 +111,7 @@ local function deposit(game)
|
||||
end
|
||||
game.stack:push(ListMenu.new(game, "DEPOSIT ITEM", buildItems(game, depositable), {
|
||||
messageBox = true,
|
||||
noSound = true, -- PlayerPCMenu holds BIT_NO_MENU_BUTTON_SOUND (#570)
|
||||
onChoose = function(item, list)
|
||||
askQuantity(game, list, inv[item.value] or 1, item.value, function(qty)
|
||||
if pcFull(game, pc, item.value) then
|
||||
@@ -130,6 +132,7 @@ local function toss(game)
|
||||
local pc = game.save.pcItems
|
||||
game.stack:push(ListMenu.new(game, "TOSS ITEM", buildItems(game, pc), {
|
||||
messageBox = true,
|
||||
noSound = true, -- PlayerPCMenu holds BIT_NO_MENU_BUTTON_SOUND (#570)
|
||||
onChoose = function(item, list)
|
||||
local def = game.data.items[item.value]
|
||||
if (def and def.keyItem) or item.value:find("^HM_") then
|
||||
|
||||
+11
-3
@@ -50,11 +50,13 @@ function PokedexMenu.new(game, opts)
|
||||
footer = Strings("SEEN %d OWNED %d", seen, owned),
|
||||
pageJump = true, -- Left/Right page jumps like the original
|
||||
onCancel = opts.onCancel, -- B returns to the start menu when opened from it
|
||||
onChoose = function(item)
|
||||
onChoose = function(item, dexList)
|
||||
if not item.value then return end
|
||||
-- the DATA / CRY / AREA / QUIT choice (engine/menus/pokedex.asm
|
||||
-- PokedexMenuItemsText); CRY keeps the side menu open like the
|
||||
-- original, QUIT returns to the list
|
||||
-- original. B is what returns to the list: HandlePokedexSideMenu
|
||||
-- hands back b=2 for B and b=1 for QUIT, and ShowPokedexMenu sends
|
||||
-- b=1 to .exitPokedex, so QUIT closes the whole Pokédex (#571)
|
||||
local Menu = require("src.ui.Menu")
|
||||
local Screens = require("src.ui.Screens")
|
||||
local entries = {
|
||||
@@ -91,7 +93,13 @@ function PokedexMenu.new(game, opts)
|
||||
or Strings("Printer error!\n%s", tostring(err))))
|
||||
end }
|
||||
end
|
||||
entries[#entries + 1] = { label = Strings("QUIT") }
|
||||
entries[#entries + 1] = { label = Strings("QUIT"), onSelect = function()
|
||||
-- .exitPokedex: drop the dex list too and hand back to whoever
|
||||
-- opened it (the start menu, whose saved cursor is still on
|
||||
-- POKéDEX -- wBattleAndStartSavedMenuItem)
|
||||
dexList:close()
|
||||
if opts.onCancel then opts.onCancel() end
|
||||
end }
|
||||
game.stack:push(Menu.new(game, entries,
|
||||
{ tx = 12, ty = 8, tw = 8,
|
||||
th = #entries * 2 + 2 }))
|
||||
|
||||
+1
-1
@@ -67,7 +67,7 @@ local function buy(game, stock)
|
||||
list.footer = notEnough
|
||||
return
|
||||
end
|
||||
if not Bag.add(game.save, item.value, qty) then
|
||||
if not Bag.add(game.save, item.value, qty, game.data) then
|
||||
list.footer = txt(game, "_PokemartItemBagFullText",
|
||||
Strings("You can't carry\nany more items."))
|
||||
return
|
||||
|
||||
@@ -109,34 +109,36 @@ function Editor.update(_dt)
|
||||
end
|
||||
|
||||
function Editor.draw()
|
||||
local ww, wh = love.graphics.getDimensions()
|
||||
local SafeArea = require("src.core.SafeArea")
|
||||
local fullW, fullH = love.graphics.getDimensions()
|
||||
local ox, oy, ww, wh = SafeArea.rect()
|
||||
local s = math.max(0.75, math.min(1.4, wh / 768))
|
||||
Editor.rects = {}
|
||||
|
||||
-- radial-ish navy field (two stacked fills; matches launcher atmosphere)
|
||||
col(PAL.bgBot)
|
||||
love.graphics.rectangle("fill", 0, 0, ww, wh)
|
||||
love.graphics.rectangle("fill", 0, 0, fullW, fullH)
|
||||
col(PAL.bgTop, 0.85)
|
||||
love.graphics.circle("fill", ww * 0.5, wh * 0.15, math.max(ww, wh) * 0.55)
|
||||
love.graphics.circle("fill", ox + ww * 0.5, oy + wh * 0.15, math.max(ww, wh) * 0.55)
|
||||
|
||||
local pad = 18 * s
|
||||
local barH = 56 * s
|
||||
local btnH = 40 * s
|
||||
local btnW = 100 * s
|
||||
|
||||
-- top bar
|
||||
-- top bar (inside the safe area so it clears the notch / status bar)
|
||||
col(PAL.card, 0.92)
|
||||
love.graphics.rectangle("fill", 0, 0, ww, barH + pad)
|
||||
love.graphics.rectangle("fill", 0, 0, fullW, oy + barH + pad)
|
||||
col(PAL.stroke, 0.35)
|
||||
love.graphics.setLineWidth(1)
|
||||
love.graphics.line(0, barH + pad, ww, barH + pad)
|
||||
love.graphics.line(0, oy + barH + pad, fullW, oy + barH + pad)
|
||||
|
||||
love.graphics.setFont(Editor.fonts.title)
|
||||
col(PAL.white)
|
||||
love.graphics.print("Touch Controls", pad, pad + 4 * s)
|
||||
love.graphics.print("Touch Controls", ox + pad, oy + pad + 4 * s)
|
||||
|
||||
-- Done / Reset
|
||||
local done = { x = ww - pad - btnW, y = pad + (barH - btnH) / 2,
|
||||
local done = { x = ox + ww - pad - btnW, y = oy + pad + (barH - btnH) / 2,
|
||||
w = btnW, h = btnH }
|
||||
local reset = { x = done.x - 10 * s - btnW, y = done.y, w = btnW, h = btnH }
|
||||
Editor.rects.done, Editor.rects.reset = done, reset
|
||||
@@ -156,9 +158,9 @@ function Editor.draw()
|
||||
chromeBtn(done, "Done", PAL.green)
|
||||
|
||||
-- enable toggle card
|
||||
local cardY = barH + pad + 14 * s
|
||||
local cardY = oy + barH + pad + 14 * s
|
||||
local cardH = 64 * s
|
||||
local cardX, cardW = pad, ww - 2 * pad
|
||||
local cardX, cardW = ox + pad, ww - 2 * pad
|
||||
col(PAL.card, 0.88)
|
||||
roundRect("fill", cardX, cardY, cardW, cardH, 12 * s)
|
||||
col(PAL.stroke, 0.4)
|
||||
@@ -190,7 +192,7 @@ function Editor.draw()
|
||||
local hint = on
|
||||
and "Drag each button to reposition. Layout is saved when you tap Done."
|
||||
or "Controls are hidden in-game. Enable them to show and edit the layout."
|
||||
love.graphics.printf(hint, pad, cardY + cardH + 12 * s, ww - 2 * pad, "left")
|
||||
love.graphics.printf(hint, ox + pad, cardY + cardH + 12 * s, ww - 2 * pad, "left")
|
||||
|
||||
-- the overlay itself (preview mode; dimmed when disabled)
|
||||
TouchControls:draw()
|
||||
|
||||
@@ -89,6 +89,11 @@ local PLAYER_SPRITES = {
|
||||
local PLAYER_PICS = {
|
||||
back = "assets/generated/battle/redb.png",
|
||||
demoBack = "assets/generated/battle/oldmanb.png",
|
||||
-- LoadPlayerBackPic keys the demo pic off wBattleType, not off "is a
|
||||
-- demo": BATTLE_TYPE_PIKACHU (Yellow's Pallet catch scene) puts PROF.OAK
|
||||
-- in the player's place with his own back pic, not the old man's (#557).
|
||||
-- Red/Blue never reach it -- only the Yellow script names that thrower.
|
||||
oakBack = "assets/generated/battle/profoakb.png",
|
||||
front = "assets/generated/trainer_card/red.png",
|
||||
}
|
||||
|
||||
@@ -185,7 +190,7 @@ FieldDefaults.CONSTANTS = {
|
||||
neighborHops = 2, -- connection hops drawn around the current map
|
||||
stepFrames = 16, -- 1px per frame, 16 frames per tile
|
||||
bikeStepFrames = 8, -- the bicycle doubles walking speed
|
||||
turnFrames = 2, -- the extra OverworldLoop pass after a turn
|
||||
turnFrames = 4, -- tap window before a turn commits to a step
|
||||
},
|
||||
-- cumulative slot thresholds out of 256 (engine/battle/wild_encounters.asm)
|
||||
encounterBuckets = { 51, 102, 141, 166, 191, 216, 229, 242, 253, 256 },
|
||||
|
||||
@@ -1155,6 +1155,13 @@ function OverworldState:handleInput()
|
||||
end
|
||||
end
|
||||
|
||||
-- .noDirectionButtonsPressed (home/overworld.asm) is the only place that
|
||||
-- sets wCheckFor180DegreeTurn, so reaching this line -- a poll that found
|
||||
-- no direction held -- is what re-arms the next turn in place. The early
|
||||
-- returns above (mid-step, A, START) skip it exactly as the original's
|
||||
-- jumps to .moveAhead and .displayDialogue do (#415).
|
||||
self.player.turnArmed = true
|
||||
|
||||
-- Cycling Road's downhill pull: with no d-pad held the bike rolls
|
||||
-- south (home/overworld.asm JoypadOverworld's simulated PAD_DOWN).
|
||||
-- The mask there is PAD_CTRL_PAD | PAD_B | PAD_A, so HOLDING A or B
|
||||
@@ -1803,7 +1810,7 @@ function OverworldState:tryHiddenObject(fx, fy)
|
||||
if h.x == fx and h.y == fy then
|
||||
save.hiddenTaken = save.hiddenTaken or {}
|
||||
if save.hiddenTaken[key] then return false end
|
||||
if not require("src.inventory.Bag").add(save, h.item, 1) then
|
||||
if not require("src.inventory.Bag").add(save, h.item, 1, Game.data) then
|
||||
Game.stack:push(TextBox.new(Game, Strings("You can't carry\nany more items!")))
|
||||
return true
|
||||
end
|
||||
@@ -1891,9 +1898,20 @@ function OverworldState:tryHiddenObject(fx, fy)
|
||||
end
|
||||
end
|
||||
|
||||
-- bench guys (data/events/bench_guys.asm)
|
||||
-- Bench guys (data/events/bench_guys.asm). A hidden_event's fourth byte
|
||||
-- is wHiddenEventFunctionArgument, not a facing gate -- pokered's own
|
||||
-- macro comment says the SPRITE_FACING_* values parked there "do not
|
||||
-- actually prevent the player from interacting with them in any
|
||||
-- direction" (data/events/hidden_events.asm). The facing that does decide
|
||||
-- a bench guy is PrintBenchGuyText's own test against BenchGuyTextPointers,
|
||||
-- SPRITE_FACING_LEFT for all twelve seats, which the manifest carries as
|
||||
-- `textFacing`. Gating on the hidden_event byte instead silenced the four
|
||||
-- seats that store SPRITE_FACING_UP (Vermilion, Saffron, Fuchsia,
|
||||
-- Cinnabar): (0,4) is the bench wall cell and can only ever be faced from
|
||||
-- the right (#488).
|
||||
for _, h in ipairs(extras.benchGuys[self.map.id] or {}) do
|
||||
if h.x == fx and h.y == fy and (not h.facing or h.facing == facing) then
|
||||
local want = h.textFacing or h.facing
|
||||
if h.x == fx and h.y == fy and (not want or want == facing) then
|
||||
local text = OverworldState.benchGuyText(Game.data, save, h.text)
|
||||
if text then
|
||||
Game.stack:push(TextBox.new(Game, text))
|
||||
@@ -2423,7 +2441,7 @@ function OverworldState:talkTo(npc)
|
||||
-- (e.g. Blue's House wall Town Map / walking Daisy, #11). Lua treats
|
||||
-- the string "0" as truthy, so screen it out and fall through to text.
|
||||
if d.item and d.item ~= "0" and d.item ~= 0 then
|
||||
if not require("src.inventory.Bag").add(Game.save, d.item, 1) then
|
||||
if not require("src.inventory.Bag").add(Game.save, d.item, 1, Game.data) then
|
||||
Game.stack:push(TextBox.new(Game, Strings("You can't carry\nany more items!")))
|
||||
return
|
||||
end
|
||||
@@ -2558,8 +2576,7 @@ function OverworldState:openPC(onDone)
|
||||
table.insert(items, {
|
||||
label = Strings("PROF.OAK's PC"),
|
||||
onSelect = function()
|
||||
self:dexRating()
|
||||
done()
|
||||
self:openOaksPC(done)
|
||||
end,
|
||||
})
|
||||
end
|
||||
@@ -2586,11 +2603,41 @@ function OverworldState:openPC(onDone)
|
||||
noSound = true }))
|
||||
end
|
||||
|
||||
-- The PROF. OAK's PC session (engine/menus/oaks_pc.asm OpenOaksPC): the
|
||||
-- access text, "Want to get your #DEX rated?" with a YES/NO, then the
|
||||
-- rating, and "Closed link to PROF.OAK's PC." before control returns -- the
|
||||
-- intro and closing links the launcher skipped, jingle ordering aside (#576).
|
||||
function OverworldState:openOaksPC(onDone)
|
||||
local done = onDone or function() end
|
||||
local text = Game.data.text or {}
|
||||
local accessed = text._AccessedOaksPCText
|
||||
or Strings("Accessed PROF.\nOAK's PC.\fAccessed POKéDEX\nRating System.")
|
||||
local rated = text._GetDexRatedText
|
||||
or Strings("Want to get your\nPOKéDEX rated?")
|
||||
local closed = text._ClosedOaksPCText
|
||||
or Strings("Closed link to\nPROF.OAK's PC.")
|
||||
local function close()
|
||||
Game.stack:push(TextBox.new(Game, closed, done))
|
||||
end
|
||||
Game.stack:push(TextBox.new(Game, accessed, function()
|
||||
-- _GetDexRatedText ends with `done`, so the YES/NO pops as soon as the
|
||||
-- text has typed out, with no button wait in between (YesNoChoice)
|
||||
Game.stack:push(TextBox.new(Game, rated, nil, {
|
||||
choice = function(yes)
|
||||
if not yes then
|
||||
close()
|
||||
return
|
||||
end
|
||||
self:dexRating(close)
|
||||
end,
|
||||
}))
|
||||
end))
|
||||
end
|
||||
|
||||
-- Prof. Oak's dex rating service (engine/events/pokedex_rating.asm):
|
||||
-- the completion line with seen AND owned counts, then the per-decade
|
||||
-- rating text.
|
||||
function OverworldState:dexRating(onDone)
|
||||
require("src.core.Sound").play(Game.data, "Pokedex_Rating")
|
||||
local seen, owned = 0, 0
|
||||
for _ in pairs(Game.save.pokedex.seen or {}) do seen = seen + 1 end
|
||||
for _ in pairs(Game.save.pokedex.owned or {}) do owned = owned + 1 end
|
||||
@@ -2607,7 +2654,15 @@ function OverworldState:dexRating(onDone)
|
||||
completion = completion
|
||||
:gsub("{NUM:hDexRatingNumMonsSeen[^}]*}", tostring(seen))
|
||||
:gsub("{NUM:hDexRatingNumMonsOwned[^}]*}", tostring(owned))
|
||||
Game.stack:push(TextBox.new(Game, completion .. "\f" .. rating, onDone))
|
||||
-- DisplayDexRating prints the completion line, then the tier text, and
|
||||
-- only then plays the rating jingle and waits for a button -- the fanfare
|
||||
-- must not pre-empt the evaluation it celebrates (#576). auto.wait hands
|
||||
-- the box to the plain A/B path once the jingle has sounded.
|
||||
Game.stack:push(TextBox.new(Game, completion .. "\f" .. rating, onDone, {
|
||||
auto = { wait = true, sound = function()
|
||||
return require("src.core.Sound").play(Game.data, "Pokedex_Rating")
|
||||
end },
|
||||
}))
|
||||
end
|
||||
|
||||
-- AnimateHealingMachine (engine/overworld/healing_machine.asm): balls
|
||||
@@ -3829,7 +3884,15 @@ end
|
||||
-- dev-mode hot reload). The neighbors go too: their strips render the
|
||||
-- same tileset. When the active map is the one that changed, the player
|
||||
-- is clamped back in bounds, the NPC pool is reused so runtime handles
|
||||
-- survive, and the tile-pair table is re-read.
|
||||
-- survive, and the tile-pair table is re-read. keepMusic: a reload is not a
|
||||
-- map entry. Its counterpart ReloadMapData (home/reload_tiles.asm) only
|
||||
-- re-reads the map view and the tileset tile patterns after the Pokedex /
|
||||
-- start menu / PC clobbered VRAM; map music starts from LoadMapData alone
|
||||
-- (home/overworld.asm, gated on BIT_NO_MAP_MUSIC). Whatever is playing
|
||||
-- belongs to the state on top, so a COLORS cycle during a battle
|
||||
-- (PaletteFX.setMode reloads the live map to rebuild its baked atlas) must
|
||||
-- not drop the route theme over the battle song (#484). The out-of-bounds
|
||||
-- fallback below is a real map change and keeps its map music.
|
||||
function OverworldState:reloadMap(mapId, reason)
|
||||
MapLoader.invalidate(mapId)
|
||||
for _, nb in ipairs(self.neighbors or {}) do MapLoader.invalidate(nb.map.id) end
|
||||
@@ -3837,7 +3900,8 @@ function OverworldState:reloadMap(mapId, reason)
|
||||
local p = self.player
|
||||
local x, y, facing = p.cellX, p.cellY, p.facing
|
||||
Collision.load(Game.data)
|
||||
self:setMap(mapId, x, y, facing, { seamless = true, via = "reload" })
|
||||
self:setMap(mapId, x, y, facing,
|
||||
{ seamless = true, via = "reload", keepMusic = true })
|
||||
if not self.map:inBounds(x, y) then
|
||||
local heal = self:healPoint()
|
||||
Logger.warn("map %s reloaded out from under the player; sending to %s",
|
||||
@@ -4568,9 +4632,9 @@ function OverworldState:drawUI()
|
||||
-- TalkToPikachu's picture box (engine/pikachu/pikachu_pic_animation.asm
|
||||
-- PlacePikapicTextBoxBorder: TextBoxBorder at (6,5) with b,c = 5,5, so a
|
||||
-- 7x7 box holding the 5x5 pic at (7,6) -- PikaAnimTilemap_1). The
|
||||
-- per-emotion frame gfx (gfx/pikachu/unknown_*) are not extracted, so the
|
||||
-- front pic stands in for every frame of the script; PikachuFollower
|
||||
-- .picLift lifts it on the runs that draw the alternate pose, and the
|
||||
-- script's base frame is ripped as pikachu/pikapic_N.png (#561) but the
|
||||
-- pikaframe overlays on top of it are not, so PikachuFollower
|
||||
-- .picLift lifts the base on the runs that draw the alternate pose, and the
|
||||
-- script's own duration times the beat (#407, #424). Palette zone
|
||||
-- PAL_PIKACHU_PORTRAIT covers (7,6)-(11,10) via sgbPalettes above.
|
||||
if self.emote and self.emote.pikaPic then
|
||||
|
||||
@@ -518,9 +518,12 @@ end
|
||||
-- pikachu_emotions.asm): pick a scripted emotion, then play its bubble
|
||||
-- and voiced PCM clip, and raise the framed Pikachu picture the original
|
||||
-- puts over the map (pikaemotion_pikapic -> pikachu_pic_animation.asm
|
||||
-- PlacePikapicTextBoxBorder), drawn by OverworldController:drawUI. The
|
||||
-- per-emotion animation frames (gfx/pikachu/unknown_*) are not extracted,
|
||||
-- so the front pic stands in for all twenty of them (#407).
|
||||
-- PlacePikapicTextBoxBorder), drawn by OverworldController:drawUI. Each
|
||||
-- script's BASE 5x5 frame is ripped as pikachu/pikapic_N.png (#561); the
|
||||
-- pikaframe overlays it alternates with are a second full-body pose out of
|
||||
-- the same blob and are still unported, so picLift below stands in for
|
||||
-- their motion and the battle front pic covers caches built before the
|
||||
-- rip (#407).
|
||||
-- ---------------------------------------------------------------------
|
||||
|
||||
-- PikachuEmotionTable, reduced to each entry's bubble + pikaemotion_pcm
|
||||
@@ -715,9 +718,17 @@ function PikachuFollower.talk(game, ow, npc, done)
|
||||
-- 40x40 front pic is the size of PikaAnimTilemap_1's 5x5 base frame;
|
||||
-- Sprites.path keeps a mod's replacement skin in play.
|
||||
local Sprites = require("src.pokemon.Sprites")
|
||||
local pic = Sprites.path(game.data, "PIKACHU", "front",
|
||||
{ kind = "overworld" })
|
||||
local anim = PIKAPIC[PIKAPIC_SCRIPT[emotion] or emotion] or PIKAPIC[1]
|
||||
-- The chosen script's own base frame (its first pikapic_loadgfx, ripped as
|
||||
-- pikachu/pikapic_N.png). Red/Blue have no such art and Yellow caches
|
||||
-- built before the rip do not carry it, so both fall back to the battle
|
||||
-- front pic that stood in for every script before (#561).
|
||||
local script = PIKAPIC_SCRIPT[emotion] or emotion
|
||||
local pic = "assets/generated/pikachu/pikapic_" .. script .. ".png"
|
||||
if not require("src.render.Assets").exists(pic) then
|
||||
pic = Sprites.path(game.data, "PIKACHU", "front",
|
||||
{ kind = "overworld" })
|
||||
end
|
||||
local anim = PIKAPIC[script] or PIKAPIC[1]
|
||||
local hold = anim.dur * PIKAPIC_TICK
|
||||
ow.emote = {
|
||||
npc = npc, frames = hold, bubble = bi or false, pikaPic = pic,
|
||||
@@ -790,10 +801,22 @@ function PikachuFollower.onBillEnteredMachine(game, ow)
|
||||
if not (GameVersion.isYellow() and ow.pikachuBillsScene) then return end
|
||||
local npc = findFollower(ow)
|
||||
if not npc then return end
|
||||
-- BillsHouseScript3 (pokeyellow scripts/BillsHouse.asm:100-115). The two
|
||||
-- movement tables are named the wrong way round in the cartridge source:
|
||||
-- hl is seeded with ..._EnterCellSeparatorDown, then
|
||||
-- and a ; cp SPRITE_FACING_DOWN (SPRITE_FACING_DOWN is 0)
|
||||
-- jr nz, .applyPikachuMovement
|
||||
-- keeps that seeded table when the player is NOT facing down, and only the
|
||||
-- fallthrough -- facing down -- swaps in ..._EnterCellSeparatorNotDown.
|
||||
-- So facing down takes the long way round the cell separator and every
|
||||
-- other facing walks straight up. Following the label names instead of the
|
||||
-- branch inverts the scene (#455).
|
||||
local steps = ow.player.facing == "down"
|
||||
and { { "up", 3 } }
|
||||
or { { "up", 1 }, { "left", 1 }, { "up", 2 }, { "right", 1 } }
|
||||
and { { "up", 1 }, { "left", 1 }, { "up", 2 }, { "right", 1 } }
|
||||
or { { "up", 3 } }
|
||||
movePikachu(ow, npc, steps, function()
|
||||
-- PIKAMOVEMENT_LOOK_UP closes the detour table before the bubble
|
||||
npc.facing = "up"
|
||||
billsHouseEmotion(game, ow, npc, "QUESTION_BUBBLE")
|
||||
end)
|
||||
end
|
||||
|
||||
+43
-7
@@ -15,11 +15,21 @@ local STEP_FRAMES = 16
|
||||
-- a turn in place blocks movement for the one extra OverworldLoop pass the
|
||||
-- original spends after a direction change: .handleDirectionButtonPress ends
|
||||
-- `jp OverworldLoop` (home/overworld.asm), and OverworldLoop burns two
|
||||
-- DelayFrame calls before the next JoypadOverworld, so the sample that can
|
||||
-- commit to a step lands exactly 2 fixed steps after the turn -- the same
|
||||
-- 2-frames-per-iteration cadence that makes STEP_FRAMES 16 above
|
||||
-- (wWalkCounter = 8, 2px per AdvancePlayerSprite) (#415)
|
||||
local TURN_FRAMES = 2
|
||||
-- DelayFrame calls before the next JoypadOverworld, so the step can only
|
||||
-- commit at the following poll -- the same 2-frames-per-iteration cadence
|
||||
-- that makes STEP_FRAMES 16 above (wWalkCounter = 8, 2px per
|
||||
-- AdvancePlayerSprite).
|
||||
-- Those polls sit on a 2-frame grid, so the hardware samples a press 0 or 1
|
||||
-- frames after the d-pad physically goes down and the release deadline lands
|
||||
-- 2 or 3 frames after that. We sample on the frame the button goes down
|
||||
-- with none of that poll latency, so a flat 2 handed every tap the tightest
|
||||
-- case the original could produce; 4 covers the grid instead of
|
||||
-- undercutting it (#415).
|
||||
local TURN_FRAMES = 4
|
||||
-- The on-screen d-pad cannot produce a 60ms tap: a finger press and release
|
||||
-- run well past it even before the OS batches the touch events, so the
|
||||
-- overlay gets a longer window than a physical pad (#415).
|
||||
local TOUCH_TURN_FRAMES = 8
|
||||
|
||||
function Player.new(data, cx, cy, facing)
|
||||
local self = setmetatable({}, Player)
|
||||
@@ -67,6 +77,11 @@ function Player.new(data, cx, cy, facing)
|
||||
self.progress = 0
|
||||
self.stepFlip = false
|
||||
self.turnTimer = 0
|
||||
-- wCheckFor180DegreeTurn (home/overworld.asm): the original only lets a
|
||||
-- turn in place happen on a poll whose previous pass found no direction
|
||||
-- held. It starts armed, tryMove spends it, and OverworldState:handleInput
|
||||
-- re-arms it from a standstill.
|
||||
self.turnArmed = true
|
||||
self.inputLocked = false
|
||||
return self
|
||||
end
|
||||
@@ -75,14 +90,35 @@ function Player:position()
|
||||
return self.cellX, self.cellY
|
||||
end
|
||||
|
||||
-- How long a fresh turn holds the step off for; see TURN_FRAMES. The
|
||||
-- overlay is detected per source rather than by whether the touch controls
|
||||
-- are on screen, so a phone with a controller attached still gets the
|
||||
-- physical pad's window (Input:isTouchDown, src/core/Input.lua).
|
||||
function Player:turnWindow()
|
||||
local frames = self.turnFrames or TURN_FRAMES
|
||||
local input = require("src.core.Game").input
|
||||
if input and input.isTouchDown and input:isTouchDown(self.facing) then
|
||||
return math.max(frames, TOUCH_TURN_FRAMES)
|
||||
end
|
||||
return frames
|
||||
end
|
||||
|
||||
-- Attempt to start a step; returns "moved"|"turned"|"blocked"|nil.
|
||||
function Player:tryMove(dir, map, entities)
|
||||
if self.moving or self.inputLocked then return nil end
|
||||
if self.facing ~= dir then
|
||||
self.facing = dir
|
||||
self.turnTimer = self.turnFrames or TURN_FRAMES
|
||||
self.bumpFrames = nil -- turning to a new facing ends any wall-bonk cycle
|
||||
return "turned"
|
||||
-- .handleDirectionButtonPress only reaches the turn while
|
||||
-- wCheckFor180DegreeTurn is still set, and .noDirectionButtonsPressed is
|
||||
-- the one place that sets it (home/overworld.asm), so a facing change
|
||||
-- made without the d-pad ever coming up steps straight away rather than
|
||||
-- paying the turn delay at every corner (#415)
|
||||
if self.turnArmed then
|
||||
self.turnArmed = false
|
||||
self.turnTimer = self:turnWindow()
|
||||
return "turned"
|
||||
end
|
||||
end
|
||||
if self.turnTimer > 0 then return nil end
|
||||
local ok, why = Collision.canMove(map, entities, self, dir)
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
-- The Cerulean badge man's list needs a CANCEL row (#569).
|
||||
-- CeruleanBadgeHouseMiddleAgedManText loops DisplayListMenuID over
|
||||
-- .BadgeItemList and takes `jr c, .done` out (pokered
|
||||
-- scripts/CeruleanBadgeHouse.asm); PrintListMenuEntries prints
|
||||
-- ListMenuCancelText at the list's $FF terminator, so CANCEL is a real row
|
||||
-- and choosing it sets carry the same way B does.
|
||||
-- POKEPORT_DRIVER=tests/drivers/badge_cancel_bug569_test.lua POKEPORT_IDENTITY=bug569 POKEPORT_TOUCH=0 POKEPORT_VERSION=red love .
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local ListMenu = require("src.ui.ListMenu")
|
||||
local TextBox = require("src.render.TextBox")
|
||||
local SHOT_DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
|
||||
|
||||
-- pokered data/maps/objects/CeruleanBadgeHouse.asm: the middle-aged man is
|
||||
-- object_event 5, 3, STAY, so he never wanders off the tile; the floor to
|
||||
-- his west is the natural approach.
|
||||
local MAP = "CERULEAN_BADGE_HOUSE"
|
||||
local NPC = "CERULEANBADGEHOUSE_MIDDLE_AGED_MAN"
|
||||
local STAND = { x = 4, y = 3, facing = "right" }
|
||||
|
||||
local function check(label, ok)
|
||||
U.log(ok and "PASS" or "FAIL", label)
|
||||
return ok
|
||||
end
|
||||
|
||||
-- a renamed or unextracted text label reads on screen exactly like the
|
||||
-- missing row did: nothing happens
|
||||
local t = game.data.text
|
||||
for _, key in ipairs({
|
||||
"_CeruleanBadgeHouseMiddleAgedManText",
|
||||
"_CeruleanBadgeHouseMiddleAgedManWhichBadgeText",
|
||||
"_CeruleanBadgeHouseMiddleAgedManVisitAnyTimeText",
|
||||
"_CeruleanBadgeHouseBoulderBadgeText",
|
||||
}) do
|
||||
check(key .. " resolves to a string", type(t[key]) == "string")
|
||||
end
|
||||
|
||||
U.teleport(game, MAP, STAND.x, STAND.y, STAND.facing)
|
||||
U.wait(10)
|
||||
|
||||
local function manIn(ow)
|
||||
for _, n in ipairs(ow.npcs or {}) do
|
||||
if n.def and n.def.name == NPC then return n end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
local function facingTheMan()
|
||||
local ow = game.overworld
|
||||
local man = ow and manIn(ow)
|
||||
if not man then return false end
|
||||
local fx, fy = ow.player:facingCell()
|
||||
return ow:npcAtCell(fx, fy) == man
|
||||
end
|
||||
|
||||
local ow = game.overworld
|
||||
local man = ow and manIn(ow)
|
||||
check("the badge man is on " .. MAP, man ~= nil)
|
||||
|
||||
if man and not facingTheMan() then
|
||||
-- a map edit moved him: take any free walkable neighbour instead.
|
||||
-- {dx, dy, facing} is the offset from him to the stand cell plus the
|
||||
-- direction that looks back, so +1 on x means facing left.
|
||||
local sides = {
|
||||
{ -1, 0, "right" }, { 1, 0, "left" }, { 0, 1, "up" }, { 0, -1, "down" },
|
||||
}
|
||||
for _, s in ipairs(sides) do
|
||||
local cx, cy = man.cellX + s[1], man.cellY + s[2]
|
||||
if ow.map:isWalkableCell(cx, cy) and not ow:npcAtCell(cx, cy) then
|
||||
U.log("approach cell is blocked, standing on", cx, cy, "facing", s[3])
|
||||
U.teleport(game, MAP, cx, cy, s[3])
|
||||
U.wait(10)
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
check("player is standing in front of the badge man", facingTheMan())
|
||||
|
||||
-- talk, then mash through the greeting and the "Which badge?" prompt
|
||||
U.tap(game, "a")
|
||||
U.wait(20)
|
||||
-- the greeting runs three pages before the "Which badge?" prompt, so give
|
||||
-- the mash room; an A landing mid-type only speeds that page up
|
||||
local list
|
||||
for _ = 1, 60 do
|
||||
local top = game.stack:top()
|
||||
if getmetatable(top) == ListMenu then list = top break end
|
||||
if getmetatable(top) == TextBox then U.tap(game, "a") end
|
||||
U.wait(10)
|
||||
end
|
||||
check("talking to him opened the badge list", list ~= nil)
|
||||
|
||||
if list then
|
||||
local labels = {}
|
||||
for i, item in ipairs(list.items) do labels[i] = item.label end
|
||||
U.log("badge list rows:", table.concat(labels, " "))
|
||||
check("the list has the eight badges plus one more row", #list.items == 9)
|
||||
local last = list.items[#list.items]
|
||||
check("the last row is CANCEL", last.label == "CANCEL")
|
||||
check("the CANCEL row carries no badge to describe", last.value == nil)
|
||||
|
||||
-- scroll it into view so the screenshot shows the row, not just the data
|
||||
for _ = 1, #list.items do
|
||||
if list.index == #list.items then break end
|
||||
U.tap(game, "down")
|
||||
U.wait(3)
|
||||
end
|
||||
check("cursor reached CANCEL", list.index == #list.items)
|
||||
check("badge list screenshot",
|
||||
U.shot(game, SHOT_DIR .. "/bug569_badge_list.png"))
|
||||
|
||||
U.tap(game, "a")
|
||||
U.wait(25)
|
||||
local top = game.stack:top()
|
||||
check("CANCEL closed the list", getmetatable(top) ~= ListMenu)
|
||||
check("CANCEL printed the goodbye line, like B does",
|
||||
getmetatable(top) == TextBox)
|
||||
check("goodbye screenshot",
|
||||
U.shot(game, SHOT_DIR .. "/bug569_after_cancel.png"))
|
||||
U.tap(game, "a")
|
||||
U.wait(25)
|
||||
end
|
||||
|
||||
U.log("The goodbye line has been dismissed; press A on the man to run the")
|
||||
U.log("list again. CANCEL should be the ninth row under EARTHBADGE and")
|
||||
U.log("should close the whole conversation with \"Visit any time\" -- the")
|
||||
U.log("near miss is a CANCEL that only closes the list and drops you back")
|
||||
U.log("on the \"Which badge?\" prompt. The badge rows above it are the")
|
||||
U.log("control: each prints its description and returns to the list.")
|
||||
U.log("Screenshots are under " .. SHOT_DIR)
|
||||
|
||||
while true do
|
||||
coroutine.yield()
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,196 @@
|
||||
-- Ear check: cycling COLORS with 2 during a battle keeps the battle song (#484).
|
||||
-- The COLORS change rebuilds the live map's atlas through reloadMap, which used
|
||||
-- to re-enter the map and fire PlayMapMusic; ReloadMapData (home/reload_tiles.asm)
|
||||
-- only re-reads the view and tile patterns, map music comes from LoadMapData
|
||||
-- alone (home/overworld.asm). The invariants are in tests/engine/battle_colors_bug484.lua.
|
||||
-- POKEPORT_DRIVER=tests/drivers/battle_colors_bug484_test.lua POKEPORT_IDENTITY=bug484 POKEPORT_TOUCH=0 SHOT_DIR=/tmp/shots love .
|
||||
-- No POKEPORT_SPEED: it scales the logic clock only while audio runs on its own
|
||||
-- real-time accumulator in Game:update, so it desyncs the ordering being judged.
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
|
||||
local Music = require("src.core.Music")
|
||||
local PaletteFX = require("src.render.PaletteFX")
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
local BattleState = require("src.battle.BattleState")
|
||||
|
||||
-- pokered data/maps/objects/Route1.asm puts both youngsters at (5,24) and
|
||||
-- (15,13) and the sign at (9,27), so the top of the road is empty; the
|
||||
-- battle is pushed straight in, the cell is only somewhere to stand.
|
||||
local MAP = "ROUTE_1"
|
||||
local STAND = { x = 5, y = 6, facing = "down" }
|
||||
local PARTY = { { "BULBASAUR", 12 }, { "PIDGEOTTO", 18 } }
|
||||
|
||||
local function check(label, ok)
|
||||
U.log(ok and "PASS" or "FAIL", label)
|
||||
return ok
|
||||
end
|
||||
|
||||
-- ---- what the ear cannot check -----------------------------------------
|
||||
local opts = game.save.options or {}
|
||||
local musicVol = opts.musicVol or 7
|
||||
local sfxVol = opts.sfxVol or 7
|
||||
if musicVol == 0 then
|
||||
U.log("FAIL MUSIC volume is 0. Everything below is about which song is")
|
||||
U.log(" playing, and at 0 there is no song. Set MUSIC to 7 in OPTION")
|
||||
U.log(" and run this again, or the whole check is worthless.")
|
||||
end
|
||||
if sfxVol == 0 then
|
||||
U.log("FAIL SFX volume is 0, so the battle menu's own click is gone too")
|
||||
U.log(" and there is no way to tell a dead audio path from a fixed bug.")
|
||||
U.log(" Set SFX to 7 in OPTION.")
|
||||
end
|
||||
check(("MUSIC volume %d, SFX volume %d"):format(musicVol, sfxVol),
|
||||
musicVol > 0 and sfxVol > 0)
|
||||
|
||||
local battleSongs = (game.data.audio or {}).battle or {}
|
||||
local mapSongs = (game.data.audio or {}).mapSongs or {}
|
||||
check(MAP .. " has a map theme to wrongly cut in (" ..
|
||||
tostring(mapSongs[MAP]) .. ")", mapSongs[MAP] ~= nil)
|
||||
check("the wild battle theme exists (" .. tostring(battleSongs.wild) .. ")",
|
||||
battleSongs.wild ~= nil)
|
||||
check("they are different songs, so the swap would be audible",
|
||||
battleSongs.wild ~= nil and battleSongs.wild ~= mapSongs[MAP])
|
||||
U.log(" COLORS ladder:", table.concat(PaletteFX.MODES, ", "))
|
||||
|
||||
-- Every music cue in the run, forwarded so playback is untouched. A map
|
||||
-- cue raised while a battle owns the screen is the bug, and it is the one
|
||||
-- thing an ear can miss if the two songs happen to open alike.
|
||||
local cues = {}
|
||||
local realPlay, realPlayMap = Music.play, Music.playMap
|
||||
Music.play = function(data, song, loop, ctx)
|
||||
cues[#cues + 1] = { song = song, reason = ctx and ctx.reason or "direct" }
|
||||
return realPlay(data, song, loop, ctx)
|
||||
end
|
||||
Music.playMap = function(data, mapId, onBike, surfing)
|
||||
cues[#cues + 1] = { song = "(playMap)", reason = "map", mapId = mapId }
|
||||
return realPlayMap(data, mapId, onBike, surfing)
|
||||
end
|
||||
local function mapCuesSince(mark)
|
||||
local hits = {}
|
||||
for i = mark + 1, #cues do
|
||||
if cues[i].reason == "map" then hits[#hits + 1] = cues[i].song end
|
||||
end
|
||||
return hits
|
||||
end
|
||||
local function lastSong()
|
||||
for i = #cues, 1, -1 do
|
||||
if cues[i].song ~= "(playMap)" then return cues[i].song end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
-- ---- get into a battle --------------------------------------------------
|
||||
game.save.party = {}
|
||||
for _, slot in ipairs(PARTY) do
|
||||
table.insert(game.save.party, Pokemon.new(game.data, slot[1], slot[2]))
|
||||
end
|
||||
U.teleport(game, MAP, STAND.x, STAND.y, STAND.facing)
|
||||
U.wait(20)
|
||||
local ow = game.overworld
|
||||
if ow and not ow.map:isWalkableCell(STAND.x, STAND.y) then
|
||||
-- a map edit moved the road: any free cell on Route 1 will do, the
|
||||
-- battle is pushed rather than walked into
|
||||
for _, d in ipairs({ { 0, 1 }, { 0, -1 }, { 1, 0 }, { -1, 0 } }) do
|
||||
local cx, cy = STAND.x + d[1], STAND.y + d[2]
|
||||
if ow.map:isWalkableCell(cx, cy) and not ow:npcAtCell(cx, cy) then
|
||||
U.log(("(%d, %d) is blocked, standing on"):format(STAND.x, STAND.y),
|
||||
cx, cy)
|
||||
U.teleport(game, MAP, cx, cy, STAND.facing)
|
||||
U.wait(20)
|
||||
ow = game.overworld
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
check("the overworld is up on " .. MAP, ow ~= nil and ow.map.id == MAP)
|
||||
U.wait(60) -- let the route theme actually get going first
|
||||
U.log(" route theme:", tostring(lastSong()))
|
||||
|
||||
local battle = BattleState.newWild(game, "RATTATA", 5)
|
||||
battle.onFinish = function() end
|
||||
ow:pushBattle(battle)
|
||||
for _ = 1, 400 do
|
||||
if game.stack:top() == battle and (battle.introSlide or 0) == 0 then break end
|
||||
U.wait(1)
|
||||
end
|
||||
check("the battle reached the screen", game.stack:top() == battle)
|
||||
for _ = 1, 120 do
|
||||
if battle.phase == "menu" then break end
|
||||
U.tap(game, "a")
|
||||
U.wait(6)
|
||||
end
|
||||
check("the battle reached its FIGHT/PKMN/ITEM/RUN menu", battle.phase == "menu")
|
||||
|
||||
local songInBattle = lastSong()
|
||||
check("the battle theme is what is playing (" .. tostring(songInBattle) .. ")",
|
||||
songInBattle == battleSongs.wild)
|
||||
U.shot(game, DIR .. "/bug484_1_" .. tostring(PaletteFX.mode) .. ".png")
|
||||
|
||||
-- ---- press 2, for real --------------------------------------------------
|
||||
local before = PaletteFX.mode
|
||||
local mark = #cues
|
||||
game:keypressed("2")
|
||||
U.wait(30)
|
||||
check("pressing 2 in a battle still changed COLORS ("
|
||||
.. PaletteFX.modeLabel(before) .. " -> "
|
||||
.. PaletteFX.modeLabel(PaletteFX.mode) .. ")",
|
||||
PaletteFX.mode ~= before)
|
||||
local strays = mapCuesSince(mark)
|
||||
check("no map music cue came out of it (#484)", #strays == 0)
|
||||
check("the battle theme is still the current song",
|
||||
lastSong() == songInBattle)
|
||||
U.shot(game, DIR .. "/bug484_2_" .. tostring(PaletteFX.mode) .. ".png")
|
||||
|
||||
-- the whole ladder, because a partial fix can leak on one mode only
|
||||
local seen = { [before] = true, [PaletteFX.mode] = true }
|
||||
local leaked = {}
|
||||
for _ = 2, #PaletteFX.MODES do
|
||||
local m = #cues
|
||||
game:keypressed("2")
|
||||
U.wait(20)
|
||||
seen[PaletteFX.mode] = true
|
||||
for _, s in ipairs(mapCuesSince(m)) do
|
||||
leaked[#leaked + 1] = PaletteFX.modeLabel(PaletteFX.mode) .. " (" .. s .. ")"
|
||||
end
|
||||
end
|
||||
local modes = 0
|
||||
for _ in pairs(seen) do modes = modes + 1 end
|
||||
check(("all %d COLORS modes were reachable from inside the battle"):format(modes),
|
||||
modes == #PaletteFX.MODES)
|
||||
check("no mode on the ladder started the route theme", #leaked == 0)
|
||||
if #leaked > 0 then U.log(" leaked on:", table.concat(leaked, ", ")) end
|
||||
check("still the battle theme after the full ladder",
|
||||
lastSong() == songInBattle)
|
||||
U.shot(game, DIR .. "/bug484_3_" .. tostring(PaletteFX.mode) .. ".png")
|
||||
|
||||
-- ---- over to you --------------------------------------------------------
|
||||
U.log("You are at the battle menu with the wild theme running. Press 2 a few")
|
||||
U.log("times: the wash behind the two pics, RATTATA's shading and the HP bars")
|
||||
U.log("move mode to mode (shots 1 and 2 are one press apart), and the music")
|
||||
U.log("underneath keeps going without a seam. #484 dropped Route 1's theme")
|
||||
U.log("over it on the first press. The near miss to listen for is the battle")
|
||||
U.log("theme restarting from its opening bar rather than being replaced, and")
|
||||
U.log("the near miss to look for is nothing moving at all -- a hotkey switched")
|
||||
U.log("off in battle is not a fix. A on the menu clicks, so that click is your")
|
||||
U.log("proof the audio path is up at all.")
|
||||
U.log("Shots: " .. DIR .. "/bug484_*.png")
|
||||
|
||||
-- keeps reporting after the hand-off: a map cue with a battle on top is the
|
||||
-- bug whether or not the two songs are easy to tell apart by ear
|
||||
local reported = #cues -- only what happens from the hand-off on
|
||||
while true do
|
||||
local top = game.stack:top()
|
||||
local inBattle = top == battle or (top and top.battle == battle)
|
||||
if inBattle and #cues > reported then
|
||||
for i = reported + 1, #cues do
|
||||
if cues[i].reason == "map" then
|
||||
U.log("FAIL map music cue during the battle:", tostring(cues[i].song),
|
||||
tostring(cues[i].mapId))
|
||||
end
|
||||
end
|
||||
end
|
||||
reported = #cues
|
||||
coroutine.yield()
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,186 @@
|
||||
-- Look at the two boxes a test cannot judge: the bike shop's BICYCLE/CANCEL
|
||||
-- price window (#568) and a Pokemon Center bench guy answering A (#488).
|
||||
-- Oracle: scripts/BikeShop.asm BikeShopClerkText (TextBoxBorder 0,0 b=4 c=15
|
||||
-- over BikeShopMenuText/BikeShopMenuPrice) and data/events/bench_guys.asm.
|
||||
-- The logic halves are asserted in tests/parity_bike_shop_clerk_bug568.lua
|
||||
-- and tests/parity_bench_guy_facing_bug488.lua.
|
||||
-- POKEPORT_DRIVER=tests/drivers/bike_shop_bug568_test.lua POKEPORT_IDENTITY=bug568 POKEPORT_TOUCH=0 POKEPORT_VERSION=red love .
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local TextBox = require("src.render.TextBox")
|
||||
local SHOT_DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
|
||||
|
||||
local failures = 0
|
||||
local function check(label, ok)
|
||||
if not ok then failures = failures + 1 end
|
||||
U.log(ok and "PASS" or "FAIL", label)
|
||||
return ok
|
||||
end
|
||||
|
||||
local function boxOnScreen()
|
||||
local top = game.stack:top()
|
||||
if getmetatable(top) ~= TextBox then return nil end
|
||||
local lines = {}
|
||||
for _, page in ipairs(top.pages or {}) do
|
||||
for _, line in ipairs(page) do lines[#lines + 1] = line end
|
||||
end
|
||||
return top, table.concat(lines, " / ")
|
||||
end
|
||||
|
||||
-- A missing string or a renamed label goes quiet in exactly the way both
|
||||
-- bugs did, so resolve everything first and say so before anyone looks.
|
||||
local t = game.data.text
|
||||
for _, label in ipairs({ "_BikeShopClerkWelcomeText",
|
||||
"_BikeShopClerkDoYouLikeItText", "_BikeShopCantAffordText",
|
||||
"_BikeShopComeAgainText", "_VermilionPokecenterGuyText" }) do
|
||||
check(label .. " resolves", type(t[label]) == "string" and t[label] ~= "")
|
||||
end
|
||||
check("BICYCLE is an item the window can name",
|
||||
game.data.items.BICYCLE ~= nil)
|
||||
|
||||
local seat = (game.data.field.hiddenExtras.benchGuys.VERMILION_POKECENTER
|
||||
or {})[1]
|
||||
check("the Vermilion bench guy is in the generated data", seat ~= nil)
|
||||
check("his seat is faced from the left (BenchGuyTextPointers)",
|
||||
seat and seat.textFacing == "left")
|
||||
|
||||
if failures > 0 then
|
||||
U.log(failures, "check(s) failed above; the screens below will not tell")
|
||||
U.log("you anything the log has not already said.")
|
||||
end
|
||||
|
||||
-- ------------------------------------------------------------- #488
|
||||
-- data/events/hidden_events.asm parks the seat at (0,4); it is the bench
|
||||
-- wall itself, so the player stands on the floor tile east of it. The
|
||||
-- cell north of the seat is walkable too and is the control: in the
|
||||
-- original, PrintBenchGuyText only answers a LEFT facing, so an A press
|
||||
-- from up there does nothing, and that is correct.
|
||||
local BENCH = { map = "VERMILION_POKECENTER", x = seat and seat.x or 0,
|
||||
y = seat and seat.y or 4 }
|
||||
|
||||
U.teleport(game, BENCH.map, BENCH.x, BENCH.y - 1, "down")
|
||||
U.wait(10)
|
||||
U.tap(game, "a")
|
||||
U.wait(20)
|
||||
check("facing the seat from the north stays silent, like the original",
|
||||
boxOnScreen() == nil)
|
||||
|
||||
-- the seat's own approach, with a fallback in case a map edit walls it in
|
||||
local stand = { x = BENCH.x + 1, y = BENCH.y, facing = "left" }
|
||||
if not game.overworld.map:isWalkableCell(stand.x, stand.y) then
|
||||
for _, side in ipairs({ { -1, 0, "right" }, { 0, 1, "up" }, { 0, -1, "down" } }) do
|
||||
local cx, cy = BENCH.x + side[1], BENCH.y + side[2]
|
||||
if game.overworld.map:isWalkableCell(cx, cy) then
|
||||
U.log("the floor east of the seat is gone; standing at", cx, cy)
|
||||
stand = { x = cx, y = cy, facing = side[3] }
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
U.teleport(game, BENCH.map, stand.x, stand.y, stand.facing)
|
||||
U.wait(10)
|
||||
U.tap(game, "a")
|
||||
U.wait(90) -- let the first page finish typing before the shot
|
||||
local box, text = boxOnScreen()
|
||||
check("A from the east opens the bench guy's box (#488)", box ~= nil)
|
||||
if box then
|
||||
U.log("bench guy reads:", text)
|
||||
check("it is his line about no universally strong POKeMON",
|
||||
text:find("universally", 1, true) ~= nil)
|
||||
check("screenshot on disk",
|
||||
U.shot(game, SHOT_DIR .. "/bug488_bench_guy.png"))
|
||||
U.log("captured", SHOT_DIR .. "/bug488_bench_guy.png")
|
||||
U.tap(game, "a")
|
||||
U.wait(20)
|
||||
end
|
||||
|
||||
-- ------------------------------------------------------------- #568
|
||||
-- pokered data/maps/objects/BikeShop.asm: the clerk stands at (6,2)
|
||||
-- behind the counter row, so the player talks across it from (6,4).
|
||||
-- Clear the bike and the voucher so the talk takes the sales pitch.
|
||||
game.save.inventory.BICYCLE = nil
|
||||
game.save.inventory.BIKE_VOUCHER = nil
|
||||
game.save.flags.EVENT_GOT_BICYCLE = nil
|
||||
|
||||
U.teleport(game, "BIKE_SHOP", 6, 4, "up")
|
||||
U.wait(10)
|
||||
|
||||
local ow = game.overworld
|
||||
local clerk
|
||||
for _, n in ipairs(ow.npcs or {}) do
|
||||
if n.def and n.def.name == "BIKESHOP_CLERK" then clerk = n end
|
||||
-- the middle-aged woman paces; park her so she cannot wander into shot
|
||||
if n.def and n.def.name == "BIKESHOP_MIDDLE_AGED_WOMAN" then
|
||||
n.frozen = true
|
||||
end
|
||||
end
|
||||
check("the clerk is on the map", clerk ~= nil)
|
||||
|
||||
-- talking across a counter reads one cell further, so the stand cell is
|
||||
-- two south of him; fall back to any free neighbour if the shop is redrawn
|
||||
local function facingTheClerk()
|
||||
local w = game.overworld
|
||||
local fx, fy = w.player:facingCell()
|
||||
if w:npcAtCell(fx, fy) == clerk then return true end
|
||||
if not w.map:isCounterCell(fx, fy) then return false end
|
||||
local Collision = require("src.world.Collision")
|
||||
local bx, by = Collision.target(fx, fy, w.player.facing)
|
||||
return w:npcAtCell(bx, by) == clerk
|
||||
end
|
||||
if clerk and not facingTheClerk() then
|
||||
for _, side in ipairs({ { 0, 2, "up" }, { 0, 1, "up" }, { 0, -1, "down" },
|
||||
{ 1, 0, "left" }, { -1, 0, "right" } }) do
|
||||
local cx, cy = clerk.cellX + side[1], clerk.cellY + side[2]
|
||||
if ow.map:isWalkableCell(cx, cy) and not ow:npcAtCell(cx, cy) then
|
||||
U.log("(6,4) no longer reaches the counter; standing at", cx, cy)
|
||||
U.teleport(game, "BIKE_SHOP", cx, cy, side[3])
|
||||
U.wait(10)
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
check("the player is at the counter", facingTheClerk())
|
||||
|
||||
U.tap(game, "a")
|
||||
U.wait(20)
|
||||
local _, welcome = boxOnScreen()
|
||||
check("A opens the welcome line", welcome ~= nil)
|
||||
if welcome then U.log("clerk reads:", welcome) end
|
||||
|
||||
-- mash through the welcome box; the pitch box then types itself out and
|
||||
-- pops on its own, handing the screen to the price window
|
||||
local window
|
||||
for _ = 1, 200 do
|
||||
local top = game.stack:top()
|
||||
if top ~= game.overworld and getmetatable(top) ~= TextBox then
|
||||
window = top
|
||||
break
|
||||
end
|
||||
U.tap(game, "a")
|
||||
U.wait(4)
|
||||
end
|
||||
check("the price window is up and holding input (#568)", window ~= nil)
|
||||
if window then
|
||||
check("the cursor starts on BICYCLE", window.index == 1)
|
||||
check("it kept the pitch line for the bottom box",
|
||||
type(window.footer) == "table" and #window.footer > 0)
|
||||
check("screenshot on disk",
|
||||
U.shot(game, SHOT_DIR .. "/bug568_price_window.png"))
|
||||
U.log("captured", SHOT_DIR .. "/bug568_price_window.png")
|
||||
end
|
||||
|
||||
U.log("The window on screen is the shop menu: BICYCLE over CANCEL at the")
|
||||
U.log("top left, \194\1651000000 beside the BICYCLE row, cursor on BICYCLE, and")
|
||||
U.log("\"It's a cool BIKE! Do you want it?\" still in the bottom box under it.")
|
||||
U.log("Input is live. Up and down move between the two rows and clamp -- the")
|
||||
U.log("cursor must not wrap. A buys, B or CANCEL backs out; either way the")
|
||||
U.log("window has to stay on screen behind the closing lines and only")
|
||||
U.log("disappear with \"Come back again some time!\". A window that vanishes")
|
||||
U.log("the moment you answer, or a bottom box that blanks while the menu is")
|
||||
U.log("up, is the near miss to watch for. Talk to him again to replay it.")
|
||||
U.log("The boy by the door is the control: he has no menu, just a line.")
|
||||
|
||||
while true do
|
||||
coroutine.yield()
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,216 @@
|
||||
-- Manual check that the rival shoves Red off the EEVEE ball on the right
|
||||
-- beat (#559). pokeyellow scripts/OaksLab.asm: OaksLabChoseStarterScript
|
||||
-- queues DOWN + RIGHT x3, and .asm_1c564 polls until wNPCNumScriptedSteps
|
||||
-- reads 1 before it simulates the PAD_RIGHT x2 -- so the push starts as the
|
||||
-- rival steps onto the player's own tile, not when he leaves the table.
|
||||
-- No POKEPORT_SPEED: the whole point is which frame the player starts on.
|
||||
-- No POKEPORT_IDENTITY either -- a sandbox save dir has no Yellow cache and
|
||||
-- boots stale Red data underneath a Yellow-looking run.
|
||||
-- POKEPORT_DRIVER=tests/drivers/eevee_shove_bug559_test.lua POKEPORT_TOUCH=0 POKEPORT_VERSION=yellow love .
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
local MapScripts = require("src.script.MapScripts")
|
||||
local Commands = require("src.script.Commands")
|
||||
|
||||
local SHOT_DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
|
||||
|
||||
local function check(label, ok)
|
||||
U.log(ok and "PASS" or "FAIL", label)
|
||||
return ok
|
||||
end
|
||||
|
||||
local function idle()
|
||||
while true do coroutine.yield() end
|
||||
end
|
||||
|
||||
-- Red and Blue load data/scripts/oaks_lab.lua instead (three starter
|
||||
-- balls, no shove at all), so every line below would fail for the wrong
|
||||
-- reason on the Red cache.
|
||||
if not check("running the Yellow cache (POKEPORT_VERSION=yellow)",
|
||||
GameVersion.isYellow()) then
|
||||
U.log("Yellow is the only version with an EEVEE ball. Re-run with")
|
||||
U.log("POKEPORT_VERSION=yellow.")
|
||||
idle()
|
||||
end
|
||||
|
||||
-- pokeyellow data/maps/objects/OaksLab.asm: OAKSLAB_RIVAL (4,3),
|
||||
-- OAKSLAB_EEVEE_POKE_BALL (7,3), OAKSLAB_OAK1 (5,2). The player talks to
|
||||
-- the ball from (7,4), and that y == 4 is what selects the shove branch
|
||||
-- in both the original (.asm_1c564 tests wYCoord) and the port.
|
||||
local MAP = "OAKS_LAB"
|
||||
local BALL = "OAKSLAB_EEVEE_POKE_BALL"
|
||||
local RIVAL_INDEX = 1
|
||||
local RIVAL_HOME = { x = 4, y = 3 }
|
||||
local STAND = { x = 7, y = 4, facing = "up" }
|
||||
|
||||
U.teleport(game, MAP, STAND.x, STAND.y, STAND.facing)
|
||||
U.wait(10)
|
||||
|
||||
-- data/scripts/init.lua picks oaks_lab_yellow over oaks_lab on the Yellow
|
||||
-- cache; if the Red module won, the ball is one of three starters and
|
||||
-- nothing below means anything. Asked after the teleport because the
|
||||
-- registry only fills when OverworldState first requires it.
|
||||
local labModule = require("data.scripts.oaks_lab_yellow")
|
||||
local bound = MapScripts.talkScript(MAP, "TEXT_OAKSLAB_EEVEE_POKE_BALL")
|
||||
U.log("the map dispatches a", type(bound), "for the ball")
|
||||
check("the Yellow ball handler is the one bound to the map",
|
||||
bound ~= nil and bound == labModule.talk.TEXT_OAKSLAB_EEVEE_POKE_BALL)
|
||||
|
||||
local ow = game.overworld
|
||||
local ctx = { game = game, save = game.save, overworld = ow }
|
||||
|
||||
local function npcNamed(name)
|
||||
for _, n in ipairs(ow.npcs or {}) do
|
||||
if n.def and n.def.name == name then return n end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
-- The scene the shove belongs to: Oak has asked the player to choose and
|
||||
-- the rival is still standing at the table. Oak himself starts hidden on
|
||||
-- this map, so put him back the way OaksLabScript does before its speech.
|
||||
game.save.flags = game.save.flags or {}
|
||||
game.save.flags.EVENT_OAK_ASKED_TO_CHOOSE_MON = true
|
||||
game.save.flags.EVENT_GOT_STARTER = nil
|
||||
game.save.flags.EVENT_FOLLOWED_OAK_INTO_LAB = true
|
||||
Commands.show_object(ctx, MAP, "OAKSLAB_RIVAL")
|
||||
Commands.show_object(ctx, MAP, "OAKSLAB_OAK1")
|
||||
U.wait(2)
|
||||
|
||||
local rival = ow:npcByIndex(RIVAL_INDEX)
|
||||
if rival and (rival.cellX ~= RIVAL_HOME.x or rival.cellY ~= RIVAL_HOME.y) then
|
||||
-- he wanders nowhere on this map (STAY), but a converted save can drop
|
||||
-- him mid-walk; pin him back on the table corner he starts from
|
||||
U.log("rival was at", rival.cellX, rival.cellY, "- pinning him to",
|
||||
RIVAL_HOME.x, RIVAL_HOME.y)
|
||||
Commands.place_npc(ctx, RIVAL_INDEX, RIVAL_HOME.x, RIVAL_HOME.y, "down")
|
||||
end
|
||||
if not check("the rival is on the map at (4,3)",
|
||||
rival ~= nil and rival.cellX == RIVAL_HOME.x
|
||||
and rival.cellY == RIVAL_HOME.y) then
|
||||
idle()
|
||||
end
|
||||
|
||||
local ball = npcNamed(BALL)
|
||||
if not check("the EEVEE ball object is on the map", ball ~= nil) then
|
||||
U.log("Nothing to talk to. A save past EVENT_GOT_STARTER has already")
|
||||
U.log("hidden it, and this scene cannot be replayed on that save.")
|
||||
idle()
|
||||
end
|
||||
|
||||
-- derive the talking cell from the ball rather than trusting (7,4): a
|
||||
-- later map edit degrades to another free neighbour instead of parking
|
||||
-- the player inside the table
|
||||
local DIRS = { { "up", 0, 1 }, { "down", 0, -1 },
|
||||
{ "left", 1, 0 }, { "right", -1, 0 } }
|
||||
local function facingBall()
|
||||
local fx, fy = ow.player:facingCell()
|
||||
return ow:npcAtCell(fx, fy) == ball
|
||||
end
|
||||
if not facingBall() then
|
||||
for _, d in ipairs(DIRS) do
|
||||
local cx, cy = ball.cellX + d[2], ball.cellY + d[3]
|
||||
if ow.map:inBounds(cx, cy) and ow.map:isWalkableCell(cx, cy)
|
||||
and not ow:npcAtCell(cx, cy) then
|
||||
U.log("standing at", cx, cy, "facing", d[1], "instead")
|
||||
U.teleport(game, MAP, cx, cy, d[1])
|
||||
U.wait(6)
|
||||
ow = game.overworld
|
||||
ctx.overworld = ow
|
||||
ball = npcNamed(BALL)
|
||||
rival = ow:npcByIndex(RIVAL_INDEX)
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
check("player is facing the ball", facingBall())
|
||||
local p = ow.player
|
||||
U.log("player at", p.cellX, p.cellY, "facing", p.facing,
|
||||
"| rival at", rival.cellX, rival.cellY,
|
||||
"| ball at", ball.cellX, ball.cellY)
|
||||
-- y == 4 is the branch under test; from anywhere else both the original
|
||||
-- and the port just walk the rival over with no shove at all
|
||||
check("the player is on the row that triggers the shove (y == 4)",
|
||||
p.cellY == 4)
|
||||
|
||||
if U.shot(game, SHOT_DIR .. "/bug559_before.png") then
|
||||
U.log("captured", SHOT_DIR .. "/bug559_before.png")
|
||||
end
|
||||
|
||||
-- ---- talk to the ball and watch the two walks frame by frame ----
|
||||
U.tap(game, "a")
|
||||
|
||||
local shoveFrame, rivalAtShove
|
||||
local trace, lastCell = {}, nil
|
||||
for f = 1, 900 do
|
||||
local cell = rival.cellX .. "," .. rival.cellY
|
||||
if cell ~= lastCell then
|
||||
lastCell = cell
|
||||
trace[#trace + 1] = "f" .. f .. " rival " .. cell
|
||||
end
|
||||
if not shoveFrame and ow.player.moving then
|
||||
shoveFrame = f
|
||||
rivalAtShove = { x = rival.cellX, y = rival.cellY }
|
||||
end
|
||||
if shoveFrame and not ow.player.moving and ow.player.cellX >= STAND.x + 2 then
|
||||
break
|
||||
end
|
||||
U.wait(1)
|
||||
end
|
||||
|
||||
U.log("rival trace:", table.concat(trace, " | "))
|
||||
if not check("the player was pushed at all", shoveFrame ~= nil) then
|
||||
U.log("The rival's walk ran but nothing moved the player, so the scene")
|
||||
U.log("never reached move_player. Check the runner did not stall on the")
|
||||
U.log("emote row.")
|
||||
idle()
|
||||
end
|
||||
U.log("the push started on driver frame", shoveFrame, "with the rival at",
|
||||
rivalAtShove.x, rivalAtShove.y)
|
||||
|
||||
-- The whole of #559: at the frame the player starts moving the rival must
|
||||
-- already be alongside him on row 4, one step from the tile he is being
|
||||
-- pushed off. Before the fix both walks started together and the rival
|
||||
-- was still up at the table (4,3)/(4,4).
|
||||
check("the rival had reached row 4 before the push", rivalAtShove.y == 4)
|
||||
check("he was one step from the player's tile, not still at the table",
|
||||
rivalAtShove.x >= ball.cellX - 1)
|
||||
|
||||
if U.shot(game, SHOT_DIR .. "/bug559_shove.png") then
|
||||
U.log("captured", SHOT_DIR .. "/bug559_shove.png")
|
||||
end
|
||||
|
||||
-- let the two walks settle, then the beat the script waits before it turns
|
||||
-- the rival up to face the player (the row after move_player)
|
||||
for _ = 1, 240 do
|
||||
if not ow.player.moving and not rival.moving and rival.facing == "up" then
|
||||
break
|
||||
end
|
||||
U.wait(1)
|
||||
end
|
||||
U.log("after the push: player at", ow.player.cellX, ow.player.cellY,
|
||||
"| rival at", rival.cellX, rival.cellY, "facing", rival.facing)
|
||||
check("the player ended two tiles right of where he stood",
|
||||
ow.player.cellX == STAND.x + 2 and ow.player.cellY == STAND.y)
|
||||
check("the rival ended on the ball's row-4 tile facing up",
|
||||
rival.cellX == ball.cellX and rival.cellY == 4
|
||||
and rival.facing == "up")
|
||||
|
||||
if U.shot(game, SHOT_DIR .. "/bug559_after.png") then
|
||||
U.log("captured", SHOT_DIR .. "/bug559_after.png")
|
||||
end
|
||||
|
||||
U.log("The shove has already happened; the box on screen is the rival")
|
||||
U.log("gloating. Press A through his five lines to see the rest. Watch the")
|
||||
U.log("shot pair: the rival walks down and across the table first, and Red")
|
||||
U.log("only slides right as the rival arrives on top of him, both moving")
|
||||
U.log("together for that last tile. The near miss to look for is Red")
|
||||
U.log("stepping away early, with the rival still up at (4,3) or halfway")
|
||||
U.log("along row 4 behind him -- that is #559 unchanged.")
|
||||
U.log("Control case: on a save where Oak has not asked you to choose yet,")
|
||||
U.log("the same A press only prints \"That's a POKe BALL\" and nobody")
|
||||
U.log("moves, which is the handler working correctly.")
|
||||
|
||||
idle()
|
||||
end
|
||||
@@ -0,0 +1,175 @@
|
||||
-- Manual check of the Silph Scope unveil in the POKEMON_TOWER_6F battle (#492).
|
||||
-- PrintBeginningBattleText .isMarowak (engine/battle/common_text.asm:49-60)
|
||||
-- enters disguised even with the scope and plays UnveiledGhostText + MarowakAnim
|
||||
-- over the GHOST before "Wild MAROWAK appeared!"; the port used to open the
|
||||
-- battle with MAROWAK already loaded. Sequencing half:
|
||||
-- tests/engine/ghost_unveil_bug492.lua. Never under POKEPORT_SPEED -- the
|
||||
-- unveil is the thing being timed.
|
||||
-- POKEPORT_DRIVER=tests/drivers/ghost_unveil_bug492_test.lua POKEPORT_IDENTITY=bug492 POKEPORT_TOUCH=0 SHOT_DIR=/tmp/shots love .
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
local BattleState = require("src.battle.BattleState")
|
||||
|
||||
local pass, fail = 0, 0
|
||||
local function check(label, ok)
|
||||
if ok then pass = pass + 1 else fail = fail + 1 end
|
||||
U.log(ok and "PASS" or "FAIL", label)
|
||||
return ok
|
||||
end
|
||||
|
||||
-- ---- machine checks ----------------------------------------------------
|
||||
-- A missing text key, a renamed asset and an unarmed branch all look like
|
||||
-- the bug from outside: the battle just opens with MAROWAK on screen.
|
||||
U.log("#492 Silph Scope unveil: machine checks")
|
||||
check("SILPH_SCOPE is a real item", game.data.items.SILPH_SCOPE ~= nil)
|
||||
check("MAROWAK is a real species", game.data.pokemon.MAROWAK ~= nil)
|
||||
check("BattleState:makeUnveiledGhost exists",
|
||||
type(BattleState.makeUnveiledGhost) == "function")
|
||||
local unveil = game.data.text._UnveiledGhostText
|
||||
check("_UnveiledGhostText extracted", type(unveil) == "string" and unveil ~= "")
|
||||
if type(unveil) == "string" then
|
||||
U.log("the unveil line reads:", (unveil:gsub("[\n\011\012]", " / ")))
|
||||
end
|
||||
check("_PokemonTower6FBeGoneText extracted",
|
||||
type(game.data.text._PokemonTower6FBeGoneText) == "string")
|
||||
-- the disguise pic resolves through the same override chain the battle uses
|
||||
local Assets = require("src.render.Assets")
|
||||
local GHOST_PIC = "assets/generated/battle/front/ghost.png"
|
||||
check("the ghost front pic is in the cache",
|
||||
Assets.exists(Assets.resolve(GHOST_PIC)))
|
||||
|
||||
local opts = game.save.options or {}
|
||||
U.log("sfxVol", tostring(opts.sfxVol), "musicVol", tostring(opts.musicVol))
|
||||
if opts.sfxVol == 0 or opts.musicVol == 0 then
|
||||
U.log("a volume is at 0: raise it in OPTION before judging what you hear")
|
||||
end
|
||||
U.log("no intro cry here is CORRECT -- .isMarowak never reaches PlayCry.")
|
||||
U.log("The CHANNELER at (12, 10) is the control: her GASTLY cries normally.")
|
||||
|
||||
-- ---- reach the trigger -------------------------------------------------
|
||||
-- pokered scripts/PokemonTower6F.asm PokemonTower6FMarowakCoords:
|
||||
-- dbmapcoord 10, 16. Row 17 is solid wall and (9, 16) is the stairwell
|
||||
-- warp down to 5F, so the approach is from (10, 15) facing down.
|
||||
local MAP = "POKEMON_TOWER_6F"
|
||||
local TRIGGER = { x = 10, y = 16 }
|
||||
local STAND = { x = 10, y = 15, facing = "down", step = "down" }
|
||||
|
||||
game.save.party = { Pokemon.new(game.data, "CHARIZARD", 50) }
|
||||
game.save.player.name = "RED"
|
||||
game.save.inventory.SILPH_SCOPE = 1
|
||||
game.save.flags.EVENT_BEAT_GHOST_MAROWAK = nil
|
||||
|
||||
U.teleport(game, MAP, STAND.x, STAND.y, STAND.facing)
|
||||
U.wait(10)
|
||||
local ow = game.overworld
|
||||
check("the overworld is up on " .. MAP, ow ~= nil and ow.map ~= nil)
|
||||
|
||||
if ow and ow.map and not ow.map:isWalkableCell(STAND.x, STAND.y) then
|
||||
-- a map edit moved the approach: take any walkable neighbour of the
|
||||
-- trigger that is not the stairwell warp, and step back toward it.
|
||||
-- {dx, dy, facing} is the offset from the trigger to the stand cell plus
|
||||
-- the direction that walks back onto it.
|
||||
local sides = { { 0, -1, "down" }, { 1, 0, "left" },
|
||||
{ -1, 0, "right" }, { 0, 1, "up" } }
|
||||
for _, s in ipairs(sides) do
|
||||
local cx, cy = TRIGGER.x + s[1], TRIGGER.y + s[2]
|
||||
if ow.map:isWalkableCell(cx, cy) and not ow.map:warpAtCell(cx, cy) then
|
||||
U.log(("(%d, %d) is blocked, approaching from"):format(STAND.x, STAND.y),
|
||||
cx, cy, "stepping", s[3])
|
||||
STAND = { x = cx, y = cy, facing = s[3], step = s[3] }
|
||||
U.teleport(game, MAP, cx, cy, s[3])
|
||||
U.wait(10)
|
||||
ow = game.overworld
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- walk onto the trigger; MapScripts onStep fires on the completed step
|
||||
U.hold(game, STAND.step, 20)
|
||||
U.wait(20)
|
||||
check("the Be-gone text opened", game.stack:top() ~= game.overworld)
|
||||
|
||||
-- ---- into the battle ---------------------------------------------------
|
||||
local battle
|
||||
for _ = 1, 300 do
|
||||
local top = game.stack:top()
|
||||
if getmetatable(top) == BattleState then battle = top break end
|
||||
U.tap(game, "a")
|
||||
U.wait(4)
|
||||
end
|
||||
check("the MAROWAK battle opened", battle ~= nil)
|
||||
if not battle then
|
||||
U.log(("machine checks: %d passed, %d failed"):format(pass, fail))
|
||||
while true do coroutine.yield() end
|
||||
end
|
||||
|
||||
battle.onFinish = battle.onFinish or function() end
|
||||
check("the scope armed the unveil, not a plain wild battle",
|
||||
battle.scopeReveal == true)
|
||||
check("IsGhostBattle stays false with the scope in the bag", not battle.ghost)
|
||||
check("the battle OPENED as the GHOST, not as MAROWAK (#492)",
|
||||
battle.enemy and battle.enemy.name == "GHOST")
|
||||
check("the real nick is held back for the unveil",
|
||||
battle.ghostReal ~= nil and battle.ghostReal.name == "MAROWAK")
|
||||
|
||||
-- ---- watch the unveil --------------------------------------------------
|
||||
-- A turns the "GHOST appeared!" and unveil boxes; stop pressing the moment
|
||||
-- MarowakAnim arms so nothing mashes past "Wild MAROWAK appeared!".
|
||||
for _ = 1, 600 do
|
||||
if battle.ghostReveal then break end
|
||||
U.tap(game, "a")
|
||||
U.wait(4)
|
||||
end
|
||||
check("MarowakAnim started", battle.ghostReveal ~= nil)
|
||||
|
||||
local FLASH = BattleState.GHOST_FLASH_FRAMES
|
||||
local FADE_OUT = BattleState.GHOST_FADE_OUT_FRAMES
|
||||
local shotFlash, swapT, nameDuringFlash = false, nil, true
|
||||
for _ = 1, 400 do
|
||||
local gr = battle.ghostReveal
|
||||
local t = gr and gr.t or nil
|
||||
if t and t <= FLASH + FADE_OUT and battle.enemy.name ~= "GHOST" then
|
||||
nameDuringFlash = false
|
||||
end
|
||||
if t and not swapT and battle.enemy.name ~= "GHOST" then swapT = t end
|
||||
if t and t >= 25 and not shotFlash then
|
||||
shotFlash = U.shot(game, DIR .. "/bug492_ghost_flashing.png")
|
||||
end
|
||||
if not gr and swapT then break end
|
||||
U.wait(1)
|
||||
end
|
||||
|
||||
check("the GHOST was still on screen through the flash and fade-out (#492)",
|
||||
nameDuringFlash)
|
||||
check(("the swap landed after the fade-out (frame %s of %d)")
|
||||
:format(tostring(swapT), FLASH + FADE_OUT),
|
||||
swapT ~= nil and swapT > FLASH + FADE_OUT)
|
||||
check("the foe is MAROWAK once the animation finishes",
|
||||
battle.enemy.name == "MAROWAK")
|
||||
check("captured the flashing ghost", shotFlash)
|
||||
|
||||
-- the "Wild MAROWAK appeared!" box that closes .isMarowak
|
||||
U.wait(30)
|
||||
check("captured the unveiled MAROWAK",
|
||||
U.shot(game, DIR .. "/bug492_marowak_revealed.png"))
|
||||
U.log(("machine checks: %d passed, %d failed"):format(pass, fail))
|
||||
|
||||
-- ---- hand off ----------------------------------------------------------
|
||||
U.log("The pad is yours in the battle the unveil just finished. What you")
|
||||
U.log("should have seen: the box says the GHOST appeared, the SILPH SCOPE")
|
||||
U.log("line follows, and only THEN does the ghost flash, dissolve and come")
|
||||
U.log("back as MAROWAK before \"Wild MAROWAK appeared!\". The near miss to")
|
||||
U.log("watch for is the sprite swapping early -- MAROWAK already standing")
|
||||
U.log("there while the unveil text is still typing, which is #492 itself.")
|
||||
U.log("To see it again: run from the battle (that steps you one cell right),")
|
||||
U.log("walk back to (10, 15) and step down onto (10, 16). Beating it sets")
|
||||
U.log("EVENT_BEAT_GHOST_MAROWAK and the trigger is spent for this save.")
|
||||
U.log("Screenshots: " .. DIR .. "/bug492_*.png")
|
||||
|
||||
while true do
|
||||
coroutine.yield()
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,234 @@
|
||||
-- Ear check: the menu click on backing out of POKéDEX / POKéMON / ITEM /
|
||||
-- OPTION, on the first A on an item in ITEM, and on A over CANCEL in OPTION
|
||||
-- (#570). HandleMenuInput_ (home/window.asm) replays SFX_PRESS_AB for the
|
||||
-- PAD_A | PAD_B half of a menu's watched keys; DisplayOptionMenu beeps only
|
||||
-- at .exitMenu (engine/menus/main_menu.asm). Silence where the original is
|
||||
-- silent counts too, so this walks the mute cases as well. Invariants are in
|
||||
-- tests/engine/menu_click_bug570.lua.
|
||||
-- POKEPORT_DRIVER=tests/drivers/menu_click_bug570_test.lua POKEPORT_IDENTITY=bug570 POKEPORT_TOUCH=0 SHOT_DIR=/tmp/shots love .
|
||||
-- No POKEPORT_SPEED: it scales the logic clock only while audio runs on its own
|
||||
-- real-time accumulator in Game:update, so it desyncs the exact ordering of
|
||||
-- press and click that this run exists to judge.
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
|
||||
local Bag = require("src.inventory.Bag")
|
||||
local ListMenu = require("src.ui.ListMenu")
|
||||
local Menu = require("src.ui.Menu")
|
||||
local OptionsMenu = require("src.ui.OptionsMenu")
|
||||
local PartyMenu = require("src.ui.PartyMenu")
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
local Sound = require("src.core.Sound")
|
||||
local Strings = require("src.core.Strings")
|
||||
|
||||
-- pokered data/maps/objects/PalletTown.asm: OAK at (8,5), the girl at (3,8),
|
||||
-- the fisher at (11,14), warps at (5,5), (13,5) and (12,11). (10,7) is clear
|
||||
-- of all of them and off every mat; the menus are what is being judged, the
|
||||
-- cell only has to be somewhere the start menu opens.
|
||||
local MAP = "PALLET_TOWN"
|
||||
local STAND = { x = 10, y = 7, facing = "down" }
|
||||
local PARTY = { { "BULBASAUR", 12 }, { "PIDGEY", 9 } }
|
||||
|
||||
local function check(label, ok)
|
||||
U.log(ok and "PASS" or "FAIL", label)
|
||||
return ok
|
||||
end
|
||||
|
||||
-- ---- what the ear cannot check -----------------------------------------
|
||||
local opts = game.save.options or {}
|
||||
local musicVol = opts.musicVol or 7
|
||||
local sfxVol = opts.sfxVol or 7
|
||||
if sfxVol == 0 then
|
||||
U.log("FAIL SFX volume is 0. Every claim below is about a click that is or")
|
||||
U.log(" is not there, and at 0 none of them are. Set SFX to 7 in OPTION")
|
||||
U.log(" and run this again, or the whole check is worthless.")
|
||||
end
|
||||
if musicVol == 0 then
|
||||
U.log("FAIL MUSIC volume is 0, so Pallet Town's theme is gone and there is")
|
||||
U.log(" nothing to tell a dead audio device from a missing click.")
|
||||
U.log(" Set MUSIC to 7 in OPTION.")
|
||||
end
|
||||
check(("SFX volume %d, MUSIC volume %d"):format(sfxVol, musicVol),
|
||||
sfxVol > 0 and musicVol > 0)
|
||||
|
||||
-- an unresolved sfx key is silent in exactly the way the bug was
|
||||
local sfx = (game.data.audio or {}).sfx or {}
|
||||
for _, key in ipairs({ "Press_AB", "Start_Menu", "Swap" }) do
|
||||
check("sfx " .. key .. " is in the cache", sfx[key] ~= nil)
|
||||
end
|
||||
|
||||
-- every cue the run makes, forwarded so playback is untouched
|
||||
local cues = {}
|
||||
local realPlay = Sound.play
|
||||
Sound.play = function(data, name)
|
||||
cues[#cues + 1] = name
|
||||
return realPlay(data, name)
|
||||
end
|
||||
local function since(mark)
|
||||
local clicks, others = 0, {}
|
||||
for i = mark + 1, #cues do
|
||||
if cues[i] == "Press_AB" then clicks = clicks + 1
|
||||
else others[#others + 1] = cues[i] end
|
||||
end
|
||||
return clicks, others
|
||||
end
|
||||
|
||||
-- one press, then long enough for the click to be its own sound
|
||||
local function press(btn, want, label)
|
||||
local mark = #cues
|
||||
U.tap(game, btn)
|
||||
U.wait(28)
|
||||
local clicks, others = since(mark)
|
||||
local note = ""
|
||||
if #others > 0 then note = " (also: " .. table.concat(others, ", ") .. ")" end
|
||||
check(("%s: %s gave %d click%s, expected %d%s")
|
||||
:format(label, btn:upper(), clicks, clicks == 1 and "" or "s",
|
||||
want, note),
|
||||
clicks == want)
|
||||
return clicks
|
||||
end
|
||||
|
||||
local function top() return game.stack:top() end
|
||||
local function isA(class) return getmetatable(top()) == class end
|
||||
|
||||
-- ---- set the save up so every start-menu row is live --------------------
|
||||
game.save.party = {}
|
||||
for _, slot in ipairs(PARTY) do
|
||||
table.insert(game.save.party, Pokemon.new(game.data, slot[1], slot[2]))
|
||||
end
|
||||
game.save.flags = game.save.flags or {}
|
||||
game.save.flags.EVENT_GOT_POKEDEX = true -- StartMenu hides POKéDEX without it
|
||||
game.save.pokedex = game.save.pokedex or { seen = {}, owned = {} }
|
||||
for _, id in ipairs({ "BULBASAUR", "PIDGEY", "RATTATA" }) do
|
||||
game.save.pokedex.seen[id] = true
|
||||
game.save.pokedex.owned[id] = true
|
||||
end
|
||||
Bag.add(game.save, "POTION", 3)
|
||||
Bag.add(game.save, "ANTIDOTE", 2)
|
||||
game.save.startMenuIndex = 1
|
||||
|
||||
U.teleport(game, MAP, STAND.x, STAND.y, STAND.facing)
|
||||
U.wait(20)
|
||||
local ow = game.overworld
|
||||
if ow and not ow.map:isWalkableCell(STAND.x, STAND.y) then
|
||||
-- a map edit blocked the cell: any free walkable neighbour will do
|
||||
for _, d in ipairs({ { 0, 1 }, { 0, -1 }, { 1, 0 }, { -1, 0 } }) do
|
||||
local cx, cy = STAND.x + d[1], STAND.y + d[2]
|
||||
if ow.map:isWalkableCell(cx, cy) and not ow:npcAtCell(cx, cy) then
|
||||
U.log(("(%d, %d) is blocked, standing on"):format(STAND.x, STAND.y),
|
||||
cx, cy)
|
||||
U.teleport(game, MAP, cx, cy, STAND.facing)
|
||||
U.wait(20)
|
||||
ow = game.overworld
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
check("standing on " .. MAP, ow ~= nil and ow.map.id == MAP)
|
||||
-- the girl and the fisher both WALK: pin them so the map under the menus
|
||||
-- looks the same on every run and in every screenshot
|
||||
for _, n in ipairs((ow or {}).npcs or {}) do n.frozen = true end
|
||||
|
||||
-- ---- walk the menus -----------------------------------------------------
|
||||
press("start", 0, "opening the start menu is silent (its own Start_Menu "
|
||||
.. "cue is not the A/B click)")
|
||||
check("the start menu is up", isA(Menu))
|
||||
|
||||
-- rows come and go with save state and the ui.start_menu.items hook, so
|
||||
-- find each one by label rather than counting cursor steps
|
||||
local function rowIndex(label)
|
||||
for i, item in ipairs(top().items or {}) do
|
||||
if item.label == label then return i end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
local function openRow(label)
|
||||
local menu = top()
|
||||
local i = rowIndex(label)
|
||||
if not i then
|
||||
check("the start menu has a " .. label .. " row", false)
|
||||
return false
|
||||
end
|
||||
menu.index = i
|
||||
menu:clampScroll()
|
||||
press("a", 1, "A on " .. label .. " clicks")
|
||||
return true
|
||||
end
|
||||
|
||||
if openRow(Strings("POKéDEX")) then
|
||||
check("the POKéDEX list opened", isA(ListMenu))
|
||||
U.shot(game, DIR .. "/bug570_1_pokedex.png")
|
||||
press("b", 1, "backing out of POKéDEX clicks (#570)")
|
||||
check("and the start menu came back", isA(Menu))
|
||||
end
|
||||
|
||||
if openRow(Strings("POKéMON")) then
|
||||
check("the party menu opened", getmetatable(top()) == PartyMenu)
|
||||
press("b", 1, "backing out of POKéMON clicks (#570)")
|
||||
check("and the start menu came back", isA(Menu))
|
||||
end
|
||||
|
||||
if openRow(Strings("ITEM")) then
|
||||
check("the bag opened", isA(ListMenu))
|
||||
check("the bag has items to click on", #(top().items or {}) > 0)
|
||||
press("a", 1, "the first A on an item in ITEM clicks (#570)")
|
||||
check("the USE / TOSS submenu opened", isA(Menu))
|
||||
U.shot(game, DIR .. "/bug570_2_bag_usetoss.png")
|
||||
press("b", 1, "backing out of USE / TOSS clicks")
|
||||
check("back on the item list", isA(ListMenu))
|
||||
-- SELECT is inside DisplayListMenuID's watched mask but outside its
|
||||
-- PAD_A | PAD_B sound test (home/list_menu.asm), so arming a swap is
|
||||
-- silent and completing it makes the swap chirp, not the menu click
|
||||
press("select", 0, "SELECT arms a swap without clicking")
|
||||
press("select", 0, "and completing it chirps instead of clicking")
|
||||
press("b", 1, "backing out of ITEM clicks (#570)")
|
||||
check("and the start menu came back", isA(Menu))
|
||||
end
|
||||
|
||||
if openRow(Strings("OPTION")) then
|
||||
check("the options screen opened", getmetatable(top()) == OptionsMenu)
|
||||
press("right", 0, "the Left/Right toggles are silent")
|
||||
press("left", 0, "in both directions")
|
||||
-- up from the first row wraps onto CANCEL, which sits one past the rows
|
||||
press("up", 0, "moving the cursor is silent")
|
||||
check("the cursor is on CANCEL",
|
||||
top().index == #top().rows + 1)
|
||||
U.shot(game, DIR .. "/bug570_3_option_cancel.png")
|
||||
press("a", 1, "A on CANCEL in OPTION clicks (#570)")
|
||||
check("and the start menu came back", isA(Menu))
|
||||
end
|
||||
|
||||
if openRow(Strings("OPTION")) then
|
||||
press("b", 1, "B out of OPTION clicks too (#570)")
|
||||
check("and the start menu came back", isA(Menu))
|
||||
end
|
||||
|
||||
press("start", 0, "START closes the start menu silently: draw_start_menu's "
|
||||
.. "mask watches it, but it is not in the PAD_A | PAD_B sound branch")
|
||||
check("back on the map", top() == game.overworld)
|
||||
|
||||
-- ---- over to you --------------------------------------------------------
|
||||
U.log("The run above already pressed all of it; do it again by ear. Open START,")
|
||||
U.log("go into POKéDEX, POKéMON, ITEM and OPTION and back out of each with B.")
|
||||
U.log("Every one of those B presses makes the same short click that A on the")
|
||||
U.log("row made going in, the first A on an item in ITEM makes it, and A on")
|
||||
U.log("CANCEL in OPTION closes it with it. #570 was silence on all of those.")
|
||||
U.log("The near miss is a click at the wrong moment: OPTION's Left/Right")
|
||||
U.log("toggles and its A on a setting row stay silent, START opens and closes")
|
||||
U.log("the start menu with its own longer cue and no click, and SELECT in the")
|
||||
U.log("bag chirps a swap rather than clicking.")
|
||||
U.log("Shots: " .. DIR .. "/bug570_*.png")
|
||||
|
||||
-- keeps printing the cue stream after the hand-off, so a click that lands a
|
||||
-- beat late reads as a late line rather than as the right sound
|
||||
local reported = #cues
|
||||
while true do
|
||||
if #cues > reported then
|
||||
for i = reported + 1, #cues do
|
||||
U.log("cue", cues[i], "frame", U.frame())
|
||||
end
|
||||
reported = #cues
|
||||
end
|
||||
coroutine.yield()
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,211 @@
|
||||
-- Look at the boxed menus whose rows moved down one tile (#564 start menu,
|
||||
-- #572 the PC and the Pokédex entry window) and watch QUIT close the whole
|
||||
-- Pokédex (#571). Row geometry comes from pokered draw_start_menu.asm
|
||||
-- (hlcoord 12,2 inside a TextBoxBorder at 10,0) and players_pc.asm
|
||||
-- (hlcoord 2,2 inside a 16x10 box); the exits come from pokedex.asm
|
||||
-- HandlePokedexSideMenu / .exitPokedex. Screenshots land in $SHOT_DIR.
|
||||
-- POKEPORT_DRIVER=tests/drivers/menu_windows_bug564_test.lua POKEPORT_IDENTITY=bug564 POKEPORT_TOUCH=0 POKEPORT_VERSION=red love .
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
local Bag = require("src.inventory.Bag")
|
||||
local SHOT_DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
|
||||
|
||||
local function check(label, ok)
|
||||
U.log(ok and "PASS" or "FAIL", label)
|
||||
return ok
|
||||
end
|
||||
|
||||
-- The bedroom PC is hidden_event 0, 1, OpenRedsPC, SPRITE_FACING_UP
|
||||
-- (pokered data/events/hidden_objects.asm), so the only approach is the
|
||||
-- floor tile below it.
|
||||
local MAP = "REDS_HOUSE_2F"
|
||||
local STAND = { x = 0, y = 2, facing = "up" }
|
||||
local DEX_SPECIES = "BULBASAUR" -- dex #1, so it is the list's first row
|
||||
local BAG_ITEM = "POTION"
|
||||
|
||||
-- a save the start menu will draw all seven rows for: dex flag on,
|
||||
-- something owned so the dex list has a real entry, a party for LINK,
|
||||
-- and one bag item so ITEM opens onto a row that has a USE/TOSS box
|
||||
game.save.flags = game.save.flags or {}
|
||||
game.save.flags.EVENT_GOT_POKEDEX = true
|
||||
game.save.pokedex = game.save.pokedex or { seen = {}, owned = {} }
|
||||
game.save.pokedex.seen[DEX_SPECIES] = true
|
||||
game.save.pokedex.owned[DEX_SPECIES] = true
|
||||
game.save.party = {
|
||||
Pokemon.new(game.data, DEX_SPECIES, 12),
|
||||
Pokemon.new(game.data, "PIKACHU", 10),
|
||||
}
|
||||
Bag.add(game.save, BAG_ITEM, 3)
|
||||
|
||||
check("dex entry for " .. DEX_SPECIES .. " exists in the loaded data",
|
||||
game.data.pokemon[DEX_SPECIES] ~= nil)
|
||||
check(BAG_ITEM .. " resolves as an item",
|
||||
game.data.items[BAG_ITEM] ~= nil)
|
||||
|
||||
-- pcTiles is what OverworldController checks before it opens the PC; an
|
||||
-- empty list here and a mis-aimed player look identical from outside
|
||||
local extras = game.data.field and game.data.field.hiddenExtras
|
||||
local pcTile = extras and (extras.pcTiles[MAP] or {})[1]
|
||||
check("the bedroom PC tile is in the field data", pcTile ~= nil)
|
||||
if pcTile then
|
||||
U.log("PC tile at", pcTile.x, pcTile.y, "facing", pcTile.facing)
|
||||
-- a map or field-data edit moves the tile: stand under it wherever it is
|
||||
STAND = { x = pcTile.x, y = pcTile.y + 1, facing = "up" }
|
||||
end
|
||||
|
||||
U.teleport(game, MAP, STAND.x, STAND.y, STAND.facing)
|
||||
U.wait(10)
|
||||
local ow = game.overworld
|
||||
if ow and not ow.map:isWalkableCell(STAND.x, STAND.y) then
|
||||
-- fall back to any free walkable neighbour that still looks at the tile
|
||||
local sides = {
|
||||
{ 0, 1, "up" }, { 0, -1, "down" }, { 1, 0, "left" }, { -1, 0, "right" },
|
||||
}
|
||||
for _, s in ipairs(sides) do
|
||||
local cx, cy = pcTile.x + s[1], pcTile.y + s[2]
|
||||
if ow.map:isWalkableCell(cx, cy) then
|
||||
U.log("standing on", cx, cy, "facing", s[3], "instead")
|
||||
STAND = { x = cx, y = cy, facing = s[3] }
|
||||
U.teleport(game, MAP, cx, cy, s[3])
|
||||
U.wait(10)
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
check("player is standing at the PC",
|
||||
game.overworld ~= nil
|
||||
and game.overworld.player.cellX == STAND.x
|
||||
and game.overworld.player.cellY == STAND.y)
|
||||
|
||||
-- walk a Menu's cursor onto a row by label, with real Down presses
|
||||
local function moveTo(menu, label)
|
||||
for _ = 1, #menu.items + 1 do
|
||||
if menu.items[menu.index] and menu.items[menu.index].label == label then
|
||||
return true
|
||||
end
|
||||
U.tap(game, "down")
|
||||
U.wait(2)
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
-- --------------------------------------------------------------- #564
|
||||
U.tap(game, "start")
|
||||
U.wait(20)
|
||||
local start = game.stack:top()
|
||||
check("START opened the start menu", start ~= nil and start.items ~= nil)
|
||||
if start and start.items then
|
||||
local labels = {}
|
||||
for i, it in ipairs(start.items) do labels[i] = it.label end
|
||||
U.log("start menu rows:", table.concat(labels, " "))
|
||||
end
|
||||
check("start menu screenshot",
|
||||
U.shot(game, SHOT_DIR .. "/bug564_start_menu.png"))
|
||||
|
||||
-- --------------------------------------------------------------- #571
|
||||
check("POKéDEX is on the start menu", moveTo(start, "POKéDEX"))
|
||||
U.tap(game, "a")
|
||||
U.wait(20)
|
||||
local dex = game.stack:top()
|
||||
check("the dex list opened", dex ~= nil and dex.screenId == "PokedexMenu")
|
||||
U.tap(game, "a")
|
||||
U.wait(20)
|
||||
local side = game.stack:top()
|
||||
local isSide = side ~= nil and side ~= dex and side.items ~= nil
|
||||
check("A on " .. DEX_SPECIES .. " opened the DATA/CRY/AREA/QUIT window",
|
||||
isSide)
|
||||
check("Pokédex entry window screenshot",
|
||||
U.shot(game, SHOT_DIR .. "/bug572_pokedex_entry_menu.png"))
|
||||
|
||||
if isSide then
|
||||
check("QUIT is on the entry window", moveTo(side, "QUIT"))
|
||||
U.tap(game, "a")
|
||||
U.wait(30)
|
||||
local top = game.stack:top()
|
||||
-- the machine-checkable half of #571: the stack has to unwind past the
|
||||
-- dex list, not just pop the side menu
|
||||
check("QUIT left the Pokédex entirely", top ~= dex and top ~= side)
|
||||
check("QUIT landed on the start menu",
|
||||
top ~= nil and top.screenId == "StartMenu")
|
||||
if top and top.items and top.items[top.index] then
|
||||
U.log("start menu cursor is on:", top.items[top.index].label)
|
||||
check("the cursor is still on POKéDEX",
|
||||
top.items[top.index].label == "POKéDEX")
|
||||
end
|
||||
check("start menu after QUIT screenshot",
|
||||
U.shot(game, SHOT_DIR .. "/bug571_after_quit.png"))
|
||||
end
|
||||
|
||||
-- --------------------------------------------------------------- #572
|
||||
-- the bag's USE/TOSS box, reached from the same start menu
|
||||
local menu = game.stack:top()
|
||||
if menu and menu.items and moveTo(menu, "ITEM") then
|
||||
U.tap(game, "a")
|
||||
U.wait(20)
|
||||
U.tap(game, "a") -- A on the first bag row opens USE/TOSS
|
||||
U.wait(20)
|
||||
local useToss = game.stack:top()
|
||||
local ok = useToss and useToss.items and #useToss.items == 2
|
||||
and useToss.items[1].label == "USE"
|
||||
check("the bag's USE/TOSS box opened", ok and true or false)
|
||||
check("USE/TOSS screenshot",
|
||||
U.shot(game, SHOT_DIR .. "/bug572_bag_use_toss.png"))
|
||||
if ok then
|
||||
-- pokered data/text_boxes.asm USE_TOSS_MENU_TEMPLATE is (13,10) to
|
||||
-- (19,14) with its text at 15,11: two rows exactly fill 11 and 13,
|
||||
-- with no blank row under the top border. Mirror Menu:draw's own
|
||||
-- bottom anchor rather than re-deriving one here -- an assertion that
|
||||
-- carries its own copy of the formula passes on whatever it assumes
|
||||
-- instead of on what the menu actually draws (#564, #572).
|
||||
local n = #useToss.items
|
||||
local bottom = useToss.ty + useToss.th - 2
|
||||
local firstRow = bottom - (n - 1) * useToss.rowStep
|
||||
local lastRow = bottom
|
||||
check("TOSS is inside the box, not on its bottom border",
|
||||
lastRow <= useToss.ty + useToss.th - 2)
|
||||
check("USE and TOSS land on the pokered rows (11 and 13)",
|
||||
firstRow == 11 and lastRow == 13)
|
||||
U.log("USE/TOSS box ty=" .. useToss.ty .. " th=" .. useToss.th
|
||||
.. ", rows land on " .. firstRow
|
||||
.. " and " .. lastRow
|
||||
.. ", bottom interior row is " .. bottom)
|
||||
end
|
||||
U.tap(game, "b")
|
||||
U.wait(10)
|
||||
U.tap(game, "b")
|
||||
U.wait(20)
|
||||
end
|
||||
|
||||
-- back out to the overworld and open the bedroom PC
|
||||
for _ = 1, 4 do
|
||||
if game.stack:top() == game.overworld then break end
|
||||
U.tap(game, "b")
|
||||
U.wait(15)
|
||||
end
|
||||
check("back in the overworld", game.stack:top() == game.overworld)
|
||||
|
||||
U.tap(game, "a")
|
||||
U.wait(25)
|
||||
local pc = game.stack:top()
|
||||
local isPC = pc ~= nil and pc.screenId == "PlayerPC"
|
||||
check("the bedroom PC opened", isPC)
|
||||
check("player's PC screenshot",
|
||||
U.shot(game, SHOT_DIR .. "/bug572_player_pc.png"))
|
||||
|
||||
U.log("The player's PC is open on screen; B closes it and START reopens")
|
||||
U.log("the start menu. In all of these the last choice should sit on the")
|
||||
U.log("row just above the bottom border and the blank row should be under")
|
||||
U.log("the top border, with the ▶ level with its label -- the near miss is")
|
||||
U.log("text that clears the top edge but leaves a gap at the bottom, or a")
|
||||
U.log("cursor that drifts a row off its own label. The text boxes the")
|
||||
U.log("Pokémon and item lists draw are the control: they never moved.")
|
||||
U.log("the bag's USE/TOSS box is the tight one: pokered draws it a row")
|
||||
U.log("shorter than the rest, so USE and TOSS fill 11 and 13 with no blank")
|
||||
U.log("row at all. If TOSS is sitting on the border there, that is the bug.")
|
||||
U.log("Screenshots are under " .. SHOT_DIR)
|
||||
|
||||
while true do
|
||||
coroutine.yield()
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,112 @@
|
||||
-- Driver: regression coverage for #601 "Wrong dialogue when interacting
|
||||
-- with Prof. Oak's last ball".
|
||||
--
|
||||
-- After the player picks a starter and the rival takes his, the leftover
|
||||
-- ball on the lab table must show "That's PROF.OAK's last Pokémon!" --
|
||||
-- pret/pokered scripts/OaksLab.asm OaksLabSelectedPokeBallScript jumps
|
||||
-- every ball handler to OaksLabLastMonScript once EVENT_GOT_STARTER is
|
||||
-- set (Oak turns to face the player first). The buggy port fell through
|
||||
-- to _OaksLabThoseArePokeBallsText ("Those are POKé BALLs...") instead.
|
||||
--
|
||||
-- Scenario A (the #601 regression): with a starter already picked, talk
|
||||
-- to the leftover ball -> Oak faces down, box says "last Pokémon!",
|
||||
-- and no starter offer/dex appears. Fails before the fix (the box
|
||||
-- says "Those are POKé BALLs").
|
||||
-- Scenario B (guard): with NO starter and not escorted in, the ball still
|
||||
-- says "Those are POKé BALLs". Passes before and after the fix.
|
||||
--
|
||||
-- Setup: flags are set directly (pick flow never runs), so all three
|
||||
-- balls stay visible; the player stands left of the Charmander ball
|
||||
-- (cell 6,3), the leftover slot for the Squirtle pick (rival took the
|
||||
-- Bulbasaur ball). The lab battle flag is set so the rival is gone and
|
||||
-- cannot intercept the talk. TextBox.new is hooked to capture the raw
|
||||
-- box text.
|
||||
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
|
||||
|
||||
local TextBox = require("src.render.TextBox")
|
||||
local origNew = TextBox.new
|
||||
local lastText
|
||||
TextBox.new = function(g, text, ...)
|
||||
lastText = text
|
||||
return origNew(g, text, ...)
|
||||
end
|
||||
|
||||
local function restore()
|
||||
TextBox.new = origNew
|
||||
end
|
||||
|
||||
local function setFlags(postPick)
|
||||
local flags = game.save.flags or {}
|
||||
game.save.flags = flags
|
||||
flags.EVENT_FOLLOWED_OAK_INTO_LAB = true
|
||||
if postPick then
|
||||
flags.EVENT_GOT_STARTER = true
|
||||
flags.EVENT_CHOSE_SQUIRTLE = true
|
||||
else
|
||||
flags.EVENT_GOT_STARTER = nil
|
||||
end
|
||||
-- rival already fought + gone, so he cannot intercept the talk
|
||||
flags.EVENT_BATTLED_RIVAL_IN_OAKS_LAB = true
|
||||
end
|
||||
|
||||
-- Talk to the ball at cell 6,3 (Charmander slot): stand one cell left
|
||||
-- facing right and press A. Returns once a TextBox has been built.
|
||||
local function talkToBall()
|
||||
lastText = nil
|
||||
U.teleport(game, "OAKS_LAB", 5, 3, "right")
|
||||
U.wait(6)
|
||||
for _ = 1, 8 do
|
||||
U.tap(game, "a")
|
||||
for _ = 1, 30 do
|
||||
if lastText then return true end
|
||||
U.wait(1)
|
||||
end
|
||||
end
|
||||
return lastText ~= nil
|
||||
end
|
||||
|
||||
-- ---- Scenario A: leftover ball after the pick
|
||||
setFlags(true)
|
||||
local aBoxOpened = talkToBall()
|
||||
U.wait(30) -- let the typewriter reveal the line
|
||||
U.shot(game, DIR .. "/a_last_ball.png")
|
||||
local aText = lastText or "<none>"
|
||||
local aPass = aBoxOpened
|
||||
and aText:find("last Pokémon!", 1, true) ~= nil
|
||||
and aText:find("Those are", 1, true) == nil
|
||||
U.log("SCENARIO A box:", aText)
|
||||
U.log("SCENARIO A", aPass and "PASS" or "FAIL")
|
||||
|
||||
-- close the box
|
||||
for _ = 1, 10 do
|
||||
if game.stack:top() == game.overworld then break end
|
||||
U.tap(game, "a")
|
||||
U.wait(2)
|
||||
end
|
||||
|
||||
-- ---- Scenario B: pre-escort ball text unchanged
|
||||
setFlags(false)
|
||||
local bBoxOpened = talkToBall()
|
||||
U.wait(30)
|
||||
U.shot(game, DIR .. "/b_pre_escort.png")
|
||||
local bText = lastText or "<none>"
|
||||
local bPass = bBoxOpened
|
||||
and bText:find("ThoseArePokeBalls", 1, true) ~= nil
|
||||
or bText:find("Those are", 1, true) ~= nil
|
||||
U.log("SCENARIO B box:", bText)
|
||||
U.log("SCENARIO B", bPass and "PASS" or "FAIL")
|
||||
|
||||
-- restore hooks before any assert so a failure can't leave them installed
|
||||
restore()
|
||||
|
||||
U.log("RESULT bug601", (aPass and bPass) and "PASS" or "FAIL")
|
||||
assert(aPass,
|
||||
"Leftover ball after the pick must say 'That's PROF.OAK's last "
|
||||
.. "Pokémon!' (no 'Those are POKé BALLs'); got: " .. aText)
|
||||
assert(bPass,
|
||||
"Pre-escort balls must keep the 'Those are POKé BALLs' line; got: "
|
||||
.. bText)
|
||||
end
|
||||
@@ -0,0 +1,262 @@
|
||||
-- Manual check of Yellow's two extra pic rips: PROF.OAK's own back pic in
|
||||
-- the Pallet catch scene (#557, LoadPlayerBackPic's BATTLE_TYPE_PIKACHU
|
||||
-- branch, pokeyellow engine/battle/core.asm:6384-6391) and the framed
|
||||
-- portrait TalkToPikachu raises (#561, each PikaPicAnimScript's own base
|
||||
-- frame, data/pikachu/pikachu_pic_animation.asm). Both are gated on files
|
||||
-- the importer writes, so the asset check comes before any staging. No
|
||||
-- POKEPORT_IDENTITY: a sandbox save dir carries no Yellow cache at all.
|
||||
-- POKEPORT_DRIVER=tests/drivers/oak_pikapic_bug557_bug561_test.lua POKEPORT_TOUCH=0 POKEPORT_VERSION=yellow love .
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
local BattleState = require("src.battle.BattleState")
|
||||
local Sprites = require("src.pokemon.Sprites")
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
|
||||
local SHOT_DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
|
||||
local PROF_BACK = "assets/generated/battle/profoakb.png"
|
||||
local OLDMAN_BACK = "assets/generated/battle/oldmanb.png"
|
||||
|
||||
local function check(label, ok)
|
||||
U.log(ok and "PASS" or "FAIL", label)
|
||||
return ok
|
||||
end
|
||||
|
||||
local function idle()
|
||||
while true do coroutine.yield() end
|
||||
end
|
||||
|
||||
if not check("running the Yellow cache (POKEPORT_VERSION=yellow)",
|
||||
GameVersion.isYellow()) then
|
||||
U.log("Neither pic exists in Red or Blue. Re-run with")
|
||||
U.log("POKEPORT_VERSION=yellow.")
|
||||
idle()
|
||||
end
|
||||
|
||||
-- ---- the two rips, before anything is staged ----
|
||||
-- The importer writes these only when the manifest carries the symbol, and
|
||||
-- both call sites fall back silently when the file is absent, so a missing
|
||||
-- file looks on screen exactly like the bug being unfixed.
|
||||
local oakPicOk = love.filesystem.getInfo(PROF_BACK) ~= nil
|
||||
check("Oak's back pic is in the cache (" .. PROF_BACK .. ")", oakPicOk)
|
||||
if not oakPicOk then
|
||||
U.log("The importer skipped it: tools/rom_manifest_yellow.json has no")
|
||||
U.log("ProfOakPicBack entry, so RomExtractor's gate never fires and")
|
||||
U.log("Sprites.playerPath falls back to " .. OLDMAN_BACK .. ".")
|
||||
U.log("Re-importing will not help until the symbol is in the manifest.")
|
||||
end
|
||||
|
||||
local pikapicFound = {}
|
||||
for script = 1, 28 do
|
||||
local path = "assets/generated/pikachu/pikapic_" .. script .. ".png"
|
||||
if love.filesystem.getInfo(path) then
|
||||
pikapicFound[#pikapicFound + 1] = script
|
||||
end
|
||||
end
|
||||
local pikapicOk = #pikapicFound > 0
|
||||
check("the pikapic base frames are in the cache (assets/generated/pikachu/)",
|
||||
pikapicOk)
|
||||
if pikapicOk then
|
||||
U.log("found base frames for scripts:", table.concat(pikapicFound, " "))
|
||||
else
|
||||
U.log("The importer skipped all 28: the Pic_e4000..Pic_f0cf4 labels are")
|
||||
U.log("absent from tools/rom_manifest_yellow.json, so PikachuFollower")
|
||||
U.log("keeps standing the battle front pic in the frame, which is #561")
|
||||
U.log("unchanged. Not staging the talk: it cannot look right yet.")
|
||||
end
|
||||
U.log("tests/engine/yellow_oak_back_pikapic.lua asserts both symbol lists.")
|
||||
|
||||
-- ---- #557: the Pallet catch scene ----
|
||||
-- Same two calls data/scripts/story2.lua:255 makes once Oak has said
|
||||
-- "That was close!" Standing in Pallet Town first so the battle draws
|
||||
-- over a real map, as it does in play.
|
||||
U.teleport(game, "PALLET_TOWN", 10, 8, "up")
|
||||
U.wait(10)
|
||||
game.save.player.name = "bryan"
|
||||
|
||||
local backPath = Sprites.playerPath(game.data, "back",
|
||||
{ kind = "battle", demo = true, oakDemo = true })
|
||||
U.log("the demo resolves its back pic to:", tostring(backPath))
|
||||
check("it is Oak's pic, not the old man's", backPath == PROF_BACK)
|
||||
|
||||
local battle = BattleState.newWild(game, "PIKACHU", 5)
|
||||
battle:makeOldManDemo("PROF.OAK")
|
||||
check("the thrower is named PROF.OAK", battle.demoName == "PROF.OAK")
|
||||
check("the battle asks for the BATTLE_TYPE_PIKACHU back pic",
|
||||
battle.oakDemo == true)
|
||||
|
||||
-- record what the scripted throw actually prints, so the name half can be
|
||||
-- judged without reading glyph codes off the canvas
|
||||
local said = {}
|
||||
local realSay = battle.say
|
||||
battle.say = function(self, text)
|
||||
said[#said + 1] = tostring(text)
|
||||
return realSay(self, text)
|
||||
end
|
||||
|
||||
game.stack:push(battle)
|
||||
U.wait(10)
|
||||
|
||||
-- clear the "Wild PIKACHU appeared!" box; the scripted cursor script only
|
||||
-- starts once the intro chrome is gone
|
||||
for _ = 1, 90 do
|
||||
if battle.phase == "menu" then break end
|
||||
U.tap(game, "a")
|
||||
U.wait(4)
|
||||
end
|
||||
if not check("the demo battle reached its scripted menu",
|
||||
battle.phase == "menu") then
|
||||
U.log("phase is", tostring(battle.phase), "- the intro never cleared.")
|
||||
idle()
|
||||
end
|
||||
U.wait(20)
|
||||
if U.shot(game, SHOT_DIR .. "/bug557_back_pic.png") then
|
||||
U.log("captured", SHOT_DIR .. "/bug557_back_pic.png")
|
||||
end
|
||||
|
||||
-- The other half of the issue title, "referred to as Pikachu".
|
||||
-- DisplayBattleMenu's simulated branch puts BATTLE_MENU_TEMPLATE on screen
|
||||
-- and nothing else (data/text_boxes.asm:31 -- FIGHT/PKMN/ITEM/RUN from
|
||||
-- column 8), so no name is printed at all: there is no player mon in this
|
||||
-- battle to name. The classic layout ports that branch; WideBattle's menu
|
||||
-- has no demo case and prints "What will <battle.player.name> do?" over the
|
||||
-- hidden placeholder battler, which is a level 5 PIKACHU.
|
||||
local options = game.save.options or {}
|
||||
U.log("battle layout option:", tostring(options.battleLayout or "classic"))
|
||||
local wide = battle:isWideBattleLayout()
|
||||
check("the layout on screen leaves the scripted menu unnamed", not wide)
|
||||
if wide then
|
||||
U.log("The wide layout is on, so the menu reads \"What will PIKACHU do?\"")
|
||||
U.log("beside Oak's throw. Switch OPTIONS to the classic battle layout")
|
||||
U.log("and re-run to see the box the original draws.")
|
||||
end
|
||||
|
||||
-- the rest runs itself: 130 frames of cursor, the forced ITEM list, then
|
||||
-- the throw. Never tap through it -- the timing is the port of
|
||||
-- DisplayBattleMenu's simulated keystrokes.
|
||||
local function thrownLine()
|
||||
for _, text in ipairs(said) do
|
||||
if text:find("POK", 1, true) and text:find("PROF.OAK", 1, true) then
|
||||
return text
|
||||
end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
for _ = 1, 600 do
|
||||
if thrownLine() then break end
|
||||
U.wait(1)
|
||||
end
|
||||
local line = thrownLine()
|
||||
check("the throw is announced under PROF.OAK's name", line ~= nil)
|
||||
if line then U.log("the box reads:", (line:gsub("\n", " / "))) end
|
||||
U.wait(45)
|
||||
if U.shot(game, SHOT_DIR .. "/bug557_throw.png") then
|
||||
U.log("captured", SHOT_DIR .. "/bug557_throw.png")
|
||||
end
|
||||
|
||||
if oakPicOk then
|
||||
U.log("Both #557 shots are taken. The back pic in the lower left is the")
|
||||
U.log("man throwing the ball: Oak in his lab coat, and the throw line")
|
||||
U.log("names PROF.OAK. The near miss is a back pic that reads as a")
|
||||
U.log("person and looks plausible while still being oldmanb.png --")
|
||||
U.log("compare against the catch tutorial north of Viridian, which")
|
||||
U.log("SHOULD be the old man and proves the demo path works at all.")
|
||||
else
|
||||
U.log("The shots are evidence of the FAIL, not of a fix: the back pic in")
|
||||
U.log("the lower left is the bald OLD MAN from the Route 5 tutorial,")
|
||||
U.log("exactly as #557 reported him. The throw line above it does name")
|
||||
U.log("PROF.OAK, so the thrower half is right and only the pic is stuck.")
|
||||
end
|
||||
|
||||
-- ---- #561: the framed portrait ----
|
||||
if not pikapicOk then
|
||||
U.log("Skipping the Pikachu portrait: see the FAIL above.")
|
||||
idle()
|
||||
end
|
||||
|
||||
for _ = 1, 300 do
|
||||
if game.stack:top() ~= battle then break end
|
||||
U.wait(1)
|
||||
end
|
||||
U.wait(20)
|
||||
|
||||
-- ShouldPikachuSpawn (pokeyellow engine/pikachu/pikachu_follow.asm): the
|
||||
-- lab gift happened and a healthy Pikachu leads the party. Pallet's
|
||||
-- objects sit at (10,4), (3,8) and (11,14), so (10,8) and the road cells
|
||||
-- around it are clear.
|
||||
game.save.party = { Pokemon.new(game.data, "PIKACHU", 12) }
|
||||
game.save.flags = game.save.flags or {}
|
||||
game.save.flags.EVENT_GOT_STARTER = true
|
||||
U.teleport(game, "PALLET_TOWN", 10, 8, "down")
|
||||
U.wait(12)
|
||||
|
||||
local ow = game.overworld
|
||||
local function follower()
|
||||
for _, n in ipairs(ow.npcs or {}) do
|
||||
if n.pikachuFollower then return n end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
local npc = follower()
|
||||
if not check("the follower spawned", npc ~= nil) then idle() end
|
||||
|
||||
-- one step then a turn back, so the follower is on the cell just vacated
|
||||
local DIRS = { { "down", 0, 1 }, { "up", 0, -1 },
|
||||
{ "left", -1, 0 }, { "right", 1, 0 } }
|
||||
local OPPOSITE = { up = "down", down = "up", left = "right", right = "left" }
|
||||
local stepDir
|
||||
for _, d in ipairs(DIRS) do
|
||||
local cx, cy = ow.player.cellX + d[2], ow.player.cellY + d[3]
|
||||
if ow.map:inBounds(cx, cy) and ow.map:isWalkableCell(cx, cy)
|
||||
and not ow:npcAtCell(cx, cy) then
|
||||
stepDir = d[1]
|
||||
break
|
||||
end
|
||||
end
|
||||
if not check("a walkable neighbour to step into exists", stepDir ~= nil) then
|
||||
idle()
|
||||
end
|
||||
U.hold(game, stepDir, 24)
|
||||
U.wait(6)
|
||||
U.tap(game, OPPOSITE[stepDir])
|
||||
U.wait(6)
|
||||
|
||||
local function facingFollower()
|
||||
local fx, fy = ow.player:facingCell()
|
||||
return ow:npcAtCell(fx, fy) == npc
|
||||
end
|
||||
if not facingFollower() then
|
||||
for _, d in ipairs(DIRS) do
|
||||
if npc.cellX == ow.player.cellX + d[2]
|
||||
and npc.cellY == ow.player.cellY + d[3] then
|
||||
U.tap(game, d[1])
|
||||
U.wait(6)
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
check("player is facing the follower", facingFollower())
|
||||
|
||||
U.tap(game, "a")
|
||||
U.wait(8)
|
||||
local emote = ow.emote
|
||||
if not check("the A press raised a portrait", emote ~= nil and emote.pikaPic) then
|
||||
U.log("Nothing answered the press, which is #407 territory, not #561.")
|
||||
idle()
|
||||
end
|
||||
U.log("the frame is drawing:", tostring(emote.pikaPic))
|
||||
check("it is this script's own base frame, not the battle front pic",
|
||||
emote.pikaPic:find("pikachu/pikapic_", 1, true) ~= nil)
|
||||
if U.shot(game, SHOT_DIR .. "/bug561_portrait.png") then
|
||||
U.log("captured", SHOT_DIR .. "/bug561_portrait.png")
|
||||
end
|
||||
|
||||
U.log("The portrait box is up over the map now. Inside the 5x5 frame is a")
|
||||
U.log("posed Pikachu drawn for that emotion, not the battle front pic")
|
||||
U.log("standing to attention -- that stand-in is the screenshot on #561.")
|
||||
U.log("Face it and press A again for another emotion; the pose should")
|
||||
U.log("change with it. A frame that never changes between emotions is the")
|
||||
U.log("near miss: the path resolved but every script picked one base.")
|
||||
|
||||
idle()
|
||||
end
|
||||
@@ -2414,14 +2414,15 @@ local function sellItem(id)
|
||||
return sold
|
||||
end
|
||||
|
||||
-- Free up bag slots so `needed` NEW item kinds fit (Bag.CAPACITY is 20
|
||||
-- slots; a stack of an item already held costs nothing). This is why
|
||||
-- Free up bag slots so `needed` NEW item kinds fit (20 slots without a
|
||||
-- capacity mod; a stack of an item already held costs nothing). This is why
|
||||
-- every restock had been reporting "HYPER_POTION x0": the buy list
|
||||
-- opened, the quantity was set, the engine said "no room" -- and the run
|
||||
-- walked into the Mansion with FULL_HEALs but not one HP restore.
|
||||
local function freeBagSlots(needed, where)
|
||||
local used = #(G.save.bagOrder or {})
|
||||
local free = 20 - used
|
||||
local capacity = require("src.inventory.Bag").capacity(G.data)
|
||||
local free = capacity - used
|
||||
for _, id in ipairs(SELLABLE_JUNK) do
|
||||
if free >= needed then break end
|
||||
if ((G.save.inventory or {})[id] or 0) > 0 then
|
||||
@@ -2632,7 +2633,8 @@ function ops.shop(s, where)
|
||||
end
|
||||
end
|
||||
local used = #(G.save.bagOrder or {})
|
||||
if newKinds > 0 and 20 - used < newKinds then
|
||||
local capacity = require("src.inventory.Bag").capacity(G.data)
|
||||
if newKinds > 0 and capacity - used < newKinds then
|
||||
freeBagSlots(newKinds, where)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
-- Soft reset from inside a battle (#563): A+B+SELECT+START held together
|
||||
-- drops back to the title, the way _Joypad's `cp PAD_BUTTONS` (pokered
|
||||
-- engine/joypad.asm:6) reads the raw pad ahead of the wJoyIgnore masking
|
||||
-- that a battle turn leans on. Counting and touch-overlay halves are
|
||||
-- asserted in tests/engine/soft_reset_bug563.lua; this is the ear and eye
|
||||
-- half -- music cutting to the title theme, unsaved progress gone.
|
||||
-- POKEPORT_DRIVER=tests/drivers/soft_reset_bug563_test.lua POKEPORT_IDENTITY=bug563 POKEPORT_TOUCH=0 SHOT_DIR=/tmp/shots love .
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
local BattleState = require("src.battle.BattleState")
|
||||
local Input = require("src.core.Input")
|
||||
|
||||
local pass, fail = 0, 0
|
||||
local function check(label, ok)
|
||||
if ok then pass = pass + 1 else fail = fail + 1 end
|
||||
U.log(ok and "PASS" or "FAIL", label)
|
||||
return ok
|
||||
end
|
||||
|
||||
local CHORD = { "a", "b", "start", "select" }
|
||||
|
||||
-- The driver presses GB buttons by name, so it proves the chord is read
|
||||
-- but says nothing about which keys reach it. Print the live map for the
|
||||
-- hand-off instead of hard-coding key names into the notes.
|
||||
local function keysFor(btn)
|
||||
local keys = {}
|
||||
for key, action in pairs(Input.keyBindings or {}) do
|
||||
if action == btn then keys[#keys + 1] = key end
|
||||
end
|
||||
table.sort(keys)
|
||||
return table.concat(keys, " / ")
|
||||
end
|
||||
|
||||
U.log("#563 soft reset: machine checks")
|
||||
check("Input:softResetHeld exists", type(Input.softResetHeld) == "function")
|
||||
check("Input:softResetStep exists", type(Input.softResetStep) == "function")
|
||||
for _, btn in ipairs(CHORD) do
|
||||
local keys = keysFor(btn)
|
||||
check(btn:upper() .. " has a key bound (" .. keys .. ")", keys ~= "")
|
||||
end
|
||||
|
||||
local musicVol = game.save.options and game.save.options.musicVol
|
||||
if musicVol == 0 then
|
||||
U.log("musicVol is 0: the battle music cutting to the title theme is the")
|
||||
U.log("main thing to listen for, so raise it in OPTION before judging")
|
||||
else
|
||||
U.log("musicVol", tostring(musicVol),
|
||||
"-- the battle theme should stop dead and the title theme start")
|
||||
end
|
||||
|
||||
-- ---- a battle to reset out of -----------------------------------------
|
||||
|
||||
game.save.party = {
|
||||
Pokemon.new(game.data, "SQUIRTLE", 30),
|
||||
Pokemon.new(game.data, "PIDGEOTTO", 28),
|
||||
}
|
||||
game.save.player.name = "RED"
|
||||
|
||||
-- data/generated/maps.lua ROUTE_1: (5, 5) is open walkable ground. The
|
||||
-- battle is pushed straight in rather than encountered, so the cell only
|
||||
-- has to be somewhere the player can legally stand.
|
||||
local START = { x = 5, y = 5 }
|
||||
U.teleport(game, "ROUTE_1", START.x, START.y, "down")
|
||||
U.wait(10)
|
||||
local ow = game.overworld
|
||||
if ow and not ow.map:isWalkableCell(START.x, START.y) then
|
||||
-- a map edit moved the path: take the nearest walkable cell instead
|
||||
local sx, sy
|
||||
for r = 1, 6 do
|
||||
for dy = -r, r do
|
||||
for dx = -r, r do
|
||||
if not sx and ow.map:isWalkableCell(START.x + dx, START.y + dy) then
|
||||
sx, sy = START.x + dx, START.y + dy
|
||||
end
|
||||
end
|
||||
end
|
||||
if sx then break end
|
||||
end
|
||||
if sx then
|
||||
U.log(("(%d, %d) is blocked, standing on"):format(START.x, START.y), sx, sy)
|
||||
U.teleport(game, "ROUTE_1", sx, sy, "down")
|
||||
U.wait(10)
|
||||
ow = game.overworld
|
||||
end
|
||||
end
|
||||
check("the overworld is up on ROUTE_1", ow ~= nil)
|
||||
|
||||
-- something to reset away from: a fight in progress and 2 unsaved mon
|
||||
local function enterBattle()
|
||||
local battle = BattleState.newWild(game, "RATTATA", 12)
|
||||
battle.onFinish = function() end
|
||||
game.overworld:pushBattle(battle)
|
||||
for _ = 1, 400 do
|
||||
if game.stack:top() == battle and (battle.introSlide or 0) == 0 then break end
|
||||
U.wait(1)
|
||||
end
|
||||
return battle
|
||||
end
|
||||
|
||||
local battle = enterBattle()
|
||||
check("the battle reached the screen", game.stack:top() == battle)
|
||||
check("battle screenshot reached disk",
|
||||
U.shot(game, DIR .. "/bug563_battle.png"))
|
||||
|
||||
-- ---- hold the chord ----------------------------------------------------
|
||||
|
||||
-- A real hold is one press edge and then sustained state, so queue the
|
||||
-- edge once and only keep `state` alive after that. Re-queueing every
|
||||
-- frame would feed the battle 40 A presses and run the fight away before
|
||||
-- the countdown ever finished. Stops the moment the title appears: the
|
||||
-- driver has no key-up, so a hold left running past the reset would press
|
||||
-- A on the title's first frame and start a new game by itself.
|
||||
local function holdChord(frames, extra)
|
||||
local buttons = { unpack(CHORD) }
|
||||
if extra then buttons[#buttons + 1] = extra end
|
||||
for frame = 1, frames do
|
||||
for _, btn in ipairs(buttons) do
|
||||
if frame == 1 then table.insert(game.input.pressQueue, btn) end
|
||||
game.input.state[btn] = true
|
||||
end
|
||||
coroutine.yield()
|
||||
local top = game.stack:top()
|
||||
if top and top.screenId == "TitleState" then break end
|
||||
end
|
||||
for _, btn in ipairs(buttons) do game.input.state[btn] = false end
|
||||
end
|
||||
|
||||
-- Control case first: `cp PAD_BUTTONS` is an equality, so the same four
|
||||
-- buttons plus a direction is not the combo however long it is held. If
|
||||
-- this one resets, the port is masking the read instead of comparing it.
|
||||
holdChord(40, "up")
|
||||
U.wait(5)
|
||||
check("four buttons plus UP for 40 frames does NOT reset",
|
||||
game.stack:top() == battle)
|
||||
|
||||
-- 16 polls is the countdown Init seeds hSoftReset with (home/init.asm:81);
|
||||
-- 60 frames leaves room for it without hiding an off-by-one, which the
|
||||
-- engine suite pins exactly.
|
||||
holdChord(60)
|
||||
U.wait(20)
|
||||
|
||||
local top = game.stack:top()
|
||||
check("the chord dropped the battle for the title",
|
||||
top ~= nil and top.screenId == "TitleState")
|
||||
check("the battle state is gone from the stack", top ~= battle)
|
||||
check("no button is left held into the title", not game.input:isDown("a"))
|
||||
check("title screenshot reached disk", U.shot(game, DIR .. "/bug563_title.png"))
|
||||
|
||||
-- the title must not act on the still-held buttons on its own first frames
|
||||
U.wait(60)
|
||||
check("the title is still up a second later, not a menu it auto-picked",
|
||||
game.stack:top() == top)
|
||||
|
||||
-- ---- hand off inside a fresh battle ------------------------------------
|
||||
|
||||
-- Handing the pad over needs a party again. A player who did this for
|
||||
-- real would be picking CONTINUE here and getting whatever was last saved,
|
||||
-- which is the point of the reset -- this is only the driver restocking.
|
||||
game.save.party = {
|
||||
Pokemon.new(game.data, "SQUIRTLE", 30),
|
||||
Pokemon.new(game.data, "PIDGEOTTO", 28),
|
||||
}
|
||||
U.teleport(game, "ROUTE_1", START.x, START.y, "down")
|
||||
U.wait(10)
|
||||
local again = enterBattle()
|
||||
check("a second battle is up for the manual attempt", game.stack:top() == again)
|
||||
|
||||
U.log("The driver already did this once: /tmp/shots/bug563_battle.png is the")
|
||||
U.log("fight it was in, bug563_title.png is where it landed. There is a")
|
||||
U.log("fresh RATTATA battle on screen now for you to repeat it by hand.")
|
||||
U.log("Hold " .. keysFor("a") .. " (A) + " .. keysFor("b") .. " (B) + "
|
||||
.. keysFor("start") .. " (START) + " .. keysFor("select") .. " (SELECT)")
|
||||
U.log("together for about a third of a second. The battle theme should cut")
|
||||
U.log("out and the title come back with its own music; CONTINUE from there")
|
||||
U.log("gives you the last save, so the fight and anything after it is gone.")
|
||||
U.log("Add any arrow key to the four and nothing should happen at all. A")
|
||||
U.log("quick tap of all four in passing must not reset either, and letting")
|
||||
U.log("one go a beat early must start the count over rather than resume it.")
|
||||
U.log("Control case if it does nothing: QUIT in the START menu runs the same")
|
||||
U.log("return-to-title, so if QUIT works the pad read is what to look at.")
|
||||
U.log("For the on-screen overlay, re-run with POKEPORT_TOUCH=1: the mouse is")
|
||||
U.log("one finger there, so the combo cannot be reached at all on desktop.")
|
||||
U.log(("checks: %d passed, %d failed"):format(pass, fail))
|
||||
|
||||
while true do
|
||||
coroutine.yield()
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,223 @@
|
||||
-- Manual check of the two trainer-battle text boxes, #565 and #566.
|
||||
-- #565 _TrainerAboutToUseText (data/text/text_2.asm:911-921): the SHIFT
|
||||
-- offer is one box with a `cont`, so the nick scrolls in UNDER "about to
|
||||
-- use" instead of landing alone on a fresh page.
|
||||
-- #566 TrainerEndBattleText (home/trainers.asm:381-386) prints
|
||||
-- _TrainerNameText -- the name then ": " -- ahead of the loss line.
|
||||
-- The string half is asserted in tests/engine/trainer_shift_prompt_bug565.lua
|
||||
-- and tests/engine/trainer_loss_line_bug566.lua; this is the look of it.
|
||||
-- POKEPORT_DRIVER=tests/drivers/trainer_battle_text_bug565_bug566_test.lua POKEPORT_IDENTITY=bug565 POKEPORT_TOUCH=0 POKEPORT_VERSION=red love .
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local SHOT_DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
local Font = require("src.render.Font")
|
||||
local ChoiceBox = require("src.ui.ChoiceBox")
|
||||
|
||||
-- pokered data/maps/objects/Route3.asm: ROUTE3_YOUNGSTER2 is object 3, at
|
||||
-- (14, 4), STAY, facing DOWN, OPP_YOUNGSTER party 1 (RATTATA + EKANS). Its
|
||||
-- TrainerHeader gives sight range 3, so the cone is (14,5)..(14,7); y = 7 is
|
||||
-- the ledge row and is solid, so the walk-in is east-to-west along y = 6.
|
||||
local MAP, MAP_LABEL = "ROUTE_3", "Route3"
|
||||
local NPC_NAME = "ROUTE3_YOUNGSTER2"
|
||||
local OBJ_INDEX = 3
|
||||
local START = { x = 16, y = 6, facing = "left" }
|
||||
|
||||
local fails = 0
|
||||
local function check(label, ok)
|
||||
if not ok then fails = fails + 1 end
|
||||
U.log(ok and "PASS" or "FAIL", label)
|
||||
return ok
|
||||
end
|
||||
|
||||
-- Everything the two boxes are assembled from. A renamed text label, an
|
||||
-- opponent whose party shrank to one mon or a species constant that no
|
||||
-- longer resolves all show up on screen as exactly the silence the bugs
|
||||
-- did, so name them here before anyone squints at a screenshot.
|
||||
local header = game.data:trainerHeader(MAP_LABEL, OBJ_INDEX)
|
||||
check("Route3 object " .. OBJ_INDEX .. " has a trainer header", header ~= nil)
|
||||
local lossText = header and header.won and game.data.text[header.won]
|
||||
check("its EndBattleText resolves to a string",
|
||||
type(lossText) == "string" and lossText ~= "")
|
||||
|
||||
local trainer = game.data.trainers.OPP_YOUNGSTER
|
||||
check("OPP_YOUNGSTER is a known trainer class", trainer ~= nil)
|
||||
local party = trainer and trainer.parties[1]
|
||||
check("its party 1 has a reserve to send out (SHIFT needs one)",
|
||||
party ~= nil and #party >= 2)
|
||||
local secondName
|
||||
if party and party[2] then
|
||||
local species = game.data.pokemon[party[2].species]
|
||||
secondName = species and species.name
|
||||
end
|
||||
check("the reserve's species resolves to a name", secondName ~= nil)
|
||||
|
||||
local TAG = trainer and trainer.name
|
||||
local firstLine = TAG and lossText
|
||||
and (TAG .. ": " .. lossText:match("^[^\n\f\v]*"))
|
||||
if firstLine then
|
||||
-- the tag is prose the box has to hold: 18 columns, TextBox MAX_COLS
|
||||
local spans = Font.split(firstLine)
|
||||
check("the tagged first line fits the 18-column box (" .. #spans .. " cols)",
|
||||
Font.spansFitting(spans, 18 * 8) >= #spans)
|
||||
U.log("loss box should read:",
|
||||
((TAG .. ": " .. lossText):gsub("[\n\v]", " / "):gsub("\f", " || ")))
|
||||
end
|
||||
|
||||
-- SHIFT only offers the switch when the player holds more than one party
|
||||
-- slot and the active mon is alive (core.asm:1366-1443)
|
||||
game.save.player.name = "RED"
|
||||
game.save.party = {
|
||||
Pokemon.new(game.data, "CHARIZARD", 40),
|
||||
Pokemon.new(game.data, "BLASTOISE", 40),
|
||||
}
|
||||
game.save.party[1].moves[1] = { id = "SLASH", pp = 20 }
|
||||
game.save.options = game.save.options or {}
|
||||
game.save.options.battleStyle = "shift"
|
||||
check("two party slots and SHIFT style are set",
|
||||
#game.save.party > 1 and game.save.options.battleStyle == "shift")
|
||||
check("the window is open and drawing",
|
||||
love.window and love.window.isOpen and love.window.isOpen())
|
||||
|
||||
if fails > 0 then
|
||||
U.log(fails .. " preflight check(s) failed; the boxes below cannot be trusted.")
|
||||
end
|
||||
|
||||
U.teleport(game, MAP, START.x, START.y, START.facing)
|
||||
U.wait(15)
|
||||
|
||||
local function findNpc()
|
||||
for _, n in ipairs((game.overworld and game.overworld.npcs) or {}) do
|
||||
if n.def and n.def.name == NPC_NAME then return n end
|
||||
end
|
||||
end
|
||||
local npc = findNpc()
|
||||
check("the YOUNGSTER is on the map", npc ~= nil)
|
||||
|
||||
-- A map edit that moves him invalidates the hand-derived walk-in, so fall
|
||||
-- back to standing next to wherever he ended up and talking: engageTrainer
|
||||
-- is the same call the line-of-sight approach makes.
|
||||
local talkedTo = false
|
||||
if npc and (npc.cellX ~= 14 or npc.cellY ~= 4) then
|
||||
local ow = game.overworld
|
||||
-- {dx, dy, facing to look back at him}
|
||||
for _, side in ipairs({ { 0, 1, "up" }, { 0, -1, "down" },
|
||||
{ 1, 0, "left" }, { -1, 0, "right" } }) do
|
||||
local cx, cy = npc.cellX + side[1], npc.cellY + side[2]
|
||||
if ow.map:isWalkableCell(cx, cy) and not ow:npcAtCell(cx, cy) then
|
||||
U.log("he has moved to", npc.cellX, npc.cellY, "- standing at", cx, cy)
|
||||
U.teleport(game, MAP, cx, cy, side[3])
|
||||
U.wait(10)
|
||||
talkedTo = true
|
||||
U.tap(game, "a")
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
local function battleNow()
|
||||
local top = game.stack:top()
|
||||
if type(top) == "table" and top.kind == "trainer" then return top end
|
||||
end
|
||||
|
||||
-- walk west into the sight cone; two cells, plus slack for the "!" bubble
|
||||
if not talkedTo then
|
||||
for _ = 1, 6 do
|
||||
if game.overworld and game.overworld.engaging then break end
|
||||
U.hold(game, "left", 18)
|
||||
U.wait(4)
|
||||
end
|
||||
end
|
||||
check("the trainer engaged",
|
||||
(game.overworld and game.overworld.engaging) or battleNow() ~= nil
|
||||
or talkedTo)
|
||||
|
||||
-- Play it out. Nothing here is fast-forwarded: the taps are A presses at
|
||||
-- the same cadence a player makes them, and the loop stops on the row
|
||||
-- under test before it can press the box away.
|
||||
local function currentRow()
|
||||
local b = battleNow()
|
||||
local row = b and b.current
|
||||
return b, row and row.text
|
||||
end
|
||||
local function pump(pred, maxFrames)
|
||||
local cool = 0
|
||||
for _ = 1, maxFrames or 4000 do
|
||||
if pred() then return true end
|
||||
if cool > 0 then
|
||||
cool = cool - 1
|
||||
U.wait(1)
|
||||
else
|
||||
U.tap(game, "a")
|
||||
local b = battleNow()
|
||||
cool = (b and (b.phase == "menu" or b.phase == "moveSelect")) and 8 or 5
|
||||
end
|
||||
end
|
||||
return pred()
|
||||
end
|
||||
|
||||
local ok = pump(function()
|
||||
local _, text = currentRow()
|
||||
return text ~= nil and text:find("about to use", 1, true) ~= nil
|
||||
end)
|
||||
check("the SHIFT offer box came up after the first KO", ok)
|
||||
|
||||
-- Hold at the `cont`: the box has typed "X is" / "about to use" and is
|
||||
-- blinking the arrow, waiting for A before it scrolls (ContText).
|
||||
pump(function()
|
||||
local b = currentRow()
|
||||
return b and b.msgWaiting
|
||||
end, 400)
|
||||
U.shot(game, SHOT_DIR .. "/bug565_1_cont_wait.png")
|
||||
local b = battleNow()
|
||||
if b and b.current then
|
||||
U.log("offer box holds:", (b.current.text:gsub("\n", " / "):gsub("\v", " CONT ")))
|
||||
end
|
||||
U.tap(game, "a")
|
||||
|
||||
-- and after the scroll: "about to use" on top, the nick beneath it
|
||||
pump(function()
|
||||
local bb = battleNow()
|
||||
return bb and (bb.charIndex or 0) >= (bb.total or 1) and not bb.msgWaiting
|
||||
end, 400)
|
||||
U.shot(game, SHOT_DIR .. "/bug565_2_scrolled.png")
|
||||
|
||||
-- decline the switch (B is NO, ChoiceBox) and finish the fight
|
||||
pump(function() return getmetatable(game.stack:top()) == ChoiceBox end, 600)
|
||||
U.wait(10)
|
||||
U.tap(game, "b")
|
||||
U.wait(10)
|
||||
|
||||
local tagged = TAG and (TAG .. ": ") or "YOUNGSTER: "
|
||||
ok = pump(function()
|
||||
local _, text = currentRow()
|
||||
return text ~= nil and text:sub(1, #tagged) == tagged
|
||||
end)
|
||||
check("the loss line came up tagged with the trainer class", ok)
|
||||
pump(function()
|
||||
local bb = battleNow()
|
||||
return bb and bb.msgPrompt
|
||||
end, 400)
|
||||
U.shot(game, SHOT_DIR .. "/bug566_loss_line.png")
|
||||
local lb = battleNow()
|
||||
if lb and lb.current then
|
||||
U.log("loss box holds:", (lb.current.text:gsub("\n", " / ")))
|
||||
end
|
||||
|
||||
U.log("Shots are in " .. SHOT_DIR .. ".")
|
||||
U.log("bug565_1 should read \"YOUNGSTER is\" / \"about to use\" with the")
|
||||
U.log("blinking arrow; bug565_2 the same box after the scroll, \"about to")
|
||||
U.log("use\" over \"EKANS!\". The near miss is a box whose two lines are")
|
||||
U.log("\"about to use\" and nothing, or a page holding only \"EKANS!\".")
|
||||
U.log("bug566 should read \"YOUNGSTER: I don't\" / \"believe it!\" -- the")
|
||||
U.log("colon and the class, not a bare \"I don't believe it!\".")
|
||||
U.log("The prize line follows it; the tag never repeats on that one.")
|
||||
U.log("A is yours now: press through the money line back to the route.")
|
||||
U.log("ROUTE3_YOUNGSTER1 at (10,6) has three mons, so the SHIFT box comes")
|
||||
U.log("up twice. ROUTE3_YOUNGSTER4 at (22,9) is the control: one mon, so")
|
||||
U.log("he must show the tagged loss line and no SHIFT offer at all.")
|
||||
|
||||
while true do
|
||||
coroutine.yield()
|
||||
end
|
||||
end
|
||||
@@ -1,40 +1,255 @@
|
||||
-- Driver: a quick directional tap should turn the player in place, not
|
||||
-- also take a step (#415). Player:tryMove (src/world/Player.lua) only
|
||||
-- steps once turnTimer has counted down; TURN_FRAMES/turnFrames set how
|
||||
-- many fixed steps a facing change blocks movement for before a held
|
||||
-- direction is allowed to commit to a step. This is an input-timing feel
|
||||
-- bug: no assertion here can tell a quick tap from a held one the way a
|
||||
-- human thumb can, so this hands the pad over rather than scripting taps
|
||||
-- at a fixed, unrealistic frame count.
|
||||
--
|
||||
-- A tap on the d-pad turns the player in place; only a held direction steps
|
||||
-- (#415). home/overworld.asm .handleDirectionButtonPress only reaches the
|
||||
-- turn while wCheckFor180DegreeTurn is set, and .noDirectionButtonsPressed
|
||||
-- (the poll that finds nothing held) is the one place that sets it, so the
|
||||
-- delay is paid once per press and never at a corner. This scripts the
|
||||
-- window it can measure and then hands the pad over, because no scripted
|
||||
-- press has a human thumb's length.
|
||||
-- POKEPORT_DRIVER=tests/drivers/turn_in_place_bug415_test.lua \
|
||||
-- POKEPORT_IDENTITY=bug415 POKEPORT_TOUCH=0 POKEPORT_VERSION=red love .
|
||||
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local TextBox = require("src.render.TextBox")
|
||||
|
||||
local function check(label, ok)
|
||||
U.log(ok and "PASS" or "FAIL", label)
|
||||
return ok
|
||||
end
|
||||
|
||||
-- REDS_HOUSE_2F: the player's own bedroom, the default new-game spawn
|
||||
-- (src/core/SaveData.lua bedroom fallback). Small enough that an
|
||||
-- accidental step is obvious -- a stray step toward the stairs at
|
||||
-- (7,1)/(7,2) or into the desk/bed furniture is immediately visible.
|
||||
U.teleport(game, "REDS_HOUSE_2F", 5, 6, "down")
|
||||
U.wait(10)
|
||||
local ow = game.stack:top()
|
||||
check("Red's bedroom is loaded", ow.map.id == "REDS_HOUSE_2F")
|
||||
check("player starts facing down", ow.player.facing == "down")
|
||||
-- pokered data/maps/objects/PalletTown.asm: PALLETTOWN_GIRL is the one
|
||||
-- WALK/ANY_DIR object on the town's open west side, at cell (3, 8) with
|
||||
-- floor on three sides -- the ordinary "someone standing next to you"
|
||||
-- shape the report is about. object_event coords are already walk-grid
|
||||
-- cells (src/world/NPC.lua), so they carry over unchanged.
|
||||
local MAP = "PALLET_TOWN"
|
||||
local MAP_LABEL = "PalletTown"
|
||||
local NPC_NAME = "PALLETTOWN_GIRL"
|
||||
local NPC_TEXT = "TEXT_PALLETTOWN_GIRL"
|
||||
local GIRL = { x = 3, y = 8 }
|
||||
|
||||
U.log("Standing in the middle of the bedroom, facing down.")
|
||||
U.log("Tap Up once, quickly, then tap Right once, quickly.")
|
||||
U.log("Right: each tap only turns you to face that way -- your feet")
|
||||
U.log("stay on the same tile both times. Wrong (#415): a quick tap")
|
||||
U.log("still slides you one tile in the new direction, same as holding.")
|
||||
U.log("Now hold Up for a beat: turning into a real step, then a walk,")
|
||||
U.log("should feel identical to before -- no extra pause was added there.")
|
||||
local SHOT_DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
|
||||
|
||||
-- Held-direction primitives that never lift the d-pad. U.hold releases at
|
||||
-- the end of every call, which is exactly what the corner case below must
|
||||
-- not do: a release re-arms the turn and hides the thing being measured.
|
||||
local function press(btn)
|
||||
table.insert(game.input.pressQueue, btn)
|
||||
game.input.state[btn] = true
|
||||
end
|
||||
local function release(btn)
|
||||
game.input.state[btn] = false
|
||||
end
|
||||
|
||||
-- an unresolved text label, a renamed object and a press that never landed
|
||||
-- all look like the same nothing-happened the bug looked like
|
||||
local girlText = game.data:resolveText(MAP_LABEL, NPC_TEXT)
|
||||
check(NPC_TEXT .. " resolves to a string",
|
||||
type(girlText) == "string" and girlText ~= "")
|
||||
|
||||
U.teleport(game, MAP, GIRL.x, GIRL.y - 1, "up")
|
||||
U.wait(10)
|
||||
local ow = game.overworld
|
||||
check("Pallet Town is loaded", ow ~= nil and ow.map.id == MAP)
|
||||
|
||||
local function girlIn(state)
|
||||
for _, n in ipairs(state.npcs or {}) do
|
||||
if n.def and n.def.name == NPC_NAME then return n end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
-- She is a WALK object, so pin her before deriving anything from her cell:
|
||||
-- NPC:update returns early on frozen, and snapping the pixels back undoes
|
||||
-- any step already in flight when the driver got here.
|
||||
local function pin(state)
|
||||
local npc = girlIn(state)
|
||||
if npc then
|
||||
npc.frozen = true
|
||||
npc.moving = false
|
||||
npc.cellX, npc.cellY = GIRL.x, GIRL.y
|
||||
npc.px, npc.py = npc.cellX * 16, npc.cellY * 16
|
||||
npc.facing = "down"
|
||||
end
|
||||
return npc
|
||||
end
|
||||
|
||||
local girl = pin(ow)
|
||||
check("the girl object loaded and is pinned at (3, 8)",
|
||||
girl ~= nil and girl.cellX == GIRL.x and girl.cellY == GIRL.y)
|
||||
|
||||
-- Stand on her north side facing away, so the first tap has to turn. If a
|
||||
-- map edit takes that cell, fall back to any free walkable neighbour and
|
||||
-- face away from her there; {dx, dy, away, toward} is the offset from her
|
||||
-- to the stand cell plus the two directions that matter from it.
|
||||
local stand = { x = GIRL.x, y = GIRL.y - 1, away = "up", toward = "down" }
|
||||
local function free(cx, cy)
|
||||
return ow.map:isWalkableCell(cx, cy) and not ow:npcAtCell(cx, cy)
|
||||
end
|
||||
if girl and not free(stand.x, stand.y) then
|
||||
for _, s in ipairs({ { 0, 1, "down", "up" }, { -1, 0, "left", "right" },
|
||||
{ 1, 0, "right", "left" } }) do
|
||||
local cx, cy = girl.cellX + s[1], girl.cellY + s[2]
|
||||
if free(cx, cy) then
|
||||
U.log(("(%d, %d) is not free; standing at"):format(stand.x, stand.y),
|
||||
cx, cy, "facing", s[3])
|
||||
stand = { x = cx, y = cy, away = s[3], toward = s[4] }
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
U.teleport(game, MAP, stand.x, stand.y, stand.away)
|
||||
U.wait(10)
|
||||
ow = game.overworld
|
||||
pin(ow)
|
||||
local p = ow.player
|
||||
check("standing beside the girl, facing away from her",
|
||||
p.cellX == stand.x and p.cellY == stand.y and p.facing == stand.away)
|
||||
|
||||
-- What the fix moved, read off the running game rather than repeated from
|
||||
-- a comment: turnFrames is the dataset constant a mod can restamp
|
||||
-- (src/world/FieldDefaults.lua) and the touch figure comes back out of
|
||||
-- Player:turnWindow with a live overlay source. turnWindow is the fix's
|
||||
-- own entry point, so a build without it falls back to the one window it
|
||||
-- had for every source and the two checks below say so.
|
||||
local function window()
|
||||
return p.turnWindow and p:turnWindow() or p.turnFrames
|
||||
end
|
||||
local padWindow = window()
|
||||
game.input:overlayPressed(p.facing)
|
||||
game.input:step()
|
||||
local touchWindow = window()
|
||||
game.input:overlayReleased(p.facing)
|
||||
game.input:step()
|
||||
check("the pad turn window is 4 fixed steps, up from 2", padWindow == 4)
|
||||
check("the on-screen d-pad gets 8", touchWindow == 8)
|
||||
U.log(("turn window: %d fixed steps on keyboard and pad (%d ms), %d on the ")
|
||||
:format(padWindow, math.floor(padWindow * 1000 / 60), touchWindow)
|
||||
.. ("on-screen d-pad (%d ms); hold past it and the step commits.")
|
||||
:format(math.floor(touchWindow * 1000 / 60)))
|
||||
|
||||
-- A press no longer than the window turns and stops there.
|
||||
for _ = 1, padWindow do
|
||||
press(stand.toward)
|
||||
coroutine.yield()
|
||||
end
|
||||
release(stand.toward)
|
||||
U.wait(6)
|
||||
check(("a %d-step press turned toward the girl"):format(padWindow),
|
||||
p.facing == stand.toward)
|
||||
check("and left the feet on the same tile",
|
||||
p.cellX == stand.x and p.cellY == stand.y)
|
||||
|
||||
-- The same press held well past the window does step. Sideways, because
|
||||
-- the girl blocks the step toward her and would mask it.
|
||||
local openDir, openBack
|
||||
for _, s in ipairs({ { "left", "right", -1, 0 }, { "right", "left", 1, 0 },
|
||||
{ "up", "down", 0, -1 }, { "down", "up", 0, 1 } }) do
|
||||
if s[1] ~= stand.toward and free(stand.x + s[3], stand.y + s[4]) then
|
||||
openDir, openBack = s[1], s[2]
|
||||
break
|
||||
end
|
||||
end
|
||||
if openDir then
|
||||
U.hold(game, openDir, 40)
|
||||
U.wait(10)
|
||||
check(("a 40-step hold of %s walks off the tile"):format(openDir),
|
||||
p.cellX ~= stand.x or p.cellY ~= stand.y)
|
||||
|
||||
-- The corner: swap direction without ever lifting the d-pad. Walk back
|
||||
-- on openBack first and break on the landing poll, so the pad is still
|
||||
-- down and the turn is still spent when the swap arrives.
|
||||
local walked = false
|
||||
for _ = 1, 240 do
|
||||
press(openBack)
|
||||
coroutine.yield()
|
||||
if p.moving then
|
||||
walked = true
|
||||
elseif walked then
|
||||
break
|
||||
end
|
||||
end
|
||||
check(("the return hold of %s is walking"):format(openBack), walked)
|
||||
|
||||
-- Nothing has re-armed wCheckFor180DegreeTurn, so the new facing steps
|
||||
-- on the poll it arrives on with no turn delay. Pre-fix, every corner
|
||||
-- paid the window again. Perpendicular and walkable from wherever the
|
||||
-- return hold landed, so a blocked cell can't read as a stall.
|
||||
local DELTA = { up = { 0, -1 }, down = { 0, 1 },
|
||||
left = { -1, 0 }, right = { 1, 0 } }
|
||||
local swap
|
||||
for _, d in ipairs({ "up", "down", "left", "right" }) do
|
||||
local dd = DELTA[d]
|
||||
if d ~= openDir and d ~= openBack
|
||||
and free(p.cellX + dd[1], p.cellY + dd[2]) then
|
||||
swap = d
|
||||
break
|
||||
end
|
||||
end
|
||||
if swap then
|
||||
local wasFacing = p.facing
|
||||
release(openBack)
|
||||
press(swap) -- same poll, so the d-pad never reads empty
|
||||
coroutine.yield()
|
||||
U.log(("corner: swapped %s for %s without a release, facing went %s to %s")
|
||||
:format(openBack, swap, wasFacing, p.facing))
|
||||
check("the swapped direction steps on the poll it arrives on",
|
||||
p.facing == swap and p.moving and p.turnTimer == 0)
|
||||
release(swap)
|
||||
else
|
||||
U.log("no perpendicular open cell here; skipped the corner check")
|
||||
release(openBack)
|
||||
end
|
||||
for _ = 1, 200 do
|
||||
if not p.moving then break end
|
||||
coroutine.yield()
|
||||
end
|
||||
else
|
||||
U.log("no open cell beside the stand tile; skipped the hold and corner")
|
||||
end
|
||||
|
||||
-- Back beside her, facing her, and talk: a silent A here would look exactly
|
||||
-- like a facing that missed.
|
||||
U.teleport(game, MAP, stand.x, stand.y, stand.toward)
|
||||
U.wait(10)
|
||||
ow = game.overworld
|
||||
p = ow.player
|
||||
girl = pin(ow)
|
||||
local fx, fy = p:facingCell()
|
||||
check("the facing cell is the girl's",
|
||||
girl ~= nil and ow:npcAtCell(fx, fy) == girl)
|
||||
|
||||
U.tap(game, "a")
|
||||
U.wait(30)
|
||||
local top = game.stack:top()
|
||||
local isBox = getmetatable(top) == TextBox
|
||||
check("A opened her text box", isBox)
|
||||
if isBox then
|
||||
local shown = {}
|
||||
for _, page in ipairs(top.pages or {}) do
|
||||
for _, line in ipairs(page) do shown[#shown + 1] = line end
|
||||
end
|
||||
U.log("box reads:", table.concat(shown, " / "))
|
||||
end
|
||||
if U.shot(game, SHOT_DIR .. "/bug415_turn_in_place.png") then
|
||||
U.log("captured", SHOT_DIR .. "/bug415_turn_in_place.png",
|
||||
"so the window did render; a blank screen later is not the driver")
|
||||
end
|
||||
U.tap(game, "b")
|
||||
U.wait(20)
|
||||
|
||||
U.log("You are on the girl's north side in Pallet Town, facing her, with")
|
||||
U.log("open ground north, east and west. Tap a direction: Red should")
|
||||
U.log("pivot on the spot and stay on his tile. Hold the same direction")
|
||||
U.log("and he walks, with no extra pause before the first step. Then walk")
|
||||
U.log("a lap and change direction without letting go; corners should stay")
|
||||
U.log("smooth, and a stall there is the half of #415 that is not the tap.")
|
||||
U.log("The near miss to watch for is a tap that turns AND slides one tile,")
|
||||
U.log("which still reads as a turn unless you watch Red's feet against the")
|
||||
U.log("fence. The girl is the control: she blocks the step, so tapping")
|
||||
U.log("toward her looked fine even while this was broken. That is the")
|
||||
U.log("Yellow complaint in the report -- the follower Pikachu does not")
|
||||
U.log("block (src/world/PikachuFollower.lua), so a tap meant to turn and")
|
||||
U.log("talk to it walked onto its cell and straight past it instead.")
|
||||
|
||||
while true do
|
||||
coroutine.yield()
|
||||
|
||||
@@ -0,0 +1,390 @@
|
||||
-- Manual check that BATTLE LAYOUT = WIDE draws move animations intact (#562).
|
||||
-- A battle sets rWY to 0 (engine/battle/core.asm), so the window the shake
|
||||
-- predefs displace is the whole screen; the wide composition moved only the
|
||||
-- two pic regions, which slid the monsters across a nailed-down HUD and text
|
||||
-- box. TAIL WHIP is the reported case (wAnimationType 6,
|
||||
-- AnimationShakeScreenHorizontallySlow b=3, animations.asm:526). Composition
|
||||
-- half: tests/engine/wide_battle_shake_bug562.lua. Never under POKEPORT_SPEED.
|
||||
-- POKEPORT_DRIVER=tests/drivers/wide_move_anim_bug562_test.lua POKEPORT_IDENTITY=bug562 POKEPORT_TOUCH=0 SHOT_DIR=/tmp/shots love .
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
local BattleState = require("src.battle.BattleState")
|
||||
local WideBattle = require("src.battle.WideBattle")
|
||||
|
||||
local pass, fail = 0, 0
|
||||
local function check(label, ok)
|
||||
if ok then pass = pass + 1 else fail = fail + 1 end
|
||||
U.log(ok and "PASS" or "FAIL", label)
|
||||
return ok
|
||||
end
|
||||
|
||||
-- One move per shake family the wide composition has to carry. `hitX` is
|
||||
-- the applying-attack amplitude PlayApplyingAttackAnimation plays after the
|
||||
-- move (AnimationTypePointerTable, animations.asm:475); `animX` is a shake
|
||||
-- the move's own subanimation runs. wAnimationType 4, 5 and 6 are the
|
||||
-- player's turn -- the vertical 1 and the wide 2 / 3 are the foe's, and the
|
||||
-- foe's rows are reported below rather than driven.
|
||||
-- TAIL WHIP type 6, AnimationShakeScreenHorizontallySlow b=3 (silent;
|
||||
-- every player status move takes this path)
|
||||
-- BUBBLEBEAM type 5, fast horizontal b=2, with the damage thud
|
||||
-- EARTHQUAKE SE_SHAKE_SCREEN inside its own animation (fastShakeProg 8),
|
||||
-- shared with FISSURE and SEISMIC TOSS
|
||||
-- WITHDRAW the control: a stat-UP effect leaves wAnimationType at 0
|
||||
-- (effects.asm, PlayCurrentMoveAnimation), so nothing moves.
|
||||
-- It also fills the fourth grid slot, which the 2x2 needs.
|
||||
local MOVES = {
|
||||
{ slot = 1, id = "TAIL_WHIP", type = 6, hitX = 3 },
|
||||
{ slot = 2, id = "BUBBLEBEAM", type = 5, hitX = 2 },
|
||||
{ slot = 3, id = "EARTHQUAKE", type = 4, animX = 8 },
|
||||
{ slot = 4, id = "WITHDRAW", type = 0, hitX = 0, hitY = 0 },
|
||||
}
|
||||
|
||||
-- ---- machine checks ----------------------------------------------------
|
||||
-- A move missing from the table, a layout that never switched and a battle
|
||||
-- that never reached WIDE all look like "the animation is fine" from a chair.
|
||||
U.log("#562 wide-layout move animations: machine checks")
|
||||
for _, m in ipairs(MOVES) do
|
||||
check(m.id .. " is in the move table", game.data.moves[m.id] ~= nil)
|
||||
end
|
||||
local anims = game.data.battle_anims and game.data.battle_anims.moveAnims
|
||||
check("battle_anims carries TAIL WHIP's own animation",
|
||||
anims ~= nil and anims.TAIL_WHIP ~= nil)
|
||||
check("...and EARTHQUAKE's, which carries SE_SHAKE_SCREEN itself",
|
||||
anims ~= nil and anims.EARTHQUAKE ~= nil)
|
||||
check("animations are on in OPTIONS (the shakes are gated on them)",
|
||||
game.save.options.animations ~= false)
|
||||
|
||||
game.save.options.battleLayout = "wide"
|
||||
check("BATTLE LAYOUT is WIDE", game.save.options.battleLayout == "wide")
|
||||
|
||||
local vol = game.save.options and game.save.options.sfxVol
|
||||
U.log("sfxVol", tostring(vol),
|
||||
vol == 0 and "-- SILENT, raise it in OPTION before judging the sound"
|
||||
or "-- BUBBLEBEAM and EARTHQUAKE carry the damage thud; TAIL WHIP is silent")
|
||||
|
||||
-- ---- fixture -----------------------------------------------------------
|
||||
local squirtle = Pokemon.new(game.data, "SQUIRTLE", 40)
|
||||
squirtle.moves = {}
|
||||
for _, m in ipairs(MOVES) do
|
||||
squirtle.moves[m.slot] = { id = m.id, pp = game.data.moves[m.id].pp }
|
||||
end
|
||||
game.save.party = { squirtle }
|
||||
game.save.player.name = "RED"
|
||||
|
||||
-- data/generated/maps.lua ROUTE_1: (5, 5) is open walkable ground; the
|
||||
-- battle is pushed straight in rather than encountered
|
||||
U.teleport(game, "ROUTE_1", 5, 5, "down")
|
||||
U.wait(10)
|
||||
local ow = game.overworld
|
||||
if ow and ow.map and not ow.map:isWalkableCell(5, 5) then
|
||||
-- a map edit moved the path: take the nearest walkable cell instead
|
||||
local sx, sy
|
||||
for r = 1, 6 do
|
||||
for dy = -r, r do
|
||||
for dx = -r, r do
|
||||
if not sx and ow.map:isWalkableCell(5 + dx, 5 + dy) then
|
||||
sx, sy = 5 + dx, 5 + dy
|
||||
end
|
||||
end
|
||||
end
|
||||
if sx then break end
|
||||
end
|
||||
if sx then
|
||||
U.log("(5, 5) is blocked, standing on", sx, sy)
|
||||
U.teleport(game, "ROUTE_1", sx, sy, "down")
|
||||
U.wait(10)
|
||||
ow = game.overworld
|
||||
end
|
||||
end
|
||||
check("the overworld is up on ROUTE_1", ow ~= nil)
|
||||
|
||||
-- a Normal-type foe: PIDGEY is Flying and would sit out EARTHQUAKE entirely
|
||||
local battle = BattleState.newWild(game, "RATTATA", 20)
|
||||
battle.onFinish = function() end
|
||||
ow:pushBattle(battle)
|
||||
for _ = 1, 400 do
|
||||
if game.stack:top() == battle and (battle.introSlide or 0) == 0 then break end
|
||||
U.wait(1)
|
||||
end
|
||||
check("the battle reached the screen", game.stack:top() == battle)
|
||||
check("the battle is drawing the wide surface", battle:wideLayout() == true)
|
||||
local uw = battle:uiSize()
|
||||
check(("the surface is %dpx wide (got %s)"):format(WideBattle.WIDTH, tostring(uw)),
|
||||
uw == WideBattle.WIDTH)
|
||||
|
||||
-- a foe that survives the whole run: every family needs its own turn, and
|
||||
-- EARTHQUAKE off a level 40 SQUIRTLE takes a large bite
|
||||
battle.enemy.mon.stats.hp = 900
|
||||
battle.enemy.mon.hp = 900
|
||||
battle.enemy.shownHP = 900
|
||||
-- WHIRLWIND ends a wild battle outright ("run"), taking the untested
|
||||
-- families with it; curMoves aliases mon.moves, so one pass covers both
|
||||
for i = #battle.enemy.mon.moves, 1, -1 do
|
||||
local id = battle.enemy.mon.moves[i].id
|
||||
if id == "WHIRLWIND" or id == "ROAR" or id == "TELEPORT" then
|
||||
table.remove(battle.enemy.mon.moves, i)
|
||||
end
|
||||
end
|
||||
|
||||
-- ---- sample the offsets the composition is fed -------------------------
|
||||
-- One row per performMove, EITHER side: a row that stayed open across the
|
||||
-- foe's reply would credit the player's move with the foe's shake. The
|
||||
-- split at the first applyHitFx separates a shake the move's own animation
|
||||
-- ran (SE_SHAKE_SCREEN) from the applying-attack one that follows it.
|
||||
local rows, cur = {}, nil
|
||||
local realPerform = battle.performMove
|
||||
battle.performMove = function(self, user, target, moveInst, ...)
|
||||
cur = { move = moveInst and moveInst.id, isPlayer = user.isPlayer,
|
||||
animX = 0, animY = 0, hitX = 0, hitY = 0, hitFrames = 0 }
|
||||
rows[#rows + 1] = cur
|
||||
return realPerform(self, user, target, moveInst, ...)
|
||||
end
|
||||
local realFx = battle.applyHitFx
|
||||
battle.applyHitFx = function(self, hit)
|
||||
if cur and cur.animType == nil then cur.animType = hit.animType or 0 end
|
||||
return realFx(self, hit)
|
||||
end
|
||||
|
||||
local function sample()
|
||||
local fx = battle.fx
|
||||
if not (cur and fx) then return end
|
||||
local dx, dy = math.abs(fx.shakeX or 0), math.abs(fx.shakeY or 0)
|
||||
if cur.animType == nil then
|
||||
cur.animX, cur.animY = math.max(cur.animX, dx), math.max(cur.animY, dy)
|
||||
return
|
||||
end
|
||||
if dx > 0 or dy > 0 then cur.hitFrames = cur.hitFrames + 1 end
|
||||
cur.hitX, cur.hitY = math.max(cur.hitX, dx), math.max(cur.hitY, dy)
|
||||
end
|
||||
|
||||
-- has this row finished its applying-attack effect? Type 4 runs a pic
|
||||
-- blink and no screen program at all, so it is done as soon as it fires.
|
||||
local function settled(row)
|
||||
return row ~= nil and row.animType ~= nil
|
||||
and (battle.fx == nil or battle.fx.shakeProg == nil)
|
||||
end
|
||||
|
||||
-- step n frames sampling every one, pressing A every `mash` frames but only
|
||||
-- while a box is up: an A on the FIGHT menu starts a turn behind our back
|
||||
local function pump(n, mash, stop)
|
||||
for i = 1, n do
|
||||
if mash and i % mash == 0 and battle.phase == "messages" then
|
||||
table.insert(game.input.pressQueue, "a")
|
||||
end
|
||||
U.wait(1)
|
||||
game.input.state.a = false
|
||||
sample()
|
||||
if stop and stop() then return end
|
||||
end
|
||||
end
|
||||
|
||||
local function toMenu()
|
||||
pump(1500, 6, function() return battle.phase == "menu" and #battle.queue == 0 end)
|
||||
return battle.phase == "menu"
|
||||
end
|
||||
|
||||
-- The wide move list is a 2x2 grid, and a press that would land on an
|
||||
-- absent fourth move holds the cursor still (WideBattle.moveGridIndex), so
|
||||
-- the route is searched through the engine's own navigation rather than
|
||||
-- assumed: walking the column never reaches slots 2 and 4, and walking the
|
||||
-- row can dead-end on a three-move party.
|
||||
local DIRS = { "up", "down", "left", "right" }
|
||||
local function routeTo(from, slot, count)
|
||||
local seen, queue = { [from] = true }, { { at = from, path = {} } }
|
||||
while #queue > 0 do
|
||||
local node = table.remove(queue, 1)
|
||||
if node.at == slot then return node.path end
|
||||
for _, dir in ipairs(DIRS) do
|
||||
local nxt = WideBattle.moveGridIndex(node.at, count, dir)
|
||||
if nxt and not seen[nxt] then
|
||||
seen[nxt] = true
|
||||
local path = { unpack(node.path) }
|
||||
path[#path + 1] = dir
|
||||
queue[#queue + 1] = { at = nxt, path = path }
|
||||
end
|
||||
end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
-- FIGHT is slot 1 of the wide command grid; A opens moveSelect. A press on
|
||||
-- a frame the battle is not reading input is simply lost, so retry.
|
||||
local function useMove(slot)
|
||||
for _ = 1, 80 do
|
||||
if battle.phase == "moveSelect" then break end
|
||||
if battle.phase == "menu" then
|
||||
if battle.menuIndex ~= 1 then
|
||||
U.tap(game, battle.menuIndex > 2 and "up" or "left")
|
||||
else
|
||||
U.tap(game, "a")
|
||||
end
|
||||
else
|
||||
U.tap(game, "a")
|
||||
end
|
||||
U.wait(4)
|
||||
end
|
||||
if battle.phase ~= "moveSelect" then return false end
|
||||
-- recompute the route every press: a press that lands on a frame the
|
||||
-- battle is not reading is lost, and the cursor simply did not move
|
||||
local count = #(battle.player.curMoves or {})
|
||||
for _ = 1, 30 do
|
||||
if battle.moveIndex == slot then break end
|
||||
local path = routeTo(battle.moveIndex, slot, count)
|
||||
if not path or #path == 0 then break end
|
||||
U.tap(game, path[1])
|
||||
U.wait(3)
|
||||
end
|
||||
if battle.moveIndex ~= slot then return false end
|
||||
for _ = 1, 20 do
|
||||
if battle.phase ~= "moveSelect" then return true end
|
||||
U.tap(game, "a")
|
||||
U.wait(3)
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
local function lastPlayerRow(id)
|
||||
for i = #rows, 1, -1 do
|
||||
if rows[i].move == id and rows[i].isPlayer then return rows[i] end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
local SHOTS = { TAIL_WHIP = "bug562_wide_tailwhip.png",
|
||||
BUBBLEBEAM = "bug562_wide_bubblebeam.png",
|
||||
EARTHQUAKE = "bug562_wide_earthquake.png" }
|
||||
-- the foe's vertical drop (type 1) plays on ITS turn, so it is taken
|
||||
-- whenever one goes past and chased down after the table below if not
|
||||
local vertShot = false
|
||||
|
||||
check("the battle reached its FIGHT menu", toMenu())
|
||||
|
||||
for _, m in ipairs(MOVES) do
|
||||
local shotAt, sent, r
|
||||
-- BUBBLEBEAM whiffs 1 in 6 and the foe's TAIL WHIP stacks accuracy drops,
|
||||
-- so give each move a few turns rather than reading a miss (which plays no
|
||||
-- applying animation at all) as a regression; hand the accuracy back first
|
||||
for _ = 1, 8 do
|
||||
battle.player.stages.accuracy = 0
|
||||
sent = useMove(m.slot)
|
||||
if not sent then
|
||||
U.log((" could not reach slot %d for %s (phase %s, move index %s, result %s)")
|
||||
:format(m.slot, m.id, tostring(battle.phase),
|
||||
tostring(battle.moveIndex), tostring(battle.result)))
|
||||
break
|
||||
end
|
||||
pump(1500, 8, function()
|
||||
local fx = battle.fx
|
||||
local row = lastPlayerRow(m.id)
|
||||
if not shotAt and row == cur and SHOTS[m.id] and fx
|
||||
and (fx.shakeX or 0) ~= 0 then
|
||||
shotAt = U.shot(game, DIR .. "/" .. SHOTS[m.id])
|
||||
end
|
||||
if not vertShot and fx and (fx.shakeY or 0) ~= 0
|
||||
and cur and not cur.isPlayer then
|
||||
vertShot = U.shot(game, DIR .. "/bug562_wide_foe_vertical.png")
|
||||
end
|
||||
if row and battle.phase == "menu" and #battle.queue == 0 then return true end
|
||||
return settled(row)
|
||||
end)
|
||||
toMenu()
|
||||
r = lastPlayerRow(m.id)
|
||||
-- a type-0 move never queues an applying-attack row, so one clean turn
|
||||
-- is all it owes
|
||||
if r and (r.animType ~= nil or m.type == 0) then break end
|
||||
U.log(" " .. m.id .. " did not connect that turn; using it again")
|
||||
end
|
||||
check("chose " .. m.id .. " from the wide move grid", sent)
|
||||
if sent and check(m.id .. " was performed", r ~= nil) then
|
||||
U.log((" %s: type %s, own animation %dpx across / %dpx down, applying "
|
||||
.. "%dpx across / %dpx down over %d frames")
|
||||
:format(m.id, tostring(r.animType), r.animX, r.animY,
|
||||
r.hitX, r.hitY, r.hitFrames))
|
||||
check(("%s is wAnimationType %d (got %d)")
|
||||
:format(m.id, m.type, r.animType or 0),
|
||||
(r.animType or 0) == m.type)
|
||||
if m.hitX then
|
||||
check(("%s shakes the screen %dpx sideways (got %d)")
|
||||
:format(m.id, m.hitX, r.hitX), r.hitX == m.hitX)
|
||||
end
|
||||
if m.hitY then
|
||||
check(("%s leaves the vertical alone (got %d)"):format(m.id, r.hitY),
|
||||
r.hitY == m.hitY)
|
||||
end
|
||||
if m.animX then
|
||||
check(("%s's own animation shakes %dpx sideways (got %d)")
|
||||
:format(m.id, m.animX, r.animX), r.animX == m.animX)
|
||||
end
|
||||
if SHOTS[m.id] then
|
||||
check("captured " .. m.id .. " mid-shake", shotAt == true)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- The vertical drop (type 1) only plays on the foe's turn, so it is taken by
|
||||
-- spending harmless WITHDRAW turns until the RATTATA picks a plain damaging
|
||||
-- move. Which move it picks is an AI roll, so a run where it never does is
|
||||
-- a thin run, not a regression.
|
||||
local function foeTypeOne()
|
||||
for _, row in ipairs(rows) do
|
||||
if not row.isPlayer and row.animType == 1 then return true end
|
||||
end
|
||||
return false
|
||||
end
|
||||
for _ = 1, 6 do
|
||||
if vertShot then break end
|
||||
if not useMove(4) then break end
|
||||
pump(1200, 8, function()
|
||||
local fx = battle.fx
|
||||
if not vertShot and fx and (fx.shakeY or 0) ~= 0
|
||||
and cur and not cur.isPlayer then
|
||||
vertShot = U.shot(game, DIR .. "/bug562_wide_foe_vertical.png")
|
||||
end
|
||||
return battle.phase == "menu" and #battle.queue == 0
|
||||
end)
|
||||
toMenu()
|
||||
end
|
||||
|
||||
local foe = {}
|
||||
for _, row in ipairs(rows) do
|
||||
if not row.isPlayer and row.animType then
|
||||
foe[#foe + 1] = ("%s type %s (%dpx across, %dpx down)")
|
||||
:format(tostring(row.move), tostring(row.animType),
|
||||
math.max(row.animX, row.hitX),
|
||||
math.max(row.animY, row.hitY))
|
||||
end
|
||||
end
|
||||
U.log("the RATTATA's rows: " .. (#foe > 0 and table.concat(foe, "; ") or "none"))
|
||||
if vertShot then
|
||||
U.log("captured the foe's 8px vertical drop as well")
|
||||
elseif foeTypeOne() then
|
||||
check("the foe's vertical drop was captured", false)
|
||||
else
|
||||
U.log("the RATTATA never used a plain damaging move in these turns, so")
|
||||
U.log("there was no vertical drop to capture; watch for one by hand")
|
||||
end
|
||||
U.log(("machine checks: %d passed, %d failed"):format(pass, fail))
|
||||
|
||||
-- ---- hand off ----------------------------------------------------------
|
||||
U.log("The pad is yours at the wide FIGHT menu, all four panels on screen.")
|
||||
U.log("Slot 1 TAIL WHIP creeps the WHOLE screen 1px at a time out to 3px and")
|
||||
U.log("back, twice, in silence: foe, both status panels and the message box")
|
||||
U.log("move as one sheet. Slot 2 BUBBLEBEAM snaps it 2px across with the")
|
||||
U.log("damage thud, slot 3 EARTHQUAKE shakes it 8px from inside its own")
|
||||
U.log("animation. Let the RATTATA hit back for the vertical one: a plain")
|
||||
U.log("TACKLE or QUICK ATTACK drops the same sheet 8px. The near miss to")
|
||||
U.log("watch for is the one #562 reported: the monsters sliding while the HUD")
|
||||
U.log("panels and the text box stay nailed down, or a pic sheared off at the")
|
||||
U.log("seam between the two side windows on the way.")
|
||||
U.log("Slot 4 WITHDRAW is the control: it moves nothing at all, on purpose.")
|
||||
U.log("So are the animations' own sprites (the bubbles, the EARTHQUAKE rocks)")
|
||||
U.log("-- those are OAM and correctly do NOT ride the shake, same as in OG.")
|
||||
U.log("Flip OPTION -> BATTLE LAYOUT to OG and repeat: it should look the same.")
|
||||
U.log("Screenshots: " .. DIR .. "/bug562_*.png")
|
||||
|
||||
while true do
|
||||
coroutine.yield()
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,158 @@
|
||||
-- Cycling COLORS during a battle must keep the battle song (#484). The
|
||||
-- pack change rebuilds the live map's baked atlas through
|
||||
-- OverworldState:reloadMap, and that used to re-enter the map: setMap's
|
||||
-- PlayMapMusic put the route theme over the battle theme. ReloadMapData
|
||||
-- (home/reload_tiles.asm) only re-reads the map view and the tileset
|
||||
-- patterns; map music comes from LoadMapData alone (home/overworld.asm,
|
||||
-- gated on BIT_NO_MAP_MUSIC), so a reload is not a map entry.
|
||||
-- The other half matters as much: the hotkey must still work in a battle,
|
||||
-- so a "fix" that gates key 2 off while BattleState is on top fails here.
|
||||
-- luajit tests/engine/battle_colors_bug484.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 OverworldState = require("src.world.OverworldController")
|
||||
|
||||
-- OverworldController takes its collaborators as file-locals, and Game is
|
||||
-- only bound when a real state enters; swapping the upvalues is how
|
||||
-- tests/mod_world_tests.lua drives one of these methods with no dataset.
|
||||
local function setUpvalue(fn, name, value)
|
||||
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, value)
|
||||
return true
|
||||
end
|
||||
i = i + 1
|
||||
end
|
||||
end
|
||||
|
||||
local emitted = {}
|
||||
for _, up in ipairs({
|
||||
{ "MapLoader", { invalidate = function() end,
|
||||
invalidateAll = function() end } },
|
||||
{ "Collision", { load = function() end } },
|
||||
{ "Game", { data = {} } },
|
||||
{ "Logger", { warn = function() end, error = function() end } },
|
||||
{ "Runtime", { emit = function(name, payload)
|
||||
emitted[#emitted + 1] = { name = name, payload = payload }
|
||||
end } },
|
||||
}) do
|
||||
check(setUpvalue(OverworldState.reloadMap, up[1], up[2]),
|
||||
"reloadMap still closes over " .. up[1])
|
||||
end
|
||||
|
||||
-- A state stub with just what reloadMap reads: the live map, the player's
|
||||
-- cell, and a setMap that records the opts instead of loading anything.
|
||||
-- `shrank` makes the reloaded map report the player's cell out of bounds
|
||||
-- once, which is the only way past the first setMap.
|
||||
local function newState(mapId, shrank)
|
||||
local calls, entered = {}, 0
|
||||
return {
|
||||
map = { id = mapId, inBounds = function() return true end },
|
||||
player = { cellX = 5, cellY = 5, facing = "down" },
|
||||
neighbors = {},
|
||||
calls = calls,
|
||||
setMap = function(self, id, x, y, facing, opts)
|
||||
calls[#calls + 1] = { id = id, x = x, y = y, facing = facing, opts = opts }
|
||||
entered = entered + 1
|
||||
local ok = not (shrank and entered == 1)
|
||||
self.map = { id = id, inBounds = function() return ok end }
|
||||
end,
|
||||
healPoint = function() return { map = "PALLET_TOWN", x = 3, y = 6 } end,
|
||||
}
|
||||
end
|
||||
|
||||
local live = newState("ROUTE_1")
|
||||
OverworldState.reloadMap(live, "ROUTE_1", "colors")
|
||||
eq(#live.calls, 1, "reloading the live map re-enters it exactly once")
|
||||
local opts = live.calls[1].opts or {}
|
||||
check(opts.keepMusic == true,
|
||||
"the reload keeps whatever song is playing (#484): a COLORS cycle "
|
||||
.. "during a battle must not start the route theme")
|
||||
eq(opts.via, "reload", "and it is still tagged as a reload, not a warp")
|
||||
check(opts.seamless == true, "with no transition wipe")
|
||||
eq(live.calls[1].x, 5, "the player is put back on the cell they were on")
|
||||
eq(live.calls[1].y, 5, "on both axes")
|
||||
|
||||
-- the escape hatch under it is a genuine map change, and PlayMapMusic
|
||||
-- belongs there: the player is being sent to a heal point
|
||||
local shrunk = newState("ROUTE_1", true)
|
||||
OverworldState.reloadMap(shrunk, "ROUTE_1", "colors")
|
||||
eq(#shrunk.calls, 2, "a map that shrank under the player sends them away")
|
||||
eq(shrunk.calls[2].id, "PALLET_TOWN", "to their heal point")
|
||||
check(not (shrunk.calls[2].opts or {}).keepMusic,
|
||||
"and that one starts the destination's own music")
|
||||
|
||||
local other = newState("ROUTE_1")
|
||||
OverworldState.reloadMap(other, "VIRIDIAN_CITY", "colors")
|
||||
eq(#other.calls, 0, "reloading a map that is not live touches nothing")
|
||||
check(emitted[#emitted] and emitted[#emitted].name == "map.reloaded",
|
||||
"map.reloaded still fires either way")
|
||||
|
||||
-- The hotkey half, end to end through the real Game and PaletteFX: with a
|
||||
-- battle on top the COLORS key is not gated off, and every press moves one
|
||||
-- rung down the ladder.
|
||||
local Game = require("src.core.Game")
|
||||
local PaletteFX = require("src.render.PaletteFX")
|
||||
|
||||
local reloads = {}
|
||||
local ow = {
|
||||
map = { id = "ROUTE_1" },
|
||||
reloadMap = function(_, id, reason)
|
||||
reloads[#reloads + 1] = { id = id, reason = reason }
|
||||
end,
|
||||
}
|
||||
Game.overworld = ow -- PaletteFX.setMode reaches the live overworld this way
|
||||
|
||||
local battle = { isOpaque = true } -- stands in for BattleState on the stack
|
||||
local session = {
|
||||
overworld = ow,
|
||||
save = { options = {} },
|
||||
stack = { states = { ow, battle },
|
||||
top = function(self) return self.states[#self.states] end },
|
||||
writeOptions = function() end,
|
||||
}
|
||||
|
||||
local seen, start = {}, PaletteFX.mode
|
||||
for _ = 1, #PaletteFX.MODES do
|
||||
Game.keypressed(session, "2")
|
||||
seen[#seen + 1] = PaletteFX.mode
|
||||
end
|
||||
eq(#reloads, #PaletteFX.MODES,
|
||||
"every COLORS press in a battle still rebuilds the map's atlas")
|
||||
eq(reloads[1].reason, "colors", "tagged as the COLORS cycle")
|
||||
eq(reloads[1].id, "ROUTE_1", "on the map the player is standing on")
|
||||
|
||||
local distinct = {}
|
||||
for _, mode in ipairs(seen) do distinct[mode] = true end
|
||||
local count = 0
|
||||
for _ in pairs(distinct) do count = count + 1 end
|
||||
eq(count, #PaletteFX.MODES,
|
||||
"the palette visibly cycles: one press per mode walks the whole ladder")
|
||||
eq(PaletteFX.mode, start, "and lands back where it started")
|
||||
eq(session.save.options.colors, PaletteFX.mode,
|
||||
"the choice is written to options like it is in the overworld")
|
||||
|
||||
-- the pre-existing gate is unchanged: a script driving the overworld still
|
||||
-- holds the key off, because reloadMap rebuilds the live NPC array
|
||||
local busy = {
|
||||
overworld = ow,
|
||||
save = { options = {} },
|
||||
stack = { states = { ow }, top = function(self) return self.states[1] end },
|
||||
writeOptions = function() end,
|
||||
}
|
||||
ow.runner = { isRunning = function() return true end }
|
||||
local before = PaletteFX.mode
|
||||
local reloadsBefore = #reloads
|
||||
Game.keypressed(busy, "2")
|
||||
eq(PaletteFX.mode, before, "a running map script still holds COLORS off")
|
||||
eq(#reloads, reloadsBefore, "and nothing reloads under it")
|
||||
|
||||
T.finish("battle_colors_bug484")
|
||||
@@ -0,0 +1,128 @@
|
||||
-- The Silph Scope unveil in the POKEMON_TOWER_6F battle (#492).
|
||||
--
|
||||
-- PrintBeginningBattleText .isMarowak (engine/battle/common_text.asm:49-60):
|
||||
-- with the scope in the bag the battle still ENTERS disguised -- InitWildBattle
|
||||
-- takes its .isGhost branch on wCurOpponent == RESTLESS_SOUL either way -- and
|
||||
-- the scope only buys the unveil that is played over the disguise:
|
||||
-- EnemyAppearedText, UnveiledGhostText, LoadEnemyMonData, MarowakAnim, then
|
||||
-- WildMonAppearedText. The port used to hand the scope-carrying player a
|
||||
-- battle that opened with MAROWAK already on screen and no unveil at all.
|
||||
--
|
||||
-- The story3 dispatch half (which branch the trigger picks, and that the ball
|
||||
-- dodge survives the unveil) is tests/parity_marowak.lua and
|
||||
-- tests/parity_marowak_ball.lua.
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local T = require("tests.modkit")
|
||||
local BattleState = require("src.battle.BattleState")
|
||||
|
||||
T.check(type(BattleState.makeUnveiledGhost) == "function",
|
||||
"BattleState:makeUnveiledGhost exists")
|
||||
T.check(BattleState.makeUnveiledGhost ~= BattleState.makeGhost,
|
||||
"the unveiled battle is not just the scope-less ghost battle")
|
||||
|
||||
local REAL_SPRITE = { marker = "the MAROWAK front pic" }
|
||||
|
||||
local function newBattle()
|
||||
return setmetatable({
|
||||
kind = "wild",
|
||||
queue = {},
|
||||
frame = 0,
|
||||
data = { text = {}, pokemon = {} },
|
||||
enemy = { name = "MAROWAK", sprite = REAL_SPRITE,
|
||||
mon = { species = "MAROWAK", level = 30 } },
|
||||
}, BattleState)
|
||||
end
|
||||
|
||||
-- ------------------------------------------------------- entering disguised
|
||||
local b = newBattle()
|
||||
b:makeUnveiledGhost()
|
||||
T.eq(b.enemy.name, "GHOST",
|
||||
"with the scope the battle still OPENS as the GHOST (#492)")
|
||||
T.check(b.enemy.sprite ~= REAL_SPRITE,
|
||||
"and wears the ghost pic, not the MAROWAK one")
|
||||
T.check(b.scopeReveal == true, "the unveil is armed")
|
||||
T.check(not b.ghost,
|
||||
"but IsGhostBattle stays false: the mon can be attacked and flees normally")
|
||||
T.eq(b.ghostReal.name, "MAROWAK", "the real nick is kept for LoadEnemyMonData")
|
||||
T.eq(b.ghostReal.sprite, REAL_SPRITE, "...and the real pic with it")
|
||||
|
||||
local ghostSprite = b.enemy.sprite
|
||||
|
||||
-- ------------------------------------------------------------- the row order
|
||||
-- .isMarowak prints over the "GHOST appeared!" box enter() already queued: the
|
||||
-- unveil line, MarowakAnim (a wait row parked over the fx state machine), then
|
||||
-- "Wild MAROWAK appeared!" under the restored name.
|
||||
b:queueScopeReveal()
|
||||
T.eq(#b.queue, 4, "the unveil is four queue rows")
|
||||
T.check(type(b.queue[1].text) == "string"
|
||||
and b.queue[1].text:find("SILPH SCOPE", 1, true) ~= nil,
|
||||
"row 1 is UnveiledGhostText")
|
||||
T.check(type(b.queue[2].fn) == "function", "row 2 starts MarowakAnim")
|
||||
T.eq(b.queue[3].wait, BattleState.GHOST_REVEAL_FRAMES,
|
||||
"row 3 holds the queue for the length of the animation")
|
||||
T.check(type(b.queue[4].text) == "string"
|
||||
and b.queue[4].text:find("MAROWAK", 1, true) ~= nil,
|
||||
"row 4 is WildMonAppearedText under the real name")
|
||||
T.check(b.queue[4].text:find("GHOST", 1, true) == nil,
|
||||
"...and not under the disguise")
|
||||
|
||||
-- ------------------------------------------------------------ the animation
|
||||
-- MarowakAnim (engine/battle/ghost_marowak_anim.asm): FlashSprite8Times, the
|
||||
-- rOBP1 fade-out, the pic swap, the fade-in. The whole point of #492 is that
|
||||
-- the swap comes AFTER the ghost has faded off the paper, so every frame up to
|
||||
-- there still shows the GHOST.
|
||||
b.queue[2].fn()
|
||||
T.check(b.ghostReveal ~= nil, "the fn arms the reveal state")
|
||||
|
||||
local FLASH = BattleState.GHOST_FLASH_FRAMES
|
||||
local FADE_OUT = BattleState.GHOST_FADE_OUT_FRAMES
|
||||
local swapFrame, fades, stillGhost = nil, {}, true
|
||||
for frame = 1, BattleState.GHOST_REVEAL_FRAMES do
|
||||
b:updateFx()
|
||||
local pf = b.picFx and b.picFx[b.enemy]
|
||||
fades[frame] = pf and pf.fade
|
||||
if not swapFrame and b.enemy.name ~= "GHOST" then swapFrame = frame end
|
||||
if frame <= FLASH + FADE_OUT and b.enemy.name ~= "GHOST" then
|
||||
stillGhost = false
|
||||
end
|
||||
end
|
||||
|
||||
T.check(stillGhost,
|
||||
"the GHOST is on screen for the whole flash and fade-out (#492)")
|
||||
T.eq(swapFrame, FLASH + FADE_OUT + 1,
|
||||
"the pic and nick swap the frame after the ghost has faded out")
|
||||
T.eq(b.enemy.name, "MAROWAK", "the real nick is back by the end")
|
||||
T.eq(b.enemy.sprite, REAL_SPRITE, "and the real pic with it")
|
||||
T.check(b.enemy.sprite ~= ghostSprite, "the ghost pic is gone")
|
||||
|
||||
-- FlashSprite8Times xors rOBP1 with $80 every 10 frames, so the body alternates
|
||||
-- between two shades rather than sitting opaque
|
||||
T.eq(fades[5], 1, "the flash opens on the ghost at full strength")
|
||||
T.eq(fades[15], 0.5, "...drops a shade 10 frames in")
|
||||
T.eq(fades[25], 1, "...and comes back")
|
||||
T.check(fades[FLASH + FADE_OUT] == 0,
|
||||
"the ghost is fully faded out when the swap lands")
|
||||
T.check(fades[FLASH + FADE_OUT + 1] > 0
|
||||
and fades[FLASH + FADE_OUT + 1] < 1,
|
||||
"the MAROWAK fades IN rather than popping on")
|
||||
T.eq(fades[BattleState.GHOST_REVEAL_FRAMES - 1], 1,
|
||||
"and reaches full strength before the box turns")
|
||||
|
||||
T.check(b.ghostReveal == nil, "the reveal state clears itself")
|
||||
T.check(b.scopeReveal == nil, "so does the flag that gated the intro cry")
|
||||
local pf = b.picFx and b.picFx[b.enemy]
|
||||
T.check(pf == nil or pf.fade == nil,
|
||||
"no leftover alpha on the pic for the rest of the battle")
|
||||
|
||||
-- ------------------------------------------------ the scope-less ghost still
|
||||
-- Nothing above may change the battle you get WITHOUT the scope: still a real
|
||||
-- ghost battle, still no unveil queued.
|
||||
local noScope = newBattle()
|
||||
noScope:makeGhost()
|
||||
T.check(noScope.ghost == true, "without the scope IsGhostBattle is true")
|
||||
T.eq(noScope.enemy.name, "GHOST", "and the disguise is the same one")
|
||||
T.check(noScope.scopeReveal == nil, "with no unveil armed")
|
||||
T.eq(#noScope.queue, 0, "and no unveil rows queued")
|
||||
|
||||
T.finish("ghost unveil")
|
||||
@@ -0,0 +1,59 @@
|
||||
-- #575: HostShell.restart on Android must never reach love.event.quit
|
||||
-- ("restart") -- the vendored love.cpp loops runlove() in-process on
|
||||
-- "restart" and the second PHYSFS_init crashes ("already initialized").
|
||||
-- The fix prefers the love.system.restartApp JNI bridge (which kills the
|
||||
-- process, so a true return is never observed live) and, on an old APK
|
||||
-- whose liblove lacks the bridge, falls back to a CLEAN quit with no
|
||||
-- argument. Desktop keeps the in-process quit("restart").
|
||||
-- luajit tests/engine/host_restart_android_bug575.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 HostShell = require("src.core.HostShell")
|
||||
|
||||
local quits = {}
|
||||
love.event = {
|
||||
-- record the argument distinctly from "called with none": quit() and
|
||||
-- quit("restart") are the whole difference this test pins
|
||||
quit = function(...)
|
||||
quits[#quits + 1] = { n = select("#", ...), arg = (...) }
|
||||
end,
|
||||
}
|
||||
|
||||
local osName = "Android"
|
||||
local restartCalls = 0
|
||||
love.system = love.system or {}
|
||||
love.system.getOS = function() return osName end
|
||||
|
||||
-- bridge present and schedulable: restart goes through it, quit untouched
|
||||
love.system.restartApp = function() restartCalls = restartCalls + 1 return true end
|
||||
HostShell.restart()
|
||||
eq(restartCalls, 1, "Android restart prefers the restartApp bridge (#575)")
|
||||
eq(#quits, 0, "a scheduled relaunch never touches love.event.quit")
|
||||
|
||||
-- bridge present but could not schedule: clean quit, never quit("restart")
|
||||
love.system.restartApp = function() restartCalls = restartCalls + 1 return false end
|
||||
HostShell.restart()
|
||||
eq(restartCalls, 2, "the bridge is still tried first")
|
||||
eq(#quits, 1, "a failed schedule falls back to one quit")
|
||||
eq(quits[1].n, 0, "and it is a bare quit(), not quit(\"restart\")")
|
||||
|
||||
-- old APK, no bridge compiled in: same clean quit fallback
|
||||
love.system.restartApp = nil
|
||||
HostShell.restart()
|
||||
eq(#quits, 2, "a bridge-less APK quits cleanly instead of crashing")
|
||||
eq(quits[2].n, 0, "again with no restart argument")
|
||||
|
||||
-- desktop (no AppImage in a test environment) keeps the in-process restart
|
||||
if not os.getenv("APPIMAGE") then
|
||||
osName = "OS X"
|
||||
HostShell.restart()
|
||||
eq(quits[3] and quits[3].arg, "restart",
|
||||
"non-Android still restarts in-process")
|
||||
end
|
||||
|
||||
T.finish("host_restart_android_bug575")
|
||||
@@ -297,4 +297,35 @@ do
|
||||
eq(rows[1].name, "bare", "a nameless row falls back to its id")
|
||||
end
|
||||
|
||||
-- ------- manifest strings are scrubbed to valid UTF-8 (MODS panel crash:
|
||||
-- LÖVE's printf raises "Invalid UTF-8" on a mangled name/description, so
|
||||
-- validate must drop bad bytes before any panel draws them)
|
||||
|
||||
do
|
||||
local m = mf({ id = "utf", entry = "m.lua",
|
||||
-- BOM-prefixed name (a real manifest shipped this way), a Latin-1 e-acute
|
||||
-- (\233, invalid as UTF-8) in the description, and a lone continuation
|
||||
-- byte in the version
|
||||
name = "\239\187\191Run Mode",
|
||||
version = "1.0\128.0",
|
||||
description = "caf\233 latt\233",
|
||||
category = "UI\255" })
|
||||
eq(m.name, "Run Mode", "a leading BOM is stripped from the name")
|
||||
eq(m.version, "1.0.0", "invalid bytes are dropped from the version")
|
||||
eq(m.description, "caf latt", "Latin-1 bytes are dropped, not replaced")
|
||||
eq(m.raw.category, "UI", "raw.category is scrubbed in place for the badge")
|
||||
|
||||
local ok2 = mf({ id = "utf2", name = "Vers\195\163oVermelha", version = "1.0.0",
|
||||
entry = "m.lua", description = "Pok\195\169mon \240\159\148\165" })
|
||||
eq(ok2.name, "Vers\195\163oVermelha", "valid two-byte sequences survive")
|
||||
eq(ok2.description, "Pok\195\169mon \240\159\148\165",
|
||||
"valid three- and four-byte sequences survive")
|
||||
|
||||
-- surrogate half (ED A0 80) and overlong slash (C0 AF) are invalid even
|
||||
-- though their lead bytes look plausible
|
||||
local bad = mf({ id = "utf3", name = "a\237\160\128b\192\175c",
|
||||
version = "1.0.0", entry = "m.lua" })
|
||||
eq(bad.name, "abc", "surrogates and overlongs are dropped")
|
||||
end
|
||||
|
||||
T.finish("launcher_mods")
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
-- #578: the launcher's "Add an index" prompt (and the rename / find-search
|
||||
-- fields) accepted no typing on Android, because nothing ever called
|
||||
-- love.keyboard.setTextInput(true) -- mobile LOVE only delivers
|
||||
-- love.textinput while it is armed. Every site that opens a text field must
|
||||
-- arm, every site that closes one must disarm, and disarm must be a no-op on
|
||||
-- desktop where the hosted save editor depends on text input staying on
|
||||
-- (tools/save-editor/Kit.lua, #529). A touch screen also has no ctrl+V, so
|
||||
-- the prompt grew a PASTE chip; both paste paths share _pasteIndexUrl and
|
||||
-- both honor the whitespace strip and the MAX_INDEX_URL cap.
|
||||
-- luajit tests/engine/launcher_text_input_bug578.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")
|
||||
|
||||
-- record every setTextInput transition; the assertions read this log
|
||||
local textInputLog = {}
|
||||
love.keyboard.setTextInput = function(on)
|
||||
textInputLog[#textInputLog + 1] = on
|
||||
end
|
||||
local function lastArm() return textInputLog[#textInputLog] end
|
||||
|
||||
local clipboard = ""
|
||||
love.system = love.system or {}
|
||||
love.system.getClipboardText = function() return clipboard end
|
||||
|
||||
-- _commitAddIndex hands the typed URL to ModIndex.addSource; a canned
|
||||
-- failure keeps the commit path off the network and out of options.lua
|
||||
local addSourceUrl = nil
|
||||
package.loaded["src.mods.ModIndex"] = {
|
||||
addSource = function(url) addSourceUrl = url return nil, "offline" end,
|
||||
}
|
||||
-- _commitRename goes through SaveData.renameSlot; record the call
|
||||
local renamed = nil
|
||||
package.loaded["src.core.SaveData"] = {
|
||||
renameSlot = function(version, id, text) renamed = { version, id, text } end,
|
||||
}
|
||||
|
||||
local RomImporter = require("src.import.RomImporter")
|
||||
|
||||
local ri = setmetatable({
|
||||
android = true, workState = nil, tab = "find",
|
||||
ready = {}, slots = { red = { { id = "s1", label = "OLD" } } },
|
||||
slotScroll = {}, activeSlot = {},
|
||||
}, RomImporter)
|
||||
ri._refreshSlots = function() end -- rename commit relists; nothing to relist
|
||||
|
||||
-- ---- index prompt: arm on open, disarm on escape and on commit ------------
|
||||
|
||||
ri:_promptAddIndex()
|
||||
check(ri._indexPrompt ~= nil, "the add-index prompt opens")
|
||||
eq(lastArm(), true, "opening the prompt arms setTextInput (#578)")
|
||||
|
||||
-- typed input strips whitespace (URLs never contain a literal space)
|
||||
ri:textinput("https://ex ample.com\n/idx")
|
||||
eq(ri._indexPrompt.text, "https://example.com/idx",
|
||||
"typed input lands with whitespace stripped")
|
||||
|
||||
ri:keypressed("escape")
|
||||
check(ri._indexPrompt == nil, "escape closes the prompt")
|
||||
eq(lastArm(), false, "and disarms setTextInput")
|
||||
|
||||
ri:_promptAddIndex()
|
||||
ri._indexPrompt.text = "https://example.com/index.json"
|
||||
ri:keypressed("return")
|
||||
check(ri._indexPrompt == nil, "enter commits and closes the prompt")
|
||||
eq(lastArm(), false, "commit disarms setTextInput too")
|
||||
eq(addSourceUrl, "https://example.com/index.json",
|
||||
"the committed text reaches ModIndex.addSource")
|
||||
check(ri.findNotice and ri.findNotice.ok == false,
|
||||
"a rejected source surfaces as a notice, not a crash")
|
||||
|
||||
-- ---- PASTE chip: same entry point the touch screen uses -------------------
|
||||
|
||||
ri:_promptAddIndex()
|
||||
-- the chip rect is what draw() published last frame (pinned: modal chrome
|
||||
-- ignores the page-scroll band); mousepressed hit-tests it while the
|
||||
-- prompt is up and everywhere else the prompt swallows the press
|
||||
ri._indexPasteRect = { x = 10, y = 10, width = 60, height = 24, pinned = true }
|
||||
clipboard = " https://example.com/mods/index.json\n"
|
||||
ri:mousepressed(200, 200, 1)
|
||||
eq(ri._indexPrompt.text, "", "a press outside the chip pastes nothing")
|
||||
ri:mousepressed(20, 20, 1)
|
||||
eq(ri._indexPrompt.text, "https://example.com/mods/index.json",
|
||||
"the PASTE chip lands the clipboard with whitespace stripped (#578)")
|
||||
|
||||
-- the cap holds through the button path: a 300-char clipboard cannot
|
||||
-- overflow MAX_INDEX_URL (200)
|
||||
ri._indexPrompt.text = ""
|
||||
clipboard = string.rep("a", 300)
|
||||
ri:mousepressed(20, 20, 1)
|
||||
eq(#ri._indexPrompt.text, 200, "the PASTE chip enforces MAX_INDEX_URL")
|
||||
|
||||
-- and through ctrl/cmd+V, which used to skip the cap entirely
|
||||
ri._indexPrompt.text = ""
|
||||
local savedIsDown = love.keyboard.isDown
|
||||
love.keyboard.isDown = function() return true end
|
||||
ri:keypressed("v")
|
||||
love.keyboard.isDown = savedIsDown
|
||||
eq(#ri._indexPrompt.text, 200, "ctrl+V routes through the same cap (#578)")
|
||||
ri:keypressed("escape")
|
||||
|
||||
-- ---- rename field: arm on open, disarm on escape and on commit ------------
|
||||
|
||||
ri:_beginRename("red", "s1")
|
||||
check(ri._rename ~= nil, "the rename modal opens")
|
||||
eq(lastArm(), true, "opening the rename arms setTextInput")
|
||||
ri:keypressed("escape")
|
||||
check(ri._rename == nil, "escape closes the rename")
|
||||
eq(lastArm(), false, "and disarms setTextInput")
|
||||
|
||||
ri:_beginRename("red", "s1")
|
||||
ri:textinput("!")
|
||||
ri:keypressed("return")
|
||||
eq(lastArm(), false, "committing the rename disarms setTextInput")
|
||||
eq(renamed and renamed[3], "OLD!", "the commit reaches SaveData.renameSlot")
|
||||
|
||||
-- ---- find-search field: arm on rect press, disarm on escape ---------------
|
||||
|
||||
ri.findSearchRect = { x = 100, y = 100, width = 80, height = 20 }
|
||||
ri:mousepressed(110, 110, 1)
|
||||
check(ri._findSearchFocus == true, "pressing the search field takes focus")
|
||||
eq(lastArm(), true, "and arms setTextInput")
|
||||
ri:keypressed("escape")
|
||||
check(ri._findSearchFocus == false, "escape drops the search caret")
|
||||
eq(lastArm(), false, "and disarms setTextInput")
|
||||
|
||||
-- a press elsewhere on the find tab also drops the caret and disarms
|
||||
ri:mousepressed(110, 110, 1)
|
||||
eq(lastArm(), true, "refocus for the click-away case")
|
||||
ri:mousepressed(400, 400, 1)
|
||||
check(ri._findSearchFocus == false, "a click away drops the caret")
|
||||
eq(lastArm(), false, "and disarms setTextInput")
|
||||
|
||||
-- ---- desktop contract (#529): disarm never lowers off Android -------------
|
||||
|
||||
ri.android = false
|
||||
ri:_promptAddIndex()
|
||||
eq(lastArm(), true, "desktop still arms (harmless, already on)")
|
||||
local before = #textInputLog
|
||||
ri:keypressed("escape")
|
||||
eq(#textInputLog, before,
|
||||
"desktop disarm is a no-op: the hosted save editor keeps text input on "
|
||||
.. "(#529)")
|
||||
|
||||
T.finish("launcher_text_input_bug578")
|
||||
@@ -0,0 +1,156 @@
|
||||
-- Menu button sounds (#570). HandleMenuInput_ (home/window.asm) replays
|
||||
-- SFX_PRESS_AB whenever the watched keys it just returned on include
|
||||
-- PAD_A | PAD_B, unless BIT_NO_MENU_BUTTON_SOUND is set in wMiscFlags;
|
||||
-- DisplayListMenuID watches PAD_A | PAD_B | PAD_SELECT (home/list_menu.asm),
|
||||
-- so SELECT is answered but never beeps; DisplayOptionMenu plays it only at
|
||||
-- .exitMenu (engine/menus/main_menu.asm), i.e. B, START and A on CANCEL.
|
||||
-- Silence is half the contract: a click the original does not make is as
|
||||
-- wrong as one it makes and the port swallows.
|
||||
-- luajit tests/engine/menu_click_bug570.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")
|
||||
|
||||
-- The UI modules reach for Sound lazily (require inside the press branch),
|
||||
-- so seeding package.loaded before they load is enough to see every cue.
|
||||
local played = {}
|
||||
package.loaded["src.core.Sound"] = {
|
||||
play = function(_, key) played[#played + 1] = key end,
|
||||
}
|
||||
|
||||
local ListMenu = require("src.ui.ListMenu")
|
||||
local Menu = require("src.ui.Menu")
|
||||
local OptionsMenu = require("src.ui.OptionsMenu")
|
||||
local PartyMenu = require("src.ui.PartyMenu")
|
||||
|
||||
-- A stub game with the three things a menu touches: a stack it can pop
|
||||
-- itself off, an input queue that is one fixed step of edges, and a
|
||||
-- non-nil `data` (the beep helpers skip themselves when data is nil, which
|
||||
-- is how the harness-driven screens stay silent).
|
||||
local function newGame()
|
||||
local game = { data = {}, save = { options = {}, party = {},
|
||||
flags = {}, inventory = {} } }
|
||||
game.stack = {
|
||||
states = {},
|
||||
push = function(self, s) table.insert(self.states, s) end,
|
||||
pop = function(self) return table.remove(self.states) end,
|
||||
top = function(self) return self.states[#self.states] end,
|
||||
}
|
||||
game.input = {
|
||||
queue = {},
|
||||
wasPressed = function(self, btn) return self.queue[btn] or false end,
|
||||
isDown = function() return false end,
|
||||
}
|
||||
function game:writeOptions() end
|
||||
return game
|
||||
end
|
||||
|
||||
-- one fixed step with `btn` on its edge; returns the cues it produced
|
||||
local function press(state, btn)
|
||||
played = {}
|
||||
state.game.input.queue = { [btn] = true }
|
||||
state:update(1 / 60)
|
||||
state.game.input.queue = {}
|
||||
return played
|
||||
end
|
||||
|
||||
local function beeps(state, btn)
|
||||
local cues = press(state, btn)
|
||||
for _, key in ipairs(cues) do
|
||||
if key ~= "Press_AB" then
|
||||
check(false, "unexpected cue " .. tostring(key) .. " on " .. btn)
|
||||
end
|
||||
end
|
||||
return #cues
|
||||
end
|
||||
|
||||
-- ITEM and POKéDEX are both DisplayListMenuID lists, and cancelling either
|
||||
-- was the silent half of the report.
|
||||
local function newList(opts)
|
||||
local game = newGame()
|
||||
local list = ListMenu.new(game, "BAG",
|
||||
{ { label = "POTION", value = "POTION" },
|
||||
{ label = "ANTIDOTE", value = "ANTIDOTE" } }, opts)
|
||||
game.stack:push(list)
|
||||
return list, game
|
||||
end
|
||||
|
||||
local list = newList({ onChoose = function() end })
|
||||
eq(beeps(list, "a"), 1, "the first A on an item in ITEM clicks once (#570)")
|
||||
eq(beeps(list, "b"), 1, "B out of a list clicks once (ITEM, POKéDEX cancel)")
|
||||
|
||||
local moves = newList({ onChoose = function() end,
|
||||
onSelectKey = function() end })
|
||||
eq(beeps(moves, "select"), 0,
|
||||
"SELECT is watched by DisplayListMenuID but outside its PAD_A | PAD_B "
|
||||
.. "sound test, so the swap key stays silent")
|
||||
eq(beeps(moves, "up"), 0, "moving the cursor is silent")
|
||||
eq(beeps(moves, "down"), 0, "moving the cursor is silent both ways")
|
||||
|
||||
-- Both PC sessions set BIT_NO_MENU_BUTTON_SOUND for their whole run
|
||||
-- (engine/menus/pc.asm, engine/menus/players_pc.asm), so their lists are
|
||||
-- the control case: the same code path, deliberately mute.
|
||||
local pc = newList({ noSound = true, onChoose = function() end })
|
||||
eq(beeps(pc, "a"), 0, "a PC list holds BIT_NO_MENU_BUTTON_SOUND: A is mute")
|
||||
eq(beeps(pc, "b"), 0, "and so is backing out of it")
|
||||
|
||||
-- an emptied bag still answers A and B, and HandleMenuInput_ does not care
|
||||
-- that the list has no rows
|
||||
local emptyGame = newGame()
|
||||
local empty = ListMenu.new(emptyGame, "BAG", {}, {})
|
||||
emptyGame.stack:push(empty)
|
||||
eq(beeps(empty, "a"), 1, "A out of an empty list still clicks")
|
||||
|
||||
-- OPTION: .exitMenu is the only PlaySound in DisplayOptionMenu
|
||||
local function newOptions()
|
||||
local game = newGame()
|
||||
local om = OptionsMenu.new(game)
|
||||
game.stack:push(om)
|
||||
return om
|
||||
end
|
||||
|
||||
local om = newOptions()
|
||||
om.index = 1
|
||||
eq(beeps(om, "a"), 0, "A on a setting row re-loops in the original: silent")
|
||||
eq(beeps(om, "right"), 0, "and the Left/Right toggles are silent too")
|
||||
eq(beeps(om, "left"), 0, "in both directions")
|
||||
om.index = #om.rows + 1 -- CANCEL sits one past the hook-built rows
|
||||
eq(beeps(om, "a"), 1, "A on CANCEL in OPTION clicks once (#570)")
|
||||
eq(beeps(newOptions(), "b"), 1, "B out of OPTION clicks once (#570)")
|
||||
eq(beeps(newOptions(), "start"), 1, "START leaves OPTION the same way")
|
||||
|
||||
-- POKéMON: HandlePartyMenuInput runs through HandleMenuInput_
|
||||
local function newParty()
|
||||
local game = newGame()
|
||||
local pm = PartyMenu.new(game, {})
|
||||
game.stack:push(pm)
|
||||
return pm
|
||||
end
|
||||
|
||||
eq(beeps(newParty(), "b"), 1, "B out of POKéMON clicks once (#570)")
|
||||
eq(beeps(newParty(), "a"), 1, "and A in POKéMON clicks")
|
||||
eq(beeps(newParty(), "down"), 0, "moving between slots is silent")
|
||||
|
||||
-- The start menu itself already beeped before the fix; pinned here because
|
||||
-- an over-eager patch that beeps on every watched key would break it.
|
||||
-- draw_start_menu.asm's mask includes PAD_START, and START is outside the
|
||||
-- PAD_A | PAD_B sound test, so closing the menu with it is silent.
|
||||
local function newStartMenu()
|
||||
local game = newGame()
|
||||
local m = Menu.new(game, { { label = "POKéDEX", onSelect = function() end },
|
||||
{ label = "ITEM", onSelect = function() end } },
|
||||
{ startCloses = true })
|
||||
game.stack:push(m)
|
||||
return m
|
||||
end
|
||||
|
||||
eq(beeps(newStartMenu(), "a"), 1, "A on a start-menu row clicks")
|
||||
eq(beeps(newStartMenu(), "b"), 1, "B closing the start menu clicks")
|
||||
eq(beeps(newStartMenu(), "start"), 0,
|
||||
"START closes the start menu silently: it is watched but not in the "
|
||||
.. "PAD_A | PAD_B branch")
|
||||
|
||||
T.finish("menu_click_bug570")
|
||||
@@ -0,0 +1,141 @@
|
||||
-- Boxed-menu row geometry (#564 start menu, #572 the PC and Pokédex windows).
|
||||
-- pokered's menu boxes hug their choices: the last one lands on the bottom
|
||||
-- interior row, so a double-spaced menu whose box is n*2+2 tall starts at
|
||||
-- ty+2 -- draw_start_menu.asm (TextBoxBorder at 10,0 b=$0e, then hlcoord
|
||||
-- 12,2 with wTopMenuItemY 2), players_pc.asm (0,0 b=8 c=14, hlcoord 2,2).
|
||||
-- The port drew every row one tile high, which parked the text against the
|
||||
-- top border and left the bottom interior row blank.
|
||||
-- ROM-free: builds the real screens over tests/fixture_data.
|
||||
-- luajit tests/engine/menu_row_offset_bug564.lua
|
||||
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local T = require("tests.modkit")
|
||||
local check, eq, same = T.check, T.eq, T.same
|
||||
local Data = T.fixtures.load()
|
||||
|
||||
local Font = require("src.render.Font")
|
||||
local SaveData = require("src.core.SaveData")
|
||||
local Screens = require("src.ui.Screens")
|
||||
local StateStack = require("src.core.StateStack")
|
||||
local Theme = require("src.ui.Theme")
|
||||
|
||||
local function newGame()
|
||||
local stack = setmetatable({}, { __index = StateStack })
|
||||
stack:init()
|
||||
local save = SaveData.newGame()
|
||||
save.flags = save.flags or {}
|
||||
save.flags.EVENT_GOT_POKEDEX = true
|
||||
save.pokedex = { seen = { FIXMON_A = true }, owned = { FIXMON_A = true } }
|
||||
save.inventory = { FIX_POTION = 3 }
|
||||
return {
|
||||
data = Data, save = save, stack = stack,
|
||||
input = {
|
||||
queue = {},
|
||||
wasPressed = function(self, btn) return self.queue[btn] or false end,
|
||||
isDown = function() return false end,
|
||||
},
|
||||
}
|
||||
end
|
||||
|
||||
-- Draw the menu with the font stubbed out and report, in tile rows, where
|
||||
-- the labels, the ▶ cursor and the "more below" arrow actually landed.
|
||||
local function layout(menu)
|
||||
local realDraw, realCode = Font.draw, Font.drawCode
|
||||
local out = { rows = {}, cols = {} }
|
||||
Font.draw = function(_, x, y)
|
||||
out.rows[#out.rows + 1] = y / 8
|
||||
out.cols[#out.cols + 1] = x / 8
|
||||
return 8
|
||||
end
|
||||
Font.drawCode = function(code, x, y)
|
||||
if code == Theme.cursor then
|
||||
out.cursor, out.cursorCol = y / 8, x / 8
|
||||
elseif code == Theme.moreArrow then
|
||||
out.arrow = y / 8
|
||||
end
|
||||
end
|
||||
local ok, err = pcall(menu.draw, menu)
|
||||
Font.draw, Font.drawCode = realDraw, realCode
|
||||
if not ok then error(err, 0) end
|
||||
return out
|
||||
end
|
||||
|
||||
-- Every boxed menu owes the same two things to its border, and the second
|
||||
-- is the one #564/#572 were about: a menu that starts at ty+1 leaves the
|
||||
-- bottom interior row empty and the text crowded under the top edge.
|
||||
local function boxRules(menu, label)
|
||||
local at = layout(menu)
|
||||
local first, last = at.rows[1], at.rows[#at.rows]
|
||||
check(first and first >= menu.ty + 1 and last <= menu.ty + menu.th - 2,
|
||||
label .. ": every choice sits inside the border (rows "
|
||||
.. tostring(first) .. ".." .. tostring(last) .. ", interior "
|
||||
.. (menu.ty + 1) .. ".." .. (menu.ty + menu.th - 2) .. ")")
|
||||
eq(last, menu.ty + menu.th - 2,
|
||||
label .. ": the last choice lands on the bottom interior row")
|
||||
eq(at.cursor, at.rows[menu.index - menu.scroll],
|
||||
label .. ": the cursor shares its row with the highlighted choice")
|
||||
eq(at.cursorCol, menu.tx + 1, label .. ": the cursor sits one tile in")
|
||||
return at
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------- #564
|
||||
-- draw_start_menu.asm: box at (10,0) 14 rows tall, first choice at row 2,
|
||||
-- seven double-spaced choices, so the last is on row 14 and the blank row
|
||||
-- is the one under the top border.
|
||||
local game = newGame()
|
||||
local start = Screens.push(game, "StartMenu")
|
||||
eq(#start.items, 7, "vanilla start menu has seven rows with the dex")
|
||||
local at = boxRules(start, "start menu")
|
||||
same(at.rows, { 2, 4, 6, 8, 10, 12, 14 }, "start menu rows match hlcoord 12,2")
|
||||
eq(at.cols[1], start.tx + 2, "start menu labels sit two tiles in")
|
||||
|
||||
start.index = 3
|
||||
eq(layout(start).cursor, 6, "cursor follows the third row down to row 6")
|
||||
|
||||
-- The "more below" glyph rides the bottom border, because the bottom
|
||||
-- interior row now belongs to a choice (#564).
|
||||
local Menu = require("src.ui.Menu")
|
||||
local many = {}
|
||||
for i = 1, 10 do many[i] = { label = "ROW" .. i } end
|
||||
local scrolled = Menu.new(game, many,
|
||||
{ tx = 9, ty = 0, tw = 11, maxVisible = 8 }) -- StartMenu's own opts
|
||||
local sat = layout(scrolled)
|
||||
eq(sat.arrow, scrolled.ty + scrolled.th - 1,
|
||||
"the more-below arrow is on the border row")
|
||||
check(sat.arrow > sat.rows[#sat.rows],
|
||||
"the arrow is clear of the last visible choice")
|
||||
|
||||
-- ---------------------------------------------------------------- #572
|
||||
-- players_pc.asm: TextBoxBorder (0,0) b=8 c=14 -> a 16x10 box whose four
|
||||
-- choices start at hlcoord 2,2.
|
||||
local pc = Screens.push(newGame(), "PlayerPC")
|
||||
eq(pc.th, 10, "player's PC box is 10 tiles tall")
|
||||
same(boxRules(pc, "player's PC").rows, { 2, 4, 6, 8 },
|
||||
"player's PC rows match hlcoord 2,2")
|
||||
|
||||
-- The Pokédex side menu (pokedex.asm PokedexMenuItemsText): DATA / CRY /
|
||||
-- AREA / QUIT, opened by choosing a seen species off the dex list.
|
||||
local dexGame = newGame()
|
||||
local dexList = Screens.push(dexGame, "PokedexMenu", {})
|
||||
local entry = dexList.items[1]
|
||||
eq(entry.value, "FIXMON_A", "the fixture dex list opens on a seen species")
|
||||
dexList.onChoose(entry, dexList)
|
||||
local side = dexGame.stack:top()
|
||||
check(side ~= dexList, "choosing an entry pushes the side menu")
|
||||
same(boxRules(side, "Pokédex side menu").rows, { 10, 12, 14, 16 },
|
||||
"side menu rows fill its box")
|
||||
|
||||
-- The bag's USE / TOSS box is the one menu pokered draws without a blank
|
||||
-- leading row: USE_TOSS_MENU_TEMPLATE (data/text_boxes.asm) is (13,10) to
|
||||
-- (19,14) with the text at 15,11, so two double-spaced choices exactly
|
||||
-- fill rows 11 and 13. #284 sized the port's box to that, which is why
|
||||
-- the shared "first choice at ty+2" rule pushes TOSS onto the border here.
|
||||
local bagGame = newGame()
|
||||
local bag = Screens.push(bagGame, "BagMenu")
|
||||
bag.onChoose(bag.items[1], bag)
|
||||
local useToss = bagGame.stack:top()
|
||||
eq(#useToss.items, 2, "the bag submenu is USE / TOSS")
|
||||
boxRules(useToss, "bag USE/TOSS")
|
||||
|
||||
T.finish("menu_row_offset_bug564")
|
||||
@@ -0,0 +1,129 @@
|
||||
-- Regression coverage for #601 "Wrong dialogue when interacting with Prof.
|
||||
-- Oak's last ball" (T2, ROM-free).
|
||||
--
|
||||
-- pret/pokered scripts/OaksLab.asm OaksLabSelectedPokeBallScript: once
|
||||
-- EVENT_GOT_STARTER is set, EVERY ball's text handler jumps to
|
||||
-- OaksLabLastMonScript -- Oak turns to face the player and reads
|
||||
-- "_OaksLabLastMonText" ("That's PROF.OAK's last #MON!") instead of
|
||||
-- re-offering the starter. The buggy port fell through to
|
||||
-- _OaksLabThoseArePokeBallsText ("Those are POKé BALLs...") on every ball
|
||||
-- once a starter had been picked. The fix also spells the ROM's "#MON"
|
||||
-- ligature out as "Pokémon".
|
||||
--
|
||||
-- The three ball scripts share one table (starterBall), so this suite
|
||||
-- drives that table through a mini ScriptRunner-compatible executor:
|
||||
-- flag checks, jumps, "end" halts and text rows are executed, UI-heavy
|
||||
-- commands (push_screen, ask, give_pokemon, npc moves) are no-ops with
|
||||
-- ask recording the offer text. It then asserts the whole flow:
|
||||
-- * GOT_STARTER + talk -> Oak faces down + "last Pokémon!" line, ends
|
||||
-- * no GOT_STARTER, not escorted in -> "Those are POKé BALLs"
|
||||
-- * no GOT_STARTER, escorted in -> the dex/ask offer (unchanged path)
|
||||
-- plus MapScripts.validateContribution stays clean (the pre-fix table
|
||||
-- carried nine out-of-range "jump 21" findings -- its run-time "end").
|
||||
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local T = require("tests.harness")
|
||||
local MapScripts = require("src.script.MapScripts")
|
||||
|
||||
local contribution = dofile("data/scripts/oaks_lab.lua")
|
||||
local problems = MapScripts.validateContribution(contribution)
|
||||
T.eq(#problems, 0, "oaks_lab contribution validates cleanly")
|
||||
for _, p in ipairs(problems) do
|
||||
T.check(false, "unexpected finding: " .. p)
|
||||
end
|
||||
|
||||
local BALL = "TEXT_OAKSLAB_CHARMANDER_POKE_BALL"
|
||||
|
||||
-- ---- mini executor over the talk rows (ScriptRunner semantics: a jump
|
||||
-- command returns the next row index or "end" to halt)
|
||||
|
||||
local function run(script, flags, answer)
|
||||
local pc, texts, offers = 1, {}, {}
|
||||
local lastCheck = nil
|
||||
while pc <= #script do
|
||||
local row = script[pc]
|
||||
local verb = row[1]
|
||||
if verb == "check_flag" then
|
||||
lastCheck = flags[row[2]] == true
|
||||
elseif verb == "jump_if_true" then
|
||||
if lastCheck then
|
||||
if row[2] == "end" then break end
|
||||
pc = row[2] goto next
|
||||
end
|
||||
elseif verb == "jump_if_false" then
|
||||
if not lastCheck then
|
||||
if row[2] == "end" then break end
|
||||
pc = row[2] goto next
|
||||
end
|
||||
elseif verb == "jump" then
|
||||
if row[2] == "end" then break end
|
||||
pc = row[2]
|
||||
goto next
|
||||
elseif verb == "show_text" then
|
||||
texts[#texts + 1] = row[2]
|
||||
elseif verb == "ask" then
|
||||
offers[#offers + 1] = row[2]
|
||||
if answer == false then
|
||||
pc = pc + 1 -- decline: the next row's jump_if_false decides
|
||||
goto next
|
||||
end
|
||||
end
|
||||
-- push_screen / give_pokemon / set_flag / hide_object / move_npc_to /
|
||||
-- face_object: no-op here (set_flag is exercised via the fixture
|
||||
-- flags table instead of being run)
|
||||
pc = pc + 1
|
||||
::next::
|
||||
end
|
||||
return texts, offers
|
||||
end
|
||||
|
||||
local function concat(list)
|
||||
return table.concat(list, "\n")
|
||||
end
|
||||
|
||||
-- ---- leftover ball after the pick: Oak faces down + the last-mon line
|
||||
local got = { EVENT_GOT_STARTER = true, EVENT_FOLLOWED_OAK_INTO_LAB = true }
|
||||
local texts, offers = run(contribution.talk[BALL], got, true)
|
||||
T.eq(#offers, 0, "no starter offer after the pick")
|
||||
local box = concat(texts)
|
||||
T.check(box:find("last Pokémon!", 1, true) ~= nil,
|
||||
"leftover ball says the last-mon line (got: " .. box .. ")")
|
||||
T.check(box:find("Those are", 1, true) == nil,
|
||||
"leftover ball no longer says 'Those are POKé BALLs'")
|
||||
T.check(box:find("#MON", 1, true) == nil,
|
||||
"the ROM #MON ligature is spelled out as Pokémon")
|
||||
|
||||
-- the pokered beat also turns Oak to face the player
|
||||
T.check(contribution.talk[BALL][20][1] == "face_object"
|
||||
and contribution.talk[BALL][20][2] == 5
|
||||
and contribution.talk[BALL][20][3] == "down",
|
||||
"row 20 faces Oak down before the line (OaksLabLastMonScript)")
|
||||
|
||||
-- ---- pre-escort: still the vanilla "Those are POKé BALLs" line
|
||||
local pre = { EVENT_GOT_STARTER = false, EVENT_FOLLOWED_OAK_INTO_LAB = false }
|
||||
local t2, o2 = run(contribution.talk[BALL], pre, true)
|
||||
T.eq(#o2, 0, "no offer before Oak escorts the player in")
|
||||
T.check(concat(t2):find("ThoseArePokeBalls", 1, true) ~= nil,
|
||||
"pre-escort balls keep the 'Those are POKé BALLs' line")
|
||||
|
||||
-- ---- escorted in but no pick yet: the dex + "You want X?" offer
|
||||
local mid = { EVENT_GOT_STARTER = false, EVENT_FOLLOWED_OAK_INTO_LAB = true }
|
||||
local t3, o3 = run(contribution.talk[BALL], mid, true)
|
||||
T.eq(#o3, 1, "the starter offer still runs before the pick")
|
||||
T.check(concat(t3):find("last Pokémon!", 1, true) == nil,
|
||||
"no last-mon line before the pick")
|
||||
|
||||
-- ---- all three balls share the same table shape (last-mon beat present)
|
||||
for _, key in ipairs({
|
||||
"TEXT_OAKSLAB_CHARMANDER_POKE_BALL",
|
||||
"TEXT_OAKSLAB_SQUIRTLE_POKE_BALL",
|
||||
"TEXT_OAKSLAB_BULBASAUR_POKE_BALL",
|
||||
}) do
|
||||
local script = contribution.talk[key]
|
||||
T.check(script and script[21] and script[21][2] and
|
||||
script[21][2]:find("Pokémon", 1, true) ~= nil,
|
||||
key .. " carries the last-mon line")
|
||||
end
|
||||
|
||||
T.finish("oaks_lab_last_ball_bug601")
|
||||
@@ -0,0 +1,163 @@
|
||||
-- Prof. Oak's PC session (#576): engine/menus/oaks_pc.asm OpenOaksPC --
|
||||
-- the access text, "Want to get your #DEX rated?" with a YES/NO, the dex
|
||||
-- rating (completion line + tier text), the rating jingle only once the
|
||||
-- rating text has printed (DisplayDexRating -> PlayPokedexRatingSfx), and
|
||||
-- the "Closed link to PROF.OAK's PC." tail before control returns. The
|
||||
-- old flow played the jingle the moment the entry was picked and skipped
|
||||
-- both the intro and the closing link.
|
||||
-- ROM-free: uses the fixture dataset so CI (no data/generated/) stays green.
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local T = require("tests.modkit")
|
||||
local Data = T.fixtures.load()
|
||||
|
||||
-- the ROM-extracted strings the fixture text table does not carry; labels
|
||||
-- and wording match pokered text/pokedex_ratings.asm + oaks_pc.asm
|
||||
Data.text._AccessedOaksPCText =
|
||||
"Accessed PROF.\nOAK's PC.\fAccessed #DEX\nRating System."
|
||||
Data.text._GetDexRatedText = "Want to get your\n#DEX rated?"
|
||||
Data.text._ClosedOaksPCText = "Closed link to\nPROF.OAK's PC."
|
||||
Data.text._DexCompletionText =
|
||||
"#DEX comp-\nletion is:\f{NUM:hDexRatingNumMonsSeen} #MON seen\n" ..
|
||||
"{NUM:hDexRatingNumMonsOwned} #MON owned\fPROF.OAK's\nRating:"
|
||||
Data.text._DexRatingText_Own50To59 =
|
||||
"You finally got at\nleast 50 species!"
|
||||
|
||||
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 plays = {}
|
||||
local stackStub = {
|
||||
push = function(_, item)
|
||||
pushed[#pushed + 1] = item
|
||||
end,
|
||||
}
|
||||
local textBoxStub = {
|
||||
new = function(_, text, onDone, opts)
|
||||
return { kind = "text", text = text, onDone = onDone, opts = opts }
|
||||
end,
|
||||
}
|
||||
local menuStub = {
|
||||
new = function(_, items, opts)
|
||||
return { kind = "menu", items = items, opts = opts or {} }
|
||||
end,
|
||||
}
|
||||
-- Sound / Menu are required lazily at the call sites; stub via package.loaded
|
||||
local realSound = package.loaded["src.core.Sound"]
|
||||
package.loaded["src.core.Sound"] = {
|
||||
play = function(_, name)
|
||||
plays[#plays + 1] = name
|
||||
end,
|
||||
playCry = function() end,
|
||||
}
|
||||
local realMenu = package.loaded["src.ui.Menu"]
|
||||
package.loaded["src.ui.Menu"] = menuStub
|
||||
|
||||
local fakeGame = {
|
||||
data = Data,
|
||||
save = SaveData.newGame(),
|
||||
stack = stackStub,
|
||||
}
|
||||
for _, name in ipairs({ "openOaksPC", "dexRating" }) do
|
||||
T.check(setUpvalue(OW[name], "TextBox", textBoxStub),
|
||||
("TextBox upvalue on %s"):format(name))
|
||||
T.check(setUpvalue(OW[name], "Game", fakeGame),
|
||||
("Game upvalue on %s"):format(name))
|
||||
end
|
||||
T.check(setUpvalue(OW.openPC, "Game", fakeGame), "Game upvalue on openPC")
|
||||
|
||||
local fakeSelf = setmetatable({}, { __index = OW })
|
||||
|
||||
local function lastPush()
|
||||
return pushed[#pushed]
|
||||
end
|
||||
local function reset()
|
||||
fakeGame.save = SaveData.newGame()
|
||||
fakeGame.save.flags.EVENT_GOT_POKEDEX = true
|
||||
local seen, owned = {}, {}
|
||||
for i = 1, 55 do seen[i] = true; owned[i] = true end
|
||||
fakeGame.save.pokedex = { seen = seen, owned = owned }
|
||||
pushed = {}
|
||||
plays = {}
|
||||
end
|
||||
local function runChain()
|
||||
-- A through every box that is up; the choice box answers YES
|
||||
local guard = 0
|
||||
while lastPush() and lastPush().kind == "text" and guard < 10 do
|
||||
guard = guard + 1
|
||||
local box = lastPush()
|
||||
if box.opts and box.opts.choice then
|
||||
box.opts.choice(true)
|
||||
elseif box.onDone then
|
||||
box.onDone()
|
||||
else
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- === full session from the launcher menu: intro, YES, rating, jingle, close
|
||||
reset()
|
||||
local done = false
|
||||
fakeSelf:openPC(function() done = true end)
|
||||
local menu = lastPush()
|
||||
T.eq(menu.kind, "menu", "openPC pushes the PC menu")
|
||||
local oak
|
||||
for _, item in ipairs(menu.items) do
|
||||
if item.label == "PROF.OAK's PC" then oak = item end
|
||||
end
|
||||
T.check(oak ~= nil, "PROF.OAK's PC is offered once the Pokédex is had")
|
||||
plays = {} -- drop the menu's Turn_On_PC; the session's jingle is what counts
|
||||
oak.onSelect()
|
||||
T.eq(pushed[2].kind, "text", "selection opens the access text")
|
||||
T.check(tostring(pushed[2].text):find("Accessed", 1, true) ~= nil,
|
||||
"first session box is the access text")
|
||||
runChain()
|
||||
T.check(done, "session completes")
|
||||
T.check(pushed[3].opts ~= nil and pushed[3].opts.choice ~= nil,
|
||||
"the rated question carries the YES/NO choice")
|
||||
T.check(tostring(pushed[3].text):find("rated", 1, true) ~= nil,
|
||||
"second session box asks for the rating")
|
||||
local ratingBox = pushed[4]
|
||||
T.check(ratingBox.opts and ratingBox.opts.auto ~= nil
|
||||
and ratingBox.opts.auto.wait ~= nil,
|
||||
"rating box sounds the jingle then waits for a button")
|
||||
T.check(tostring(ratingBox.text):find("55", 1, true) ~= nil,
|
||||
"completion line carries the seen/owned counts")
|
||||
T.check(tostring(ratingBox.text):find("least 50 species", 1, true) ~= nil,
|
||||
"rating box carries the Own50To59 tier text")
|
||||
T.eq(#plays, 0, "no jingle while the evaluation is printing")
|
||||
ratingBox.opts.auto.sound()
|
||||
T.eq(#plays, 1, "jingle fires once the rating text is printed")
|
||||
T.eq(plays[1], "Pokedex_Rating", "jingle is the Pokedex_Rating fanfare")
|
||||
T.check(tostring(pushed[5].text):find("Closed link", 1, true) ~= nil,
|
||||
"the closing link prints at the end of the session")
|
||||
|
||||
-- === declining the rating skips the evaluation but still closes the link
|
||||
reset()
|
||||
done = false
|
||||
fakeSelf:openOaksPC(function() done = true end)
|
||||
T.eq(pushed[1].kind, "text", "openOaksPC opens with the access text")
|
||||
pushed[1].onDone()
|
||||
T.check(pushed[2].opts and pushed[2].opts.choice ~= nil,
|
||||
"rated question is a YES/NO")
|
||||
pushed[2].opts.choice(false)
|
||||
T.check(tostring(lastPush().text):find("Closed link", 1, true) ~= nil,
|
||||
"NO skips the rating and closes the PC")
|
||||
T.eq(#plays, 0, "declining plays no jingle")
|
||||
|
||||
package.loaded["src.ui.Menu"] = realMenu
|
||||
if realSound ~= nil then package.loaded["src.core.Sound"] = realSound end
|
||||
|
||||
T.finish("oaks_pc_flow")
|
||||
@@ -0,0 +1,94 @@
|
||||
-- QUIT on a Pokédex entry closes the whole Pokédex (#571).
|
||||
-- HandlePokedexSideMenu hands ShowPokedexMenu b=1 for QUIT and b=2 for B
|
||||
-- (engine/menus/pokedex.asm); only b=2 loops back to .doPokemonListMenu,
|
||||
-- b=1 falls through to .exitPokedex, which drops the dex and returns to
|
||||
-- whoever opened it with wBattleAndStartSavedMenuItem intact. The port
|
||||
-- popped the side menu and left the list up, so QUIT looked like B.
|
||||
-- ROM-free: real StateStack and real screens over tests/fixture_data.
|
||||
-- luajit tests/engine/pokedex_quit_bug571.lua
|
||||
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local T = require("tests.modkit")
|
||||
local check, eq = T.check, T.eq
|
||||
local Data = T.fixtures.load()
|
||||
|
||||
local SaveData = require("src.core.SaveData")
|
||||
local Screens = require("src.ui.Screens")
|
||||
local StateStack = require("src.core.StateStack")
|
||||
|
||||
local function newGame()
|
||||
local stack = setmetatable({}, { __index = StateStack })
|
||||
stack:init()
|
||||
local save = SaveData.newGame()
|
||||
save.flags = save.flags or {}
|
||||
save.flags.EVENT_GOT_POKEDEX = true
|
||||
save.pokedex = { seen = { FIXMON_A = true }, owned = { FIXMON_A = true } }
|
||||
return {
|
||||
data = Data, save = save, stack = stack,
|
||||
input = {
|
||||
queue = {},
|
||||
wasPressed = function(self, btn) return self.queue[btn] or false end,
|
||||
isDown = function() return false end,
|
||||
},
|
||||
}
|
||||
end
|
||||
|
||||
local function press(game, btn)
|
||||
game.input.queue = { [btn] = true }
|
||||
game.stack:update(1 / 60)
|
||||
game.input.queue = {}
|
||||
end
|
||||
|
||||
-- what the stack is carrying, named the way Screens tags its screens
|
||||
local function stackIds(game)
|
||||
local ids = {}
|
||||
for i, state in ipairs(game.stack.states) do
|
||||
ids[i] = tostring(state.screenId or "sideMenu")
|
||||
end
|
||||
return table.concat(ids, " > ")
|
||||
end
|
||||
|
||||
local function openSideMenu(game)
|
||||
local start = Screens.push(game, "StartMenu")
|
||||
eq(start.items[start.index].label, "POKéDEX",
|
||||
"the start menu opens with the cursor on POKéDEX")
|
||||
press(game, "a")
|
||||
local list = game.stack:top()
|
||||
eq(list.screenId, "PokedexMenu", "POKéDEX opens the dex list")
|
||||
eq(list.items[list.index].value, "FIXMON_A",
|
||||
"the cursor is on a seen species, so A opens the side menu")
|
||||
press(game, "a")
|
||||
local side = game.stack:top()
|
||||
check(side ~= list, "A on the entry pushed the DATA/CRY/AREA/QUIT menu")
|
||||
return list, side
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------- QUIT
|
||||
local game = newGame()
|
||||
local list, side = openSideMenu(game)
|
||||
-- the start menu popped itself when POKéDEX was chosen (Menu pops before
|
||||
-- onSelect unless the row is keepOpen), so this is dex list + side menu
|
||||
eq(#game.stack.states, 2, "dex list and side menu are what is on the stack")
|
||||
eq(side.items[#side.items].label, "QUIT", "QUIT is the last side-menu row")
|
||||
side.index = #side.items
|
||||
press(game, "a")
|
||||
|
||||
eq(#game.stack.states, 1,
|
||||
"QUIT unwound the side menu and the dex list (" .. stackIds(game) .. ")")
|
||||
local top = game.stack:top()
|
||||
eq(top.screenId, "StartMenu", "QUIT lands back on the start menu")
|
||||
check(top ~= list, "the dex list is not what is left on the stack")
|
||||
eq(top.items[top.index].label, "POKéDEX",
|
||||
"the start menu cursor is still on POKéDEX (wBattleAndStartSavedMenuItem)")
|
||||
|
||||
-- ---------------------------------------------------------------- B
|
||||
-- The control case: B is b=2, which re-shows the list rather than leaving.
|
||||
-- If QUIT and B ever behave the same again, one of these two fails.
|
||||
local bGame = newGame()
|
||||
local bList = select(1, openSideMenu(bGame))
|
||||
press(bGame, "b")
|
||||
eq(#bGame.stack.states, 1, "B off the side menu keeps the dex list up")
|
||||
eq(bGame.stack:top(), bList, "B returns to the dex list, not the start menu")
|
||||
|
||||
T.finish("pokedex_quit_bug571")
|
||||
@@ -2,7 +2,10 @@
|
||||
-- screen that captured it is still steering by that map (#510). Swapping A
|
||||
-- and B used to close the screen mid-swap, because Input:applyBindings ran
|
||||
-- inside BindingsMenu:storeBinding and turned the player's next confirm
|
||||
-- press into a cancel. No pokered cite: rebinding is port-only (gap C2).
|
||||
-- press into a cancel. A capture commits on the RELEASE of its press, a
|
||||
-- second held input cancels it, and a captured input that another row owns
|
||||
-- swaps rather than steals (#589). No pokered cite: rebinding is
|
||||
-- port-only (gap C2).
|
||||
-- luajit tests/engine/rebind_capture_bug510.lua
|
||||
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
@@ -73,19 +76,29 @@ eq(game.wroteOptions, 0, "and does not touch options on disk")
|
||||
bm.index = ROW_B
|
||||
press(bm, "a")
|
||||
bm:onKeyPressed("z")
|
||||
eq(game.save.options.bindings.b.key, "z", "the capture stores B = Z")
|
||||
eq(bm.items[ROW_B].right, "Z", "the row shows the new key straight away")
|
||||
check(game.save.options.bindings == nil,
|
||||
"a capture holds its press; nothing stores before the release (#589)")
|
||||
bm:onKeyReleased("z")
|
||||
eq(game.save.options.bindings.b.key, "z", "releasing the press stores B = Z")
|
||||
eq(bm.items[ROW_B].right, "Z/B", "the row shows the new key straight away")
|
||||
eq(game.wroteOptions, 1, "the choice persists immediately")
|
||||
eq(Input.keyBindings["z"], "a",
|
||||
"but the live map still reads Z as A while the screen is open (#510)")
|
||||
eq(#game.stack.states, 1, "capturing Z does not close the screen")
|
||||
|
||||
-- Z was the A row's effective key, so the steal became a swap: the A row
|
||||
-- inherits B's previous key and no key serves two rows (#589)
|
||||
eq(game.save.options.bindings.a.key, "x",
|
||||
"capturing A's key for B hands A the old B key")
|
||||
eq(bm.items[ROW_A].right, "X/A", "and the A row redraws with it")
|
||||
|
||||
-- the next Z the player presses is still confirm, so the A row can be armed
|
||||
bm.index = ROW_A
|
||||
press(bm, "a")
|
||||
eq(bm.capture, bm.items[ROW_A], "the A row arms instead of the screen closing")
|
||||
bm:onKeyPressed("x")
|
||||
eq(game.save.options.bindings.a.key, "x", "the swap's other half stores")
|
||||
bm:onKeyReleased("x")
|
||||
eq(game.save.options.bindings.a.key, "x", "re-capturing A's own key keeps it")
|
||||
eq(Input.keyBindings["x"], "b", "and X is still cancel until the screen closes")
|
||||
|
||||
-- closing commits both halves at once, through ListMenu's onCancel
|
||||
@@ -107,8 +120,23 @@ local padBm = openMenu(padGame)
|
||||
padBm.index = ROW_B
|
||||
press(padBm, "a")
|
||||
padBm:onGamepadPressed("y")
|
||||
padBm:onGamepadReleased("y")
|
||||
eq(padGame.save.options.bindings.b.pad, "y", "a pad capture stores")
|
||||
eq(Input.padBindings["y"], nil, "and stays out of the live pad map until close")
|
||||
|
||||
-- a second input going down while the first is held backs the capture out
|
||||
-- with no keyboard in reach, the pad's Escape (#589)
|
||||
padBm.index = ROW_A
|
||||
press(padBm, "a")
|
||||
padBm:onGamepadPressed("x")
|
||||
padBm:onGamepadPressed("b")
|
||||
check(padBm.capture == nil, "a second press cancels the armed capture")
|
||||
-- Game only calls the hook while it is armed; the straggling release of
|
||||
-- the first button reaches a disarmed menu and stores nothing
|
||||
if padBm.onGamepadReleased then padBm:onGamepadReleased("x") end
|
||||
eq(padGame.save.options.bindings.a, nil,
|
||||
"a cancelled capture's release writes no binding")
|
||||
|
||||
press(padBm, "b")
|
||||
eq(Input.padBindings["y"], "b", "closing commits the pad half too")
|
||||
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
-- CONTROLS rebinding, the #589 feature set beyond the capture deferral that
|
||||
-- tests/engine/rebind_capture_bug510.lua pins: a captured pad button another
|
||||
-- row effectively owns SWAPS with that row (no input serves two rows, no row
|
||||
-- goes empty), a second input of either kind cancels an armed capture,
|
||||
-- SELECT forgets one row's rebind so it falls back to the default, and START
|
||||
-- confirms then drops the whole overlay. Everything is driven through the
|
||||
-- entry points Game routes raw input to (onKeyPressed/onKeyReleased/
|
||||
-- onGamepadPressed/onGamepadReleased) and through update() for the menu
|
||||
-- keys. No pokered cite: rebinding is port-only (gap C2).
|
||||
-- luajit tests/engine/rebind_swap_clear_bug589.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 Input = require("src.core.Input")
|
||||
local Strings = require("src.core.Strings")
|
||||
local BindingsMenu = require("src.ui.BindingsMenu")
|
||||
|
||||
-- same doubles as rebind_capture_bug510: a stack the menu can pop itself
|
||||
-- off and an input whose queue is one fixed step of edges. data = {} keeps
|
||||
-- ChoiceBox's un-guarded Sound.play on the headless no-audio path.
|
||||
local function newGame()
|
||||
local game = { save = { options = {} }, data = {}, wroteOptions = 0 }
|
||||
game.stack = {
|
||||
states = {},
|
||||
push = function(self, s) table.insert(self.states, s) end,
|
||||
pop = function(self) return table.remove(self.states) end,
|
||||
top = function(self) return self.states[#self.states] end,
|
||||
}
|
||||
game.input = {
|
||||
queue = {},
|
||||
wasPressed = function(self, btn) return self.queue[btn] or false end,
|
||||
isDown = function() return false end,
|
||||
}
|
||||
function game:writeOptions() self.wroteOptions = self.wroteOptions + 1 end
|
||||
return game
|
||||
end
|
||||
|
||||
local function press(state, btn)
|
||||
state.game.input.queue = { [btn] = true }
|
||||
state:update(1 / 60)
|
||||
state.game.input.queue = {}
|
||||
end
|
||||
|
||||
-- rows are BindingsMenu's BUTTONS order
|
||||
local ROW_A, ROW_B, ROW_SELECT = 5, 6, 8
|
||||
|
||||
-- ---- (a) pad capture swaps with the row that owns the button --------------
|
||||
|
||||
Input:init()
|
||||
local game = newGame()
|
||||
local bm = BindingsMenu.new(game)
|
||||
game.stack:push(bm)
|
||||
|
||||
bm.index = ROW_A
|
||||
press(bm, "a")
|
||||
eq(bm.capture, bm.items[ROW_A], "A arms the A row")
|
||||
bm:onGamepadPressed("b")
|
||||
check(game.save.options.bindings == nil,
|
||||
"a pad capture holds its press; nothing stores before the release")
|
||||
bm:onGamepadReleased("b")
|
||||
local bindings = game.save.options.bindings
|
||||
eq(bindings.a.pad, "b", "releasing pad B stores it on the A row")
|
||||
eq(bindings.b.pad, "a",
|
||||
"and the B row, which owned pad B, inherits the A row's old pad (#589)")
|
||||
eq(bm.items[ROW_A].right, "Z/B", "the A row redraws with the new pad")
|
||||
eq(bm.items[ROW_B].right, "X/A", "so does the B row")
|
||||
|
||||
-- after applying, every row's effective pad is unique and none is lost
|
||||
Input:applyBindings(bindings)
|
||||
eq(Input.padBindings["b"], "a", "applied: pad B is action A")
|
||||
eq(Input.padBindings["a"], "b", "applied: pad A is action B")
|
||||
local seen, actions = {}, {}
|
||||
for button, action in pairs(Input.padBindings) do
|
||||
check(not seen[action], "no action is reachable from two pad buttons: "
|
||||
.. tostring(action))
|
||||
seen[action] = button
|
||||
actions[#actions + 1] = action
|
||||
end
|
||||
eq(#actions, 8, "all eight actions still have exactly one pad button")
|
||||
Input:init()
|
||||
|
||||
-- ---- (b) a second input while the first is held cancels -------------------
|
||||
|
||||
-- key then key: the straggling release of the first press writes nothing
|
||||
bm.index = ROW_SELECT
|
||||
press(bm, "a")
|
||||
bm:onKeyPressed("q")
|
||||
bm:onKeyPressed("w")
|
||||
check(bm.capture == nil, "a second key cancels the armed capture")
|
||||
if bm.onKeyReleased then bm:onKeyReleased("q") end
|
||||
check(bindings.select == nil, "the cancelled key capture wrote nothing")
|
||||
|
||||
-- key then pad: cancel crosses input kinds too
|
||||
press(bm, "a")
|
||||
bm:onKeyPressed("q")
|
||||
bm:onGamepadPressed("x")
|
||||
check(bm.capture == nil, "a pad press cancels a held key capture")
|
||||
if bm.onKeyReleased then bm:onKeyReleased("q") end
|
||||
if bm.onGamepadReleased then bm:onGamepadReleased("x") end
|
||||
check(bindings.select == nil, "and still nothing stored")
|
||||
local writesAfterSwap = game.wroteOptions
|
||||
|
||||
-- ---- (c) commit happens on release, not press -----------------------------
|
||||
|
||||
press(bm, "a")
|
||||
bm:onKeyPressed("q")
|
||||
check(bindings.select == nil, "press alone commits nothing (#589)")
|
||||
eq(game.wroteOptions, writesAfterSwap, "and touches nothing on disk")
|
||||
bm:onKeyReleased("q")
|
||||
eq(bindings.select.key, "q", "the release is the commit")
|
||||
eq(bm.items[ROW_SELECT].right, "Q/BACK", "the row shows the new key")
|
||||
|
||||
-- ---- (d) SELECT on a row forgets its rebind -------------------------------
|
||||
|
||||
press(bm, "select")
|
||||
check(bindings.select == nil, "SELECT drops the row's overlay entry (#589)")
|
||||
eq(bm.items[ROW_SELECT].right, "TAB/BACK", "the row falls back to the default")
|
||||
-- the swapped B row clears the same way; a second SELECT on the now
|
||||
-- default row is a no-op, not a write
|
||||
local writes = game.wroteOptions
|
||||
bm.index = ROW_B
|
||||
press(bm, "select")
|
||||
eq(game.wroteOptions, writes + 1, "clearing the swapped B row writes once")
|
||||
check(game.save.options.bindings.b == nil, "and drops its overlay entry")
|
||||
press(bm, "select")
|
||||
eq(game.wroteOptions, writes + 1, "clearing an already-default row writes nothing")
|
||||
|
||||
-- ---- (e) START confirms, then drops the whole overlay ---------------------
|
||||
|
||||
-- rebuild a dirty overlay to reset
|
||||
bm.index = ROW_A
|
||||
press(bm, "a")
|
||||
bm:onKeyPressed("p")
|
||||
bm:onKeyReleased("p")
|
||||
eq(game.save.options.bindings.a.key, "p", "fixture rebind in place")
|
||||
|
||||
press(bm, "start")
|
||||
local box = game.stack:top()
|
||||
check(box ~= bm, "START pushes the confirm box instead of resetting outright")
|
||||
eq(bm.footer, Strings("RESET ALL BINDINGS?"),
|
||||
"the footer doubles as the prompt")
|
||||
|
||||
-- the box starts on NO: a bare A press must keep the overlay
|
||||
press(box, "a")
|
||||
eq(game.stack:top(), bm, "answering pops the box")
|
||||
check(game.save.options.bindings ~= nil, "NO keeps the bindings (defaultNo)")
|
||||
eq(bm.items[ROW_A].right, "P/B", "and the rows keep showing them")
|
||||
|
||||
-- again, flip to YES: the overlay goes away and every row reads default
|
||||
press(bm, "start")
|
||||
box = game.stack:top()
|
||||
press(box, "up")
|
||||
press(box, "a")
|
||||
check(game.save.options.bindings == nil, "YES clears options.bindings (#589)")
|
||||
eq(bm.items[ROW_A].right, "Z/A", "the A row reads its default again")
|
||||
eq(bm.items[ROW_B].right, "X/B", "so does the B row the swap had touched")
|
||||
|
||||
-- closing after the reset leaves the live map at the defaults
|
||||
press(bm, "b")
|
||||
eq(#game.stack.states, 0, "B closes the screen")
|
||||
eq(Input.keyBindings["z"], "a", "the live map is back to Z = A")
|
||||
eq(Input.padBindings["b"], "b", "and pad B = B")
|
||||
|
||||
Input:init()
|
||||
T.finish("rebind_swap_clear_bug589")
|
||||
@@ -0,0 +1,62 @@
|
||||
-- Unit coverage for the render.compose seam (D14: a public-API test names
|
||||
-- the hook, gate_hooks supplies the no-mod parity, docs/modding.md documents
|
||||
-- it). render.compose lets a mod take over window composition: a wrap that
|
||||
-- returns true without calling next owns the whole window; a wrap that calls
|
||||
-- next lets the engine's normal single-window composite run and decorates it.
|
||||
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local T = require("tests.modkit")
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
local Hooks = require("src.mods.Hooks")
|
||||
|
||||
local savedEvents, savedHooks = Runtime.events, Runtime.hooks
|
||||
local bus = Hooks.new()
|
||||
Runtime.hooks = bus
|
||||
|
||||
-- the shape Renderer:endFrame hands the hook
|
||||
local function fakeCtx()
|
||||
return {
|
||||
renderer = {}, worldCanvas = {}, uiCanvas = {},
|
||||
worldActive = true, zones = {}, worldZones = nil,
|
||||
ww = 480, wh = 432, ox = 0, oy = 12, scale = 3,
|
||||
dpiX = 1, dpiY = 1, secondScreen = {},
|
||||
}
|
||||
end
|
||||
|
||||
-- takeover: a mod drawing its own layout returns true and never calls next,
|
||||
-- so the engine's vanilla composite is skipped entirely
|
||||
do
|
||||
local vanillaRan, gotCtx = false, nil
|
||||
bus:wrap("render.compose", function(next, renderer, ctx)
|
||||
gotCtx = ctx
|
||||
return true
|
||||
end, 0, "ds-mod")
|
||||
local handled = Runtime.call("render.compose",
|
||||
function() vanillaRan = true; return false end,
|
||||
{ tag = "renderer" }, fakeCtx())
|
||||
T.eq(handled, true, "a mod returning true signals full window takeover")
|
||||
T.eq(vanillaRan, false, "takeover skips the engine composite (vanilla not run)")
|
||||
T.check(gotCtx ~= nil and gotCtx.ww == 480 and gotCtx.secondScreen ~= nil,
|
||||
"the hook receives the frame ctx (metrics, canvases, secondScreen)")
|
||||
bus.chains["render.compose"] = nil
|
||||
end
|
||||
|
||||
-- decorate: a mod calling next lets the engine composite run, and the
|
||||
-- engine's not-handled return (false) flows back through the chain
|
||||
do
|
||||
local vanillaRan = false
|
||||
bus:wrap("render.compose", function(next, renderer, ctx)
|
||||
return next()
|
||||
end, 0, "ds-mod")
|
||||
local handled = Runtime.call("render.compose",
|
||||
function() vanillaRan = true; return false end,
|
||||
{ tag = "renderer" }, fakeCtx())
|
||||
T.eq(vanillaRan, true, "calling next runs the engine composite")
|
||||
T.eq(handled, false, "the engine's not-handled return flows back through next")
|
||||
bus.chains["render.compose"] = nil
|
||||
end
|
||||
|
||||
Runtime.events, Runtime.hooks = savedEvents, savedHooks
|
||||
|
||||
T.finish("render_compose_seam")
|
||||
@@ -0,0 +1,197 @@
|
||||
-- Soft reset: A+B+SELECT+START held together drops the game back to the
|
||||
-- title from anywhere, mid-battle included (#563). _Joypad
|
||||
-- (engine/joypad.asm:6) tests the raw read with `cp PAD_BUTTONS` -- an
|
||||
-- equality, so a d-pad direction in the mix cancels it -- and does so ahead
|
||||
-- of the wJoyIgnore / BIT_DISABLE_JOYPAD masking further down, which is why
|
||||
-- the combo still works where ordinary input is being thrown away.
|
||||
-- TrySoftReset then decrements hSoftReset, seeded with 16 by Init
|
||||
-- (home/init.asm:81), one poll at a time. The on-screen overlay's half is
|
||||
-- here too: one finger only ever claims one control.
|
||||
-- luajit tests/engine/soft_reset_bug563.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 Input = require("src.core.Input")
|
||||
local TouchControls = require("src.core.TouchControls")
|
||||
local Game = require("src.core.Game")
|
||||
|
||||
-- pokered's 16 polls; the port counts fixed steps, which are the same 60Hz
|
||||
local HOLD_STEPS = 16
|
||||
|
||||
-- The keys the hand-off tells a player to press. Pinned because the combo
|
||||
-- is only reachable if all four GB buttons have a key, and because the
|
||||
-- letter A is bound to LEFT here -- a player reaching for "A" on the
|
||||
-- keyboard adds a direction and cancels the chord instead of arming it.
|
||||
Input:init()
|
||||
eq(Input.keyBindings["z"], "a", "Z is the A button")
|
||||
eq(Input.keyBindings["x"], "b", "X is the B button")
|
||||
eq(Input.keyBindings["escape"], "start", "Escape is START")
|
||||
eq(Input.keyBindings["tab"], "select", "Tab is SELECT")
|
||||
eq(Input.keyBindings["a"], "left", "the letter A is LEFT, not the A button")
|
||||
|
||||
-- ---- the chord itself --------------------------------------------------
|
||||
|
||||
local function holdKeys(...)
|
||||
Input:init()
|
||||
for _, key in ipairs({ ... }) do Input:keypressed(key) end
|
||||
end
|
||||
|
||||
holdKeys("z", "x", "escape", "tab")
|
||||
check(Input:softResetHeld(), "all four buttons down arms the combo")
|
||||
|
||||
for _, missing in ipairs({ "z", "x", "escape", "tab" }) do
|
||||
local keys = {}
|
||||
for _, key in ipairs({ "z", "x", "escape", "tab" }) do
|
||||
if key ~= missing then keys[#keys + 1] = key end
|
||||
end
|
||||
holdKeys(unpack(keys))
|
||||
check(not Input:softResetHeld(),
|
||||
"three of the four is not the combo (without " .. missing .. ")")
|
||||
end
|
||||
|
||||
-- `cp PAD_BUTTONS` is an equality: any direction held alongside the four
|
||||
-- takes the raw read off PAD_BUTTONS and TrySoftReset is never reached.
|
||||
for _, dir in ipairs({ "up", "down", "left", "right" }) do
|
||||
holdKeys("z", "x", "escape", "tab", dir)
|
||||
check(not Input:softResetHeld(), dir .. " held alongside cancels the combo")
|
||||
end
|
||||
|
||||
-- ---- the 16-step countdown ---------------------------------------------
|
||||
|
||||
-- one Game:step's worth of the chord; returns whether the reset fired
|
||||
local function stepChord()
|
||||
Input:step()
|
||||
return Input:softResetStep()
|
||||
end
|
||||
|
||||
holdKeys("z", "x", "escape", "tab")
|
||||
for i = 1, HOLD_STEPS - 1 do
|
||||
check(not stepChord(), "step " .. i .. " of the hold does not reset yet")
|
||||
end
|
||||
check(stepChord(), "the " .. HOLD_STEPS .. "th consecutive step resets")
|
||||
|
||||
-- hSoftReset is never re-seeded on release in the original, so its count
|
||||
-- leaks across a session; the port re-arms instead, or a session's worth of
|
||||
-- stray four-button presses would eventually add up to a reset.
|
||||
holdKeys("z", "x", "escape", "tab")
|
||||
for _ = 1, HOLD_STEPS - 1 do stepChord() end
|
||||
Input:keyreleased("tab")
|
||||
check(not stepChord(), "lifting SELECT one step short cancels")
|
||||
Input:keypressed("tab")
|
||||
for i = 1, HOLD_STEPS - 1 do
|
||||
check(not stepChord(),
|
||||
"re-pressing it restarts the count from 16, not from 1 (step " .. i .. ")")
|
||||
end
|
||||
check(stepChord(), "and a full fresh hold resets")
|
||||
|
||||
-- same re-arm for the direction case: a thumb brushing the d-pad mid-hold
|
||||
holdKeys("z", "x", "escape", "tab")
|
||||
for _ = 1, HOLD_STEPS - 1 do stepChord() end
|
||||
Input:keypressed("up")
|
||||
check(not stepChord(), "a direction one step short cancels")
|
||||
Input:keyreleased("up")
|
||||
for _ = 1, HOLD_STEPS - 1 do
|
||||
check(not stepChord(), "and the count starts over")
|
||||
end
|
||||
check(stepChord(), "reaching 16 again from the restart")
|
||||
|
||||
-- ---- Game:step routes it above the state stack --------------------------
|
||||
|
||||
-- Doubles for the two services Game:step touches on the reset path. The
|
||||
-- real stack is not needed: what is being pinned is that the combo is read
|
||||
-- before stack:update, so it fires from a battle, a menu or a cutscene and
|
||||
-- not just from the overworld (#563).
|
||||
local function fakeGame()
|
||||
local game = { input = Input, save = {}, returned = 0 }
|
||||
game.stack = {
|
||||
updates = 0,
|
||||
update = function(self) self.updates = self.updates + 1 end,
|
||||
}
|
||||
function game:returnToTitle() self.returned = self.returned + 1 end
|
||||
return game
|
||||
end
|
||||
|
||||
local game = fakeGame()
|
||||
holdKeys("z", "x", "escape", "tab")
|
||||
for _ = 1, HOLD_STEPS do Game.step(game, 1 / 60) end
|
||||
eq(game.returned, 1, "Game:step falls back to the title on the 16th step")
|
||||
eq(game.stack.updates, HOLD_STEPS - 1,
|
||||
"and the state on top never gets that step, whatever it was")
|
||||
check(not Input:isDown("a"),
|
||||
"the still-physically-held A is cleared, so the title screen does not "
|
||||
.. "read it as a menu choice on its first frame")
|
||||
|
||||
-- A tool mod may hand Game a stand-in input; Game guards on the method
|
||||
-- existing rather than assuming it, so those simply never soft reset.
|
||||
local bare = fakeGame()
|
||||
bare.input = { step = function() end, isDown = function() return false end }
|
||||
for _ = 1, HOLD_STEPS * 2 do Game.step(bare, 1 / 60) end
|
||||
eq(bare.returned, 0, "an input with no chord bookkeeping never resets")
|
||||
|
||||
-- ---- the on-screen overlay ---------------------------------------------
|
||||
|
||||
-- Force the overlay live the way a phone would have it. img is only ever
|
||||
-- tested for truthiness on the input path (drawing is what reads it), so a
|
||||
-- placeholder keeps this suite off love.graphics.newImage.
|
||||
Input:init()
|
||||
TouchControls:init()
|
||||
TouchControls.active, TouchControls.enabled = true, true
|
||||
TouchControls.img = { stub = true }
|
||||
local L = TouchControls:layout()
|
||||
|
||||
-- One finger, one control: touchpressed returns on its first hit, so no
|
||||
-- single touch can ever arm more than a quarter of the chord however the
|
||||
-- layout editor has moved the controls around.
|
||||
for _, btn in ipairs({ "a", "b", "start", "select" }) do
|
||||
Input:init()
|
||||
TouchControls:reset()
|
||||
TouchControls:touchpressed(1, L[btn].cx, L[btn].cy)
|
||||
local held = 0
|
||||
for _ in pairs(TouchControls.held) do held = held + 1 end
|
||||
eq(held, 1, "a finger on " .. btn:upper() .. " presses that button alone")
|
||||
check(not Input:softResetHeld(), "...which is not the combo")
|
||||
end
|
||||
|
||||
-- Four fingers on four controls is the only way there, and it still has to
|
||||
-- survive the same 16 steps -- better than a quarter second of everything
|
||||
-- staying put.
|
||||
Input:init()
|
||||
TouchControls:reset()
|
||||
local ids = { a = 1, b = 2, start = 3, select = 4 }
|
||||
for btn, id in pairs(ids) do
|
||||
TouchControls:touchpressed(id, L[btn].cx, L[btn].cy)
|
||||
end
|
||||
check(Input:softResetHeld(), "four fingers on A, B, START and SELECT arm it")
|
||||
for i = 1, HOLD_STEPS - 1 do
|
||||
check(not stepChord(), "the overlay holds the same countdown (step " .. i .. ")")
|
||||
end
|
||||
check(stepChord(), "and resets on the 16th")
|
||||
|
||||
-- the accidental version: one finger slips off part way through
|
||||
Input:init()
|
||||
TouchControls:reset()
|
||||
for btn, id in pairs(ids) do
|
||||
TouchControls:touchpressed(id, L[btn].cx, L[btn].cy)
|
||||
end
|
||||
for _ = 1, HOLD_STEPS - 1 do stepChord() end
|
||||
TouchControls:touchreleased(ids.select, L.select.cx, L.select.cy)
|
||||
check(not stepChord(), "a finger leaving SELECT one step short cancels")
|
||||
check(not Input:softResetHeld(), "and the chord is no longer armed")
|
||||
|
||||
-- the fifth finger a real two-handed grip has on the d-pad
|
||||
Input:init()
|
||||
TouchControls:reset()
|
||||
for btn, id in pairs(ids) do
|
||||
TouchControls:touchpressed(id, L[btn].cx, L[btn].cy)
|
||||
end
|
||||
TouchControls:touchpressed(5, L.dpad.cx, L.dpad.cy - L.dpad.w * 0.4)
|
||||
check(not Input:softResetHeld(),
|
||||
"a thumb on the d-pad cancels it the same way a keyboard direction does")
|
||||
|
||||
Input:init()
|
||||
TouchControls:reset()
|
||||
T.finish("soft_reset_bug563")
|
||||
@@ -0,0 +1,107 @@
|
||||
-- A beaten trainer's own line is spoken, so it carries their name tag.
|
||||
--
|
||||
-- TrainerEndBattleText (home/trainers.asm:381-386) prints _TrainerNameText
|
||||
-- -- wNameBuffer then ": " (data/text/text_1.asm:16-19) -- and only then
|
||||
-- the saved EndBattleText pointer, so the loss line opens
|
||||
-- "BUG CATCHER: ...". The port printed the pointer alone, leaving an
|
||||
-- unattributed sentence on the battle screen (#566). SaveTrainerName reads
|
||||
-- TrainerNamePointers, whose RIVAL1/2/3 entries aim at wTrainerName, so the
|
||||
-- rival tags with his name and never with the class id.
|
||||
--
|
||||
-- The tag belongs to the box, not to every page: a `para` inside the loss
|
||||
-- text is a second page and prints untagged.
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local T = require("tests.modkit")
|
||||
local Data = T.fixtures.fresh()
|
||||
local Font = require("src.render.Font")
|
||||
Font.load(Data)
|
||||
local TypeChart = require("src.battle.TypeChart")
|
||||
TypeChart.load(Data)
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
local SaveData = require("src.core.SaveData")
|
||||
local BattleState = require("src.battle.BattleState")
|
||||
|
||||
local function freshGame()
|
||||
local save = SaveData.newGame()
|
||||
save.player.name = "RED"
|
||||
save.player.rival = "GARY"
|
||||
save.party = { Pokemon.new(Data, "FIXMON_A", 30) }
|
||||
return { data = Data, save = save,
|
||||
stack = { top = function() return nil end,
|
||||
push = function() end, pop = function() end } }
|
||||
end
|
||||
|
||||
-- KO the whole enemy party and take the win branch of enemyMonFainted
|
||||
local function beat(game, oppClass, endText)
|
||||
local battle = BattleState.newTrainer(game, oppClass, 1)
|
||||
battle.participants = {}
|
||||
battle.playVictoryMusic = function() end
|
||||
battle.endBattleText = endText
|
||||
for _, mon in ipairs(battle.enemyParty) do mon.hp = 0 end
|
||||
battle.enemyIndex = #battle.enemyParty
|
||||
battle.enemy.mon = battle.enemyParty[battle.enemyIndex]
|
||||
battle:enemyMonFainted()
|
||||
local texts = {}
|
||||
for _, row in ipairs(battle.queue) do
|
||||
if row.text then texts[#texts + 1] = row.text end
|
||||
end
|
||||
return battle, texts
|
||||
end
|
||||
|
||||
-- index of the first queued message containing `needle`
|
||||
local function findText(texts, needle)
|
||||
for i, text in ipairs(texts) do
|
||||
if text:find(needle, 1, true) then return i end
|
||||
end
|
||||
end
|
||||
|
||||
do
|
||||
local game = freshGame()
|
||||
local battle, texts = beat(game, "OPP_FIX_YOUNGSTER",
|
||||
"Well fixed!\fBut I will win next time.")
|
||||
T.eq(battle.result, "win", "the last enemy mon fainting ends the battle")
|
||||
|
||||
local loss = findText(texts, "Well fixed!")
|
||||
T.check(loss ~= nil, "the trainer's loss line is queued")
|
||||
T.eq(texts[loss], battle.trainer.name .. ": Well fixed!",
|
||||
"the loss line opens with the trainer class and a colon")
|
||||
T.eq(texts[loss + 1], "But I will win next time.",
|
||||
"the `para` page is a page of the same box, so it is not tagged again")
|
||||
|
||||
-- TrainerBattleVictory (core.asm:915-949) order: TrainerDefeatedText,
|
||||
-- the pic scroll, PrintEndBattleText, then MoneyForWinningText
|
||||
local defeated = findText(texts, "defeated")
|
||||
local money = findText(texts, "for winning")
|
||||
T.check(defeated and defeated < loss, "the tag follows 'RED defeated ...'")
|
||||
T.check(money and money > loss + 1, "the prize line still comes last")
|
||||
end
|
||||
|
||||
-- RIVAL1/2/3 tag with the rival's name, not the class id: newTrainer
|
||||
-- overlays wRivalName the way GetTrainerName_ does.
|
||||
do
|
||||
local game = freshGame()
|
||||
Data.trainers.OPP_RIVAL1 = {
|
||||
id = "OPP_RIVAL1", index = 2, name = "RIVAL1", baseMoney = 15,
|
||||
parties = { { { level = 5, species = "FIXMON_C" } } },
|
||||
}
|
||||
local battle, texts = beat(game, "OPP_RIVAL1", "You're still learning!")
|
||||
T.eq(battle.trainer.name, "GARY", "the rival battles under his own name")
|
||||
local loss = findText(texts, "still learning")
|
||||
T.check(loss ~= nil, "the rival's loss line is queued")
|
||||
T.eq(texts[loss], "GARY: You're still learning!",
|
||||
"the rival tag is his name, never the RIVAL1 class id")
|
||||
end
|
||||
|
||||
-- A scripted battle that prints its own follow-up leaves endBattleText nil;
|
||||
-- nothing may invent a tagged empty box for it.
|
||||
do
|
||||
local game = freshGame()
|
||||
local _, texts = beat(game, "OPP_FIX_YOUNGSTER", nil)
|
||||
for _, text in ipairs(texts) do
|
||||
T.check(not text:match("^FIX YOUNGSTER: %s*$"),
|
||||
"no empty tagged box when the trainer has no loss line")
|
||||
end
|
||||
end
|
||||
|
||||
T.finish("trainer loss line bug566")
|
||||
@@ -0,0 +1,80 @@
|
||||
-- SHIFT's "about to use" offer is one box with a CONT, not two boxes.
|
||||
--
|
||||
-- _TrainerAboutToUseText (data/text/text_2.asm:911-921) is
|
||||
-- "<wTrainerName> is" / line "about to use" / cont "<wEnemyMonNick>!": the
|
||||
-- cont scrolls "X is" off the top so the nick arrives UNDER the words that
|
||||
-- explain it. Splitting that into two say() rows put the nick alone on a
|
||||
-- fresh page, which reads as a bare Pokemon name with no sentence around
|
||||
-- it (#565).
|
||||
--
|
||||
-- The rendered half is checked through BattleState:startMessage, the same
|
||||
-- parser the battle box types from, so this fails if \v ever stops meaning
|
||||
-- ContText there.
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local T = require("tests.modkit")
|
||||
local Data = T.fixtures.fresh()
|
||||
local Font = require("src.render.Font")
|
||||
Font.load(Data)
|
||||
local TypeChart = require("src.battle.TypeChart")
|
||||
TypeChart.load(Data)
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
local SaveData = require("src.core.SaveData")
|
||||
local BattleState = require("src.battle.BattleState")
|
||||
|
||||
-- SHIFT only offers the switch when the player has more than one party
|
||||
-- slot and the active mon is alive (core.asm:1366-1443)
|
||||
local save = SaveData.newGame()
|
||||
save.player.name = "RED"
|
||||
save.party = { Pokemon.new(Data, "FIXMON_A", 30), Pokemon.new(Data, "FIXMON_B", 30) }
|
||||
save.options = save.options or {}
|
||||
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)
|
||||
T.check(#battle.enemyParty >= 2, "the fixture trainer has a reserve to send out")
|
||||
battle.participants = {}
|
||||
|
||||
-- KO the lead so enemyMonFainted queues the send-out for slot 2
|
||||
battle.enemyParty[1].hp = 0
|
||||
battle.enemy.mon = battle.enemyParty[1]
|
||||
battle:enemyMonFainted()
|
||||
|
||||
local texts = {}
|
||||
for _, row in ipairs(battle.queue) do
|
||||
if row.text then texts[#texts + 1] = row.text end
|
||||
end
|
||||
|
||||
local offer
|
||||
for _, text in ipairs(texts) do
|
||||
if text:find("about to use", 1, true) then offer = text end
|
||||
end
|
||||
T.check(offer ~= nil, "SHIFT queues the about-to-use offer")
|
||||
|
||||
local nick = Data.pokemon[battle.enemyParty[2].species].name
|
||||
T.eq(offer, battle.trainer.name .. " is\nabout to use\v" .. nick .. "!",
|
||||
"one box: name, the line break, then a CONT to the nick")
|
||||
|
||||
-- the regression itself: the nick must never be a message of its own
|
||||
for _, text in ipairs(texts) do
|
||||
T.neq(text, nick .. "!", "no bare-nickname box follows the offer")
|
||||
end
|
||||
|
||||
-- and what the box actually renders: three lines, the third scrolled in,
|
||||
-- so the two visible rows when the nick lands are "about to use" / nick
|
||||
battle:startMessage({ text = offer })
|
||||
T.eq(#battle.lines, 3, "the offer types as three lines")
|
||||
T.check(not battle.lines[2].cont, "line 2 follows a plain line break")
|
||||
-- guarded so a regression reports the missing line instead of dying on it
|
||||
if battle.lines[3] then
|
||||
T.check(battle.lines[3].cont, "line 3 is a ContText scroll, not a new page")
|
||||
T.same(battle.lines[2].codes, Font.encode("about to use"),
|
||||
"the line above the nick still says 'about to use'")
|
||||
T.same(battle.lines[3].codes, Font.encode(nick .. "!"),
|
||||
"the scrolled-in line is the nick")
|
||||
end
|
||||
|
||||
T.finish("trainer shift prompt bug565")
|
||||
@@ -0,0 +1,156 @@
|
||||
-- A tap on the d-pad turns in place; only a held direction steps (#415).
|
||||
-- home/overworld.asm .handleDirectionButtonPress reaches the turn only while
|
||||
-- wCheckFor180DegreeTurn is set, and .noDirectionButtonsPressed -- the poll
|
||||
-- that finds nothing held -- is the one place that sets it. So the turn
|
||||
-- delay is spent once per press, not at every facing change: a direction
|
||||
-- swapped mid-walk keeps stepping. Player.turnArmed is that flag and
|
||||
-- Player:turnWindow is how long the fresh turn holds the step off.
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local T = require("tests.modkit")
|
||||
local Data = T.fixtures.fresh()
|
||||
local Collision = require("src.world.Collision")
|
||||
local FieldDefaults = require("src.world.FieldDefaults")
|
||||
local Input = require("src.core.Input")
|
||||
local Player = require("src.world.Player")
|
||||
|
||||
Collision.load(Data)
|
||||
|
||||
-- the fixture dataset ships one player sheet under its own id; Player.new
|
||||
-- builds a SpriteRenderer off whatever field.playerSprites names
|
||||
Data.field.playerSprites = { walk = "SPRITE_FIX_PLAYER" }
|
||||
|
||||
-- Collision.canMove only ever asks a map these four things, so open floor
|
||||
-- is cheaper to state directly than to stand a real MapLoader up (that
|
||||
-- pulls the tileset atlas in, which is the asset pipeline, not this).
|
||||
local map = {
|
||||
def = { tileset = "FIX_OUT" },
|
||||
inBounds = function(_, x, y) return x >= 0 and y >= 0 and x < 20 and y < 20 end,
|
||||
isWalkableCell = function() return true end,
|
||||
isWaterCell = function() return false end,
|
||||
cellTile = function() return 0 end,
|
||||
}
|
||||
|
||||
local function newPlayer(facing)
|
||||
local p = Player.new(Data, 5, 5, facing or "down")
|
||||
Input:init()
|
||||
local Game = require("src.core.Game")
|
||||
Game.input = Input
|
||||
return p
|
||||
end
|
||||
|
||||
-- One fixed step of the overworld's input half: OverworldState:handleInput
|
||||
-- polls the held direction and re-arms turnArmed when it finds none, then
|
||||
-- Player:update ticks turnTimer down. `dir` nil is a released d-pad.
|
||||
local function step(p, dir)
|
||||
local result
|
||||
if dir then
|
||||
result = p:tryMove(dir, map, {})
|
||||
else
|
||||
p.turnArmed = true
|
||||
end
|
||||
p:update()
|
||||
return result
|
||||
end
|
||||
|
||||
-- Hold `dir` from a standstill until the step it starts lands, and stop on
|
||||
-- the landing poll: one more held poll would chain straight into the next
|
||||
-- tile, which is the walk this suite is not measuring.
|
||||
local function walkOneTile(p, dir)
|
||||
local fromX, fromY = p.cellX, p.cellY
|
||||
for _ = 1, 120 do
|
||||
step(p, dir)
|
||||
if not p.moving and (p.cellX ~= fromX or p.cellY ~= fromY) then
|
||||
return true
|
||||
end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
-- ---- the tap: one poll with the direction down, then nothing -------------
|
||||
|
||||
do
|
||||
local p = newPlayer("down")
|
||||
T.eq(step(p, "up"), "turned", "the first poll of a fresh press turns")
|
||||
T.eq(p.facing, "up", "and the facing lands on the tapped direction")
|
||||
for _ = 1, 20 do step(p, nil) end
|
||||
T.eq(p.cellY, 5, "a released d-pad never commits the step")
|
||||
T.eq(p.cellX, 5, "and never drifts sideways either")
|
||||
T.eq(p.facing, "up", "the turn survives the release")
|
||||
end
|
||||
|
||||
-- ---- the hold: the same press kept down steps once the window closes -----
|
||||
|
||||
do
|
||||
local window = FieldDefaults.world(Data, "turnFrames")
|
||||
T.eq(window, 4, "the dataset's turn window is 4 fixed steps")
|
||||
|
||||
-- one short of the window: still turning, still on the tile
|
||||
local short = newPlayer("down")
|
||||
T.eq(step(short, "up"), "turned", "hold: the first poll turns")
|
||||
for _ = 1, window - 1 do
|
||||
T.eq(step(short, "up"), nil, "a hold inside the window takes no step")
|
||||
end
|
||||
T.eq(short.cellY, 5, "so the tile is unchanged after " .. window .. " polls")
|
||||
|
||||
-- the poll after the window closes is the one that moves
|
||||
T.eq(step(short, "up"), "moved", "the poll past the window commits the step")
|
||||
T.check(short.moving, "and the step animation is running")
|
||||
end
|
||||
|
||||
-- ---- the corner: a direction swapped without ever letting go -------------
|
||||
--
|
||||
-- This is what turnArmed buys. Walking a corner never lifts the d-pad, so
|
||||
-- .noDirectionButtonsPressed never runs and the original charges no turn
|
||||
-- delay for the new facing; gating on the timer alone stalled every corner.
|
||||
|
||||
do
|
||||
local p = newPlayer("down")
|
||||
T.check(walkOneTile(p, "up"), "the held direction walks a whole tile")
|
||||
T.eq(p.cellY, 4, "one tile north")
|
||||
|
||||
local turned = step(p, "right") -- swap without a release in between
|
||||
T.eq(p.facing, "right", "the swap changes the facing")
|
||||
T.eq(turned, "moved", "and steps straight away: no second turn delay")
|
||||
T.eq(p.turnTimer, 0, "the corner charges no turn window")
|
||||
end
|
||||
|
||||
-- ---- re-arming: the flag only comes back from a standstill ---------------
|
||||
|
||||
do
|
||||
local p = newPlayer("down")
|
||||
T.eq(p.turnArmed, true, "a fresh player starts armed")
|
||||
step(p, "left")
|
||||
T.eq(p.turnArmed, false, "the turn spends the arm")
|
||||
step(p, "left")
|
||||
T.eq(p.turnArmed, false, "a still-held direction does not re-arm it")
|
||||
step(p, nil)
|
||||
T.eq(p.turnArmed, true, "the poll that finds nothing held re-arms it")
|
||||
end
|
||||
|
||||
-- ---- the on-screen d-pad gets the longer window -------------------------
|
||||
--
|
||||
-- A finger cannot produce a 4-frame press, so Player:turnWindow widens it
|
||||
-- for the overlay only. Keyed on the live source (Input:isTouchDown), not
|
||||
-- on whether the overlay is visible, so a phone with a pad plugged in keeps
|
||||
-- the physical window.
|
||||
|
||||
do
|
||||
local p = newPlayer("down")
|
||||
p.facing = "up"
|
||||
T.eq(p:turnWindow(), 4, "keyboard and pad presses get the short window")
|
||||
|
||||
Input:overlayPressed("up")
|
||||
Input:step()
|
||||
T.check(Input:isTouchDown("up"), "the overlay registers as a touch source")
|
||||
T.eq(p:turnWindow(), 8, "an overlay press gets the long window")
|
||||
|
||||
Input:keypressed("up")
|
||||
Input:overlayReleased("up")
|
||||
Input:step()
|
||||
T.check(Input:isDown("up"), "the keyboard still holds the direction")
|
||||
T.check(not Input:isTouchDown("up"), "but the touch source is gone")
|
||||
T.eq(p:turnWindow(), 4, "so the window drops back to the short one")
|
||||
end
|
||||
|
||||
T.finish("turn in place")
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user