mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-12 16:31:05 +02:00
Compare commits
34 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 |
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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
|
||||
@@ -448,3 +448,15 @@ 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.
|
||||
|
||||
@@ -231,6 +231,28 @@ bool syncHealthSteps()
|
||||
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
|
||||
*/
|
||||
|
||||
@@ -81,6 +81,15 @@ bool showCreateDocument(const char *suggestedName = nullptr);
|
||||
*/
|
||||
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
|
||||
*/
|
||||
|
||||
@@ -221,6 +221,15 @@ bool System::syncHealthSteps() const
|
||||
#endif
|
||||
}
|
||||
|
||||
bool System::restartApp() const
|
||||
{
|
||||
#ifdef LOVE_ANDROID
|
||||
return love::android::restartApp();
|
||||
#else
|
||||
return false;
|
||||
#endif
|
||||
}
|
||||
|
||||
bool System::hasBackgroundMusic() const
|
||||
{
|
||||
#if defined(LOVE_ANDROID)
|
||||
|
||||
@@ -133,6 +133,14 @@ public:
|
||||
*/
|
||||
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.
|
||||
|
||||
@@ -115,6 +115,14 @@ int w_syncHealthSteps(lua_State *L)
|
||||
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());
|
||||
@@ -133,6 +141,7 @@ static const luaL_Reg functions[] =
|
||||
{ "pickFile", w_pickFile },
|
||||
{ "createFile", w_createFile },
|
||||
{ "syncHealthSteps", w_syncHealthSteps },
|
||||
{ "restartApp", w_restartApp },
|
||||
{ "hasBackgroundMusic", w_hasBackgroundMusic },
|
||||
{ 0, 0 }
|
||||
};
|
||||
|
||||
@@ -37,7 +37,9 @@ 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;
|
||||
@@ -75,6 +77,7 @@ public class GameActivity extends SDLActivity {
|
||||
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
|
||||
@@ -447,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) {
|
||||
@@ -469,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());
|
||||
@@ -499,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) {
|
||||
|
||||
@@ -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/`
|
||||
|
||||
@@ -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,
|
||||
|
||||
+46
-52
@@ -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)
|
||||
@@ -54,11 +53,11 @@ 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
|
||||
@@ -160,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)"
|
||||
}
|
||||
@@ -188,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" ] \
|
||||
@@ -207,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
|
||||
@@ -565,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).
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1241,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 /
|
||||
@@ -5220,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
|
||||
|
||||
@@ -320,7 +320,7 @@ 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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -491,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)
|
||||
@@ -509,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")
|
||||
|
||||
@@ -1386,6 +1386,7 @@ function RomImporter:_cycleTab(delta)
|
||||
self._slotPress = nil
|
||||
self._modPress = nil
|
||||
self._findSearchFocus = false
|
||||
self:_disarmTextInput()
|
||||
end
|
||||
|
||||
function RomImporter:_updatePadCursor(dt)
|
||||
@@ -2264,7 +2265,7 @@ function RomImporter:draw()
|
||||
col(PAL.bgBot, 0.72)
|
||||
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 = oy + (height - dh) / 2
|
||||
local rr = 12 * s
|
||||
@@ -2308,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"),
|
||||
@@ -2649,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
|
||||
@@ -2756,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
|
||||
@@ -2877,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
|
||||
@@ -2909,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
|
||||
@@ -2925,6 +2948,7 @@ function RomImporter:keypressed(key)
|
||||
self:_commitRename()
|
||||
elseif key == "escape" then
|
||||
self._rename = nil
|
||||
self:_disarmTextInput()
|
||||
end
|
||||
return
|
||||
end
|
||||
@@ -2935,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
|
||||
@@ -2965,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
|
||||
@@ -3518,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
|
||||
@@ -3525,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
|
||||
@@ -3552,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)
|
||||
@@ -4595,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 "")
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
@@ -641,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
|
||||
|
||||
+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
|
||||
|
||||
@@ -2576,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
|
||||
@@ -2604,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
|
||||
@@ -2625,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
|
||||
|
||||
@@ -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,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,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")
|
||||
@@ -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")
|
||||
@@ -397,11 +397,11 @@ check(getmetatable(bm) == BindingsMenu,
|
||||
check(bm.screenId == "BindingsMenu",
|
||||
"the pushed rebind screen carries its screen id")
|
||||
check(#bm.items == 8, "one row per logical button")
|
||||
check(bm.items[1].label == "UP" and bm.items[1].right == "UP"
|
||||
and bm.items[5].label == "A" and bm.items[5].right == "Z"
|
||||
and bm.items[7].label == "START" and bm.items[7].right == "ESCAPE"
|
||||
check(bm.items[1].label == "UP" and bm.items[1].right == "UP/D-UP"
|
||||
and bm.items[5].label == "A" and bm.items[5].right == "Z/A"
|
||||
and bm.items[7].label == "START" and bm.items[7].right == "ESC/START"
|
||||
and bm.items[8].label == "SELECT" and bm.items[8].right == "TAB/BACK",
|
||||
"with no rebind the rows mirror the fixed map")
|
||||
"with no rebind the rows mirror the fixed map, key and pad both (#589)")
|
||||
check(cbGame.save.options.bindings == nil,
|
||||
"opening the screen alone writes nothing")
|
||||
check(bm.onKeyPressed == nil and bm.onGamepadPressed == nil,
|
||||
@@ -412,18 +412,20 @@ check(bm.capture == bm.items[1] and bm.onKeyPressed ~= nil,
|
||||
local wroteOptions = false
|
||||
function cbGame:writeOptions() wroteOptions = true end
|
||||
bm:onKeyPressed("j")
|
||||
bm:onKeyReleased("j") -- a capture commits on the press's release (#589)
|
||||
check(cbGame.save.options.bindings.up.key == "j",
|
||||
"a captured key lands in options.bindings")
|
||||
check(bm.items[1].right == "J", "the row shows the new key")
|
||||
check(bm.items[1].right == "J/D-UP", "the row shows the new key")
|
||||
check(wroteOptions, "a rebind persists through writeOptions")
|
||||
check(bm.capture == nil and bm.onKeyPressed == nil
|
||||
and bm.onGamepadPressed == nil, "the capture disarms after one input")
|
||||
bm.index = 5
|
||||
press(bm, "a")
|
||||
bm:onGamepadPressed("y")
|
||||
bm:onGamepadReleased("y")
|
||||
check(cbGame.save.options.bindings.a.pad == "y",
|
||||
"a captured pad button lands beside the key slot")
|
||||
check(bm.items[5].right == "Z", "a pad rebind keeps the key column")
|
||||
check(bm.items[5].right == "Z/Y", "a pad rebind keeps the key column")
|
||||
press(bm, "b")
|
||||
check(#cbGame.stack.states == 0, "B closes the rebind screen")
|
||||
|
||||
|
||||
@@ -98,8 +98,9 @@ check(wild.queue[1] and wild.queue[1].fn ~= nil,
|
||||
check(wild.queue[2] and wild.queue[2].text == wild.introText,
|
||||
"the intro text is still the second queue row")
|
||||
|
||||
-- let the silhouette slide land so the intro box is genuinely up
|
||||
for _ = 1, 45 do wild:update(1 / 60) end
|
||||
-- let the silhouette slide land so the intro box is genuinely up (the slide
|
||||
-- now runs 80 frames at 2px/frame, matching the original ~2px/frame)
|
||||
for _ = 1, 85 do wild:update(1 / 60) end
|
||||
eq(wild.introSlide or 0, 0, "the silhouette slide has landed")
|
||||
eq(wild.introBalls, true, "the window is still open under the intro text")
|
||||
eq(currentText(wild), wild.introText, "the intro box is the row on screen")
|
||||
@@ -164,7 +165,7 @@ local tr = BattleState.newTrainer(game2, "OPP_YOUNGSTER", 1)
|
||||
tr.onFinish = function() end
|
||||
tr:enter()
|
||||
eq(tr.introBalls, true, "the trainer intro opens the same window")
|
||||
for _ = 1, 45 do tr:update(1 / 60) end
|
||||
for _ = 1, 85 do tr:update(1 / 60) end
|
||||
|
||||
local ok3, err3, rows3 = snapshotHUD(tr)
|
||||
check(ok3, "drawHUDs runs during the trainer intro: " .. tostring(err3))
|
||||
|
||||
@@ -0,0 +1,288 @@
|
||||
-- Parity (#617): Yellow's Viridian old man is the OLD_MAN2 at (18,9),
|
||||
-- not the Red/Blue OLD_MAN at (17,5), and his dialog has no yes/no
|
||||
-- choice -- the apology speech runs the RATTATA demo battle straight
|
||||
-- away, the post-battle line is the losing-my-touch text, and he walks
|
||||
-- off and hides.
|
||||
--
|
||||
-- Oracle: pokeyellow scripts/OaksLab.asm (OaksLabOakGivesPokedexScript:
|
||||
-- HideObject TOGGLE_LYING_OLD_MAN / ShowObject TOGGLE_OLD_MAN_2),
|
||||
-- scripts/ViridianCity.asm (ViridianCityCheckWaitingOldMan,
|
||||
-- ViridianCityOldMan2Text, ...InitialCatchTrainingScript,
|
||||
-- ...PostInitialCatchTraining) and scripts/ViridianCity_2.asm
|
||||
-- (ViridianCityPrintOldManText). The Red/Blue "Are you in a hurry?"
|
||||
-- script was running against Yellow's text: YES printed the TimeIsMoney
|
||||
-- alias (_ViridianCityOldManLosingMyTouchText) and NO ran the demo --
|
||||
-- every talk, forever.
|
||||
--
|
||||
-- Self-contained: `luajit tests/parity_yellow_old_man.lua`; also globbed
|
||||
-- by tests/run_tests.lua.
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
if not _G.love then _G.love = require("tests.love_stub") end
|
||||
|
||||
local Data = require("src.core.Data")
|
||||
if not (Data.maps and Data.maps.PALLET_TOWN) then Data:load() end
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
local SaveData = require("src.core.SaveData")
|
||||
local ScriptRunner = require("src.script.ScriptRunner")
|
||||
local TextBox = require("src.render.TextBox")
|
||||
local BattleState = require("src.battle.BattleState")
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
|
||||
local S = require("tests.harness").suite("parity Yellow old man (#617)")
|
||||
local check, eq = S.check, S.eq
|
||||
|
||||
local oldVersion = GameVersion.get()
|
||||
|
||||
local MAP = "VIRIDIAN_CITY"
|
||||
local SLEEPER = "VIRIDIANCITY_OLD_MAN_SLEEPY"
|
||||
local WALKER = "VIRIDIANCITY_OLD_MAN"
|
||||
local OLD_MAN2 = "VIRIDIANCITY_OLD_MAN2"
|
||||
local DONE_FLAG = "EVENT_COMPLETED_CATCH_TRAINING"
|
||||
|
||||
-- The Yellow wiring must be attached before anything else caches the
|
||||
-- map-script registry: data.scripts.init branches on GameVersion at
|
||||
-- load, so flip it first (this file owns its own process when run
|
||||
-- standalone). Under tests/run_tests.lua the registry is already
|
||||
-- cached with the Red wiring, so attach the Yellow modules directly
|
||||
-- afterwards -- attachBase merges per TEXT constant and replaces hooks,
|
||||
-- which is a no-op on a fresh process and the fix on a shared one.
|
||||
GameVersion.set("yellow")
|
||||
local mapScripts = require("data.scripts.init")
|
||||
local MapScripts = require("src.script.MapScripts")
|
||||
MapScripts.attachBase(MAP,
|
||||
require("data.scripts.yellow_viridian_old_man").VIRIDIAN_CITY)
|
||||
MapScripts.attachBase("OAKS_LAB",
|
||||
require("data.scripts.oaks_lab_yellow"))
|
||||
local oldManMod = require("data.scripts.yellow_viridian_old_man")
|
||||
|
||||
-- ------------------------------------------------------- the demo species
|
||||
-- The catch demo is a RATTATA in Yellow (SetupBattle sets wCurOpponent
|
||||
-- = RATTATA) but the Yellow manifest inherited Red's WEEDLE; the runtime
|
||||
-- override in Data:applyVersionedFieldData repairs old caches. Kept
|
||||
-- active until the end of this file so the demo-battle assertions below
|
||||
-- run against the Yellow value; restored before S.finish() like
|
||||
-- parity_yellow_trades does for its trades table.
|
||||
local originalOldManBattle = Data.field.oldManBattle
|
||||
or { species = "WEEDLE", level = 5 } -- the fixture carries no oldManBattle
|
||||
local originalTrades = Data.field.trades
|
||||
eq(originalOldManBattle.species, "WEEDLE",
|
||||
"Red/Blue's old man still demos a Weedle")
|
||||
GameVersion.set("yellow")
|
||||
Data:applyVersionedFieldData()
|
||||
eq(Data.field.oldManBattle.species, "RATTATA",
|
||||
"Yellow's old man demos a Rattata (#617)")
|
||||
|
||||
local manifestFile = assert(io.open("tools/rom_manifest_yellow.json", "r"))
|
||||
local yellowManifest = manifestFile:read("*a")
|
||||
manifestFile:close()
|
||||
check(yellowManifest:find('"species": "RATTATA"', 1, true) ~= nil,
|
||||
"the Yellow manifest stamps RATTATA for fresh imports")
|
||||
local redManifestFile = assert(io.open("tools/rom_manifest.json", "r"))
|
||||
local redManifest = redManifestFile:read("*a")
|
||||
redManifestFile:close()
|
||||
check(redManifest:find('"species": "WEEDLE"', 1, true) ~= nil,
|
||||
"and the Red/Blue manifest keeps WEEDLE")
|
||||
|
||||
-- ------------------------------------------------------- the Pokedex swap
|
||||
-- OaksLabOakGivesPokedexScript shows TOGGLE_OLD_MAN_2 (the tutorial old
|
||||
-- man standing on the sleeper's cell), never the Red/Blue walker
|
||||
local oaksRows = mapScripts.talkScript("OAKS_LAB", "TEXT_OAKSLAB_OAK1")
|
||||
check(type(oaksRows) == "table",
|
||||
"the Yellow OaksLab Oak talk resolves to rows")
|
||||
local sawSleepHide, sawOldMan2Show, sawOldManShow = false, false, false
|
||||
for _, row in ipairs(oaksRows or {}) do
|
||||
if row[1] == "hide_object" and row[3] == SLEEPER then sawSleepHide = true end
|
||||
if row[1] == "show_object" and row[3] == OLD_MAN2 then sawOldMan2Show = true end
|
||||
if row[1] == "show_object" and row[3] == WALKER then sawOldManShow = true end
|
||||
end
|
||||
check(sawSleepHide, "the Pokédex hand-over hides the lying old man")
|
||||
check(sawOldMan2Show, "it shows OLD_MAN2 on the sleeper's cell")
|
||||
check(not sawOldManShow, "it never shows the Red/Blue OLD_MAN (#617)")
|
||||
|
||||
-- both Yellow gamblers default hidden (toggle OFF), like pokeyellow
|
||||
-- data/maps/toggleable_objects.asm. OLD_MAN2 only exists in a Yellow
|
||||
-- import -- a Red-imported checkout carries just OLD_MAN -- so the
|
||||
-- dataset checks tolerate its absence and the Yellow manifest carries
|
||||
-- the OLD_MAN2 default instead.
|
||||
local walkerDef, oldMan2Def
|
||||
if Data.maps[MAP] then
|
||||
for _, o in ipairs(Data.maps[MAP].objects or {}) do
|
||||
if o.name == WALKER then walkerDef = o end
|
||||
if o.name == OLD_MAN2 then oldMan2Def = o end
|
||||
end
|
||||
end
|
||||
check(walkerDef == nil or walkerDef.hidden == true,
|
||||
"VIRIDIANCITY_OLD_MAN defaults hidden in Yellow")
|
||||
check(oldMan2Def == nil or oldMan2Def.hidden == true,
|
||||
"VIRIDIANCITY_OLD_MAN2 defaults hidden in Yellow")
|
||||
local om2Name = yellowManifest:find('"name": "VIRIDIANCITY_OLD_MAN2"', 1, true)
|
||||
local om2Hidden = om2Name and yellowManifest:sub(
|
||||
math.max(1, om2Name - 40), om2Name):find('"hidden": true', 1, true)
|
||||
check(om2Hidden ~= nil,
|
||||
"the Yellow manifest ships OLD_MAN2 with the toggle OFF")
|
||||
|
||||
-- ------------------------------------------------------- script registry
|
||||
local talk = mapScripts.talkScript(MAP, "TEXT_VIRIDIANCITY_OLD_MAN2")
|
||||
check(type(talk) == "function",
|
||||
"TEXT_VIRIDIANCITY_OLD_MAN2 resolves to the Yellow handler")
|
||||
check(type(mapScripts.talkScript(MAP, "TEXT_VIRIDIANCITY_OLD_MAN")) == "table",
|
||||
"the Red/Blue OLD_MAN talk is still registered (unreachable in Yellow)")
|
||||
local hooks = mapScripts.get(MAP)
|
||||
check(hooks and type(hooks.onEnter) == "function",
|
||||
"VIRIDIAN_CITY.onEnter is the Yellow swap")
|
||||
check(hooks and type(hooks.onStep) == "function",
|
||||
"VIRIDIAN_CITY.onStep chains the gym lock and sleeper gate")
|
||||
check(oldManMod.VIRIDIAN_CITY and oldManMod.VIRIDIAN_CITY.talk
|
||||
and oldManMod.VIRIDIAN_CITY.talk.TEXT_VIRIDIANCITY_OLD_MAN2 == talk,
|
||||
"the handler is the module's own, not a leftover merge")
|
||||
|
||||
-- ------------------------------------------------------- completed branch
|
||||
do
|
||||
local pushed = {}
|
||||
local game = {
|
||||
data = Data,
|
||||
save = SaveData.newGame(),
|
||||
stack = { push = function(_, s) pushed[#pushed + 1] = s end },
|
||||
}
|
||||
game.save.flags.EVENT_COMPLETED_CATCH_TRAINING = true
|
||||
local done = false
|
||||
talk(game, nil, {}, function() done = true end)
|
||||
eq(#pushed, 1, "a second talk only prints one box")
|
||||
eq(getmetatable(pushed[1]), TextBox, "the losing-my-touch line, in a box")
|
||||
pushed[1].onDone()
|
||||
check(done, "closing it hands input back")
|
||||
end
|
||||
|
||||
-- ------------------------------- the initial tutorial, end to end
|
||||
-- Needs real species in the dataset (the fixture carries only FIX_*);
|
||||
-- the engine's old-man demo machinery itself is parity_J's territory.
|
||||
if Data.pokemon.RATTATA and Data.pokemon.PIKACHU then
|
||||
do
|
||||
require("src.render.Font").load(Data)
|
||||
local pushed = {}
|
||||
local save = SaveData.newGame()
|
||||
save.party = { Pokemon.new(Data, "PIKACHU", 12) }
|
||||
local moves = {}
|
||||
local man = { def = { index = 8, name = OLD_MAN2 } }
|
||||
local ow = {
|
||||
map = { id = MAP, def = { label = "ViridianCity" } },
|
||||
npcs = { man }, entities = { man },
|
||||
player = { cellX = 19, cellY = 9, facing = "left" },
|
||||
scriptMove = function(_, _, dir, _, cb) moves[#moves + 1] = dir; cb() end,
|
||||
npcByIndex = function(_, i) if i == 8 then return man end end,
|
||||
}
|
||||
local game = {
|
||||
data = Data,
|
||||
save = save,
|
||||
stack = { push = function(_, s) pushed[#pushed + 1] = s end },
|
||||
}
|
||||
local runner = ScriptRunner.new(game, ow)
|
||||
ow.runner = runner
|
||||
local done = false
|
||||
talk(game, ow, man, function() done = true end)
|
||||
|
||||
eq(#pushed, 1, "the initial talk opens the apology speech")
|
||||
eq(getmetatable(pushed[1]), TextBox, "in a text box")
|
||||
pushed[1].onDone() -- A: the apology closes, the demo battle starts
|
||||
|
||||
eq(#pushed, 2, "the demo battle starts with no choice in between")
|
||||
local battle = pushed[2]
|
||||
check(battle and battle.demo, "it is the old-man demo battle")
|
||||
eq(battle and battle.enemy and battle.enemy.mon.species, "RATTATA",
|
||||
"the demo is a RATTATA in Yellow (#617)")
|
||||
eq(save.flags[DONE_FLAG], nil, "the flag is still clear mid-demo")
|
||||
battle.onFinish() -- the battle ends, the post-battle text prints
|
||||
|
||||
eq(save.flags[DONE_FLAG], true, "EVENT_COMPLETED_CATCH_TRAINING is set")
|
||||
eq(#pushed, 3, "the losing-my-touch line follows the demo")
|
||||
pushed[3].onDone() -- A: the old man walks off
|
||||
|
||||
eq(#moves, 6, "with the player on (19,9) he walks down 6 tiles")
|
||||
check(moves[1] == "down" and moves[6] == "down",
|
||||
"all six steps are the ViridianCityOldManMovementData2 walk")
|
||||
eq(save.objectToggles[MAP] and save.objectToggles[MAP][OLD_MAN2], false,
|
||||
"TOGGLE_OLD_MAN_2 hides once the walk finishes")
|
||||
check(done, "and the talk hands input back")
|
||||
end
|
||||
|
||||
-- ---------------------------------- side talk: player not on (19,9) cell
|
||||
do
|
||||
local pushed = {}
|
||||
local save = SaveData.newGame()
|
||||
save.party = { Pokemon.new(Data, "PIKACHU", 12) }
|
||||
local moves = {}
|
||||
local man = { def = { index = 8, name = OLD_MAN2 } }
|
||||
local pika = { def = { index = 99, name = "PIKACHU_FOLLOWER" },
|
||||
pikachuFollower = true }
|
||||
local ow = {
|
||||
map = { id = MAP, def = { label = "ViridianCity" } },
|
||||
npcs = { man, pika }, entities = { man, pika },
|
||||
player = { cellX = 18, cellY = 8, facing = "down" },
|
||||
scriptMove = function(_, _, dir, _, cb) moves[#moves + 1] = dir; cb() end,
|
||||
npcByIndex = function(_, i) if i == 8 then return man elseif i == 99 then return pika end end,
|
||||
}
|
||||
local game = {
|
||||
data = Data,
|
||||
save = save,
|
||||
stack = { push = function(_, s) pushed[#pushed + 1] = s end },
|
||||
}
|
||||
local runner = ScriptRunner.new(game, ow)
|
||||
ow.runner = runner
|
||||
talk(game, ow, man, function() end)
|
||||
pushed[1].onDone()
|
||||
pushed[2].onFinish()
|
||||
pushed[3].onDone()
|
||||
eq(moves[1], "right", "Pikachu steps aside first (ViridianCityMovePikachu)")
|
||||
eq(moves[2], "right", "then the old man turns right one tile")
|
||||
eq(#moves, 2, "and no more")
|
||||
end
|
||||
else
|
||||
check(true, "fixture dataset: demo-battle flow skipped (no RATTATA)")
|
||||
end
|
||||
|
||||
-- --------------------------------------------------------- the (19,9) step
|
||||
do
|
||||
local pushed = {}
|
||||
local save = SaveData.newGame()
|
||||
local man = { def = { index = 8, name = OLD_MAN2 } }
|
||||
local ow = {
|
||||
map = { id = MAP, def = { label = "ViridianCity" } },
|
||||
npcs = { man }, entities = { man },
|
||||
player = { cellX = 19, cellY = 9, facing = "down" },
|
||||
scriptMove = function(_, _, _, _, cb) cb() end,
|
||||
npcByIndex = function() end,
|
||||
}
|
||||
local game = {
|
||||
data = Data,
|
||||
save = save,
|
||||
stack = { push = function(_, s) pushed[#pushed + 1] = s end },
|
||||
}
|
||||
local runner = ScriptRunner.new(game, ow)
|
||||
ow.runner = runner
|
||||
|
||||
check(not hooks.onStep(game, ow, 5, 5),
|
||||
"off the trigger cell the step passes through")
|
||||
check(hooks.onStep(game, ow, 19, 9),
|
||||
"pre-Pokedex the sleeper gate owns (19,9)")
|
||||
eq(#pushed, 1, "with the sleepy text box")
|
||||
check(save.flags[DONE_FLAG] ~= true, "the tutorial is not running")
|
||||
|
||||
save.flags.EVENT_GOT_POKEDEX = true
|
||||
check(hooks.onStep(game, ow, 19, 9),
|
||||
"with the Pokedex, (19,9) starts the tutorial")
|
||||
eq(man.facing, "right", "the old man faces the player")
|
||||
eq(ow.player.facing, "left", "and the player turns to face him")
|
||||
eq(#pushed, 2, "the apology box is up")
|
||||
check(save.flags[DONE_FLAG] ~= true,
|
||||
"no flag until the demo battle actually runs")
|
||||
|
||||
save.flags.EVENT_COMPLETED_CATCH_TRAINING = true
|
||||
check(not hooks.onStep(game, ow, 19, 9),
|
||||
"once the tutorial is done the cell is quiet again")
|
||||
end
|
||||
|
||||
Data.field.trades = originalTrades
|
||||
Data.field.oldManBattle = originalOldManBattle
|
||||
GameVersion.set(oldVersion)
|
||||
|
||||
S.finish()
|
||||
@@ -6574,7 +6574,7 @@
|
||||
"battleType": "BATTLE_TYPE_OLD_MAN",
|
||||
"level": 5,
|
||||
"map": "VIRIDIAN_CITY",
|
||||
"species": "WEEDLE",
|
||||
"species": "RATTATA",
|
||||
"text": "ViridianCityOldManText"
|
||||
},
|
||||
"overworldFx": {
|
||||
|
||||
@@ -29,10 +29,11 @@ local kbField = nil -- id of the field the OS soft keyboard is raised for
|
||||
-- Mobile LOVE only delivers love.textinput while setTextInput(true) is
|
||||
-- active, and that call is what raises the Android/iOS soft keyboard; the
|
||||
-- rect keeps the focused field visible above it. Desktop has text input on
|
||||
-- by default and the launcher hosting this editor depends on that -- nothing
|
||||
-- in src/import/RomImporter.lua (slot rename #205, ROM finder, mod index
|
||||
-- prompt) ever enables it -- so the editor only ever raises there and never
|
||||
-- lowers, since setTextInput is global SDL state, not per-widget (#529).
|
||||
-- by default and the launcher hosting this editor depends on that -- the
|
||||
-- launcher's own fields (slot rename #205, mod index prompt, find search)
|
||||
-- follow the same rule since #578: arm on open, lower only on mobile -- so
|
||||
-- neither side ever turns desktop text input off, since setTextInput is
|
||||
-- global SDL state, not per-widget (#529).
|
||||
local function mobile()
|
||||
local osName = love and love.system and love.system.getOS
|
||||
and love.system.getOS()
|
||||
|
||||
Reference in New Issue
Block a user