mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-12 16:31:05 +02:00
Compare commits
26 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 078bb1ade6 | |||
| f205464aa4 | |||
| b820d3917c | |||
| fea81f03fc | |||
| 75aa71a886 | |||
| 398eefc380 | |||
| b549e319c2 | |||
| 1a57b67e14 | |||
| 378246728b | |||
| 41e326f376 | |||
| f9adeca5ca | |||
| 838f87c224 | |||
| 5b519165fc | |||
| 6b012a31ce | |||
| 1a2b23a066 | |||
| f109530c5f | |||
| 4cb132199c | |||
| fe16b3259c | |||
| 2e46954fe0 | |||
| aedc63c40d | |||
| 00d13d3c35 | |||
| 1b8b3ad538 | |||
| 5f75cfd691 | |||
| 5f89def2ce | |||
| 7c26eb9a24 | |||
| d951fe8fc5 |
@@ -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,12 +22,91 @@ 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@v7
|
||||
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@v7
|
||||
- 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@v7
|
||||
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
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
# LuaJIT, not lua5.4: LOVE 11.x embeds LuaJIT 2.1 and the engine is
|
||||
# written to Lua 5.1 semantics, so CI must run the interpreter the
|
||||
@@ -47,7 +124,7 @@ jobs:
|
||||
name: fixture dataset integrity
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v7
|
||||
- run: sudo apt-get update && sudo apt-get install -y luajit
|
||||
- run: python3 -m pip install --upgrade pillow
|
||||
|
||||
@@ -89,7 +166,7 @@ jobs:
|
||||
name: screenshot differ (capture not yet wired)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v7
|
||||
- run: python3 -m pip install --upgrade pillow
|
||||
|
||||
# 21-testing-and-ci §"Testing & acceptance criteria": compare_shots
|
||||
@@ -137,7 +214,7 @@ jobs:
|
||||
name: mod lint (no ROM-derived content)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
# the MK305 dump check key-diffs shipped tables through luajit, and
|
||||
# modkit treats a missing interpreter as a fatal MK100 -- without
|
||||
|
||||
@@ -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,7 +42,7 @@ 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
|
||||
@@ -52,7 +52,7 @@ jobs:
|
||||
run: echo "::add-mask::$HOME"
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
fetch-tags: true
|
||||
@@ -127,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"
|
||||
@@ -182,13 +183,17 @@ jobs:
|
||||
brew list xcbeautify >/dev/null 2>&1 || brew install xcbeautify
|
||||
|
||||
- name: Build iOS
|
||||
env:
|
||||
CANONICAL_REPOSITORY: ${{ github.repository == 'bryanthaboi/gen1recomp' }}
|
||||
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 }}"
|
||||
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: |
|
||||
@@ -198,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}"
|
||||
@@ -232,6 +238,7 @@ jobs:
|
||||
echo "Notarized + stapled ✓"
|
||||
|
||||
- name: Stage release assets
|
||||
if: github.repository == 'bryanthaboi/gen1recomp'
|
||||
id: assets
|
||||
run: |
|
||||
set -euo pipefail
|
||||
@@ -271,6 +278,7 @@ jobs:
|
||||
cat "$outdir/sha256sums.txt"
|
||||
|
||||
- name: Publish GitHub Release
|
||||
if: github.repository == 'bryanthaboi/gen1recomp'
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
@@ -376,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
|
||||
|
||||
|
||||
|
||||
+165
-151
@@ -1683,158 +1683,172 @@ return {
|
||||
{ 0, 0, 0 },
|
||||
},
|
||||
},
|
||||
-- Species -> palette NAME, taken from pokered-gbc data/pokemon/palettes.asm
|
||||
-- ELSE branch -- the one that is NOT gated on GEN_2_GRAPHICS.
|
||||
--
|
||||
-- That file has two tables. The IF GEN_2_GRAPHICS branch assigns a
|
||||
-- PER-SPECIES palette (PAL_BULBASAUR, PAL_SQUIRTLE, ...) authored for Gen 2
|
||||
-- sprite art; the ELSE branch keeps Gen 1's own assignments (GREENMON,
|
||||
-- CYANMON, ...). This port extracts Gen 1 pics from the ROM, so the Gen 1
|
||||
-- branch is the matching one. The per-species table was imported here by
|
||||
-- mistake, which pointed every mon at colours shaded for different art:
|
||||
-- Bulbasaur wore PAL_BULBASAUR's red-orange over a sprite that has no red
|
||||
-- on it, and Squirtle wore PAL_SQUIRTLE's shell brown on his head.
|
||||
--
|
||||
-- The palette VALUES below are still pokered-gbc's, so ADVANCED keeps its
|
||||
-- richer colours -- only which palette each species points at changed.
|
||||
pokemon = {
|
||||
ABRA = "ABRA",
|
||||
AERODACTYL = "AERODACTYL",
|
||||
ALAKAZAM = "ALAKAZAM",
|
||||
ARBOK = "ARBOK",
|
||||
ARCANINE = "ARCANINE",
|
||||
ARTICUNO = "ARTICUNO",
|
||||
BEEDRILL = "BEEDRILL",
|
||||
BELLSPROUT = "BELLSPROUT",
|
||||
BLASTOISE = "BLASTOISE",
|
||||
BULBASAUR = "BULBASAUR",
|
||||
BUTTERFREE = "BUTTERFREE",
|
||||
CATERPIE = "CATERPIE",
|
||||
CHANSEY = "CHANSEY",
|
||||
CHARIZARD = "CHARIZARD",
|
||||
CHARMANDER = "CHARMANDER",
|
||||
CHARMELEON = "CHARMELEON",
|
||||
CLEFABLE = "CLEFABLE",
|
||||
CLEFAIRY = "CLEFAIRY",
|
||||
CLOYSTER = "CLOYSTER",
|
||||
CUBONE = "CUBONE",
|
||||
DEWGONG = "DEWGONG",
|
||||
DIGLETT = "DIGLETT",
|
||||
DITTO = "DITTO",
|
||||
DODRIO = "DODRIO",
|
||||
DODUO = "DODUO",
|
||||
DRAGONAIR = "DRAGONAIR",
|
||||
DRAGONITE = "DRAGONITE",
|
||||
DRATINI = "DRATINI",
|
||||
DROWZEE = "DROWZEE",
|
||||
DUGTRIO = "DUGTRIO",
|
||||
EEVEE = "EEVEE",
|
||||
EKANS = "EKANS",
|
||||
ELECTABUZZ = "ELECTABUZZ",
|
||||
ELECTRODE = "ELECTRODE",
|
||||
EXEGGCUTE = "EXEGGCUTE",
|
||||
EXEGGUTOR = "EXEGGUTOR",
|
||||
FARFETCHD = "FARFETCH_D",
|
||||
FEAROW = "FEAROW",
|
||||
FLAREON = "FLAREON",
|
||||
GASTLY = "GASTLY",
|
||||
GENGAR = "GENGAR",
|
||||
GEODUDE = "GEODUDE",
|
||||
GLOOM = "GLOOM",
|
||||
GOLBAT = "GOLBAT",
|
||||
GOLDEEN = "GOLDEEN",
|
||||
GOLDUCK = "GOLDUCK",
|
||||
GOLEM = "GOLEM",
|
||||
GRAVELER = "GRAVELER",
|
||||
GRIMER = "GRIMER",
|
||||
GROWLITHE = "GROWLITHE",
|
||||
GYARADOS = "GYARADOS",
|
||||
HAUNTER = "HAUNTER",
|
||||
HITMONCHAN = "HITMONCHAN",
|
||||
HITMONLEE = "HITMONLEE",
|
||||
HORSEA = "HORSEA",
|
||||
HYPNO = "HYPNO",
|
||||
IVYSAUR = "IVYSAUR",
|
||||
JIGGLYPUFF = "JIGGLYPUFF",
|
||||
JOLTEON = "JOLTEON",
|
||||
JYNX = "JYNX",
|
||||
KABUTO = "KABUTO",
|
||||
KABUTOPS = "KABUTOPS",
|
||||
KADABRA = "KADABRA",
|
||||
KAKUNA = "KAKUNA",
|
||||
KANGASKHAN = "KANGASKHAN",
|
||||
KINGLER = "KINGLER",
|
||||
KOFFING = "KOFFING",
|
||||
KRABBY = "KRABBY",
|
||||
LAPRAS = "LAPRAS",
|
||||
LICKITUNG = "LICKITUNG",
|
||||
MACHAMP = "MACHAMP",
|
||||
MACHOKE = "MACHOKE",
|
||||
MACHOP = "MACHOP",
|
||||
MAGIKARP = "MAGIKARP",
|
||||
MAGMAR = "MAGMAR",
|
||||
MAGNEMITE = "MAGNEMITE",
|
||||
MAGNETON = "MAGNETON",
|
||||
MANKEY = "MANKEY",
|
||||
MAROWAK = "MAROWAK",
|
||||
MEOWTH = "MEOWTH",
|
||||
METAPOD = "METAPOD",
|
||||
MEW = "MEW",
|
||||
MEWTWO = "MEWTWO",
|
||||
MOLTRES = "MOLTRES",
|
||||
MR_MIME = "MR_MIME",
|
||||
MUK = "MUK",
|
||||
NIDOKING = "NIDOKING",
|
||||
NIDOQUEEN = "NIDOQUEEN",
|
||||
NIDORAN_F = "NIDORAN_F",
|
||||
NIDORAN_M = "NIDORAN_M",
|
||||
NIDORINA = "NIDORINA",
|
||||
NIDORINO = "NIDORINO",
|
||||
NINETALES = "NINETALES",
|
||||
ODDISH = "ODDISH",
|
||||
OMANYTE = "OMANYTE",
|
||||
OMASTAR = "OMASTAR",
|
||||
ONIX = "ONIX",
|
||||
PARAS = "PARAS",
|
||||
PARASECT = "PARASECT",
|
||||
PERSIAN = "PERSIAN",
|
||||
PIDGEOT = "PIDGEOT",
|
||||
PIDGEOTTO = "PIDGEOTTO",
|
||||
PIDGEY = "PIDGEY",
|
||||
PIKACHU = "PIKACHU",
|
||||
PINSIR = "PINSIR",
|
||||
POLIWAG = "POLIWAG",
|
||||
POLIWHIRL = "POLIWHIRL",
|
||||
POLIWRATH = "POLIWRATH",
|
||||
PONYTA = "PONYTA",
|
||||
PORYGON = "PORYGON",
|
||||
PRIMEAPE = "PRIMEAPE",
|
||||
PSYDUCK = "PSYDUCK",
|
||||
RAICHU = "RAICHU",
|
||||
RAPIDASH = "RAPIDASH",
|
||||
RATICATE = "RATICATE",
|
||||
RATTATA = "RATTATA",
|
||||
RHYDON = "RHYDON",
|
||||
RHYHORN = "RHYHORN",
|
||||
SANDSHREW = "SANDSHREW",
|
||||
SANDSLASH = "SANDSLASH",
|
||||
SCYTHER = "SCYTHER",
|
||||
SEADRA = "SEADRA",
|
||||
SEAKING = "SEAKING",
|
||||
SEEL = "SEEL",
|
||||
SHELLDER = "SHELLDER",
|
||||
SLOWBRO = "SLOWBRO",
|
||||
SLOWPOKE = "SLOWPOKE",
|
||||
SNORLAX = "SNORLAX",
|
||||
SPEAROW = "SPEAROW",
|
||||
SQUIRTLE = "SQUIRTLE",
|
||||
STARMIE = "STARMIE",
|
||||
STARYU = "STARYU",
|
||||
TANGELA = "TANGELA",
|
||||
TAUROS = "TAUROS",
|
||||
TENTACOOL = "TENTACOOL",
|
||||
TENTACRUEL = "TENTACRUEL",
|
||||
VAPOREON = "VAPOREON",
|
||||
VENOMOTH = "VENOMOTH",
|
||||
VENONAT = "VENONAT",
|
||||
VENUSAUR = "VENUSAUR",
|
||||
VICTREEBEL = "VICTREEBEL",
|
||||
VILEPLUME = "VILEPLUME",
|
||||
VOLTORB = "VOLTORB",
|
||||
VULPIX = "VULPIX",
|
||||
WARTORTLE = "WARTORTLE",
|
||||
WEEDLE = "WEEDLE",
|
||||
WEEPINBELL = "WEEPINBELL",
|
||||
WEEZING = "WEEZING",
|
||||
WIGGLYTUFF = "WIGGLYTUFF",
|
||||
ZAPDOS = "ZAPDOS",
|
||||
ZUBAT = "ZUBAT",
|
||||
ABRA = "YELLOWMON",
|
||||
AERODACTYL = "GRAYMON",
|
||||
ALAKAZAM = "YELLOWMON",
|
||||
ARBOK = "PURPLEMON",
|
||||
ARCANINE = "REDMON",
|
||||
ARTICUNO = "BLUEMON",
|
||||
BEEDRILL = "YELLOWMON",
|
||||
BELLSPROUT = "GREENMON",
|
||||
BLASTOISE = "CYANMON",
|
||||
BULBASAUR = "GREENMON",
|
||||
BUTTERFREE = "CYANMON",
|
||||
CATERPIE = "GREENMON",
|
||||
CHANSEY = "PINKMON",
|
||||
CHARIZARD = "REDMON",
|
||||
CHARMANDER = "REDMON",
|
||||
CHARMELEON = "REDMON",
|
||||
CLEFABLE = "PINKMON",
|
||||
CLEFAIRY = "PINKMON",
|
||||
CLOYSTER = "GRAYMON",
|
||||
CUBONE = "GRAYMON",
|
||||
DEWGONG = "BLUEMON",
|
||||
DIGLETT = "BROWNMON",
|
||||
DITTO = "GRAYMON",
|
||||
DODRIO = "BROWNMON",
|
||||
DODUO = "BROWNMON",
|
||||
DRAGONAIR = "BLUEMON",
|
||||
DRAGONITE = "BROWNMON",
|
||||
DRATINI = "GRAYMON",
|
||||
DROWZEE = "YELLOWMON",
|
||||
DUGTRIO = "BROWNMON",
|
||||
EEVEE = "GRAYMON",
|
||||
EKANS = "PURPLEMON",
|
||||
ELECTABUZZ = "YELLOWMON",
|
||||
ELECTRODE = "YELLOWMON",
|
||||
EXEGGCUTE = "PINKMON",
|
||||
EXEGGUTOR = "GREENMON",
|
||||
FARFETCHD = "BROWNMON",
|
||||
FEAROW = "BROWNMON",
|
||||
FLAREON = "REDMON",
|
||||
GASTLY = "PURPLEMON",
|
||||
GENGAR = "PURPLEMON",
|
||||
GEODUDE = "GRAYMON",
|
||||
GLOOM = "REDMON",
|
||||
GOLBAT = "BLUEMON",
|
||||
GOLDEEN = "REDMON",
|
||||
GOLDUCK = "CYANMON",
|
||||
GOLEM = "GRAYMON",
|
||||
GRAVELER = "GRAYMON",
|
||||
GRIMER = "PURPLEMON",
|
||||
GROWLITHE = "BROWNMON",
|
||||
GYARADOS = "BLUEMON",
|
||||
HAUNTER = "PURPLEMON",
|
||||
HITMONCHAN = "BROWNMON",
|
||||
HITMONLEE = "BROWNMON",
|
||||
HORSEA = "CYANMON",
|
||||
HYPNO = "YELLOWMON",
|
||||
IVYSAUR = "GREENMON",
|
||||
JIGGLYPUFF = "PINKMON",
|
||||
JOLTEON = "YELLOWMON",
|
||||
JYNX = "MEWMON",
|
||||
KABUTO = "BROWNMON",
|
||||
KABUTOPS = "BROWNMON",
|
||||
KADABRA = "YELLOWMON",
|
||||
KAKUNA = "YELLOWMON",
|
||||
KANGASKHAN = "BROWNMON",
|
||||
KINGLER = "REDMON",
|
||||
KOFFING = "PURPLEMON",
|
||||
KRABBY = "REDMON",
|
||||
LAPRAS = "CYANMON",
|
||||
LICKITUNG = "PINKMON",
|
||||
MACHAMP = "GRAYMON",
|
||||
MACHOKE = "GRAYMON",
|
||||
MACHOP = "GRAYMON",
|
||||
MAGIKARP = "REDMON",
|
||||
MAGMAR = "REDMON",
|
||||
MAGNEMITE = "GRAYMON",
|
||||
MAGNETON = "GRAYMON",
|
||||
MANKEY = "BROWNMON",
|
||||
MAROWAK = "GRAYMON",
|
||||
MEOWTH = "YELLOWMON",
|
||||
METAPOD = "GREENMON",
|
||||
MEW = "MEWMON",
|
||||
MEWTWO = "MEWMON",
|
||||
MOLTRES = "REDMON",
|
||||
MR_MIME = "PINKMON",
|
||||
MUK = "PURPLEMON",
|
||||
NIDOKING = "PURPLEMON",
|
||||
NIDOQUEEN = "BLUEMON",
|
||||
NIDORAN_F = "BLUEMON",
|
||||
NIDORAN_M = "PURPLEMON",
|
||||
NIDORINA = "BLUEMON",
|
||||
NIDORINO = "PURPLEMON",
|
||||
NINETALES = "YELLOWMON",
|
||||
ODDISH = "GREENMON",
|
||||
OMANYTE = "BLUEMON",
|
||||
OMASTAR = "BLUEMON",
|
||||
ONIX = "GRAYMON",
|
||||
PARAS = "REDMON",
|
||||
PARASECT = "REDMON",
|
||||
PERSIAN = "YELLOWMON",
|
||||
PIDGEOT = "BROWNMON",
|
||||
PIDGEOTTO = "BROWNMON",
|
||||
PIDGEY = "BROWNMON",
|
||||
PIKACHU = "YELLOWMON",
|
||||
PINSIR = "BROWNMON",
|
||||
POLIWAG = "BLUEMON",
|
||||
POLIWHIRL = "BLUEMON",
|
||||
POLIWRATH = "BLUEMON",
|
||||
PONYTA = "REDMON",
|
||||
PORYGON = "GRAYMON",
|
||||
PRIMEAPE = "BROWNMON",
|
||||
PSYDUCK = "YELLOWMON",
|
||||
RAICHU = "YELLOWMON",
|
||||
RAPIDASH = "REDMON",
|
||||
RATICATE = "GRAYMON",
|
||||
RATTATA = "GRAYMON",
|
||||
RHYDON = "GRAYMON",
|
||||
RHYHORN = "GRAYMON",
|
||||
SANDSHREW = "BROWNMON",
|
||||
SANDSLASH = "BROWNMON",
|
||||
SCYTHER = "GREENMON",
|
||||
SEADRA = "CYANMON",
|
||||
SEAKING = "REDMON",
|
||||
SEEL = "BLUEMON",
|
||||
SHELLDER = "GRAYMON",
|
||||
SLOWBRO = "PINKMON",
|
||||
SLOWPOKE = "PINKMON",
|
||||
SNORLAX = "PINKMON",
|
||||
SPEAROW = "BROWNMON",
|
||||
SQUIRTLE = "CYANMON",
|
||||
STARMIE = "GRAYMON",
|
||||
STARYU = "REDMON",
|
||||
TANGELA = "BLUEMON",
|
||||
TAUROS = "GRAYMON",
|
||||
TENTACOOL = "CYANMON",
|
||||
TENTACRUEL = "CYANMON",
|
||||
VAPOREON = "CYANMON",
|
||||
VENOMOTH = "PURPLEMON",
|
||||
VENONAT = "PURPLEMON",
|
||||
VENUSAUR = "GREENMON",
|
||||
VICTREEBEL = "GREENMON",
|
||||
VILEPLUME = "REDMON",
|
||||
VOLTORB = "YELLOWMON",
|
||||
VULPIX = "REDMON",
|
||||
WARTORTLE = "CYANMON",
|
||||
WEEDLE = "YELLOWMON",
|
||||
WEEPINBELL = "GREENMON",
|
||||
WEEZING = "PURPLEMON",
|
||||
WIGGLYTUFF = "PINKMON",
|
||||
ZAPDOS = "YELLOWMON",
|
||||
ZUBAT = "BLUEMON",
|
||||
},
|
||||
source = "pokered-gbc data/super_palettes.asm + data/mon_palettes.asm + color/**",
|
||||
world = {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -254,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
|
||||
@@ -294,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
|
||||
@@ -0,0 +1,382 @@
|
||||
# Timing parity with the Game Boy
|
||||
|
||||
The port's clock is faithful: `src/core/FixedStep.lua` advances game logic in
|
||||
whole 1/60 s steps off wall-clock `dt`, the default speed multiplier is 1x
|
||||
(`src/core/GameSpeed.lua:22`), and audio runs on its own real-time accumulator
|
||||
so fast-forward cannot pitch it. Almost nothing in `src/` is seconds-based.
|
||||
|
||||
What diverges is the **frame budget of composed sequences**. The original
|
||||
spends a large fraction of its running time inside `DelayFrames` calls that
|
||||
produce no visible change - the pause after a page break, the beat before a
|
||||
status move resolves, the drain of an HP bar one point at a time. Those are
|
||||
invisible in a screenshot and easy to drop when porting behavior rather than
|
||||
timing. Dropping them is why the port reads as faster and snappier than
|
||||
hardware even though every individual animation is correct.
|
||||
|
||||
This document is the specification: what each sequence costs on hardware, and
|
||||
where that number comes from.
|
||||
|
||||
## Method
|
||||
|
||||
`tools/scan_pokered_delays.ps1` walks the disassembly and reports every
|
||||
frame-consuming wait with its enclosing routine label:
|
||||
|
||||
| Kind | Meaning | Frames |
|
||||
| --- | --- | --- |
|
||||
| `DelayFrames` | `ld c, N` + `call DelayFrames` (`home/delay.asm:1`) | N |
|
||||
| `DelayFrame` | one vblank wait (`home/vblank.asm:92`) | 1 |
|
||||
| `Delay3` | `home/palettes.asm:14`, three frames for a full bg-map update | 3 |
|
||||
| `Fade` | the `GBFade*` helpers, expanded to their totals below | 24 or 32 |
|
||||
|
||||
Current inventory against `pokered-master`: **450 sites** - 181 `DelayFrames`,
|
||||
164 `Delay3`, 65 `DelayFrame`, 31 `GBFade*`, and 9 `DelayFrames` calls whose
|
||||
count is computed at runtime.
|
||||
|
||||
The four fades all live in `home/fade.asm` and are loops of
|
||||
`ld c, 8 / call DelayFrames`:
|
||||
|
||||
| Routine | Iterations | Frames | Source |
|
||||
| --- | --- | --- | --- |
|
||||
| `GBFadeInFromBlack` | 4 | **32** | `home/fade.asm:21` |
|
||||
| `GBFadeOutToBlack` | 4 | **32** | `home/fade.asm:43` |
|
||||
| `GBFadeOutToWhite` | 3 | **24** | `home/fade.asm:26` |
|
||||
| `GBFadeInFromWhite` | 3 | **24** | `home/fade.asm:48` |
|
||||
|
||||
## The metric
|
||||
|
||||
For each catalog entry, `delta = |port - truth| / truth`; the entry passes at
|
||||
`delta <= 0.05`. The headline number is the **exposure-weighted** pass rate,
|
||||
weighting each sequence by how often it occurs in ordinary play - a 30-frame
|
||||
error on every page of dialogue matters more than a 30-frame error in the Hall
|
||||
of Fame. Weights are in the tier column: T1 sequences recur constantly, T2 are
|
||||
frequent, T3 are set pieces seen once or twice per playthrough.
|
||||
|
||||
## Tier 1 - constant exposure
|
||||
|
||||
These recur every few seconds of play and dominate perceived pacing. Both
|
||||
sides are verified.
|
||||
|
||||
### Overworld and transitions
|
||||
|
||||
| Sequence | Hardware | Source | Port | Port source | Delta |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| Overworld loop iteration | 2 frames (two `DelayFrame`) | `home/overworld.asm:41-44` | 1 step | `src/core/Game.lua:174` | see note |
|
||||
| Warp / door: fade out | **32** | `home/overworld.asm:703` -> `GBFadeOutToBlack` | 32 | `src/render/Transition.lua` | **fixed** |
|
||||
| Warp / door: fade in | **0** (map is drawn under blacked palettes, no fade) | `home/overworld.asm:690-703` | 0 | `src/render/Transition.lua` | **fixed** |
|
||||
| Return to overworld after a battle | **10** hold, then `GBFadeInFromWhite` **24** | `home/overworld.asm:351-352`, `:22`, `:749-753` | 10 + 24 | `Transition.battleReturn` | **fixed** |
|
||||
| Special warp entry (fly / teleport / dungeon) | `Delay3` + `GBFadeInFromWhite` = **27** | `engine/overworld/player_animations.asm:5-7` | - | - | unmeasured |
|
||||
| Dungeon-warp arrival hold | **50** | `engine/overworld/player_animations.asm:43` | - | - | unmeasured |
|
||||
| Player step (walk) | 16 | 8 loop iterations x 2 frames | 16 | `src/world/Player.lua:14` | **ok** |
|
||||
| Turn in place | 2 | one extra loop pass | 2 | `src/world/Player.lua:18` | **ok** |
|
||||
|
||||
Note on the overworld loop: `OverworldLoop` calls `DelayFrame` and then falls
|
||||
through to `OverworldLoopLessDelay`, which calls it again - so a full pass
|
||||
costs 2 frames, and input is sampled every other frame. The port steps logic
|
||||
and samples input every frame. This does not change walking speed (the 2-frame
|
||||
loop moves 2 px, giving the same 16 frames per tile) but it does halve input
|
||||
latency versus hardware. Flagged rather than "wrong": matching it exactly would
|
||||
make the port feel less responsive than the original does on a modern display,
|
||||
and it is the one place where a deliberate divergence is defensible.
|
||||
|
||||
### Text
|
||||
|
||||
The typewriter cadence itself is already correct - 1/3/5 frames per character
|
||||
from `TextSpeedOptionData`, implemented at `src/render/TextBox.lua:267`. The
|
||||
gaps around it are missing.
|
||||
|
||||
| Sequence | Hardware | Source | Port | Port source | Delta |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| `<CONT>` line scroll (after the A press) | `ProtectedDelay3` 3 + 2x `ScrollTextUpOneLine` 5 = **13** | `home/text.asm:262-277`, `:283-307` | 13 | `src/render/TextBox.lua` | **fixed** |
|
||||
| `<PARA>` paragraph break | `ProtectedDelay3` 3 + clear + **20** = **23** | `home/text.asm:230-243` | 23 | `src/render/TextBox.lua` | **fixed** |
|
||||
| Page break (`PageChar`) | 3 + **20** = **23** | `home/text.asm:245-260` | 23 | `src/render/TextBox.lua` | **fixed** |
|
||||
| `TextCommand_PAUSE` | **30** | `home/text.asm:500` | - | - | unmeasured |
|
||||
| `TextCommand_DOTS` | **10** per dot | `home/text.asm:576` | - | - | unmeasured |
|
||||
|
||||
The three ProtectedDelay3 frames are a *pre*-input hold: the arrow is already
|
||||
up and the button is ignored, because `ManualTextScroll` only starts watching
|
||||
the joypad after the delay returns. Mashing A through a long conversation
|
||||
therefore cannot go faster than 3 frames per line on hardware, and now cannot
|
||||
here either. `PromptText` (`home/text.asm:209-217`) has the same shape, so a
|
||||
finished page holds three frames before it can be dismissed too.
|
||||
|
||||
**Battle text is a second, separate engine.** `BattleState` types its own
|
||||
messages rather than going through `src/render/TextBox.lua`, so none of the
|
||||
fixes above reached it and it had drifted further than the overworld box:
|
||||
|
||||
| Sequence | Hardware | Source | Was | Now |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| Per character | 1 glyph per `wOptions & $f` frames (1/3/5, default **3**) | `home/print_text.asm:4-45` | 2 glyphs **per frame**, option ignored | 3 |
|
||||
| Per character, A or B held | **1** frame | `print_text.asm:27-36` | 2 glyphs per frame | 1 |
|
||||
| `<CONT>` pre-input hold | **3** | `home/text.asm:263-267` | 0 | 3 |
|
||||
| `<CONT>` scroll after the press | **10** | `home/text.asm:280-305` | 0 | 10 |
|
||||
| Finished page, pre-input hold | **3** | `home/text.asm:213-217` | 0 | 3 |
|
||||
|
||||
At the default text speed the battle typewriter was running **six times**
|
||||
hardware speed, which is most of why battle text read as a blur, and it
|
||||
ignored the OPTION text-speed setting entirely.
|
||||
|
||||
`ScrollTextUpOneLine` is `ld b, 5` of `DelayFrame` (`home/text.asm:301-305`)
|
||||
and its own comment notes it is "always called twice in a row", so a CONT
|
||||
scroll blocks for 10 frames. The port's `scrollPx` slide at
|
||||
`src/render/TextBox.lua:305-307` is a cosmetic 8 px at 2 px/frame running in
|
||||
`draw()`, not on the logic step, and it does not gate the typewriter.
|
||||
|
||||
### Menus
|
||||
|
||||
| Sequence | Hardware | Source | Port | Port source | Delta |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| Yes/no answer (either option) | **15** | `engine/menus/text_box.asm:322-323`, `:333-334` | 15 | `src/ui/ChoiceBox.lua` | **fixed** |
|
||||
| List menu open (bag, PC, party-as-list) | **10** | `home/list_menu.asm:55-56` | 0 | `src/ui/ListMenu.lua` | **-100%, open** |
|
||||
| List menu redraw per input | `Delay3` = **3** | `home/list_menu.asm:64` | 0 | - | **-100%, open** |
|
||||
| Field move from the party menu | `Delay3` = **3** | `engine/menus/start_sub_menus.asm:4,27,175-203` | 7 (white flash) | `src/render/Transition.lua:8` | see note |
|
||||
| Teleport from the party menu | **60** + `Delay3` | `engine/menus/start_sub_menus.asm:224-225` | - | - | unmeasured |
|
||||
|
||||
The port's 7-frame `white_flash` models `GBPalWhiteOutWithDelay3` plus the
|
||||
screen-tile restore, which is a defensible reading of the same sequence; it is
|
||||
listed here to be reconciled against the exact path rather than treated as a
|
||||
bug.
|
||||
|
||||
### Battle entry
|
||||
|
||||
The wipe into a battle and the silhouette slide behind it. Numbers here come
|
||||
from **pokered-c** (`C:\Users\Anthony\pokered`), whose `battle_transition.c`
|
||||
derives each wipe from `battle_transitions.asm` and then corrects it against a
|
||||
live side-by-side with the ROM. Where that project's measured value and a
|
||||
naive reading of the asm disagree, the measured value wins - see the inward
|
||||
spiral below.
|
||||
|
||||
| Sequence | Hardware | Was | Now |
|
||||
| --- | --- | --- | --- |
|
||||
| DoubleCircle wipe (wild, weak) | 10 x 3 = **30** | 40 | 30 |
|
||||
| Circle wipe (wild, strong) | 20 x 3 = **60** | 40 | 60 |
|
||||
| Spiral outward (trainer, strong) | 360 fills / 3 per frame = **120** | 40 | 120 |
|
||||
| Spiral inward (trainer, weak) | 7 tiles per `Delay3` = **~150** | 40 | 156 |
|
||||
| HStripes (dungeon wild, weak) | 20 x 3 = **60** | 24 | 60 |
|
||||
| VStripes (dungeon wild, strong) | 18 x 3 = **54** | 24 | 54 |
|
||||
| Shrink (dungeon trainer, weak) | 9 x 6 = **54** | 24 | 54 |
|
||||
| Split (dungeon trainer, strong) | 9 x 6 = **54** | 24 | 54 |
|
||||
| Flash before the circle wipes | 12 x 2 x 3 = **72** | 72 | 72 |
|
||||
| Black hold before the battle draws | not stated by pokered; ~30 floor, calibrated **60** | 30 | 60 |
|
||||
| Silhouette slide in | 144 px at 2 px/frame = **72** | 40 (160 px at 4 px/frame) | 72 |
|
||||
| Trainer intro, before balls + text | `WaitForSoundToFinish` + `DelayFrames 20` | 0 | sfx wait + 20 |
|
||||
|
||||
The trainer intro's sound is `SFX_Silph_Scope`, extracted here as
|
||||
`Trainer_Appeared` (`tools/rom_manifest.json` `audio.sfxHeaders`, bank 8 /
|
||||
`$42bb`). It was being extracted and never played by anything. It now plays
|
||||
into a clear window: `PrintBeginningBattleText .trainerBattle` does
|
||||
`PlaySound` then `WaitForSoundToFinish`, which **blocks**, and only then
|
||||
`DelayFrames 20` before `DrawAllPokeballs` and the text. `BattleState`'s
|
||||
message queue grew a `waitSound` row for that, since `WaitForSoundToFinish`
|
||||
waits on the sound actually stopping rather than on a fixed frame count.
|
||||
|
||||
**Scripted battles were skipping the transition entirely.** `BattleTransition`
|
||||
runs from `DoBattleTransitionAndInitBattleVariables`, which both
|
||||
`InitBattleCommon` (`core.asm:6680`, trainers) and `InitWildBattle` (`:6699`)
|
||||
call unconditionally - every battle on hardware enters through a wipe. In the
|
||||
port only `OverworldState:pushBattle` built one, and `Commands.start_battle`
|
||||
pushed the `BattleState` straight onto the stack. The trainer-*sight* path
|
||||
went through `pushBattle`, but every **script-driven** battle did not: gym
|
||||
leaders, the rival, Giovanni, and every scripted wild encounter cut straight
|
||||
to the battle screen with no transition at all. The catch tutorial
|
||||
(`old_man_demo`) had the same gap; `InitWildBattle` has no
|
||||
`BATTLE_TYPE_OLD_MAN` special case, so it gets a wipe too.
|
||||
|
||||
**Beyond 160x144.** Both halves of the transition used to stop at the classic
|
||||
letterbox. The flash filled the 160x144 UI canvas, and the wipe handed the
|
||||
surrounding window a generic centre-out square cascade
|
||||
(`Renderer:drawBattleCascade`) regardless of which of the eight styles was
|
||||
running - so at any zoom a spiral read as "a spiral in a box, with something
|
||||
else happening around it".
|
||||
|
||||
- The flash is a palette write (`rBGP`), and a palette register tints every
|
||||
pixel the LCD shows; there is no "outside the screen" for it to miss. It is
|
||||
now published to the renderer as a screen-space veil and painted over the
|
||||
finished composite, so it covers the whole surface at any zoom.
|
||||
- The spiral and circle walks are now generated for whatever grid the window
|
||||
works out to (`BattleTransition.gridOrder`), and the area outside the
|
||||
letterbox is filled in that order instead of the square cascade. The
|
||||
authentic 20x18 builders still own the letterbox itself, overrun and all,
|
||||
so nothing changes at 1x. The generic builders are deliberately *not* the
|
||||
ROM's walk - out there the hardware has no behaviour to be faithful to,
|
||||
only a shape to continue.
|
||||
- `shrink`, `split` and the two stripe styles are plain geometry rather than
|
||||
a tile order, so they keep the cascade for now. Extending them is
|
||||
rectangles, not a walk, and has not been done.
|
||||
|
||||
Note that the **flash is wild-only**: `BattleTransition_FlashScreen` is called
|
||||
from `BattleTransition_Circle` (`:585`) and `BattleTransition_DoubleCircle`
|
||||
(`:628`) and nowhere else. A trainer battle's transition is the spiral -
|
||||
inward against a weaker foe, outward against a stronger one
|
||||
(`wBattleTransitionSpiralDirection`, `:119-126`) - with no flash in front of
|
||||
it. The port's `flash` mapping was already correct; the transition simply
|
||||
never ran for those battles.
|
||||
|
||||
Two of these deserve their reasoning recorded:
|
||||
|
||||
**The inward spiral** writes one tile per iteration and calls
|
||||
`BattleTransition_TransferDelay3` every seventh tile. That helper is not a
|
||||
one-frame transfer - it is `ld a,1 / ldh [hAutoBGTransferEnabled] / call
|
||||
Delay3 / xor a / ldh [...]` (`battle_transitions.asm:619`), so the cadence is
|
||||
7 tiles per **three** frames. Reading it as one frame runs the whole wipe 3x
|
||||
too fast, ~46 frames against the ROM's ~150. pokered-c hit that exact bug and
|
||||
caught it on a live comparison.
|
||||
|
||||
**The black hold** is not a number pokered states anywhere; it is incidental
|
||||
load cost that a modern port does not pay. The derivable floor is ~13 frames
|
||||
(`LoadHpBarAndStatusTilePatterns` 4, `LoadHudTilePatterns` 2, `ClearScreen`'s
|
||||
`Delay3` 3, the `DisableLCD` LY wait 1, `Delay3` after `EnableLCD` 3), but the
|
||||
two sprite decompressors (`UncompressSpriteFromDE` for the 7x7 front pic and
|
||||
`LoadPlayerBackPic`'s uncompress + `ScaleSpriteByTwo`) are bit-level RLE/delta
|
||||
decoders that cannot be cycle-counted from the asm at all. So the derivation
|
||||
bottoms out around 25-30 with an unbounded remainder. pokered-c set 60 by ear
|
||||
against the real ROM and marked it ~95% right rather than frame-matched. The
|
||||
credible range is 30-60; it should not be "corrected" down toward the floor on
|
||||
the strength of the derivation, because the omitted decompressors are exactly
|
||||
the unbounded part. Pinning it exactly wants a frame-by-frame capture.
|
||||
|
||||
### Battle turns
|
||||
|
||||
| Sequence | Hardware | Source | Port | Port source | Delta |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| Player HP bar drain of D HP over P pixels | **D + 2P + 6** | `engine/gfx/hp_bar.asm:81-135`, `:140-159`, `:234` | D + 2P + 6 | `src/battle/BattleState.lua` `stepHPDrain` | **fixed** |
|
||||
| Enemy HP bar drain over P pixels | **2P + 5** (no per-HP frame) | same, gated at `:207-209` | 2P + 5 | same | **fixed** |
|
||||
| Status move / missed move beat | **30** | `engine/battle/core.asm:3145,3158,3185-3186`; enemy `:5588` | 30 | `EffectRegistry` `missBeat`, `BattleState:performMove` | **fixed** |
|
||||
| Applying anim type 1, vertical shake b=8 | **48** | `animations.asm:500-503` | 48 | `BattleState:applyHitFx` | ok |
|
||||
| Applying anim type 2, fast horizontal b=8 | **72** | `animations.asm:505-508` | 72 | same | ok |
|
||||
| Applying anim type 3, slow horizontal 6,2 | **48** | `animations.asm:510-512,526-549` | 48 | same | ok |
|
||||
| Applying anim type 4, `AnimationBlinkMon` | **60** | `animations.asm:514-516`, `:1360-1376` | 60 | same | **fixed** |
|
||||
| Applying anim type 5, fast horizontal b=2 | **18** | `animations.asm:518-521` | 18 | same | ok |
|
||||
| Applying anim type 6, slow horizontal 3,2 | **24** | `animations.asm:523-525` | 24 | same | ok |
|
||||
| Faint slide-down | **14** (`PIC_HEIGHT` x `DelayFrames 2`) | `engine/battle/core.asm:1186-1222` | 14 | `BattleState:onFaint` | **fixed** |
|
||||
| Before every move animation | `Delay3` = **3** | `engine/battle/core.asm:6635-6640` | 3 | `BattleState:updateQueue` | **fixed** |
|
||||
| Battle start, after enemy send-out | **40** | `engine/battle/core.asm:152-156` | 40 | `BattleState:enter` | **fixed** |
|
||||
| Poison / burn / leech-seed tick | **20** | `engine/battle/core.asm:529-530` | 20 | `src/battle/BattleState.lua:1817` | **ok** |
|
||||
| Switch player mon | **50** | `engine/battle/core.asm:2421-2422` | 50 | `src/battle/BattleState.lua:1666-1668` | **ok** |
|
||||
| Post-hit hold (crit text or not) | **20** | `engine/battle/core.asm:3798-3814` | 20 | `EffectRegistry.runDamaging` | **fixed** |
|
||||
| Fainted mon slide-down | **2** per row | `engine/battle/core.asm:1216-1217` | - | - | unmeasured |
|
||||
| Trainer pic slide off | **2** per column | `engine/battle/core.asm:1267-1268` | - | - | unmeasured |
|
||||
| Trainer battle victory | **40** | `engine/battle/core.asm:940-941` | - | - | unmeasured |
|
||||
| Player blackout | **40** | `engine/battle/core.asm:1143-1144` | - | - | unmeasured |
|
||||
| No moves left (Struggle) | **60** | `engine/battle/core.asm:2753-2754` | - | - | unmeasured |
|
||||
| Send-out animation | `Delay3` + **4** + **5** | `engine/battle/core.asm:6814-6830` | - | - | unmeasured |
|
||||
|
||||
The HP bar was the single largest battle divergence. `UpdateHPBar` steps **one
|
||||
HP point per loop iteration**; each iteration pays 1 frame in
|
||||
`UpdateHPBar_PrintHPNumber` whenever `wHPBarType != 0` - the player's own HUD
|
||||
and the party menu, but not the enemy HUD - plus 2 frames for each pixel the
|
||||
bar actually moves. The tail (`.animateHPBarDone`, `:132-135`) prints the
|
||||
number once more, animates one last pixel and falls into `Delay3`, so it costs
|
||||
6 frames player-side and 5 enemy-side.
|
||||
|
||||
A 150 HP mon losing everything therefore costs 150 + 96 + 6 = **252 frames
|
||||
(4.2 s)** on the player's HUD, against 96 + 5 = 101 on the enemy's. The port
|
||||
used a flat `maxHP/96` - the enemy-side rate applied to both sides - and ran
|
||||
that same drain in 96 frames (1.6 s).
|
||||
|
||||
## Tier 2 - frequent
|
||||
|
||||
Status-effect failures (`engine/battle/effects.asm:161,1162,1205`) hold **50**
|
||||
frames each. `SwitchAndTeleportEffect` uses 50 and 20
|
||||
(`:834-901`). Evolution holds **50** then **40**
|
||||
(`engine/pokemon/evos_moves.asm:123,155`). The healing machine
|
||||
(`engine/overworld/healing_machine.asm`), item effects
|
||||
(`engine/items/item_effects.asm`, 259 frames across 8 sites), and the fishing
|
||||
animation (**10** then **100**, `engine/overworld/player_animations.asm:380,399`)
|
||||
are all in this tier.
|
||||
|
||||
## Tier 3 - set pieces
|
||||
|
||||
Highest total budgets in the scan, all seen rarely:
|
||||
`engine/movie/hall_of_fame.asm` (529 frames / 7 sites),
|
||||
`engine/link/cable_club.asm` (507 / 12), `engine/movie/credits.asm` (502 / 7),
|
||||
`engine/movie/trade.asm` (494 / 19), `engine/movie/intro.asm` (317 / 7),
|
||||
`engine/menus/main_menu.asm` (271 / 15), `engine/menus/save.asm` (250 / 3),
|
||||
`engine/battle/end_of_battle.asm` (200 / 1, the link-battle win/lose string).
|
||||
Several of these already have faithful implementations - see
|
||||
`src/ui/IntroMovie.lua`, `src/ui/Credits.lua`, `src/ui/HallOfFame.lua`, whose
|
||||
constants cite their asm sources directly.
|
||||
|
||||
## Status
|
||||
|
||||
Closed, and locked by `tests/engine/timing_parity.lua`:
|
||||
|
||||
1. **Text page and CONT breaks** - were 0 frames where hardware spends 13-23,
|
||||
on every page of every dialogue in the game. Now exact, including the
|
||||
three-frame pre-input hold that swallows a mashed A.
|
||||
2. **Player HP bar drain** - was ~2.5x too fast on a typical mon. Now steps
|
||||
one HP point at a time at the hardware rate, with the enemy HUD correctly
|
||||
cheaper than the player's.
|
||||
3. **Warp fade** - was a symmetric 12/12; now 32 out and no fade in, which is
|
||||
both the right duration and the right shape.
|
||||
4. **Yes/no answer** - was 0 frames where hardware spends 15, with the cursor
|
||||
snapping to NO on B for the duration as `.choseSecondMenuItem` does.
|
||||
|
||||
5. **Battle entry** - every wipe ran at a flat 40/24 against budgets of 30-156,
|
||||
the silhouette slide was 40 frames against 72, the black hold was half
|
||||
what pokered-c calibrated, and the trainer intro's 20-frame gap before the
|
||||
balls and text was missing entirely. This was the single most compressed
|
||||
stretch in the game.
|
||||
|
||||
6. **Battle turns** - the type-4 blink ran at 20 frames against 60. That is
|
||||
the applying animation for every plain damaging move the player uses, so
|
||||
it was the single most-repeated timing error in the game. The 30-frame
|
||||
beat that hardware spends on every status move and every miss was missing
|
||||
entirely. The faint slide, unusually, ran *slower* than hardware (30
|
||||
against 14).
|
||||
|
||||
Two things worth recording about that last batch, because both contradict a
|
||||
plausible reading:
|
||||
|
||||
- **`PrintCriticalOHKOText`'s 20-frame hold is not conditional.** The
|
||||
"no critical hit" early-out at `core.asm:3799` jumps to `.done`, and
|
||||
`.done` *is* the `ld c, 20 / jp DelayFrames`. Every landed hit pays it,
|
||||
which is where the beat before "It's super effective!" comes from.
|
||||
- **`StartBattle`'s 40-frame hold is not conditional either.** The `call nz`
|
||||
at `core.asm:154` gates only `EnemySendOutFirstMon`; the `DelayFrames 40`
|
||||
under it runs for wild battles too, and it lands *between* the enemy's
|
||||
send-out and `.playerSendOutFirstMon` (`:166`) rather than at the end of
|
||||
the intro.
|
||||
|
||||
Trainer victory (24-frame scroll-in + 40-frame hold) and the ball shake
|
||||
(`SFX_TINK` + `DelayFrames 40` per rock) were already correct -
|
||||
`BattleState.lua`'s `wait = 64` and `AnimPlayer.lua`'s `emit(40)`. Note that
|
||||
pokered-c's `BUI_TRAINER_VICTORY_SLIDE` comment calls the scroll-in 14
|
||||
frames; `_ScrollTrainerPicAfterBattle` is 6 loop passes of `DelayFrames 4`,
|
||||
so 24 is right and this port already had it.
|
||||
|
||||
**Leaving a battle was a cut, not a fade.** `EnterMap` checks
|
||||
`BIT_BATTLE_OVER_OR_BLACKOUT` and calls `MapEntryAfterBattle`
|
||||
(`home/overworld.asm:22`), which is `GBFadeInFromWhite` - so the map fades up
|
||||
from white over 24 frames, behind the 10-frame hold at `:351-352`. The port
|
||||
popped the battle and the overworld was simply there. `Transition.battleReturn`
|
||||
supplies both halves, and steps the veil in three palette stages of 8 frames
|
||||
rather than tweening it, because `GBFadeIncCommon` writes a palette and holds
|
||||
it with `ld c, 8 / call DelayFrames` (`home/fade.asm:30-41`) three times over.
|
||||
|
||||
It is wrapped around `battle.onFinish` in `OverworldState:pushBattle` - the
|
||||
one funnel every battle goes through - rather than living in `afterBattle`.
|
||||
That placement matters: a script-driven **win** defers `afterBattle` into
|
||||
`ctx.afterScript` so an evolution screen cannot be buried under the trainer's
|
||||
follow-up text (`Commands.start_battle`), and a fade inside `afterBattle`
|
||||
inherited that deferral. On a rival battle it fired after the post-battle
|
||||
dialogue *and* the rival's walk-off, rather than when the battle ended.
|
||||
|
||||
The rest of `onFinish` runs as the fade's `onDone`, which is also the hardware
|
||||
order: `MapEntryAfterBattle` fades the map back in and only then does the map
|
||||
script run. The overworld is frozen meanwhile - `StateStack` updates the top
|
||||
state only - so nothing moves under the white.
|
||||
|
||||
Hardware skips the fade on a dark map (`wMapPalOffset` nonzero takes the
|
||||
`LoadGBPal` branch at `:754`). This port has no `wMapPalOffset` equivalent -
|
||||
no map needs FLASH to be lit - so that branch is unreachable here;
|
||||
`battleReturn` accepts `opts.instant` for it if that ever changes.
|
||||
|
||||
Still open, hardware number confirmed but not yet wired:
|
||||
|
||||
- List menu open (10) and per-input redraw (`Delay3`).
|
||||
- Every battle-table row marked "unmeasured" above - the status/miss beat (30)
|
||||
is the highest-exposure of them, since it is paid on every status move and
|
||||
every miss.
|
||||
|
||||
Entries marked "unmeasured" have confirmed hardware numbers but the port side
|
||||
has not been traced; they are remaining work, not known-good.
|
||||
+214
-32
@@ -23,6 +23,7 @@ local Pokemon = require("src.pokemon.Pokemon")
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
local Screens = require("src.ui.Screens")
|
||||
local Status = require("src.battle.Status")
|
||||
local Timing = require("src.core.Timing")
|
||||
local TrainerAI = require("src.battle.TrainerAI")
|
||||
local TurnOrder = require("src.battle.TurnOrder")
|
||||
local TypeChart = require("src.battle.TypeChart")
|
||||
@@ -50,6 +51,41 @@ function BattleState:wideLayout()
|
||||
return self:isWideBattleLayout()
|
||||
end
|
||||
|
||||
-- BATTLE SIZE: "fixed" keeps the classic integer-scaled letterbox (a GB pixel
|
||||
-- is a whole number of screen pixels, and the battle is the same size at any
|
||||
-- zoom); "fill" scales the battle surface to the window instead, so it fills
|
||||
-- vertically. Filling means a fractional scale, so pixels stop being evenly
|
||||
-- sized -- that is the trade, which is why it is a setting rather than a
|
||||
-- change. Only the battle surface is affected; the overworld is unchanged.
|
||||
function BattleState:wantsFillScale()
|
||||
local options = self.game and self.game.save and self.game.save.options
|
||||
return options and options.battleFit == "fill" or false
|
||||
end
|
||||
|
||||
-- BATTLE BG: what fills the screen AROUND the battle -- the letterbox voids
|
||||
-- that grow as the window gets bigger or the view is zoomed out. The battle
|
||||
-- screen itself is untouched: it keeps its white paper field in every mode.
|
||||
--
|
||||
-- "white" the display mode's paper shade (the classic look)
|
||||
-- "black" plain black bars
|
||||
-- "world" the frozen overworld, dimmed
|
||||
--
|
||||
-- "world" works by making the battle NON-opaque: StateStack:visibleBase then
|
||||
-- finds the overworld below it and Game:draw keeps drawing the map, so the
|
||||
-- voids show it instead of a flat clear. The battle still paints its own
|
||||
-- opaque 160x144 field over the top, so only the surround changes.
|
||||
function BattleState:bgMode()
|
||||
local options = self.game and self.game.save and self.game.save.options
|
||||
local mode = options and options.battleBg
|
||||
if mode == "black" or mode == "world" then return mode end
|
||||
return "white"
|
||||
end
|
||||
|
||||
-- How far to dim the overworld behind a "world" background, 0..1. Enough
|
||||
-- that the battle reads as the foreground rather than competing with a fully
|
||||
-- lit map behind it.
|
||||
BattleState.BG_WORLD_DIM = 0.55
|
||||
|
||||
-- Renderer:setUISize asks the top state for its surface before anything draws
|
||||
function BattleState:uiSize()
|
||||
if self:wideLayout() then return WideBattle.WIDTH, WideBattle.HEIGHT end
|
||||
@@ -844,9 +880,30 @@ function BattleState:drainNext(battler, stopAt)
|
||||
{ drain = true, battler = battler, stopAt = stopAt })
|
||||
end
|
||||
|
||||
-- One frame of the HP-bar drain (engine/gfx/hp_bar.asm UpdateHPBar):
|
||||
-- the bar animates a pixel per two frames, so displayed HP moves at
|
||||
-- maxHP/96 per frame (48-pixel bar). Returns true while animating.
|
||||
-- Queue a pure frame hold at the current insert point, the way the original
|
||||
-- spends DelayFrames between the beats of a turn. Mirrors sayNext/drainNext
|
||||
-- so a caller can interleave holds with messages in source order.
|
||||
function BattleState:waitNext(frames)
|
||||
if not frames or frames <= 0 then return end
|
||||
self.nextInsert = (self.nextInsert or 0) + 1
|
||||
table.insert(self.queue, self.nextInsert, { wait = frames })
|
||||
end
|
||||
|
||||
-- One frame of the HP-bar drain (engine/gfx/hp_bar.asm UpdateHPBar).
|
||||
--
|
||||
-- The original walks the bar ONE HP POINT per loop iteration (:81-120), and
|
||||
-- what each iteration costs depends on the side:
|
||||
-- * UpdateHPBar_PrintHPNumber spends a DelayFrame (:234) reprinting the
|
||||
-- number, but only when wHPBarType is nonzero (:207-209) -- the player's
|
||||
-- own HUD and the party menu, never the enemy's;
|
||||
-- * UpdateHPBar_AnimateHPBar spends 2 frames for each pixel the bar
|
||||
-- actually moved (:147-148), and most single-HP steps move none.
|
||||
-- So the player's bar drains at 1 HP per frame plus 2 frames per pixel,
|
||||
-- while the enemy's costs nothing until it crosses a pixel boundary. The
|
||||
-- old flat maxHP/96 rate was the enemy-side formula applied to both, which
|
||||
-- ran a 150 HP mon's full drain in 96 frames against hardware's 249.
|
||||
--
|
||||
-- Returns true while animating.
|
||||
function BattleState:stepHPDrain()
|
||||
local busy = false
|
||||
for _, b in ipairs({ self.player, self.enemy }) do
|
||||
@@ -857,14 +914,30 @@ function BattleState:stepHPDrain()
|
||||
and b.shownHP >= b.drainFloor then
|
||||
goal = b.drainFloor
|
||||
end
|
||||
if b.shownHP ~= goal then
|
||||
local step = math.max(1, b.mon.stats.hp) / 96
|
||||
if b.shownHP > goal then
|
||||
b.shownHP = math.max(goal, b.shownHP - step)
|
||||
else
|
||||
b.shownHP = math.min(goal, b.shownHP + step)
|
||||
if (b.drainHold or 0) > 0 then
|
||||
b.drainHold = b.drainHold - 1
|
||||
busy = true
|
||||
elseif b.shownHP ~= goal then
|
||||
local maxHP = math.max(1, b.mon.stats.hp)
|
||||
local playerSide = (b == self.player)
|
||||
local cost = 0
|
||||
-- consume whole HP steps until this frame's budget is spent; on the
|
||||
-- enemy HUD several free steps can land in the same frame
|
||||
while b.shownHP ~= goal and cost < 1 do
|
||||
local nextHP = b.shownHP + ((b.shownHP > goal) and -1 or 1)
|
||||
cost = cost + Timing.hpDrainStepFrames(b.shownHP, nextHP,
|
||||
maxHP, playerSide)
|
||||
b.shownHP = nextHP
|
||||
end
|
||||
busy = busy or b.shownHP ~= goal
|
||||
b.drainHold = math.max(0, cost - 1)
|
||||
b.draining = true
|
||||
busy = true
|
||||
elseif b.draining then
|
||||
-- .animateHPBarDone's final number print, one more pixel step and
|
||||
-- Delay3 (hp_bar.asm:132-135); this frame is the first of them
|
||||
b.draining = nil
|
||||
b.drainHold = Timing.hpDrainClosingFrames(b == self.player) - 1
|
||||
busy = true
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -939,6 +1012,14 @@ function BattleState:updateQueue()
|
||||
self.waitFrames = self.waitFrames - 1
|
||||
return true
|
||||
end
|
||||
-- WaitForSoundToFinish (home/delay.asm:15-20) blocks until the sfx has
|
||||
-- actually stopped sounding, which is how the original gives a sound its
|
||||
-- own clear window instead of letting the next beat play over it
|
||||
if self.waitingSound then
|
||||
local src = self.waitingSound
|
||||
if src and src.isPlaying and src:isPlaying() then return true end
|
||||
self.waitingSound = nil
|
||||
end
|
||||
-- an HP-bar drain holds the queue until the bar catches up
|
||||
if self.draining then
|
||||
if self:stepHPDrain() then return true end
|
||||
@@ -990,6 +1071,12 @@ function BattleState:updateQueue()
|
||||
self.waitFrames = item.wait
|
||||
return true
|
||||
end
|
||||
if item.waitSound then
|
||||
-- the source is fetched now, not when the row was queued, so the
|
||||
-- act() that started the sound has already run
|
||||
self.waitingSound = item.waitSound()
|
||||
return true
|
||||
end
|
||||
if item.mimicSelect then
|
||||
-- pause the queue on Mimic's copy menu (MoveSelectionMenu with
|
||||
-- wMoveMenuType = 1 lists the enemy's moves; cursor starts on 1)
|
||||
@@ -1010,6 +1097,16 @@ function BattleState:updateQueue()
|
||||
-- the animation ends (hitRow rows carry a hit with no animation --
|
||||
-- thrash/rage continuation turns that skip the announcement).
|
||||
if item.anim or item.hitRow then
|
||||
-- PlayMoveAnimation writes wAnimationID, calls Delay3, and only then
|
||||
-- jumps to MoveAnimation (core.asm:6635-6640), so three frames pass
|
||||
-- between the move's announcement and the first frame of its
|
||||
-- animation. Put the row back and pay that first.
|
||||
if item.anim and not item.animDelayed then
|
||||
item.animDelayed = true
|
||||
table.insert(self.queue, 1, item)
|
||||
self.waitFrames = Timing.MOVE_ANIM_PRE
|
||||
return true
|
||||
end
|
||||
local mdef = item.anim and self.data.moves[item.anim]
|
||||
local anim = mdef and mdef.anim
|
||||
if item.anim == "POOF_ANIM" then
|
||||
@@ -1081,17 +1178,38 @@ function BattleState:updateQueue()
|
||||
-- a \v CONT wait holds the box until A/B, then scrolls the next line in
|
||||
-- (home/text.asm ContText); this keeps a 3rd line on-screen (#216)
|
||||
if self.msgWaiting then
|
||||
-- _ContText prints the â–¼ and runs ProtectedDelay3 BEFORE ManualTextScroll
|
||||
-- starts watching the joypad (home/text.asm:263-267), so three frames
|
||||
-- pass with the arrow up and the button ignored
|
||||
if (self.msgPreWait or 0) > 0 then
|
||||
self.msgPreWait = self.msgPreWait - 1
|
||||
return true
|
||||
end
|
||||
if input:wasPressed("a") or input:wasPressed("b") then
|
||||
self.msgWaiting = nil
|
||||
self:beginMsgLine()
|
||||
-- then the two ScrollTextUpOneLine calls block for 5 frames each
|
||||
-- (home/text.asm:280-305) before the next line starts typing
|
||||
self.waitFrames = Timing.TEXT_SCROLL_PAIR
|
||||
end
|
||||
return true
|
||||
end
|
||||
local cur = self.shown[#self.shown]
|
||||
if #cur < #self.codes then
|
||||
-- battle typewriter cadence: two glyphs per fixed step (as before)
|
||||
for _ = 1, 2 do
|
||||
if #cur >= #self.codes then break end
|
||||
-- Battle text prints through the same PrintText path as everything
|
||||
-- else, so it pays PrintLetterDelay per character (home/print_text.asm:
|
||||
-- 4-45): hFrameCounter is loaded from wOptions & $f -- the OPTION text
|
||||
-- speed, 1/3/5, default 3 -- and the loop spins until it drains, unless
|
||||
-- A or B is held, which collapses the wait to a single DelayFrame.
|
||||
-- This used to run two glyphs per frame flat, six times hardware speed
|
||||
-- at the default setting, and ignored the text-speed option entirely.
|
||||
local delay = (self.game.save.options and self.game.save.options.textSpeed)
|
||||
or 3
|
||||
if delay ~= 1 and delay ~= 3 and delay ~= 5 then delay = 3 end
|
||||
if input:isDown("a") or input:isDown("b") then delay = 1 end
|
||||
self.charTimer = (self.charTimer or 0) + 1
|
||||
while self.charTimer >= delay and #cur < #self.codes do
|
||||
self.charTimer = self.charTimer - delay
|
||||
cur[#cur + 1] = self.codes[#cur + 1]
|
||||
self.charIndex = self.charIndex + 1
|
||||
end
|
||||
@@ -1100,6 +1218,7 @@ function BattleState:updateQueue()
|
||||
-- scrolling, \n advances now (beginMsgLine scrolls if the box is full)
|
||||
if self.lines[self.lineIndex + 1].cont then
|
||||
self.msgWaiting = true
|
||||
self.msgPreWait = Timing.TEXT_PRE_ADVANCE
|
||||
else
|
||||
self:beginMsgLine()
|
||||
end
|
||||
@@ -1126,8 +1245,18 @@ function BattleState:updateQueue()
|
||||
-- only on a \v CONT hold (#317). A flag of its own, not msgWaiting:
|
||||
-- that branch above scrolls the NEXT line in, which this page has not
|
||||
-- got, so reusing it would call beginMsgLine on a drained message.
|
||||
self.msgPrompt = true
|
||||
if input:wasPressed("a") or input:wasPressed("b") then
|
||||
if not self.msgPrompt then
|
||||
self.msgPrompt = true
|
||||
-- PromptText runs ProtectedDelay3 between writing the arrow and
|
||||
-- ManualTextScroll (home/text.asm:213-217), so the page holds for
|
||||
-- three frames with the button ignored before it can be dismissed.
|
||||
-- Without it a queued A press could clear a page the same frame its
|
||||
-- last glyph landed, which is most of "it doesn't hold sometimes".
|
||||
self.msgPromptWait = Timing.TEXT_PRE_ADVANCE
|
||||
end
|
||||
if (self.msgPromptWait or 0) > 0 then
|
||||
self.msgPromptWait = self.msgPromptWait - 1
|
||||
elseif input:wasPressed("a") or input:wasPressed("b") then
|
||||
self.msgPrompt = nil
|
||||
self.current = nil
|
||||
end
|
||||
@@ -1248,11 +1377,12 @@ function BattleState:enter()
|
||||
-- without a transition (link battles, scripted pushes)
|
||||
Music.playBattle(self.data, self.musicKind)
|
||||
-- intro presentation (SlidePlayerAndEnemySilhouettesOnScreen): both
|
||||
-- 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
|
||||
-- sides slide in; the trainer pics stay up until the send-outs
|
||||
-- BATTLE BG "world" drops this battle's opacity so StateStack keeps drawing
|
||||
-- the overworld underneath it (see bgMode). Per instance, so the class
|
||||
-- default stays opaque for every other battle and for older saves.
|
||||
self.isOpaque = self:bgMode() ~= "world"
|
||||
self.introSlide = Timing.BATTLE_SLIDE_IN_FRAMES
|
||||
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 /
|
||||
@@ -1298,6 +1428,25 @@ function BattleState:enter()
|
||||
and not self.ghost and not self.scopeReveal then
|
||||
queueEnemyCry()
|
||||
end
|
||||
-- PrintBeginningBattleText .trainerBattle (common_text.asm): a trainer
|
||||
-- battle gives SFX_SILPH_SCOPE a clear window -- PlaySound, then
|
||||
-- WaitForSoundToFinish, which blocks -- and only after `ld c, 20 /
|
||||
-- DelayFrames` do DrawAllPokeballs and the "wants to fight!" text run.
|
||||
-- The balls and the text used to appear on the same frame the silhouettes
|
||||
-- landed, so the sound had to share its whole duration with the ball draw
|
||||
-- and the text scroll instead of landing on its own.
|
||||
--
|
||||
-- The sfx is extracted as "Trainer_Appeared" (tools/rom_manifest.json
|
||||
-- sfxHeaders, bank 8 / $42bb -- the same header pokered names
|
||||
-- SFX_Silph_Scope); nothing had ever played it.
|
||||
if self.kind == "trainer" then
|
||||
self:act(function()
|
||||
self.introSfx = require("src.core.Sound").play(self.data,
|
||||
"Trainer_Appeared")
|
||||
end)
|
||||
table.insert(self.queue, { waitSound = function() return self.introSfx end })
|
||||
table.insert(self.queue, { wait = Timing.TRAINER_INTRO_SFX_GAP })
|
||||
end
|
||||
self:say(self.introText)
|
||||
-- the unveil rides on that same box, before _InitBattleCommon clears the
|
||||
-- intro chrome below (#492)
|
||||
@@ -1339,6 +1488,14 @@ function BattleState:enter()
|
||||
end)
|
||||
queueEnemyCry()
|
||||
end
|
||||
-- StartBattle .foundFirstAliveEnemyMon (core.asm:152-156): the `call nz`
|
||||
-- gates only EnemySendOutFirstMon -- the `ld c, 40 / call DelayFrames`
|
||||
-- after it is unconditional, so a wild battle pays it too, between
|
||||
-- "Wild X appeared!" and "Go! Y!". It lands before .playerSendOutFirstMon
|
||||
-- (:166), not at the end of the intro. Appended, not waitNext'd: the
|
||||
-- intro is built linearly, and waitNext's insert point is for rows added
|
||||
-- while the queue is already running.
|
||||
table.insert(self.queue, { wait = Timing.BATTLE_START_SENDOUT })
|
||||
if not self.safari and not self.demo then
|
||||
-- StartBattle .playerSendOutFirstMon (core.asm:236-240): the back pic
|
||||
-- walks off the LEFT edge (SlideTrainerPicOffScreen, hlcoord 1,5,
|
||||
@@ -2510,24 +2667,27 @@ function BattleState:applyHitFx(hit)
|
||||
prog[#prog + 1] = { dy = 0, frames = 3 }
|
||||
end
|
||||
self.fx.shakeProg = prog
|
||||
self.waitFrames = 48 -- the predef blocks until the shake settles
|
||||
self.waitFrames = Timing.SHAKE_VERTICAL -- the predef blocks until it settles
|
||||
elseif t == 2 then
|
||||
self.fx.shakeProg = fastShakeProg(8)
|
||||
self.waitFrames = 72
|
||||
self.waitFrames = Timing.SHAKE_HORIZ_HEAVY
|
||||
elseif t == 3 then
|
||||
self.fx.shakeProg = slowShakeProg(6, 2)
|
||||
self.waitFrames = 48
|
||||
self.waitFrames = Timing.SHAKE_HORIZ_SLOW
|
||||
elseif t == 4 then
|
||||
if hit.blink then
|
||||
self.fx.blink = { target = hit.blink, frames = 20 }
|
||||
self.waitFrames = 20
|
||||
-- AnimationBlinkMon: 6 iterations of hide/5 frames/show/5 frames.
|
||||
-- This is the animation for every plain damaging move the player
|
||||
-- uses, and it ran at a third of its length.
|
||||
self.fx.blink = { target = hit.blink, frames = Timing.BLINK_MON }
|
||||
self.waitFrames = Timing.BLINK_MON
|
||||
end
|
||||
elseif t == 5 then
|
||||
self.fx.shakeProg = fastShakeProg(2)
|
||||
self.waitFrames = 18
|
||||
self.waitFrames = Timing.SHAKE_HORIZ_LIGHT
|
||||
elseif t == 6 then
|
||||
self.fx.shakeProg = slowShakeProg(3, 2)
|
||||
self.waitFrames = 24
|
||||
self.waitFrames = Timing.SHAKE_HORIZ_SLOW2
|
||||
end
|
||||
end
|
||||
|
||||
@@ -3305,10 +3465,13 @@ function BattleState:onFaint(battler)
|
||||
Sound.playCry(self.data, battler.mon.species)
|
||||
Sound.play(self.data, "Faint_Fall")
|
||||
self.fx = self.fx or {}
|
||||
self.fx.faint = { battler = battler, frames = 30 }
|
||||
-- SlideDownFaintedMonPic: PIC_HEIGHT (7) slide steps, each closing with
|
||||
-- DelayFrames 2 (core.asm:1186-1222). The port held this one twice as
|
||||
-- long as hardware.
|
||||
self.fx.faint = { battler = battler, frames = Timing.FAINT_SLIDE }
|
||||
end)
|
||||
self.nextInsert = (self.nextInsert or 0) + 1
|
||||
table.insert(self.queue, self.nextInsert, { wait = 30 })
|
||||
table.insert(self.queue, self.nextInsert, { wait = Timing.FAINT_SLIDE })
|
||||
if not battler.isPlayer and self.kind == "wild" then
|
||||
-- FaintEnemyPokemon .wild_win (core.asm:792-795): beating a wild
|
||||
-- mon calls EndLowHealthAlarm and starts MUSIC_DEFEATED_WILD_MON
|
||||
@@ -4267,7 +4430,21 @@ function BattleState:finish()
|
||||
require("src.core.Music").restoreMap(self.data)
|
||||
self.game.stack:pop()
|
||||
Runtime.emit("battle.ended", { battle = self, result = self.result or "run" })
|
||||
if self.onFinish then self.onFinish(self.result or "run") end
|
||||
-- Coming back from the battle screen is a fade, not a cut: EnterMap sees
|
||||
-- BIT_BATTLE_OVER_OR_BLACKOUT set and runs MapEntryAfterBattle
|
||||
-- (home/overworld.asm:22, :749-753) = GBFadeInFromWhite. This is the one
|
||||
-- choke point every battle -- wild, trainer, walk-up, scripted, link --
|
||||
-- passes through on its way out, so the fade is guaranteed here rather
|
||||
-- than depending on each caller having wrapped onFinish correctly.
|
||||
local result = self.result or "run"
|
||||
local onFinish = self.onFinish
|
||||
if result == "lose" then
|
||||
-- the blackout path warps to the heal point with its own transition
|
||||
if onFinish then onFinish(result) end
|
||||
return
|
||||
end
|
||||
self.game.stack:push(require("src.render.Transition").battleReturn(self.game,
|
||||
function() if onFinish then onFinish(result) end end))
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
@@ -4323,10 +4500,14 @@ function BattleState:growInScale(battler)
|
||||
end
|
||||
|
||||
-- battler hidden this frame? (damage blink)
|
||||
--
|
||||
-- AnimationBlinkMon hides the pic, waits DelayFrames 5, shows it, waits
|
||||
-- DelayFrames 5, six times over (animations.asm:1360-1376) -- a 10-frame
|
||||
-- period, not 8. With Timing.BLINK_MON that is exactly six blinks.
|
||||
function BattleState:fxHidden(battler)
|
||||
local fx = self.fx
|
||||
if fx and fx.blink and fx.blink.target == battler and fx.blink.frames > 0 then
|
||||
return self.frame % 8 < 4
|
||||
return self.frame % 10 < 5
|
||||
end
|
||||
return false
|
||||
end
|
||||
@@ -5230,7 +5411,8 @@ 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) * 2 -- intro slide-in offset (2px/frame)
|
||||
-- intro slide-in offset: 2 px per frame, so 144 px over 72 frames
|
||||
local slide = (self.introSlide or 0) * Timing.BATTLE_SLIDE_PX_PER_FRAME
|
||||
|
||||
if self:colorMode() then
|
||||
-- SGB pipeline: gray BG canvas -> (wavy) -> zone recolor with the
|
||||
|
||||
@@ -9,9 +9,22 @@ local MoveEffects = require("src.battle.MoveEffects")
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
local StatusRegistry = require("src.battle.StatusRegistry")
|
||||
local Strings = require("src.core.Strings")
|
||||
local Timing = require("src.core.Timing")
|
||||
|
||||
local EffectRegistry = {}
|
||||
|
||||
-- A move that misses -- or that registers as missed, which in Gen 1 includes
|
||||
-- a type immunity and damage floored to zero -- skips its animation and
|
||||
-- falls into PlayerCheckIfFlyOrChargeEffect's `ld c, 30 / call DelayFrames`
|
||||
-- (engine/battle/core.asm:3155-3158 and :3185; enemy twin at :5588) before
|
||||
-- anything is printed. EXPLODE_EFFECT is the exception: core.asm:3157
|
||||
-- branches it to PlayPlayerMoveAnimation instead, so it pays the animation
|
||||
-- rather than the hold -- the same condition that gates cancelMoveAnim.
|
||||
local function missBeat(battle, record)
|
||||
if record and record.explode then return end
|
||||
battle:waitNext(Timing.MOVE_STATUS_OR_MISS)
|
||||
end
|
||||
|
||||
-- pokered's <USER>/<TARGET> text macros print "Enemy " before the enemy
|
||||
-- mon's nickname (home/text.asm PlaceMoveUsersName)
|
||||
local function displayName(b)
|
||||
@@ -92,6 +105,7 @@ function EffectRegistry.runDamaging(battle, ctx, record)
|
||||
if target.invulnerable and not neverMiss then
|
||||
-- Explosion/Selfdestruct still animate on a miss (HandleIfPlayerMoveMissed)
|
||||
if not (record and record.explode) then battle:cancelMoveAnim() end
|
||||
missBeat(battle, record)
|
||||
battle:sayNext(Strings("%s's\nattack missed!", displayName(user)))
|
||||
-- MoveHitTest's INVULNERABLE branch sets the same wMoveMissed as a
|
||||
-- failed accuracy roll (core.asm:5260), and the miss handler still
|
||||
@@ -120,6 +134,7 @@ function EffectRegistry.runDamaging(battle, ctx, record)
|
||||
if not battle:accuracyRoll(move, user, target) then
|
||||
-- Explosion/Selfdestruct still animate on a miss (HandleIfPlayerMoveMissed)
|
||||
if not (record and record.explode) then battle:cancelMoveAnim() end
|
||||
missBeat(battle, record)
|
||||
battle:sayNext(Strings("%s's\nattack missed!", displayName(user)))
|
||||
-- Jump Kick crash, Explode self-destruct
|
||||
if record and record.onMiss then record.onMiss(ctx, "accuracy") end
|
||||
@@ -147,6 +162,7 @@ function EffectRegistry.runDamaging(battle, ctx, record)
|
||||
end
|
||||
if not counterable or (battle.lastDamage or 0) == 0 then
|
||||
battle:cancelMoveAnim()
|
||||
missBeat(battle, record)
|
||||
battle:sayNext(Strings("%s's\nattack missed!", displayName(user)))
|
||||
return
|
||||
end
|
||||
@@ -170,6 +186,7 @@ function EffectRegistry.runDamaging(battle, ctx, record)
|
||||
if info.typeMult == 0 then
|
||||
-- type immunity zeros damage and sets wMoveMissed in Gen 1, so no anim
|
||||
if not (record and record.explode) then battle:cancelMoveAnim() end
|
||||
missBeat(battle, record)
|
||||
battle:sayNext(Strings("It doesn't affect\n%s!", displayName(target)))
|
||||
if record and record.onMiss then record.onMiss(ctx, "immune") end
|
||||
return
|
||||
@@ -177,6 +194,7 @@ function EffectRegistry.runDamaging(battle, ctx, record)
|
||||
if info.missed then
|
||||
-- 0.25x floored the damage to zero: the original registers a miss
|
||||
if not (record and record.explode) then battle:cancelMoveAnim() end
|
||||
missBeat(battle, record)
|
||||
battle:sayNext(Strings("%s's\nattack missed!", displayName(user)))
|
||||
if record and record.onMiss then record.onMiss(ctx, "floored") end
|
||||
return
|
||||
@@ -231,6 +249,13 @@ function EffectRegistry.runDamaging(battle, ctx, record)
|
||||
-- every strike -- damage was only rolled once
|
||||
if info.crit then battle:sayNext(Strings("Critical hit!")) end
|
||||
if info.ohko then battle:sayNext(Strings("One-hit KO!")) end
|
||||
-- PrintCriticalOHKOText closes with `ld c, 20 / jp DelayFrames` at its
|
||||
-- .done label (core.asm:3812-3814) -- and the no-crit path jumps to that
|
||||
-- same label (:3799), so this hold is paid on EVERY landed hit, not just
|
||||
-- critical ones. It sits between the crit text and DisplayEffectiveness
|
||||
-- (:3228-3229), which is where the beat before "It's super effective!"
|
||||
-- comes from.
|
||||
battle:waitNext(Timing.CRIT_OHKO_TEXT)
|
||||
if info.typeMult > 10 then
|
||||
battle:sayNext(Strings("It's super\neffective!"))
|
||||
elseif info.typeMult < 10 then
|
||||
|
||||
@@ -320,7 +320,9 @@ 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) * 2
|
||||
-- same 2 px/frame silhouette slide the 160px layout uses
|
||||
local slide = (battle.introSlide or 0)
|
||||
* require("src.core.Timing").BATTLE_SLIDE_PX_PER_FRAME
|
||||
|
||||
-- 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
|
||||
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
-- Faithful resolution: lock the window to an exact integer multiple of the
|
||||
-- Game Boy's 160x144 screen, 1X through 4X.
|
||||
--
|
||||
-- At any other window size the renderer picks the largest integer scale that
|
||||
-- fits and letterboxes the remainder (Renderer:fitScale), so the game is
|
||||
-- already crisp -- what it is not is *exact*: there are bars, and at a wide
|
||||
-- window a lot of them. Locking the window to 160*N x 144*N removes the
|
||||
-- letterbox entirely, so the surface is the Game Boy screen and nothing else.
|
||||
--
|
||||
-- Persisted as save.options.faithfulRes (0 = OFF). Applied from OptionsMenu
|
||||
-- and on boot via Game:applyOptions. No-ops on mobile and in headless stubs
|
||||
-- that lack love.window.
|
||||
|
||||
local FaithfulRes = {}
|
||||
|
||||
FaithfulRes.WIDTH, FaithfulRes.HEIGHT = 160, 144
|
||||
FaithfulRes.LEVELS = { 0, 1, 2, 3, 4 }
|
||||
FaithfulRes.DEFAULT = 0
|
||||
|
||||
-- conf.lua's floor for the resizable desktop window, restored when the lock
|
||||
-- is released. 1X and 2X are BELOW it, so the lock has to lower the minimum
|
||||
-- as well as set the size or LOVE clamps the window back up.
|
||||
FaithfulRes.MIN_W, FaithfulRes.MIN_H = 480, 360
|
||||
|
||||
-- whether this module currently owns the window size
|
||||
FaithfulRes.locked = false
|
||||
|
||||
function FaithfulRes.normalize(v)
|
||||
v = math.floor(tonumber(v) or FaithfulRes.DEFAULT)
|
||||
if v < 0 then return 0 end
|
||||
if v > 4 then return 4 end
|
||||
return v
|
||||
end
|
||||
|
||||
function FaithfulRes.label(v)
|
||||
v = FaithfulRes.normalize(v)
|
||||
if v == 0 then return "OFF" end
|
||||
return tostring(v) .. "X"
|
||||
end
|
||||
|
||||
function FaithfulRes.cycle(v, dir)
|
||||
local levels = FaithfulRes.LEVELS
|
||||
local cur = 1
|
||||
for i, level in ipairs(levels) do
|
||||
if level == FaithfulRes.normalize(v) then cur = i break end
|
||||
end
|
||||
return levels[(cur - 1 + (dir or 1)) % #levels + 1]
|
||||
end
|
||||
|
||||
function FaithfulRes.isMobile()
|
||||
if not love or not love.system or not love.system.getOS then return false end
|
||||
local osName = love.system.getOS()
|
||||
return osName == "Android" or osName == "iOS"
|
||||
end
|
||||
|
||||
-- Physical pixels per LOVE unit for the CURRENT window.
|
||||
--
|
||||
-- Deliberately NOT love.window.getDPIScale: that reports the display's
|
||||
-- scaling factor even when the window is not high-DPI aware, and conf.lua
|
||||
-- only sets t.window.highdpi on mobile. On a plain desktop window a unit IS
|
||||
-- a pixel, so dividing by the display scale just shrinks the window -- at
|
||||
-- 125% scaling a 2X request became 256x230 pixels, which Renderer:fitScale
|
||||
-- floors to 1, and 4X became 512x461, which floors to 3. That is exactly
|
||||
-- the "2X renders at 1X, 4X renders at 3X" this shipped with.
|
||||
--
|
||||
-- Measuring the ratio the window actually reports is correct in both worlds:
|
||||
-- 1 on a plain desktop window, the real scale on a high-DPI one.
|
||||
local function pixelsPerUnit()
|
||||
local g = love and love.graphics
|
||||
if not (g and g.getDimensions and g.getPixelDimensions) then return 1 end
|
||||
local uw = tonumber((g.getDimensions()))
|
||||
local pw = tonumber((g.getPixelDimensions()))
|
||||
if not uw or not pw or uw <= 0 or pw <= 0 then return 1 end
|
||||
return pw / uw
|
||||
end
|
||||
|
||||
-- The window size in LOVE UNITS that puts 160*v x 144*v PHYSICAL pixels on
|
||||
-- screen.
|
||||
function FaithfulRes.size(v)
|
||||
v = FaithfulRes.normalize(v)
|
||||
if v == 0 then return nil end
|
||||
local ratio = pixelsPerUnit()
|
||||
return math.floor(FaithfulRes.WIDTH * v / ratio + 0.5),
|
||||
math.floor(FaithfulRes.HEIGHT * v / ratio + 0.5)
|
||||
end
|
||||
|
||||
-- Push the lock into the live window. Returns true when the window is
|
||||
-- locked afterwards.
|
||||
function FaithfulRes.apply(v)
|
||||
if FaithfulRes.isMobile() then return false end
|
||||
if not love or not love.window or not love.window.setMode
|
||||
or not love.window.getMode then
|
||||
return false
|
||||
end
|
||||
v = FaithfulRes.normalize(v)
|
||||
local curW, curH, flags = love.window.getMode()
|
||||
flags = flags or {}
|
||||
|
||||
if v == 0 then
|
||||
-- only touch the window if we were the one holding it: an OFF setting on
|
||||
-- boot must not resize a window the player sized themselves
|
||||
if not FaithfulRes.locked then return false end
|
||||
flags.resizable = true
|
||||
flags.minwidth, flags.minheight = FaithfulRes.MIN_W, FaithfulRes.MIN_H
|
||||
love.window.setMode(curW, curH, flags)
|
||||
FaithfulRes.locked = false
|
||||
return false
|
||||
end
|
||||
|
||||
local w, h = FaithfulRes.size(v)
|
||||
-- An exact size and a desktop-fullscreen mode cannot both hold. The lock
|
||||
-- is the more specific request, so it wins and drops fullscreen; VIDEO MODE
|
||||
-- reads BORDERLESS until the player changes it, which then releases this.
|
||||
flags.fullscreen = false
|
||||
-- resizing by hand would silently break the lock, and nothing re-applies it
|
||||
-- (there is no love.resize handler -- the renderer re-reads the size every
|
||||
-- frame), so the window is fixed while locked rather than left draggable.
|
||||
flags.resizable = false
|
||||
flags.minwidth, flags.minheight = w, h
|
||||
love.window.setMode(w, h, flags)
|
||||
FaithfulRes.locked = true
|
||||
return true
|
||||
end
|
||||
|
||||
function FaithfulRes.applyOptions(opts)
|
||||
return FaithfulRes.apply(opts and opts.faithfulRes)
|
||||
end
|
||||
|
||||
return FaithfulRes
|
||||
+17
-4
@@ -10,6 +10,7 @@ local MAX_ACCUM = 0.25 -- avoid spiral of death after a stall
|
||||
function FixedStep:init(callback)
|
||||
self.accum = 0
|
||||
self.callback = callback
|
||||
self.suppressCatchup = false
|
||||
end
|
||||
|
||||
-- The anti-spiral clamp doubles as a steps-per-frame ceiling (0.25s = 15
|
||||
@@ -20,6 +21,16 @@ end
|
||||
FixedStep.maxAccum = MAX_ACCUM
|
||||
|
||||
function FixedStep:update(dt)
|
||||
-- A hitch's oversized dt lands on the frame AFTER discardCatchup was
|
||||
-- called (the hitch itself already ran inside the current step); absorb
|
||||
-- that one frame as a single step instead of the normal accumulator so
|
||||
-- the burst it would otherwise release doesn't play out as a slide.
|
||||
if self.suppressCatchup then
|
||||
self.suppressCatchup = false
|
||||
self.accum = 0
|
||||
self.callback(self.STEP)
|
||||
return
|
||||
end
|
||||
self.accum = math.min(self.accum + dt, self.maxAccum or MAX_ACCUM)
|
||||
while self.accum >= self.STEP do
|
||||
self.accum = self.accum - self.STEP
|
||||
@@ -27,12 +38,14 @@ function FixedStep:update(dt)
|
||||
end
|
||||
end
|
||||
|
||||
-- Drop any pending catch-up steps. A hitch inside one logic step (map
|
||||
-- seam setMap / song start) makes the next real-time dt huge; without this
|
||||
-- the while-loop above would advance many walk frames before the next
|
||||
-- draw, which looks like a slide with no leg animation (issue #93).
|
||||
-- Drop any pending catch-up steps and arm the one-frame clamp above. A
|
||||
-- hitch inside one logic step (map seam setMap / song start) makes the
|
||||
-- next real-time dt huge; without this the while-loop above would advance
|
||||
-- many walk frames before the next draw, which looks like a slide with no
|
||||
-- leg animation (issue #93).
|
||||
function FixedStep:discardCatchup()
|
||||
self.accum = 0
|
||||
self.suppressCatchup = true
|
||||
end
|
||||
|
||||
return FixedStep
|
||||
|
||||
@@ -265,6 +265,38 @@ end
|
||||
-- exactly as the owning state computed it
|
||||
local function sameZones(_, zones) return zones end
|
||||
|
||||
-- Dim alpha for a BATTLE BG "world" battle anywhere in the stack, or nil.
|
||||
-- Same whole-stack rule as fillScaleInStack: a party menu or text box opened
|
||||
-- during the battle must not drop the dim for a frame.
|
||||
function Game.worldBgBattleDim(stack)
|
||||
for i = #(stack and stack.states or {}), 1, -1 do
|
||||
local state = stack.states[i]
|
||||
if state and state.bgMode and state:bgMode() == "world" then
|
||||
return state.BG_WORLD_DIM or 0.55
|
||||
end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
-- Does anything on the stack want the surface scaled to FILL the window
|
||||
-- (aspect preserved, bars on the long axis) rather than sit at the fixed
|
||||
-- integer scale?
|
||||
--
|
||||
-- Asked of the WHOLE stack, not just the top. For BATTLE SIZE "fill" that is
|
||||
-- because the party menu, bag and text boxes a battle opens must not snap the
|
||||
-- surface back to the fixed scale for a frame; the title screen and intro want
|
||||
-- it unconditionally, since neither has a world behind it and neither has any
|
||||
-- reason to sit in a small box in the middle of a large window.
|
||||
function Game.fillScaleInStack(stack)
|
||||
for i = #(stack and stack.states or {}), 1, -1 do
|
||||
local state = stack.states[i]
|
||||
if state and state.wantsFillScale and state:wantsFillScale() then
|
||||
return true
|
||||
end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
-- A wide battle owns the surface until it leaves the stack. The party,
|
||||
-- bag, choice and text states it opens still draw their original 160px UI,
|
||||
-- but the canvas must not snap to 160px between those states.
|
||||
@@ -319,6 +351,14 @@ function Game:draw()
|
||||
else
|
||||
Renderer:setUISize(Renderer.WIDTH, Renderer.HEIGHT)
|
||||
end
|
||||
-- BATTLE SIZE: scale the battle surface to the window instead of the
|
||||
-- classic integer letterbox. Read from the whole stack, not just the top,
|
||||
-- so a party menu or text box opened mid-battle keeps the same surface.
|
||||
Renderer.uiFill = Game.fillScaleInStack(self.stack)
|
||||
-- BATTLE BG "world": dim the overworld the battle is drawn over. Read off
|
||||
-- the stack for the same reason as uiFill above -- a prompt opened during
|
||||
-- the battle must not drop the dim for a frame.
|
||||
Renderer.battleDim = Game.worldBgBattleDim(self.stack)
|
||||
Renderer:beginFrame(worldBelow)
|
||||
for i = self.stack:visibleBase(), #self.stack.states do
|
||||
local state = self.stack.states[i]
|
||||
@@ -658,6 +698,9 @@ function Game:applyOptions(opts)
|
||||
-- returns true when a persisted GBC FX level was cleared on mobile
|
||||
local gbcCleared = require("src.render.GBCFX").applyOptions(opts)
|
||||
require("src.core.VideoMode").applyOptions(opts)
|
||||
-- after VideoMode: a faithful-resolution lock is an exact window size, so
|
||||
-- it has to be the last word on the window (it drops fullscreen to hold)
|
||||
require("src.core.FaithfulRes").applyOptions(opts)
|
||||
-- normalizes a nil/garbage cap to the 60 default, so old saves with no
|
||||
-- fpsCap key pace at the standard rate (issue #88)
|
||||
require("src.core.FrameCap").applyOptions(opts)
|
||||
|
||||
@@ -219,6 +219,15 @@ function SaveData.defaultOptions()
|
||||
-- battle screen composition: og (the 160x144 original) | wide
|
||||
-- (304x144, src/battle/WideBattle.lua)
|
||||
battleLayout = "og",
|
||||
-- BATTLE SIZE: "fixed" = the classic integer-scaled letterbox; "fill" =
|
||||
-- scale the battle surface to the window so it fills vertically. See
|
||||
-- BattleState:wantsFillScale.
|
||||
battleFit = "fixed",
|
||||
-- BATTLE BG: what fills the screen behind and around the battle.
|
||||
-- "white" = the display mode's paper shade (the classic look),
|
||||
-- "black" = plain black bars, "world" = the frozen overworld showing
|
||||
-- through, dimmed. See BattleState:bgMode.
|
||||
battleBg = "white",
|
||||
ruleset = "gen1_faithful",
|
||||
-- 0-7 like the GB's NR50 master volume
|
||||
musicVol = 7,
|
||||
@@ -239,6 +248,9 @@ function SaveData.defaultOptions()
|
||||
voidFill = "trees",
|
||||
-- windowed | borderless (desktop fullscreen); ignored on mobile
|
||||
videoMode = "windowed",
|
||||
-- lock the window to an exact 160x144 multiple, 1..4 (0 = OFF); see
|
||||
-- src/core/FaithfulRes.lua. Ignored on mobile.
|
||||
faithfulRes = 0,
|
||||
-- hard render frame-rate cap; render-only pacing (issue #88, FrameCap.lua)
|
||||
fpsCap = 60,
|
||||
-- graphics performance tier: auto | high | balanced | low. "auto"
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
-- Hardware frame budgets, in fixed 60Hz logic steps.
|
||||
--
|
||||
-- The original spends a large share of its running time inside DelayFrames
|
||||
-- calls that produce no visible change -- the pause after a page break, the
|
||||
-- beat before a status move resolves, the one-HP-at-a-time drain of an HP
|
||||
-- bar. Porting the visible half of a sequence and dropping the wait is what
|
||||
-- makes a port read as snappier than hardware, so every one of those waits
|
||||
-- lives here with its asm citation instead of as a file-local constant.
|
||||
--
|
||||
-- See docs/timing-parity.md for the full catalog and the measurement method;
|
||||
-- tools/scan_pokered_delays.ps1 regenerates the hardware side from a
|
||||
-- disassembly checkout.
|
||||
|
||||
local Timing = {}
|
||||
|
||||
-- home/palettes.asm:14 -- three frames to let the bg map fully update
|
||||
Timing.DELAY3 = 3
|
||||
|
||||
-- home/fade.asm: each fade is a loop of `ld c, 8 / call DelayFrames`
|
||||
Timing.FADE_IN_FROM_BLACK = 32 -- fade.asm:21, b = 4
|
||||
Timing.FADE_OUT_TO_BLACK = 32 -- fade.asm:43, b = 4
|
||||
Timing.FADE_OUT_TO_WHITE = 24 -- fade.asm:26, b = 3
|
||||
Timing.FADE_IN_FROM_WHITE = 24 -- fade.asm:48, b = 3
|
||||
|
||||
-- Overworld -----------------------------------------------------------------
|
||||
|
||||
-- home/overworld.asm:703 PlayMapChangeSound tail-calls GBFadeOutToBlack on
|
||||
-- every map change. There is no matching fade in: the new map is drawn while
|
||||
-- the palettes are still blacked out and LoadGBPal restores them in one write,
|
||||
-- so the map appears instantly.
|
||||
Timing.WARP_FADE_OUT = Timing.FADE_OUT_TO_BLACK
|
||||
Timing.WARP_FADE_IN = 0
|
||||
|
||||
-- home/overworld.asm:351-352 -- after a battle, before EnterMap
|
||||
Timing.POST_BATTLE_RETURN = 10
|
||||
|
||||
-- engine/overworld/player_animations.asm:5-7 -- EnterMapAnim, the fly /
|
||||
-- teleport / dungeon-warp arrival: Delay3 then GBFadeInFromWhite
|
||||
Timing.SPECIAL_WARP_ENTRY = Timing.DELAY3 + Timing.FADE_IN_FROM_WHITE
|
||||
-- player_animations.asm:43 -- dungeon warp holds before handing back control
|
||||
Timing.DUNGEON_WARP_ARRIVAL = 50
|
||||
|
||||
-- Text ----------------------------------------------------------------------
|
||||
|
||||
-- home/text.asm:283-307 ScrollTextUpOneLine is `ld b, 5` of DelayFrame, and
|
||||
-- its own comment notes it is "always called twice in a row"
|
||||
Timing.TEXT_SCROLL_LINE = 5
|
||||
Timing.TEXT_SCROLL_PAIR = Timing.TEXT_SCROLL_LINE * 2
|
||||
|
||||
-- Both _ContText (home/text.asm:262-277) and Paragraph (:230-243) print the
|
||||
-- â–¼ and call ProtectedDelay3 *before* ManualTextScroll starts watching the
|
||||
-- joypad, so three frames pass with the arrow up and the button ignored.
|
||||
Timing.TEXT_PRE_ADVANCE = Timing.DELAY3
|
||||
|
||||
-- Paragraph / PageChar clear the box and then hold (home/text.asm:239-240,
|
||||
-- :254-255) before the next page starts typing.
|
||||
Timing.TEXT_PAGE_CLEAR = 20
|
||||
|
||||
-- Totals, for the catalog and the parity tests.
|
||||
Timing.TEXT_CONT = Timing.TEXT_PRE_ADVANCE + Timing.TEXT_SCROLL_PAIR
|
||||
Timing.TEXT_PARAGRAPH = Timing.TEXT_PRE_ADVANCE + Timing.TEXT_PAGE_CLEAR
|
||||
Timing.TEXT_PAGE = Timing.TEXT_PARAGRAPH
|
||||
|
||||
Timing.TEXT_PAUSE = 30 -- home/text.asm:500 TextCommand_PAUSE
|
||||
Timing.TEXT_DOT = 10 -- home/text.asm:576 TextCommand_DOTS, per dot
|
||||
|
||||
-- Menus ---------------------------------------------------------------------
|
||||
|
||||
-- engine/menus/text_box.asm:322-323 / :333-334 -- both branches of a
|
||||
-- two-option (yes/no) menu hold before restoring the screen tiles
|
||||
Timing.YES_NO_ANSWER = 15
|
||||
|
||||
Timing.LIST_MENU_OPEN = 10 -- home/list_menu.asm:55-56
|
||||
Timing.LIST_MENU_REDRAW = Timing.DELAY3 -- home/list_menu.asm:64
|
||||
|
||||
-- engine/menus/start_sub_menus.asm:224-225
|
||||
Timing.FIELD_TELEPORT = 60 + Timing.DELAY3
|
||||
|
||||
-- Battle --------------------------------------------------------------------
|
||||
|
||||
-- SlidePlayerAndEnemySilhouettesOnScreen (engine/battle/core.asm:9-49):
|
||||
-- the enemy comes in on BG SCX $90 -> $00 and the player's back pic on
|
||||
-- decrementing OAM x, both 2 px per frame -- so 144 px over 72 frames. The
|
||||
-- port ran 160 px at 4 px/frame (40 frames), a little under twice too fast.
|
||||
Timing.BATTLE_SLIDE_IN_FRAMES = 72
|
||||
Timing.BATTLE_SLIDE_PX_PER_FRAME = 2
|
||||
|
||||
-- PrintBeginningBattleText .trainerBattle (engine/battle/common_text.asm):
|
||||
-- SFX_SILPH_SCOPE plays into a clear window (PlaySound then
|
||||
-- WaitForSoundToFinish, which blocks), and only after `ld c, 20 /
|
||||
-- DelayFrames` do DrawAllPokeballs and the "wants to fight!" text run.
|
||||
Timing.TRAINER_INTRO_SFX_GAP = 20
|
||||
|
||||
Timing.BATTLE_START_SENDOUT = 40 -- engine/battle/core.asm:155-156
|
||||
Timing.MOVE_ANIM_PRE = Timing.DELAY3 -- core.asm:6638 PlayMoveAnimation
|
||||
|
||||
-- core.asm:3185-3186 (player) / :5587-5588 (enemy). Reached when the move
|
||||
-- has 0 BP (core.asm:3145 -- every status move) or missed (:3158), so this
|
||||
-- beat is paid on a large fraction of all turns.
|
||||
Timing.MOVE_STATUS_OR_MISS = 30
|
||||
|
||||
-- PlayApplyingAttackAnimation's six types (AnimationTypePointerTable,
|
||||
-- engine/battle/animations.asm:490-524). The two shake families are
|
||||
-- `AnimationShakeScreenHorizontallySlow`, whose double push/pop makes each
|
||||
-- outer pass cost 4b frames and run c times -- so c * 4b.
|
||||
Timing.SHAKE_VERTICAL = 48 -- type 1, b=8: 8 x 6
|
||||
Timing.SHAKE_HORIZ_HEAVY = 72 -- type 2, b=8: 8 x 9
|
||||
Timing.SHAKE_HORIZ_SLOW = 48 -- type 3, lb bc, 6, 2: 2 x 4x6
|
||||
Timing.SHAKE_HORIZ_LIGHT = 18 -- type 5, b=2: 2 x 9
|
||||
Timing.SHAKE_HORIZ_SLOW2 = 24 -- type 6, lb bc, 3, 2: 2 x 4x3
|
||||
|
||||
-- Type 4 -- the player's damaging move with no added effect, and so the
|
||||
-- single most common animation in the game -- is AnimationBlinkMon
|
||||
-- (animations.asm:1360-1376): `ld c, 6` iterations of hide + DelayFrames 5
|
||||
-- + show + DelayFrames 5. The asm's own comment calls it "a second or
|
||||
-- two"; the port ran it in 20 frames, three times too fast, which is a
|
||||
-- large part of why trading blows felt hurried.
|
||||
Timing.BLINK_MON = 60
|
||||
|
||||
-- SlideDownFaintedMonPic (engine/battle/core.asm:1181-1222): b = PIC_HEIGHT
|
||||
-- (7) outer iterations, each closing with `ld c, 2 / call DelayFrames`.
|
||||
-- This one the port ran SLOWER than hardware, at 30.
|
||||
Timing.FAINT_SLIDE = 14
|
||||
|
||||
Timing.RESIDUAL_TICK = 20 -- core.asm:529-530 poison/burn/leech seed
|
||||
Timing.CRIT_OHKO_TEXT = 20 -- core.asm:3813-3814
|
||||
Timing.SWITCH_PLAYER_MON = 50 -- core.asm:2421-2422
|
||||
Timing.NO_MOVES_LEFT = 60 -- core.asm:2753-2754
|
||||
Timing.TRAINER_VICTORY = 40 -- core.asm:940-941
|
||||
Timing.PLAYER_BLACKOUT = 40 -- core.asm:1143-1144
|
||||
Timing.FAINT_SLIDE_ROW = 2 -- core.asm:1216-1217, per row
|
||||
Timing.TRAINER_SLIDE_COL = 2 -- core.asm:1267-1268, per column
|
||||
|
||||
-- HP bar (engine/gfx/hp_bar.asm) ---------------------------------------------
|
||||
--
|
||||
-- UpdateHPBar steps ONE HP point per loop iteration (:81-120). Each
|
||||
-- iteration pays:
|
||||
-- * 1 frame in UpdateHPBar_PrintHPNumber's DelayFrame (:234) -- but only
|
||||
-- when wHPBarType is nonzero (:207-209), i.e. the player's own HUD and
|
||||
-- the party menu, never the enemy HUD; and
|
||||
-- * 2 frames per pixel the bar actually moved, from
|
||||
-- UpdateHPBar_AnimateHPBar's `ld c, 2 / call DelayFrames` (:147-148).
|
||||
-- The drain closes with one more pixel step and a Delay3 (:133-135).
|
||||
--
|
||||
-- So a player-side drain of D HP across P pixels costs D + 2P + 6 frames,
|
||||
-- while the same drain on the enemy HUD costs only 2P + 5. A 150 HP mon
|
||||
-- losing everything takes 150 + 96 + 6 = 252 frames on hardware.
|
||||
|
||||
Timing.HP_BAR_PIXELS = 48 -- the bar is 48 px wide (GetHPBarLength)
|
||||
Timing.HP_BAR_PIXEL_STEP = 2 -- frames per pixel of bar movement
|
||||
Timing.HP_BAR_HP_STEP = 1 -- frames per HP point, player-side HUD only
|
||||
|
||||
-- Pixels the bar shows for `hp` out of `maxHP`. GetHPBarLength floors the
|
||||
-- 48ths and clamps the result to at least 1 for any nonzero HP
|
||||
-- (engine/gfx/hp_bar.asm:42-45); an empty bar is 0.
|
||||
function Timing.hpBarPixels(hp, maxHP)
|
||||
if not maxHP or maxHP <= 0 then return 0 end
|
||||
if hp <= 0 then return 0 end
|
||||
local px = math.floor(hp * Timing.HP_BAR_PIXELS / maxHP)
|
||||
if px < 1 then px = 1 end
|
||||
return px
|
||||
end
|
||||
|
||||
-- Frames one single-HP step of the drain costs: the per-HP number print
|
||||
-- (player side only) plus two frames for every pixel that step moved.
|
||||
function Timing.hpDrainStepFrames(fromHP, toHP, maxHP, playerSide)
|
||||
local pixels = math.abs(Timing.hpBarPixels(toHP, maxHP)
|
||||
- Timing.hpBarPixels(fromHP, maxHP))
|
||||
local frames = pixels * Timing.HP_BAR_PIXEL_STEP
|
||||
if playerSide then frames = frames + Timing.HP_BAR_HP_STEP end
|
||||
return frames
|
||||
end
|
||||
|
||||
-- After the loop, .animateHPBarDone prints the number one last time, runs
|
||||
-- AnimateHPBar for a single pixel and falls into Delay3 (hp_bar.asm:132-135)
|
||||
-- -- so the tail costs 6 frames on the player's HUD and 5 on the enemy's.
|
||||
function Timing.hpDrainClosingFrames(playerSide)
|
||||
local frames = Timing.HP_BAR_PIXEL_STEP + Timing.DELAY3
|
||||
if playerSide then frames = frames + Timing.HP_BAR_HP_STEP end
|
||||
return frames
|
||||
end
|
||||
|
||||
-- Total cost of draining `fromHP` to `toHP`, for tests and for anything that
|
||||
-- needs to budget the whole animation up front.
|
||||
function Timing.hpDrainFrames(fromHP, toHP, maxHP, playerSide)
|
||||
local total = 0
|
||||
local hp = fromHP
|
||||
local dir = (toHP < fromHP) and -1 or 1
|
||||
while hp ~= toHP do
|
||||
local nextHP = hp + dir
|
||||
total = total + Timing.hpDrainStepFrames(hp, nextHP, maxHP, playerSide)
|
||||
hp = nextHP
|
||||
end
|
||||
return total + Timing.hpDrainClosingFrames(playerSide)
|
||||
end
|
||||
|
||||
return Timing
|
||||
+158
-24
@@ -32,10 +32,23 @@ local FLASH_CYCLES = 3
|
||||
-- InitBattleCommon decompresses the front pic (core.asm:6694-6730), and
|
||||
-- SlidePlayerAndEnemySilhouettesOnScreen rebuilds the whole tilemap between
|
||||
-- DisableLCD and EnableLCD (core.asm:9-49) before anything moves. This port
|
||||
-- has no load to hide behind, so the hold has to be explicit (#315). 30 is a
|
||||
-- frame budget for that work, not a number pokered states; retune this one
|
||||
-- constant against a reference recording if it reads long or short.
|
||||
local BLACK_HOLD = 30
|
||||
-- has no load to hide behind, so the hold has to be explicit (#315). This is
|
||||
-- a frame budget for that work, not a number pokered states.
|
||||
--
|
||||
-- 60 comes from pokered-c (BTRANS_BLACK_HOLD_FRAMES), which itemized the
|
||||
-- derivable floor at ~13 frames -- LoadHpBarAndStatusTilePatterns 4,
|
||||
-- LoadHudTilePatterns 2, ClearScreen's Delay3 3, the DisableLCD LY wait 1,
|
||||
-- Delay3 after EnableLCD 3 -- and then noted that the two sprite
|
||||
-- decompressors (UncompressSpriteFromDE for the 7x7 front pic,
|
||||
-- LoadPlayerBackPic's uncompress + ScaleSpriteByTwo) are bit-level RLE/delta
|
||||
-- decoders whose cost cannot be cycle-counted from the asm at all. So the
|
||||
-- derivation bottoms out around 25-30 with an unbounded remainder, and 60
|
||||
-- was set by ear against the real ROM ("a solid second") and confirmed as
|
||||
-- ~95% right rather than frame-matched. The credible range is 30-60; do NOT
|
||||
-- "correct" this down toward the floor on the strength of the derivation,
|
||||
-- because the omitted decompressors are exactly the unbounded part. It
|
||||
-- wants a frame-by-frame capture against hardware to pin exactly.
|
||||
local BLACK_HOLD = 60
|
||||
|
||||
local TILE = 8
|
||||
local COLS, ROWS = 160 / TILE, 144 / TILE -- 20 x 18 tiles
|
||||
@@ -106,11 +119,12 @@ end
|
||||
-- HalfCircle2 continues (1,11) down under the bottom back to (18,11)).
|
||||
-- arms = 1 (Circle, halves in sequence) or 2 (DoubleCircle, both halves
|
||||
-- at once, so opposite arms)
|
||||
local function sweepOrder(arms)
|
||||
local cx, cy = COLS / 2, ROWS / 2
|
||||
local function sweepOrder(arms, cols, rows)
|
||||
cols, rows = cols or COLS, rows or ROWS
|
||||
local cx, cy = cols / 2, rows / 2
|
||||
local tiles = {}
|
||||
for y = 0, ROWS - 1 do
|
||||
for x = 0, COLS - 1 do
|
||||
for y = 0, rows - 1 do
|
||||
for x = 0, cols - 1 do
|
||||
local a = math.atan2(cy - (y + 0.5), x + 0.5 - cx)
|
||||
if a < 0 then a = a + 2 * math.pi end
|
||||
if arms == 2 then a = a % math.pi end
|
||||
@@ -121,18 +135,116 @@ local function sweepOrder(arms)
|
||||
return tiles
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- Arbitrary-grid wipes, for the surface OUTSIDE the classic letterbox
|
||||
-- ---------------------------------------------------------------------
|
||||
--
|
||||
-- The builders above reproduce the ROM's exact walks on its 20x18 tilemap,
|
||||
-- overrun and all, and stay the authority inside the 160x144 box. A zoomed
|
||||
-- or windowed surface has more grid than the Game Boy ever had, and no
|
||||
-- hardware behaviour to be faithful to out there -- but filling it with a
|
||||
-- generic square cascade made a spiral read as "a spiral in a box, with
|
||||
-- something else happening around it". These generalise the same shapes to
|
||||
-- whatever grid the window works out to so the whole surface wipes as one
|
||||
-- figure.
|
||||
|
||||
-- perimeter inward, counterclockwise, starting down the left edge -- the
|
||||
-- direction BattleTransition_InwardSpiral walks
|
||||
local function spiralInGrid(cols, rows)
|
||||
local order = {}
|
||||
local x0, y0, x1, y1 = 0, 0, cols - 1, rows - 1
|
||||
while x0 <= x1 and y0 <= y1 do
|
||||
for y = y0, y1 do order[#order + 1] = { x0, y } end
|
||||
x0 = x0 + 1
|
||||
if x0 > x1 then break end
|
||||
for x = x0, x1 do order[#order + 1] = { x, y1 } end
|
||||
y1 = y1 - 1
|
||||
if y0 > y1 then break end
|
||||
for y = y1, y0, -1 do order[#order + 1] = { x1, y } end
|
||||
x1 = x1 - 1
|
||||
if x0 > x1 then break end
|
||||
for x = x1, x0, -1 do order[#order + 1] = { x, y0 } end
|
||||
y0 = y0 + 1
|
||||
end
|
||||
return order
|
||||
end
|
||||
|
||||
-- the outward spiral is the same walk read from the middle out
|
||||
local function spiralOutGrid(cols, rows)
|
||||
local inward = spiralInGrid(cols, rows)
|
||||
local order = {}
|
||||
for i = #inward, 1, -1 do order[#order + 1] = inward[i] end
|
||||
return order
|
||||
end
|
||||
|
||||
local GRID_BUILDERS = {
|
||||
spiralin = spiralInGrid,
|
||||
spiralout = spiralOutGrid,
|
||||
circle = function(c, r) return sweepOrder(1, c, r) end,
|
||||
doublecircle = function(c, r) return sweepOrder(2, c, r) end,
|
||||
}
|
||||
|
||||
-- Tile order for `style` on an arbitrary cols x rows grid, or nil for the
|
||||
-- styles whose shape is plain geometry (stripes / shrink / split) and which
|
||||
-- the caller extends with rectangles instead. Cached per style+size: the
|
||||
-- window grid only changes on a resize or a zoom step.
|
||||
local orderFor -- defined below; the authentic 20x18 builders
|
||||
|
||||
local gridCache = {}
|
||||
function BattleTransition.gridOrder(style, cols, rows)
|
||||
local build = GRID_BUILDERS[style]
|
||||
if not build or cols < 1 or rows < 1 then return nil end
|
||||
-- At exactly the Game Boy's grid the ROM's own walk is the answer, overrun
|
||||
-- and all -- so an unzoomed window is the classic wipe, not a lookalike.
|
||||
if cols == COLS and rows == ROWS then return orderFor(style, nil) end
|
||||
local key = style .. ":" .. cols .. "x" .. rows
|
||||
local hit = gridCache[key]
|
||||
if hit == nil then
|
||||
hit = build(cols, rows) or false
|
||||
gridCache[key] = hit
|
||||
end
|
||||
return hit or nil
|
||||
end
|
||||
|
||||
-- Wipe lengths, taken from pokered-c's battle_transition.c frame budget --
|
||||
-- derived from battle_transitions.asm and then checked on a live
|
||||
-- side-by-side against the ROM. Each wipe is `steps x frames-per-step`:
|
||||
--
|
||||
-- DoubleCircle 10 x 3 = 30 SpiralOut 360 fills / 3 per frame = 120
|
||||
-- Circle 20 x 3 = 60 HStripes 20 x 3 = 60
|
||||
-- Shrink 9 x 6 = 54 VStripes 18 x 3 = 54
|
||||
-- Split 9 x 6 = 54 (asm:386-392 and :418-424)
|
||||
--
|
||||
-- The port used a flat 40/24 for all eight, which ran every wipe between
|
||||
-- 1.5x and 3x too fast -- the single biggest reason a battle used to open
|
||||
-- so much more abruptly here than on hardware.
|
||||
--
|
||||
-- The inward spiral is the one that bites. It writes one tile per
|
||||
-- iteration and calls BattleTransition_TransferDelay3 every seventh tile
|
||||
-- (wInwardSpiralUpdateScreenCounter counts 7 down to 0), and that helper is
|
||||
-- `ld a,1 / ldh [hAutoBGTransferEnabled] / call Delay3 / xor a / ldh [...]`
|
||||
-- (battle_transitions.asm:619) -- THREE frames, not a one-frame transfer.
|
||||
-- Reading it as one frame runs the whole spiral 3x too fast; pokered-c
|
||||
-- caught that against the real ROM. Deriving the length from the path we
|
||||
-- actually walk keeps the cadence right if the order ever changes.
|
||||
local SPIRAL_IN_TILES_PER_STEP = 7
|
||||
local SPIRAL_IN_STEP_FRAMES = 3 -- TransferDelay3
|
||||
local SPIRAL_IN_FRAMES = math.ceil(#inwardSpiralOrder()
|
||||
/ SPIRAL_IN_TILES_PER_STEP)
|
||||
* SPIRAL_IN_STEP_FRAMES
|
||||
|
||||
-- The eight wipes as records: frames is the wipe length, flash marks the
|
||||
-- two circle wipes that call BattleTransition_FlashScreen first. new()
|
||||
-- reads them, and the transitions registry serves the same table.
|
||||
BattleTransition.STYLES = {
|
||||
doublecircle = { kind = "wipe", frames = 40, flash = true },
|
||||
spiralin = { kind = "wipe", frames = 40 },
|
||||
circle = { kind = "wipe", frames = 40, flash = true },
|
||||
spiralout = { kind = "wipe", frames = 40 },
|
||||
hstripes = { kind = "wipe", frames = 24 },
|
||||
shrink = { kind = "wipe", frames = 24 },
|
||||
vstripes = { kind = "wipe", frames = 24 },
|
||||
split = { kind = "wipe", frames = 24 },
|
||||
doublecircle = { kind = "wipe", frames = 30, flash = true },
|
||||
spiralin = { kind = "wipe", frames = SPIRAL_IN_FRAMES },
|
||||
circle = { kind = "wipe", frames = 60, flash = true },
|
||||
spiralout = { kind = "wipe", frames = 120 },
|
||||
hstripes = { kind = "wipe", frames = 60 },
|
||||
shrink = { kind = "wipe", frames = 54 },
|
||||
vstripes = { kind = "wipe", frames = 54 },
|
||||
split = { kind = "wipe", frames = 54 },
|
||||
}
|
||||
|
||||
-- the eight wipes plus Transition's two warp fades: one registrant owns
|
||||
@@ -164,7 +276,7 @@ local BUILTIN_ORDERS = {
|
||||
-- A registered style may bring its own tile order (a list of {x, y}, or a
|
||||
-- function returning one); the four built-in orders are the defaults for
|
||||
-- the styles that have always had them.
|
||||
local function orderFor(style, def)
|
||||
function orderFor(style, def)
|
||||
if ORDERS[style] == nil then
|
||||
local order = def and def.order
|
||||
if type(order) == "function" then
|
||||
@@ -235,6 +347,15 @@ function BattleTransition:draw()
|
||||
local v = FLASH_STEPS[step]
|
||||
if v ~= 0 then
|
||||
local shade = v > 0 and 0 or 1
|
||||
-- The flash is a palette write (rBGP), so on hardware it tints every
|
||||
-- pixel the LCD shows. Hand it to the renderer as a screen-space veil
|
||||
-- so it covers the whole surface at any zoom; only the headless and
|
||||
-- no-renderer paths fall back to filling the 160x144 box.
|
||||
local r = self.game and self.game.renderer
|
||||
if r then
|
||||
r.screenVeil = { shade, math.abs(v) }
|
||||
return
|
||||
end
|
||||
love.graphics.setColor(shade, shade, shade, math.abs(v))
|
||||
love.graphics.rectangle("fill", 0, 0, 160, 144)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
@@ -242,22 +363,35 @@ function BattleTransition:draw()
|
||||
return
|
||||
end
|
||||
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
local prog = math.min(1, self.t / self.wipeLen)
|
||||
-- Cascade black 8x8 blocks across the window area *outside* the classic
|
||||
-- 160x144 wipe square, in lockstep with the OG wipe progress. Renderer
|
||||
-- paints them in screen space after the world blit (see endFrame).
|
||||
local renderer = self.game and self.game.renderer
|
||||
if renderer then renderer.battleCascadeProg = prog end
|
||||
local style = self.style
|
||||
|
||||
-- a registered style may draw itself; the eight built-ins do not
|
||||
-- a registered style may draw itself; the eight built-ins do not. A custom
|
||||
-- draw owns the 160x144 UI canvas as it always has.
|
||||
if self.def and self.def.draw then
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
self.def.draw(self, prog)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
return
|
||||
end
|
||||
|
||||
-- With a renderer the wipe is drawn ONCE over the whole surface in screen
|
||||
-- space (Renderer:drawBattleWipe), on a tile grid anchored to the letterbox
|
||||
-- and extended outward at the same tile size. That makes it a single
|
||||
-- continuous figure: the spiral starts at the outermost edge of the window
|
||||
-- and works inward, instead of one spiral inside the letterbox running
|
||||
-- alongside a second one outside it. Nothing is stretched -- the pattern is
|
||||
-- continued with more tiles, not scaled-up pixels -- so at 1x the grid works
|
||||
-- out to exactly 20x18 and this is the classic wipe unchanged.
|
||||
local renderer = self.game and self.game.renderer
|
||||
if renderer then
|
||||
renderer.battleWipe = { style = style, prog = prog }
|
||||
return
|
||||
end
|
||||
|
||||
-- headless / no renderer: the classic 160x144 path
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
|
||||
local order = orderFor(style, self.def)
|
||||
if order then
|
||||
-- tile-order wipes: spiral / circle sweeps
|
||||
|
||||
+249
-46
@@ -127,6 +127,35 @@ function Renderer:fitScale()
|
||||
return math.max(1, math.floor(math.min(pw / w, ph / h)))
|
||||
end
|
||||
|
||||
-- Integer framebuffer pixels per GB pixel for the UI pass.
|
||||
--
|
||||
-- Survey zoom only ever scaled the world: the UI kept blitting at fitScale,
|
||||
-- so zooming out left a full-size dialogue box over a shrunken world, which
|
||||
-- reads as the UI growing. Stepping the UI down with the zoom keeps the two
|
||||
-- in proportion. Whole integers only, so a UI pixel stays a whole number of
|
||||
-- screen pixels and the font does not resample; and never below half of
|
||||
-- fitScale, because past that the text stops being readable.
|
||||
--
|
||||
-- Zooming IN does not scale the UI up -- a dialogue box larger than the
|
||||
-- classic one has no reference to be faithful to, and the letterbox it sits
|
||||
-- in does not grow either.
|
||||
function Renderer:uiScale()
|
||||
local S = self:fitScale()
|
||||
local off = Zoom.offset or 0
|
||||
-- Only follow the zoom when a world is actually on screen. Survey zoom is
|
||||
-- an OVERWORLD control; the title screen, the intro and the credits show no
|
||||
-- world at all, and shrinking them to match a zoom level the player set for
|
||||
-- the map is meaningless. worldActive is this frame's answer -- beginFrame
|
||||
-- clears it and beginWorldPass sets it -- so a state that draws no world
|
||||
-- keeps the full fit scale.
|
||||
if not self.worldActive then return S end
|
||||
if off >= 0 then return S end
|
||||
local floorS = math.ceil(S / 2) -- at most a 50% reduction
|
||||
local s = S + off -- one integer step per zoom-out step
|
||||
if s < floorS then s = floorS end
|
||||
return math.max(1, s)
|
||||
end
|
||||
|
||||
-- the native-pixel UI surface in use right now
|
||||
function Renderer:uiSize()
|
||||
return self.uiWidth or self.WIDTH, self.uiHeight or self.HEIGHT
|
||||
@@ -200,8 +229,14 @@ function Renderer:beginFrame(transparent)
|
||||
-- warp-fade overlay from Transition (issue #121); cleared each frame so
|
||||
-- a popped transition cannot leave a sticky black veil
|
||||
self.worldFadeAlpha = nil
|
||||
-- battle-transition cascade outside the 160x144 wipe (BattleTransition)
|
||||
self.battleCascadeProg = nil
|
||||
-- battle-transition wipe, drawn over the whole surface (BattleTransition)
|
||||
self.battleWipe = nil
|
||||
-- whole-surface veil in screen space (battle-transition flash, the
|
||||
-- fade in from white after a battle) -- covers the window, not just the
|
||||
-- 160x144 letterbox
|
||||
self.screenVeil = nil
|
||||
-- edge-anchored UI regions, re-declared by their elements each frame
|
||||
self.uiAnchors = nil
|
||||
-- last frame's trueColor rects and sprite redraws go before anything
|
||||
-- draws this one
|
||||
PaletteFX.clearTrueColor()
|
||||
@@ -218,44 +253,94 @@ function Renderer:beginFrame(transparent)
|
||||
end
|
||||
end
|
||||
|
||||
-- Black 8x8 (scaled) blocks cascading outward from the classic GB letterbox
|
||||
-- into the surrounding window, matching BattleTransition wipe progress.
|
||||
-- Tiles that sit entirely inside the 160x144 square are left to the OG wipe.
|
||||
-- The battle-transition wipe, drawn once over the WHOLE surface in screen
|
||||
-- space rather than as a 160x144 wipe plus a separate fill around it.
|
||||
--
|
||||
-- The tile grid is anchored on the letterbox and extended outward at the same
|
||||
-- tile size, so the figure is continuous: a spiral begins at the outermost
|
||||
-- edge of the window and works inward through the letterbox to the middle.
|
||||
-- Nothing is stretched -- the pattern is continued with more tiles, not
|
||||
-- scaled-up pixels -- and at 1x the grid works out to exactly 20x18, where
|
||||
-- BattleTransition.gridOrder hands back the ROM's own walk, so an unzoomed
|
||||
-- window is the classic wipe unchanged.
|
||||
--
|
||||
-- Sx/Sy are LOVE-unit scales (Sy defaults to Sx on uniform surfaces).
|
||||
function Renderer:drawBattleCascade(prog, ww, wh, ox, oy, vpw, vph, Sx, Sy)
|
||||
if not prog or prog <= 0 then return end
|
||||
function Renderer:drawBattleWipe(wipe, ww, wh, ox, oy, vpw, vph, Sx, Sy)
|
||||
if not wipe or not wipe.prog or wipe.prog <= 0 then return end
|
||||
Sy = Sy or Sx
|
||||
local TILE_W, TILE_H = 8 * Sx, 8 * Sy
|
||||
if TILE_W < 1 then TILE_W = 1 end
|
||||
if TILE_H < 1 then TILE_H = 1 end
|
||||
local cols = math.ceil(ww / TILE_W)
|
||||
local rows = math.ceil(wh / TILE_H)
|
||||
local cx, cy = ox + vpw / 2, oy + vph / 2
|
||||
local order = {}
|
||||
for row = 0, rows - 1 do
|
||||
for col = 0, cols - 1 do
|
||||
local x, y = col * TILE_W, row * TILE_H
|
||||
-- any tile with area outside the letterbox participates
|
||||
if x < ox or y < oy or x + TILE_W > ox + vpw or y + TILE_H > oy + vph then
|
||||
local dist = math.max(math.abs(x + TILE_W / 2 - cx),
|
||||
math.abs(y + TILE_H / 2 - cy))
|
||||
order[#order + 1] = { x, y, dist }
|
||||
end
|
||||
end
|
||||
end
|
||||
if #order == 0 then return end
|
||||
table.sort(order, function(a, b)
|
||||
if a[3] ~= b[3] then return a[3] < b[3] end
|
||||
if a[2] ~= b[2] then return a[2] < b[2] end
|
||||
return a[1] < b[1]
|
||||
end)
|
||||
local n = math.floor(#order * math.min(1, prog) + 1e-6)
|
||||
if prog >= 1 then n = #order end
|
||||
local TW, TH = 8 * Sx, 8 * Sy
|
||||
if TW < 1 then TW = 1 end
|
||||
if TH < 1 then TH = 1 end
|
||||
local prog = math.min(1, wipe.prog)
|
||||
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
love.graphics.setScissor(0, 0, ww, wh)
|
||||
for i = 1, n do
|
||||
local t = order[i]
|
||||
love.graphics.rectangle("fill", t[1], t[2], TILE_W, TILE_H)
|
||||
|
||||
if prog >= 1 then
|
||||
love.graphics.rectangle("fill", 0, 0, ww, wh)
|
||||
love.graphics.setScissor()
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
return
|
||||
end
|
||||
|
||||
-- whole-tile padding out to each window edge, keeping the grid in phase
|
||||
-- with the letterbox's tiles
|
||||
local padL = math.max(0, math.ceil(ox / TW))
|
||||
local padT = math.max(0, math.ceil(oy / TH))
|
||||
local padR = math.max(0, math.ceil((ww - ox - vpw) / TW))
|
||||
local padB = math.max(0, math.ceil((wh - oy - vph) / TH))
|
||||
local lbCols = math.max(1, math.floor(vpw / TW + 0.5))
|
||||
local lbRows = math.max(1, math.floor(vph / TH + 0.5))
|
||||
local cols, rows = padL + lbCols + padR, padT + lbRows + padB
|
||||
local x0, y0 = ox - padL * TW, oy - padT * TH
|
||||
|
||||
-- required here rather than at the top: BattleTransition reaches the
|
||||
-- renderer through game.renderer at draw time, and a module-level require
|
||||
-- would put the two files in a load cycle
|
||||
local BattleTransition = require("src.render.BattleTransition")
|
||||
local order = BattleTransition.gridOrder(wipe.style, cols, rows)
|
||||
|
||||
if order then
|
||||
local n = math.floor(#order * prog + 1e-6)
|
||||
for i = 1, n do
|
||||
local t = order[i]
|
||||
love.graphics.rectangle("fill", x0 + t[1] * TW, y0 + t[2] * TH, TW, TH)
|
||||
end
|
||||
else
|
||||
-- shrink / split / the stripes are geometry, not a walk: the same shapes
|
||||
-- measured against the window instead of the letterbox
|
||||
local style = wipe.style
|
||||
if style == "hstripes" then
|
||||
local w = ww * prog
|
||||
for row = 0, rows - 1 do
|
||||
local y = y0 + row * TH
|
||||
if row % 2 == 0 then
|
||||
love.graphics.rectangle("fill", 0, y, w, TH)
|
||||
else
|
||||
love.graphics.rectangle("fill", ww - w, y, w, TH)
|
||||
end
|
||||
end
|
||||
elseif style == "vstripes" then
|
||||
local h = wh * prog
|
||||
for col = 0, cols - 1 do
|
||||
local x = x0 + col * TW
|
||||
if col % 2 == 0 then
|
||||
love.graphics.rectangle("fill", x, 0, TW, h)
|
||||
else
|
||||
love.graphics.rectangle("fill", x, wh - h, TW, h)
|
||||
end
|
||||
end
|
||||
elseif style == "shrink" then
|
||||
local h, w = wh / 2 * prog, ww / 2 * prog
|
||||
love.graphics.rectangle("fill", 0, 0, ww, h)
|
||||
love.graphics.rectangle("fill", 0, wh - h, ww, h)
|
||||
love.graphics.rectangle("fill", 0, 0, w, wh)
|
||||
love.graphics.rectangle("fill", ww - w, 0, w, wh)
|
||||
else -- split: a black cross growing out of the centre in both axes
|
||||
local h, w = wh / 2 * prog, ww / 2 * prog
|
||||
love.graphics.rectangle("fill", 0, wh / 2 - h, ww, h * 2)
|
||||
love.graphics.rectangle("fill", ww / 2 - w, 0, w * 2, wh)
|
||||
end
|
||||
end
|
||||
love.graphics.setScissor()
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
@@ -509,6 +594,38 @@ function Renderer:blitCanvas(canvas, sx, sy, zoneList, zoneSx, zoneSy,
|
||||
love.graphics.setShader()
|
||||
end
|
||||
|
||||
-- Take a rect out of a list of rects, splitting each overlapped one into up
|
||||
-- to four pieces. Used to vacate an anchored UI region from the letterbox
|
||||
-- blit, so the element is drawn at its anchor and not also in place.
|
||||
local function subtractRect(list, x, y, w, h)
|
||||
local out = {}
|
||||
local x2, y2 = x + w, y + h
|
||||
for _, r in ipairs(list) do
|
||||
local rx, ry, rw, rh = r[1], r[2], r[3], r[4]
|
||||
local rx2, ry2 = rx + rw, ry + rh
|
||||
if x2 <= rx or x >= rx2 or y2 <= ry or y >= ry2 then
|
||||
out[#out + 1] = r -- disjoint
|
||||
else
|
||||
if ry < y then out[#out + 1] = { rx, ry, rw, y - ry } end
|
||||
if y2 < ry2 then out[#out + 1] = { rx, y2, rw, ry2 - y2 } end
|
||||
local ty, ty2 = math.max(ry, y), math.min(ry2, y2)
|
||||
if rx < x then out[#out + 1] = { rx, ty, x - rx, ty2 - ty } end
|
||||
if x2 < rx2 then out[#out + 1] = { x2, ty, rx2 - x2, ty2 - ty } end
|
||||
end
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
-- A UI element that should sit against a screen edge rather than inside the
|
||||
-- centred letterbox. Declared during the element's own draw, in UI-canvas
|
||||
-- pixels, and consumed by endFrame this frame only.
|
||||
-- anchor: "bottom" | "topright" | "topleft" | "bottomright"
|
||||
function Renderer:setUIAnchor(x, y, w, h, anchor)
|
||||
self.uiAnchors = self.uiAnchors or {}
|
||||
self.uiAnchors[#self.uiAnchors + 1] =
|
||||
{ x = x, y = y, w = w, h = h, anchor = anchor }
|
||||
end
|
||||
|
||||
-- zones: optional list of SGB palette regions (see PaletteFX) in
|
||||
-- 160x144 UI space, applied to the UI pass. worldZones: optional
|
||||
-- regions in world-canvas pixels (overworld survey zoom colors each
|
||||
@@ -529,6 +646,23 @@ function Renderer:endFrame(zones, worldZones)
|
||||
-- Snap the letterbox origin to a framebuffer pixel, then convert to units.
|
||||
local ox = math.floor((pw - uiw * Sp) / 2) / dpiX
|
||||
local oy = math.floor((ph - uih * Sp) / 2) / dpiY
|
||||
-- The UI has its own scale: it steps down as the survey zoom goes out (see
|
||||
-- uiScale), so it can be smaller than the world letterbox. Un-zoomed these
|
||||
-- are identical to Sp/ox/oy and every rect below is what it always was.
|
||||
local Up = self:uiScale()
|
||||
-- BATTLE SIZE "fill" (Renderer.uiFill, set per frame by Game:draw): scale
|
||||
-- the surface to the window rather than to whole GB pixels, so the battle
|
||||
-- fills vertically at any zoom or window size. Fractional by nature -- a
|
||||
-- GB pixel stops being a whole number of screen pixels, which is the trade
|
||||
-- the setting exists to offer. Clamped on the horizontal too, so a narrow
|
||||
-- window scales to fit instead of overflowing off both sides.
|
||||
if self.uiFill then
|
||||
Up = math.min(ph / uih, pw / uiw)
|
||||
end
|
||||
local Ux, Uy = Up / dpiX, Up / dpiY
|
||||
local uvpw, uvph = uiw * Ux, uih * Uy
|
||||
local uox = math.floor((pw - uiw * Up) / 2) / dpiX
|
||||
local uoy = math.floor((ph - uih * Up) / 2) / dpiY
|
||||
local GBCFX = require("src.render.GBCFX")
|
||||
-- Forced mono/Classic modes still need a whole-screen zone when a state
|
||||
-- exposes no SGB packets (raw DMG canvas), so sendColors can remap.
|
||||
@@ -606,7 +740,12 @@ function Renderer:endFrame(zones, worldZones)
|
||||
local stack = ok and Game and Game.stack
|
||||
local base = stack and stack.visibleBase and stack:visibleBase()
|
||||
local state = base and stack.states and stack.states[base]
|
||||
if state and state.letterboxWhite then
|
||||
-- BATTLE BG "black" keeps the default black clear; "white" (and any
|
||||
-- non-battle state that opts in) uses the paper shade. "world" never
|
||||
-- reaches here -- it makes the battle non-opaque, so the world pass is
|
||||
-- active and this whole branch is skipped.
|
||||
if state and state.letterboxWhite
|
||||
and not (state.bgMode and state:bgMode() == "black") then
|
||||
clearR, clearG, clearB = PaletteFX.paperShade(Game and Game.data)
|
||||
end
|
||||
end
|
||||
@@ -655,9 +794,6 @@ function Renderer:endFrame(zones, worldZones)
|
||||
love.graphics.rectangle("fill", 0, 0, ww, wh)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
end
|
||||
if self.battleCascadeProg then
|
||||
self:drawBattleCascade(self.battleCascadeProg, ww, wh, ox, oy, vpw, vph, Sx, Sy)
|
||||
end
|
||||
elseif self.worldActive then
|
||||
local sp = Zoom.scale(Sp)
|
||||
local sx, sy = sp / dpiX, sp / dpiY
|
||||
@@ -732,14 +868,81 @@ function Renderer:endFrame(zones, worldZones)
|
||||
love.graphics.rectangle("fill", 0, 0, ww, wh)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
end
|
||||
-- Battle transition: cascade black blocks into the area outside the
|
||||
-- classic 160x144 wipe square (world still shows through until filled).
|
||||
if self.battleCascadeProg then
|
||||
self:drawBattleCascade(self.battleCascadeProg, ww, wh, ox, oy, vpw, vph, Sx, Sy)
|
||||
end
|
||||
-- BATTLE BG "world": the frozen overworld has just been composited and the
|
||||
-- battle is about to blit over it. Dim the world first, so the battle
|
||||
-- reads as the foreground instead of competing with a fully lit map. Goes
|
||||
-- here rather than in the letterbox clear because with the world pass
|
||||
-- active there is no clear -- the world already covers the surface.
|
||||
if self.battleDim and self.battleDim > 0 then
|
||||
love.graphics.setColor(0, 0, 0, self.battleDim)
|
||||
love.graphics.rectangle("fill", 0, 0, ww, wh)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
end
|
||||
|
||||
-- UI: anchored regions against their screen edges, the rest in the classic
|
||||
-- centred letterbox. With nothing anchored this is the single blit it has
|
||||
-- always been.
|
||||
local anchors = self.uiAnchors
|
||||
if not anchors or #anchors == 0 then
|
||||
blit(self.canvas, Ux, Uy, zones, Ux, Uy, uox, uoy, uox, uoy, uvpw, uvph)
|
||||
else
|
||||
local rest = { { uox, uoy, uvpw, uvph } }
|
||||
local placed = {}
|
||||
for _, a in ipairs(anchors) do
|
||||
local dw, dh = a.w * Ux, a.h * Uy
|
||||
-- Anchors are edge-RELATIVE: an element keeps its distance from the
|
||||
-- canvas edge, measured against the screen edge instead. That is what
|
||||
-- keeps a stack together -- the yes/no box sits 48px above the canvas
|
||||
-- bottom, so bottom-anchoring lands it 48px above the screen bottom,
|
||||
-- still directly over the dialogue box, rather than on top of it.
|
||||
local gapR = (uiw - (a.x + a.w)) * Ux
|
||||
local gapB = (uih - (a.y + a.h)) * Uy
|
||||
local dx, dy
|
||||
if a.anchor == "bottom" then
|
||||
dx = uox + a.x * Ux -- horizontally it stays with the letterbox
|
||||
dy = wh - gapB - dh
|
||||
elseif a.anchor == "topright" then
|
||||
dx = ww - gapR - dw
|
||||
dy = a.y * Uy
|
||||
else -- unknown anchor: leave it where it is
|
||||
dx, dy = uox + a.x * Ux, uoy + a.y * Uy
|
||||
end
|
||||
placed[#placed + 1] = { a = a, dx = dx, dy = dy, dw = dw, dh = dh }
|
||||
rest = subtractRect(rest, uox + a.x * Ux, uoy + a.y * Uy, dw, dh)
|
||||
end
|
||||
for _, r in ipairs(rest) do
|
||||
blit(self.canvas, Ux, Uy, zones, Ux, Uy, uox, uoy, r[1], r[2], r[3], r[4])
|
||||
end
|
||||
for _, p in ipairs(placed) do
|
||||
-- shift the draw origin so canvas pixel (a.x, a.y) lands on (dx, dy).
|
||||
-- The zone scissors are computed from the same origin, so an SGB
|
||||
-- region travels with the element instead of staying in the letterbox.
|
||||
blit(self.canvas, Ux, Uy, zones, Ux, Uy,
|
||||
p.dx - p.a.x * Ux, p.dy - p.a.y * Uy, p.dx, p.dy, p.dw, p.dh)
|
||||
end
|
||||
end
|
||||
-- UI stays in the classic centered GB letterbox
|
||||
blit(self.canvas, Sx, Sy, zones, Sx, Sy, ox, oy, ox, oy, vpw, vph)
|
||||
|
||||
-- The battle wipe covers the whole surface, letterbox included, so it goes
|
||||
-- over the finished composite rather than under the UI blit. On hardware
|
||||
-- it is the tilemap being overwritten -- there is nothing it does not cover.
|
||||
if self.battleWipe then
|
||||
self:drawBattleWipe(self.battleWipe, ww, wh, ox, oy, vpw, vph, Sx, Sy)
|
||||
end
|
||||
|
||||
-- Palette-register effects (BattleTransition_FlashScreen's rBGP writes, the
|
||||
-- GBFadeInFromWhite after a battle) tint every pixel the LCD shows -- there
|
||||
-- is no "outside the screen" on hardware for them to miss. So they are
|
||||
-- painted here, over the finished composite, rather than into the 160x144
|
||||
-- UI canvas: at any zoom above 1x a letterbox-only veil left the
|
||||
-- surrounding window untouched, which read as the effect happening inside
|
||||
-- a window rather than to the whole screen. { shade, alpha }.
|
||||
local veil = self.screenVeil
|
||||
if veil and veil[2] > 0 then
|
||||
love.graphics.setColor(veil[1], veil[1], veil[1], veil[2])
|
||||
love.graphics.rectangle("fill", 0, 0, ww, wh)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
end
|
||||
|
||||
if present then
|
||||
love.graphics.setCanvas()
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
|
||||
local Font = require("src.render.Font")
|
||||
local Theme = require("src.ui.Theme")
|
||||
local Timing = require("src.core.Timing")
|
||||
|
||||
local TextBox = {}
|
||||
TextBox.__index = TextBox
|
||||
@@ -177,6 +178,13 @@ end
|
||||
function TextBox:update(dt)
|
||||
local input = self.game.input
|
||||
self.blink = (self.blink + 1) % 60
|
||||
-- A page or CONT advance blocks the whole box while the original's scroll
|
||||
-- and clear run (src/core/Timing.lua TEXT_SCROLL_PAIR / TEXT_PAGE_CLEAR).
|
||||
-- Nothing types and no input is read until it drains.
|
||||
if (self.holdFrames or 0) > 0 then
|
||||
self.holdFrames = self.holdFrames - 1
|
||||
return
|
||||
end
|
||||
if self.done then
|
||||
if self.auto then
|
||||
if not self.autoStarted then
|
||||
@@ -244,6 +252,13 @@ function TextBox:update(dt)
|
||||
return
|
||||
end
|
||||
if self.waiting then
|
||||
-- _ContText and Paragraph both print the â–¼ and run ProtectedDelay3
|
||||
-- before ManualTextScroll starts watching the joypad (home/text.asm:265,
|
||||
-- :234), so the arrow is up for three frames that swallow the button.
|
||||
if (self.preWait or 0) > 0 then
|
||||
self.preWait = self.preWait - 1
|
||||
return
|
||||
end
|
||||
if input:wasPressed("a") or input:wasPressed("b") then
|
||||
require("src.core.Sound").play(self.game.data, "Press_AB")
|
||||
self.waiting = false
|
||||
@@ -252,11 +267,17 @@ function TextBox:update(dt)
|
||||
self.contAdvance = false
|
||||
self.lineIndex = self.lineIndex + 1
|
||||
self:beginLine()
|
||||
-- ScrollTextUpOneLine is 5 blocking frames and, as its own comment
|
||||
-- says, is "always called twice in a row" (home/text.asm:280-305)
|
||||
self.holdFrames = Timing.TEXT_SCROLL_PAIR
|
||||
else
|
||||
self.shown = {}
|
||||
self.pageIndex = self.pageIndex + 1
|
||||
self.lineIndex = 1
|
||||
self:beginLine()
|
||||
-- ClearScreenArea then DelayFrames 20: the box sits empty before the
|
||||
-- next page starts typing (home/text.asm:236-240)
|
||||
self.holdFrames = Timing.TEXT_PAGE_CLEAR
|
||||
end
|
||||
end
|
||||
return
|
||||
@@ -283,6 +304,7 @@ function TextBox:update(dt)
|
||||
if conts and conts[nextIdx] then
|
||||
-- pokered <CONT>: ▼ + WaitForTextScrollButtonPress before scroll
|
||||
self.waiting = true
|
||||
self.preWait = Timing.TEXT_PRE_ADVANCE
|
||||
self.contAdvance = true
|
||||
else
|
||||
self.lineIndex = nextIdx
|
||||
@@ -290,6 +312,7 @@ function TextBox:update(dt)
|
||||
end
|
||||
elseif self.pageIndex < #self.pages then
|
||||
self.waiting = true
|
||||
self.preWait = Timing.TEXT_PRE_ADVANCE
|
||||
self.contAdvance = false
|
||||
else
|
||||
self.done = true
|
||||
@@ -300,6 +323,15 @@ function TextBox:update(dt)
|
||||
end
|
||||
|
||||
function TextBox:draw()
|
||||
-- The dialogue box belongs against the bottom of the screen, not floating
|
||||
-- in the middle of a zoomed-out letterbox. Declared per frame; the
|
||||
-- renderer blits this region to the screen edge and the rest of the UI
|
||||
-- where it always was (Renderer:setUIAnchor).
|
||||
local r = self.game and self.game.renderer
|
||||
if r and r.setUIAnchor then
|
||||
r:setUIAnchor(self.boxTx * 8, self.boxTy * 8,
|
||||
self.boxTw * 8, self.boxTh * 8, "bottom")
|
||||
end
|
||||
Font.drawBox(self.boxTx, self.boxTy, self.boxTw, self.boxTh)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
if self.scrollPx and self.scrollPx > 0 then
|
||||
|
||||
@@ -1,17 +1,27 @@
|
||||
-- Screen fade used for warps: fade out, run a callback (map switch), fade in.
|
||||
-- Pushed on the state stack above the overworld.
|
||||
|
||||
local Timing = require("src.core.Timing")
|
||||
|
||||
local Transition = {}
|
||||
Transition.__index = Transition
|
||||
|
||||
local FRAMES = 12
|
||||
-- PlayMapChangeSound tail-calls GBFadeOutToBlack on every map change
|
||||
-- (home/overworld.asm:703), which is four palette steps of DelayFrames 8 --
|
||||
-- 32 frames. There is no matching fade in: the new map is built while the
|
||||
-- palettes are still blacked out and OverworldLoop's LoadGBPal
|
||||
-- (home/overworld.asm:45) restores them in a single write, so the map pops
|
||||
-- in. The old symmetric 12/12 fade was both too fast and a shape the
|
||||
-- hardware never had.
|
||||
local FRAMES = Timing.WARP_FADE_OUT
|
||||
local FRAMES_IN = Timing.WARP_FADE_IN
|
||||
local FLASH_FRAMES = 7
|
||||
|
||||
-- The two fades as transitions records, so a mod retimes a warp fade the
|
||||
-- same way it retimes a battle wipe. BattleTransition.registerInto pulls
|
||||
-- these in with its eight wipes -- one registrant owns the registry.
|
||||
Transition.STYLES = {
|
||||
warp_fade = { kind = "fade", frames = FRAMES },
|
||||
warp_fade = { kind = "fade", frames = FRAMES, framesIn = FRAMES_IN },
|
||||
white_flash = { kind = "fade", frames = FLASH_FRAMES },
|
||||
}
|
||||
|
||||
@@ -36,26 +46,39 @@ function Transition.new(game, onMidpoint, onDone)
|
||||
self.onDone = onDone
|
||||
self.t = 0
|
||||
self.phase = "out"
|
||||
self.frames = styleOf(game, "warp_fade").frames or FRAMES
|
||||
local style = styleOf(game, "warp_fade")
|
||||
self.frames = style.frames or FRAMES
|
||||
-- a style may still ask for a fade in (mods, and the record is data-driven);
|
||||
-- the built-in warp is 0, matching hardware
|
||||
self.framesIn = style.framesIn or FRAMES_IN
|
||||
return self
|
||||
end
|
||||
|
||||
function Transition:finish()
|
||||
self.game.stack:pop()
|
||||
if self.onDone then self.onDone() end
|
||||
end
|
||||
|
||||
function Transition:update(dt)
|
||||
self.t = self.t + 1
|
||||
if self.t >= self.frames then
|
||||
local len = (self.phase == "out") and self.frames or self.framesIn
|
||||
if self.t >= len then
|
||||
self.t = 0
|
||||
if self.phase == "out" then
|
||||
self.phase = "in"
|
||||
if self.onMidpoint then self.onMidpoint() end
|
||||
-- LoadGBPal restores the palettes in one write, so with no fade in the
|
||||
-- map is simply there on the next frame
|
||||
if (self.framesIn or 0) <= 0 then self:finish() end
|
||||
else
|
||||
self.game.stack:pop()
|
||||
if self.onDone then self.onDone() end
|
||||
self:finish()
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
function Transition:draw()
|
||||
local alpha = self.t / self.frames
|
||||
local len = (self.phase == "out") and self.frames or self.framesIn
|
||||
local alpha = (len and len > 0) and (self.t / len) or 1
|
||||
if self.phase == "in" then alpha = 1 - alpha end
|
||||
-- Survey zoom draws the overworld into a window-filling world canvas
|
||||
-- while the UI pass stays the classic 160x144 letterbox. A rect on the
|
||||
@@ -101,4 +124,66 @@ function WhiteFlash:draw()
|
||||
love.graphics.rectangle("fill", 0, 0, 160, 144)
|
||||
end
|
||||
|
||||
-- Coming back to the overworld after a battle.
|
||||
--
|
||||
-- The battle screen is torn down with the palettes still whited out, the
|
||||
-- caller spends `ld c, 10 / call DelayFrames` (home/overworld.asm:351-352),
|
||||
-- and then EnterMap sees BIT_BATTLE_OVER_OR_BLACKOUT set and runs
|
||||
-- MapEntryAfterBattle (:22, :749-753), which is GBFadeInFromWhite -- three
|
||||
-- palette steps of DelayFrames 8, so 24 frames. The port had none of it and
|
||||
-- simply cut from the battle to the map.
|
||||
--
|
||||
-- A dark map takes the other branch: wMapPalOffset is nonzero there, so
|
||||
-- MapEntryAfterBattle does a plain LoadGBPal and the map is just there. Pass
|
||||
-- opts.instant for that case.
|
||||
local BattleReturn = {}
|
||||
BattleReturn.__index = BattleReturn
|
||||
BattleReturn.isOpaque = false -- the overworld draws underneath
|
||||
|
||||
function Transition.battleReturn(game, onDone, opts)
|
||||
opts = opts or {}
|
||||
return setmetatable({
|
||||
game = game, onDone = onDone, t = 0,
|
||||
hold = opts.hold or Timing.POST_BATTLE_RETURN,
|
||||
frames = opts.instant and 0 or (opts.frames or Timing.FADE_IN_FROM_WHITE),
|
||||
}, BattleReturn)
|
||||
end
|
||||
|
||||
function BattleReturn:update(dt)
|
||||
self.t = self.t + 1
|
||||
if self.t >= self.hold + self.frames then
|
||||
self.game.stack:pop()
|
||||
if self.onDone then self.onDone() end
|
||||
end
|
||||
end
|
||||
|
||||
-- GBFadeInFromWhite is a palette staircase, not a smooth ramp: GBFadeIncCommon
|
||||
-- writes one palette then holds it with `ld c, 8 / call DelayFrames`
|
||||
-- (home/fade.asm:30-41), three times over. Stepping the veil the same way
|
||||
-- keeps the fade reading like a Game Boy palette fade rather than a tween.
|
||||
local FADE_STEP_FRAMES = 8
|
||||
|
||||
function BattleReturn:alpha()
|
||||
if self.t < self.hold then return 1 end
|
||||
if self.frames <= 0 then return 0 end
|
||||
local steps = math.max(1, math.floor(self.frames / FADE_STEP_FRAMES))
|
||||
local step = math.floor((self.t - self.hold) / FADE_STEP_FRAMES)
|
||||
if step >= steps then return 0 end
|
||||
return (steps - step - 1) / steps
|
||||
end
|
||||
|
||||
function BattleReturn:draw()
|
||||
local a = self:alpha()
|
||||
-- the fade is a palette write, so it covers the whole surface; the
|
||||
-- renderer paints it in screen space (see Renderer.screenVeil)
|
||||
local r = self.game and self.game.renderer
|
||||
if r then
|
||||
r.screenVeil = { 1, a }
|
||||
return
|
||||
end
|
||||
love.graphics.setColor(1, 1, 1, a)
|
||||
love.graphics.rectangle("fill", 0, 0, 160, 144)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
end
|
||||
|
||||
return Transition
|
||||
|
||||
+22
-2
@@ -281,7 +281,19 @@ function Commands.start_battle(ctx, kind, a, b)
|
||||
end
|
||||
runner:resume()
|
||||
end
|
||||
ctx.game.stack:push(battle)
|
||||
-- Every battle enters through the transition wipe, script-driven ones
|
||||
-- included: BattleTransition (engine/battle/battle_transitions.asm:1) runs
|
||||
-- from DoBattleTransitionAndInitBattleVariables for all of them, and
|
||||
-- GetBattleTransitionID_WildOrTrainer picks the style from the battle kind.
|
||||
-- Pushing the BattleState straight onto the stack skipped the wipe
|
||||
-- entirely, so every scripted trainer -- gym leaders, the rival, Giovanni --
|
||||
-- and every scripted wild battle simply cut to the battle screen. The
|
||||
-- trainer-sight path already went through pushBattle; this one did not.
|
||||
if ctx.overworld and ctx.overworld.pushBattle then
|
||||
ctx.overworld:pushBattle(battle)
|
||||
else
|
||||
ctx.game.stack:push(battle)
|
||||
end
|
||||
runner:yield()
|
||||
end
|
||||
|
||||
@@ -770,7 +782,15 @@ function Commands.old_man_demo(ctx)
|
||||
local battle = BattleState.newWild(ctx.game, om.species, om.level)
|
||||
battle:makeOldManDemo()
|
||||
battle.onFinish = function() runner:resume() end
|
||||
ctx.game.stack:push(battle)
|
||||
-- InitWildBattle calls DoBattleTransitionAndInitBattleVariables
|
||||
-- unconditionally (core.asm:6699) -- there is no BATTLE_TYPE_OLD_MAN
|
||||
-- special case -- so the catch tutorial gets the wipe like any other
|
||||
-- wild battle
|
||||
if ctx.overworld and ctx.overworld.pushBattle then
|
||||
ctx.overworld:pushBattle(battle)
|
||||
else
|
||||
ctx.game.stack:push(battle)
|
||||
end
|
||||
runner:yield()
|
||||
end
|
||||
|
||||
|
||||
+27
-4
@@ -3,6 +3,7 @@
|
||||
local Font = require("src.render.Font")
|
||||
local Theme = require("src.ui.Theme")
|
||||
local Strings = require("src.core.Strings")
|
||||
local Timing = require("src.core.Timing")
|
||||
|
||||
local ChoiceBox = {}
|
||||
ChoiceBox.__index = ChoiceBox
|
||||
@@ -25,6 +26,19 @@ end
|
||||
|
||||
function ChoiceBox:update(dt)
|
||||
local input = self.game.input
|
||||
-- Both branches of DisplayTwoOptionMenu hold 15 frames with the menu still
|
||||
-- on screen before TwoOptionMenu_RestoreScreenTiles hands control back
|
||||
-- (engine/menus/text_box.asm:322-323, :333-334).
|
||||
if self.pending ~= nil then
|
||||
self.holdFrames = self.holdFrames - 1
|
||||
if self.holdFrames <= 0 then
|
||||
local yes = self.pending
|
||||
self.pending = nil
|
||||
self.game.stack:pop()
|
||||
self.onChoose(yes)
|
||||
end
|
||||
return
|
||||
end
|
||||
if input:wasPressed("up") or input:wasPressed("down") then
|
||||
self.index = self.index == 1 and 2 or 1
|
||||
elseif input:wasPressed("a") then
|
||||
@@ -32,19 +46,28 @@ function ChoiceBox:update(dt)
|
||||
if not self.noSound then
|
||||
require("src.core.Sound").play(self.game.data, "Press_AB")
|
||||
end
|
||||
self.game.stack:pop()
|
||||
self.onChoose(self.index == 1)
|
||||
self.pending = (self.index == 1)
|
||||
self.holdFrames = Timing.YES_NO_ANSWER
|
||||
elseif input:wasPressed("b") then
|
||||
if not self.noSound then
|
||||
require("src.core.Sound").play(self.game.data, "Press_AB")
|
||||
end
|
||||
self.game.stack:pop()
|
||||
self.onChoose(false)
|
||||
-- .choseSecondMenuItem writes wCurrentMenuItem = 1 before the hold, so
|
||||
-- the cursor visibly snaps to NO for those 15 frames
|
||||
self.index = 2
|
||||
self.pending = false
|
||||
self.holdFrames = Timing.YES_NO_ANSWER
|
||||
end
|
||||
end
|
||||
|
||||
function ChoiceBox:draw()
|
||||
local tx, ty, tw, th = self.tx, self.ty, self.tw, self.th
|
||||
-- rides the same bottom anchor as the dialogue box it sits above, so the
|
||||
-- pair travels together (the anchor keeps each element's gap from the edge)
|
||||
local r = self.game and self.game.renderer
|
||||
if r and r.setUIAnchor then
|
||||
r:setUIAnchor(tx * 8, ty * 8, tw * 8, th * 8, "bottom")
|
||||
end
|
||||
Font.drawBox(tx, ty, tw, th)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
Font.draw(Strings("YES"), (tx + 2) * 8, (ty + 1) * 8)
|
||||
|
||||
@@ -33,6 +33,10 @@ local IntroMovie = {}
|
||||
IntroMovie.__index = IntroMovie
|
||||
IntroMovie.isOpaque = true
|
||||
|
||||
-- Same as the title screen: full-bleed art, no world behind it, no player zoom
|
||||
-- to respect, so fill the window rather than sit at the fixed integer scale.
|
||||
function IntroMovie:wantsFillScale() return true end
|
||||
|
||||
-- SGB intro palettes: the splash uses PalPacket_GameFreakIntro (logo
|
||||
-- GAMEFREAK, falling star columns RED/VIRIDIAN/BLUEMON), the attract
|
||||
-- fight PalPacket_NidorinoIntro (PURPLEMON letterbox, BLACK bars)
|
||||
|
||||
@@ -50,6 +50,9 @@ function Menu.new(game, items, opts)
|
||||
-- only menus whose real mask includes PAD_START -- the start menu
|
||||
-- (engine/menus/draw_start_menu.asm) -- opt in here.
|
||||
self.startCloses = opts.startCloses or false
|
||||
-- screen-edge anchor for this menu (see Menu:draw); nil keeps it in the
|
||||
-- classic centred letterbox
|
||||
self.anchor = opts.anchor
|
||||
self.onCancel = opts.onCancel
|
||||
-- BIT_NO_MENU_BUTTON_SOUND (wMiscFlags): the PC session runs its
|
||||
-- menus silent (home/window.asm HandleMenuInput_)
|
||||
@@ -104,6 +107,14 @@ function Menu:update(dt)
|
||||
end
|
||||
|
||||
function Menu:draw()
|
||||
-- opts.anchor opts a menu out of the centred letterbox and onto a screen
|
||||
-- edge (the START menu asks for "topright"). Only menus that ask for it
|
||||
-- move; every other menu is placed exactly as before.
|
||||
local r = self.anchor and self.game and self.game.renderer
|
||||
if r and r.setUIAnchor then
|
||||
r:setUIAnchor(self.tx * 8, self.ty * 8,
|
||||
self.tw * 8, self.th * 8, self.anchor)
|
||||
end
|
||||
Font.drawBox(self.tx, self.ty, self.tw, self.th)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
local visible = (self.maxVisible and math.min(self.maxVisible, #self.items))
|
||||
|
||||
@@ -19,6 +19,7 @@ local TileRenderer = require("src.render.TileRenderer")
|
||||
local GameSpeed = require("src.core.GameSpeed")
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
local VideoMode = require("src.core.VideoMode")
|
||||
local FaithfulRes = require("src.core.FaithfulRes")
|
||||
local FrameCap = require("src.core.FrameCap")
|
||||
local Performance = require("src.core.Performance")
|
||||
local Logger = require("src.core.Logger")
|
||||
@@ -156,6 +157,40 @@ local function buildRows(game)
|
||||
o.battleLayout = o.battleLayout == "wide" and "og" or "wide"
|
||||
return true
|
||||
end },
|
||||
-- FIXED keeps the classic integer-scaled letterbox -- a GB pixel is a
|
||||
-- whole number of screen pixels and the battle is the same size at any
|
||||
-- zoom. FILL scales the battle surface to the window so it fills
|
||||
-- vertically; that needs a fractional scale, so pixels stop being evenly
|
||||
-- sized. Battle only: the overworld is untouched either way.
|
||||
{ id = "battleFit", label = Strings("BATTLE SIZE"),
|
||||
value = function(g)
|
||||
return g.save.options.battleFit == "fill" and Strings("FILL")
|
||||
or Strings("FIXED")
|
||||
end,
|
||||
step = function(g)
|
||||
local o = g.save.options
|
||||
o.battleFit = o.battleFit == "fill" and "fixed" or "fill"
|
||||
return true
|
||||
end },
|
||||
-- What sits behind and around the battle. WHITE is the classic paper
|
||||
-- field; BLACK swaps it for black bars; WORLD leaves the frozen overworld
|
||||
-- visible underneath, dimmed (the battle stops being opaque, so the map
|
||||
-- shows through everywhere the battle does not paint).
|
||||
{ id = "battleBg", label = Strings("BATTLE BG"),
|
||||
value = function(g)
|
||||
local m = g.save.options.battleBg
|
||||
if m == "black" then return Strings("BLACK") end
|
||||
if m == "world" then return Strings("WORLD") end
|
||||
return Strings("WHITE")
|
||||
end,
|
||||
step = function(g, dir)
|
||||
local o = g.save.options
|
||||
local order = { "white", "black", "world" }
|
||||
local cur = 1
|
||||
for i, m in ipairs(order) do if o.battleBg == m then cur = i break end end
|
||||
o.battleBg = order[(cur - 1 + (dir or 1)) % #order + 1]
|
||||
return true
|
||||
end },
|
||||
{ id = "ruleset", label = Strings("RULESET"),
|
||||
value = function(g) return rulesetName(g) end,
|
||||
step = function(g, dir)
|
||||
@@ -298,6 +333,20 @@ local function buildRows(game)
|
||||
VideoMode.apply(o.videoMode)
|
||||
return true
|
||||
end },
|
||||
-- Lock the window to an exact 160x144 multiple, so the surface IS the
|
||||
-- Game Boy screen with no letterbox at all. Sits next to VIDEO MODE
|
||||
-- because it overrides it: holding an exact size means dropping
|
||||
-- fullscreen.
|
||||
{ id = "faithfulRes", label = Strings("FAITHFUL RES"),
|
||||
value = function(g)
|
||||
return FaithfulRes.label(g.save.options.faithfulRes)
|
||||
end,
|
||||
step = function(g, dir)
|
||||
local o = g.save.options
|
||||
o.faithfulRes = FaithfulRes.cycle(o.faithfulRes, dir)
|
||||
FaithfulRes.apply(o.faithfulRes)
|
||||
return true
|
||||
end },
|
||||
-- hard render cap (issue #88): bounds the present rate so a
|
||||
-- driver-forced vsync-off run cannot spin at thousands of FPS. Logic
|
||||
-- is fixed-step off dt, so this touches presentation only.
|
||||
|
||||
@@ -133,7 +133,12 @@ function StartMenu.new(game)
|
||||
local rowStep = 2
|
||||
local maxVisible = math.floor((Renderer.HEIGHT / 8 - 2) / rowStep)
|
||||
local menu = Menu.new(game, items,
|
||||
{ tx = 9, ty = 0, tw = 11, maxVisible = maxVisible, startCloses = true })
|
||||
-- the START menu hugs the top-right corner of the SCREEN, not of a
|
||||
-- centred letterbox: at 9,0 x 11 it is already flush with the top and
|
||||
-- right of the 20x18 grid, so the anchor keeps it flush when the view
|
||||
-- is zoomed out and the letterbox no longer fills the window
|
||||
{ tx = 9, ty = 0, tw = 11, maxVisible = maxVisible, startCloses = true,
|
||||
anchor = "topright" })
|
||||
-- the cursor position survives closing the menu
|
||||
-- (wBattleAndStartSavedMenuItem, home/start_menu.asm)
|
||||
menu.index = math.min(game.save.startMenuIndex or 1, #items)
|
||||
|
||||
@@ -14,6 +14,12 @@ local TitleState = {}
|
||||
TitleState.__index = TitleState
|
||||
TitleState.isOpaque = true
|
||||
|
||||
-- Fill the window (aspect preserved, bars on the long axis) instead of sitting
|
||||
-- at the fixed integer scale. The title screen is a full-bleed picture with no
|
||||
-- world behind it, so a small centred box in a large window is just wasted
|
||||
-- glass -- and unlike the overworld it has no zoom the player chose to respect.
|
||||
function TitleState:wantsFillScale() return true end
|
||||
|
||||
-- SGB title zones (PalPacket_Titlescreen): the logo rows get LOGO2,
|
||||
-- the version-ribbon band LOGO1, the rest MEWMON.
|
||||
--
|
||||
|
||||
@@ -717,6 +717,11 @@ function OverworldState:pushBattle(battle)
|
||||
if battle.computeMusicKind then
|
||||
require("src.core.Music").playBattle(Game.data, battle:computeMusicKind())
|
||||
end
|
||||
|
||||
-- The fade back in from white on the way out is BattleState:finish()'s
|
||||
-- job now -- the one choke point every battle passes through on exit,
|
||||
-- guaranteed regardless of which caller pushed the battle -- so this
|
||||
-- function only owns the entry wipe.
|
||||
Game.stack:push(BattleTransition.new(Game, function()
|
||||
Game.stack:push(battle)
|
||||
end, {
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
-- ADVANCED (redpp) must use pokered-gbc's GEN 1 species->palette map.
|
||||
--
|
||||
-- pokered-gbc's data/pokemon/palettes.asm carries TWO tables:
|
||||
--
|
||||
-- IF GEN_2_GRAPHICS db PAL_BULBASAUR / PAL_SQUIRTLE / ... (per species)
|
||||
-- ELSE db PAL_GREENMON ; BULBASAUR
|
||||
-- db PAL_CYANMON ; SQUIRTLE (Gen 1's own)
|
||||
-- ENDC
|
||||
--
|
||||
-- The per-species palettes are authored for GEN 2 sprite art, whose shading
|
||||
-- puts different regions on different 2bpp shades. This port extracts Gen 1
|
||||
-- pics from the ROM, so the ELSE branch is the matching one.
|
||||
--
|
||||
-- data/palettes_gbc.lua had imported the GEN_2_GRAPHICS table, which pointed
|
||||
-- every species at colours shaded for art it does not use. Bulbasaur wore
|
||||
-- PAL_BULBASAUR's red-orange (255,82,49) across 231 pixels of a sprite that
|
||||
-- has no red on it at all, and Squirtle wore PAL_SQUIRTLE's shell brown on
|
||||
-- his head instead of blue. It looked least wrong on mons whose two mid
|
||||
-- tones are close in hue, which is why it survived so long.
|
||||
--
|
||||
-- The palette VALUES are untouched -- ADVANCED keeps pokered-gbc's richer
|
||||
-- colours. Only the species -> name mapping changed.
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local T = require("tests.modkit")
|
||||
local pack = require("data.palettes_gbc")
|
||||
|
||||
T.check(pack and pack.pokemon, "the ADVANCED pack carries a species map")
|
||||
|
||||
local n = 0
|
||||
for _ in pairs(pack.pokemon) do n = n + 1 end
|
||||
T.eq(n, 151, "every species is mapped")
|
||||
|
||||
-- Gen 1's palette set: the ten MonsterPalettes names plus nothing else. A
|
||||
-- per-species name here means the GEN_2_GRAPHICS table crept back in.
|
||||
local GEN1_NAMES = {
|
||||
MEWMON = true, BLUEMON = true, REDMON = true, CYANMON = true,
|
||||
PURPLEMON = true, BROWNMON = true, GREENMON = true, PINKMON = true,
|
||||
YELLOWMON = true, GRAYMON = true,
|
||||
}
|
||||
|
||||
local strays = {}
|
||||
for species, name in pairs(pack.pokemon) do
|
||||
if not GEN1_NAMES[name] then strays[#strays + 1] = species .. "->" .. name end
|
||||
end
|
||||
table.sort(strays)
|
||||
T.eq(#strays, 0,
|
||||
"no species points at a Gen-2-only per-species palette (" ..
|
||||
table.concat(strays, ", ", 1, math.min(#strays, 6)) .. ")")
|
||||
|
||||
-- the four the bug was reported on, plus their lines
|
||||
local EXPECT = {
|
||||
BULBASAUR = "GREENMON", IVYSAUR = "GREENMON", VENUSAUR = "GREENMON",
|
||||
CHARMANDER = "REDMON", CHARMELEON = "REDMON", CHARIZARD = "REDMON",
|
||||
SQUIRTLE = "CYANMON", WARTORTLE = "CYANMON", BLASTOISE = "CYANMON",
|
||||
MEW = "MEWMON", MEWTWO = "MEWMON", JYNX = "MEWMON",
|
||||
LAPRAS = "CYANMON",
|
||||
}
|
||||
for species, want in pairs(EXPECT) do
|
||||
T.eq(pack.pokemon[species], want, species .. " uses " .. want)
|
||||
end
|
||||
|
||||
-- Bulbasaur's palette must contain no red channel dominance in either mid --
|
||||
-- the concrete symptom that was reported ("he shouldn't have red anywhere").
|
||||
local green = pack.palettes[pack.pokemon.BULBASAUR]
|
||||
T.check(green ~= nil, "GREENMON resolves to colours")
|
||||
for _, i in ipairs({ 2, 3 }) do
|
||||
local c = green[i]
|
||||
T.check(c[2] > c[1], ("GREENMON mid %d is green-dominant, not red (%d,%d,%d)")
|
||||
:format(i - 1, c[1], c[2], c[3]))
|
||||
end
|
||||
|
||||
-- and Squirtle's mids must be blue-dominant
|
||||
local cyan = pack.palettes[pack.pokemon.SQUIRTLE]
|
||||
for _, i in ipairs({ 2, 3 }) do
|
||||
local c = cyan[i]
|
||||
T.check(c[3] >= c[1], ("CYANMON mid %d is blue-dominant (%d,%d,%d)")
|
||||
:format(i - 1, c[1], c[2], c[3]))
|
||||
end
|
||||
|
||||
T.finish("advanced palette map")
|
||||
@@ -0,0 +1,81 @@
|
||||
-- BATTLE SIZE (save.options.battleFit): "fixed" keeps the classic
|
||||
-- integer-scaled letterbox, "fill" scales the battle surface to the window so
|
||||
-- it fills vertically.
|
||||
--
|
||||
-- The bit that regresses is not the arithmetic, it is WHERE the flag is read
|
||||
-- from: a battle opens party menus, the bag and text boxes on top of itself,
|
||||
-- so reading it off the TOP state would snap the surface back to the fixed
|
||||
-- scale for as long as one of those is up. It is read off the whole stack,
|
||||
-- the same way the wide-battle layout is.
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local T = require("tests.modkit")
|
||||
local Game = require("src.core.Game")
|
||||
local BattleState = require("src.battle.BattleState")
|
||||
|
||||
local function battleWith(fit)
|
||||
return setmetatable({ game = { save = { options = { battleFit = fit } } } },
|
||||
{ __index = BattleState })
|
||||
end
|
||||
|
||||
T.eq(battleWith("fill"):wantsFillScale(), true, "fill asks for the fill scale")
|
||||
T.eq(battleWith("fixed"):wantsFillScale(), false, "fixed does not")
|
||||
T.eq(battleWith(nil):wantsFillScale(), false, "and neither does an old save")
|
||||
|
||||
-- a battle with no game/options at all must not throw
|
||||
T.eq(setmetatable({}, { __index = BattleState }):wantsFillScale(), false,
|
||||
"a battle with no options is fixed")
|
||||
|
||||
-- the stack scan
|
||||
local function stack(...) return { states = { ... } } end
|
||||
local overworld = {}
|
||||
local fillBattle = battleWith("fill")
|
||||
local fixedBattle = battleWith("fixed")
|
||||
local partyMenu = {} -- no wantsFillScale at all, like every non-battle state
|
||||
|
||||
T.eq(Game.fillScaleInStack(stack(overworld)), false,
|
||||
"no battle in the stack means no fill")
|
||||
T.eq(Game.fillScaleInStack(stack(overworld, fixedBattle)), false,
|
||||
"a fixed battle does not fill")
|
||||
T.eq(Game.fillScaleInStack(stack(overworld, fillBattle)), true,
|
||||
"a fill battle does")
|
||||
T.eq(Game.fillScaleInStack(stack(overworld, fillBattle, partyMenu)), true,
|
||||
"and keeps filling while a party menu sits on top of it")
|
||||
T.eq(Game.fillScaleInStack(stack()), false, "an empty stack is safe")
|
||||
T.eq(Game.fillScaleInStack(nil), false, "and so is no stack at all")
|
||||
|
||||
-- ---------------------------------------------------------------- BATTLE BG
|
||||
|
||||
-- What fills the voids AROUND the battle. The battle screen itself keeps its
|
||||
-- white field in every mode -- only the surround changes.
|
||||
local function battleBg(bg)
|
||||
return setmetatable({ game = { save = { options = { battleBg = bg } } } },
|
||||
{ __index = BattleState })
|
||||
end
|
||||
|
||||
T.eq(battleBg("white"):bgMode(), "white", "white is white")
|
||||
T.eq(battleBg("black"):bgMode(), "black", "black is black")
|
||||
T.eq(battleBg("world"):bgMode(), "world", "world is world")
|
||||
T.eq(battleBg(nil):bgMode(), "white", "an old save defaults to white")
|
||||
T.eq(battleBg("nonsense"):bgMode(), "white", "and so does a bad value")
|
||||
T.eq(setmetatable({}, { __index = BattleState }):bgMode(), "white",
|
||||
"a battle with no options is white")
|
||||
|
||||
-- "world" is the only mode that drops opacity, because it is the only one
|
||||
-- that needs the overworld to keep drawing underneath.
|
||||
T.eq(Game.worldBgBattleDim(stack(overworld, battleBg("world"))),
|
||||
BattleState.BG_WORLD_DIM, "a world-bg battle asks for its dim")
|
||||
T.eq(Game.worldBgBattleDim(stack(overworld, battleBg("white"))), nil,
|
||||
"a white-bg battle asks for none")
|
||||
T.eq(Game.worldBgBattleDim(stack(overworld, battleBg("black"))), nil,
|
||||
"and neither does black")
|
||||
T.eq(Game.worldBgBattleDim(stack(overworld, battleBg("world"), partyMenu)),
|
||||
BattleState.BG_WORLD_DIM,
|
||||
"the dim survives a party menu opened over the battle")
|
||||
T.eq(Game.worldBgBattleDim(stack(overworld)), nil, "no battle, no dim")
|
||||
T.eq(Game.worldBgBattleDim(nil), nil, "and no stack is safe")
|
||||
|
||||
T.check(BattleState.BG_WORLD_DIM > 0 and BattleState.BG_WORLD_DIM < 1,
|
||||
"the dim is a fraction, not a full blackout")
|
||||
|
||||
T.finish("battle fit option")
|
||||
@@ -0,0 +1,135 @@
|
||||
-- FAITHFUL RES: lock the window to an exact 160x144 multiple so the surface
|
||||
-- is the Game Boy screen with no letterbox at all.
|
||||
--
|
||||
-- The interesting parts are the two things a naive setMode gets wrong: the
|
||||
-- window minimum from conf.lua (480x360) sits ABOVE 1X and 2X, so the lock
|
||||
-- has to lower it or LOVE clamps the window straight back up; and on a HiDPI
|
||||
-- display a LOVE unit is more than a pixel, so asking for the pixel count
|
||||
-- directly gives a window twice the size intended.
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local T = require("tests.modkit")
|
||||
local FaithfulRes = require("src.core.FaithfulRes")
|
||||
|
||||
-- ---------------------------------------------------------------- values
|
||||
|
||||
T.eq(FaithfulRes.normalize(nil), 0, "no setting is OFF")
|
||||
T.eq(FaithfulRes.normalize("junk"), 0, "garbage degrades to OFF")
|
||||
T.eq(FaithfulRes.normalize(-3), 0, "negatives clamp to OFF")
|
||||
T.eq(FaithfulRes.normalize(9), 4, "above 4X clamps to 4X")
|
||||
T.eq(FaithfulRes.normalize(2.7), 2, "fractions floor to a whole multiple")
|
||||
|
||||
T.eq(FaithfulRes.label(0), "OFF", "0 reads OFF")
|
||||
T.eq(FaithfulRes.label(1), "1X", "1 reads 1X")
|
||||
T.eq(FaithfulRes.label(4), "4X", "4 reads 4X")
|
||||
|
||||
-- the row cycles OFF -> 1X -> 2X -> 3X -> 4X -> OFF
|
||||
local seen, v = {}, 0
|
||||
for _ = 1, 5 do
|
||||
seen[#seen + 1] = FaithfulRes.label(v)
|
||||
v = FaithfulRes.cycle(v, 1)
|
||||
end
|
||||
T.eq(table.concat(seen, ","), "OFF,1X,2X,3X,4X", "the row cycles through OFF..4X")
|
||||
T.eq(FaithfulRes.cycle(4, 1), 0, "and wraps back to OFF")
|
||||
T.eq(FaithfulRes.cycle(0, -1), 4, "stepping back from OFF lands on 4X")
|
||||
|
||||
-- ---------------------------------------------------------------- sizing
|
||||
|
||||
local savedWindow = love.window
|
||||
local savedSystem = love.system
|
||||
local savedDims = love.graphics and love.graphics.getDimensions
|
||||
local savedPixels = love.graphics and love.graphics.getPixelDimensions
|
||||
|
||||
-- `ratio` is PHYSICAL PIXELS PER UNIT, which is what the window reports and
|
||||
-- what FaithfulRes measures -- not love.window.getDPIScale, which lies about
|
||||
-- a window that is not high-DPI aware (that was the bug).
|
||||
local function stubWindow(ratio)
|
||||
local calls = {}
|
||||
love.graphics = love.graphics or {}
|
||||
love.graphics.getDimensions = function() return 1024, 768 end
|
||||
love.graphics.getPixelDimensions = function()
|
||||
return 1024 * ratio, 768 * ratio
|
||||
end
|
||||
love.window = {
|
||||
-- deliberately WRONG, and deliberately present: nothing may read it
|
||||
getDPIScale = function() return 999 end,
|
||||
getMode = function()
|
||||
return 1024, 768, { fullscreen = true, resizable = true,
|
||||
minwidth = 480, minheight = 360, vsync = 1 }
|
||||
end,
|
||||
setMode = function(w, h, flags)
|
||||
calls[#calls + 1] = { w = w, h = h, flags = flags }
|
||||
end,
|
||||
}
|
||||
return calls
|
||||
end
|
||||
|
||||
-- A plain desktop window reports units == pixels, whatever the DISPLAY
|
||||
-- scaling is. Dividing by the display scale here is what made 2X render at
|
||||
-- 1X and 4X at 3X, so the stub returns a nonsense getDPIScale to prove
|
||||
-- nothing consults it.
|
||||
stubWindow(1)
|
||||
local w, h = FaithfulRes.size(2)
|
||||
T.eq(w, 320, "2X is 320 units wide when a unit is a pixel")
|
||||
T.eq(h, 288, "2X is 288 units tall when a unit is a pixel")
|
||||
T.eq(FaithfulRes.size(0), nil, "OFF has no size")
|
||||
|
||||
local w4, h4 = FaithfulRes.size(4)
|
||||
T.eq(w4, 640, "4X is 640 wide, not shrunk by the display scale")
|
||||
T.eq(h4, 576, "4X is 576 tall")
|
||||
|
||||
-- a genuinely high-DPI window reports more pixels than units, so the unit
|
||||
-- size halves to keep the PHYSICAL pixel count exact
|
||||
stubWindow(2)
|
||||
local w2, h2 = FaithfulRes.size(2)
|
||||
T.eq(w2, 160, "2X asks for 160 units at 2 px/unit, which is 320 pixels")
|
||||
T.eq(h2, 144, "and 144 units, which is 288 pixels")
|
||||
|
||||
-- ---------------------------------------------------------------- applying
|
||||
|
||||
FaithfulRes.locked = false
|
||||
local calls = stubWindow(1)
|
||||
love.system = { getOS = function() return "Windows" end }
|
||||
|
||||
-- OFF on boot must not touch a window the player sized themselves
|
||||
T.eq(FaithfulRes.apply(0), false, "OFF reports unlocked")
|
||||
T.eq(#calls, 0, "and does not resize an unlocked window on boot")
|
||||
|
||||
T.eq(FaithfulRes.apply(3), true, "3X reports locked")
|
||||
T.eq(#calls, 1, "which took one setMode")
|
||||
T.eq(calls[1].w, 480, "3X is 480 wide")
|
||||
T.eq(calls[1].h, 432, "3X is 432 tall")
|
||||
T.eq(calls[1].flags.fullscreen, false,
|
||||
"an exact size drops fullscreen -- the two cannot both hold")
|
||||
T.eq(calls[1].flags.resizable, false,
|
||||
"and fixes the window, since a drag would silently break the lock")
|
||||
|
||||
-- 1X and 2X are below conf.lua's 480x360 floor; without lowering it LOVE
|
||||
-- clamps the window back up and the lock silently does nothing
|
||||
calls = stubWindow(1)
|
||||
FaithfulRes.apply(1)
|
||||
T.eq(calls[1].w, 160, "1X is 160 wide")
|
||||
T.eq(calls[1].flags.minwidth, 160, "and lowers the window minimum to match")
|
||||
T.eq(calls[1].flags.minheight, 144, "on both axes")
|
||||
|
||||
-- releasing the lock restores the resizable window and its original floor
|
||||
calls = stubWindow(1)
|
||||
T.eq(FaithfulRes.apply(0), false, "OFF reports unlocked")
|
||||
T.eq(#calls, 1, "and this time it does resize, because we held the window")
|
||||
T.eq(calls[1].flags.resizable, true, "handing resizing back to the player")
|
||||
T.eq(calls[1].flags.minwidth, FaithfulRes.MIN_W, "with conf.lua's floor restored")
|
||||
T.eq(calls[1].flags.minheight, FaithfulRes.MIN_H, "on both axes")
|
||||
T.eq(FaithfulRes.locked, false, "and the module no longer claims the window")
|
||||
|
||||
-- mobile has no resizable window to lock
|
||||
calls = stubWindow(1)
|
||||
love.system = { getOS = function() return "Android" end }
|
||||
T.eq(FaithfulRes.apply(4), false, "mobile reports unlocked")
|
||||
T.eq(#calls, 0, "and never touches the window")
|
||||
|
||||
love.window, love.system = savedWindow, savedSystem
|
||||
if love.graphics then
|
||||
love.graphics.getDimensions = savedDims
|
||||
love.graphics.getPixelDimensions = savedPixels
|
||||
end
|
||||
T.finish("faithful resolution")
|
||||
@@ -17,6 +17,7 @@ love = love or require("tests.love_stub")
|
||||
|
||||
local Input = require("src.core.Input")
|
||||
local Strings = require("src.core.Strings")
|
||||
local Timing = require("src.core.Timing")
|
||||
local BindingsMenu = require("src.ui.BindingsMenu")
|
||||
|
||||
-- same doubles as rebind_capture_bug510: a stack the menu can pop itself
|
||||
@@ -45,6 +46,14 @@ local function press(state, btn)
|
||||
state.game.input.queue = {}
|
||||
end
|
||||
|
||||
-- ChoiceBox now holds YES/NO answers on screen for Timing.YES_NO_ANSWER
|
||||
-- frames before it commits and pops (DisplayTwoOptionMenu's 15-frame hold,
|
||||
-- #GH-627 timing parity); a bare press only arms the choice, so answering
|
||||
-- it needs the hold run out before the pop/commit is visible.
|
||||
local function settleChoice(state)
|
||||
for _ = 1, Timing.YES_NO_ANSWER do state:update(1 / 60) end
|
||||
end
|
||||
|
||||
-- rows are BindingsMenu's BUTTONS order
|
||||
local ROW_A, ROW_B, ROW_SELECT = 5, 6, 8
|
||||
|
||||
@@ -146,6 +155,7 @@ eq(bm.footer, Strings("RESET ALL BINDINGS?"),
|
||||
|
||||
-- the box starts on NO: a bare A press must keep the overlay
|
||||
press(box, "a")
|
||||
settleChoice(box)
|
||||
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")
|
||||
@@ -155,6 +165,7 @@ press(bm, "start")
|
||||
box = game.stack:top()
|
||||
press(box, "up")
|
||||
press(box, "a")
|
||||
settleChoice(box)
|
||||
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")
|
||||
|
||||
@@ -0,0 +1,598 @@
|
||||
-- Timing parity: every sequence in docs/timing-parity.md must cost the same
|
||||
-- number of 60Hz logic steps here as it does on hardware.
|
||||
--
|
||||
-- The port's clock was always right; what drifted was the frame budget of
|
||||
-- composed sequences, because the original spends much of its running time
|
||||
-- inside DelayFrames calls that produce no visible change. Those are
|
||||
-- invisible in a screenshot, so nothing else in the suite catches them --
|
||||
-- this file is the only thing standing between the port and a slow slide
|
||||
-- back to "snappier than a Game Boy".
|
||||
--
|
||||
-- Hardware numbers carry their asm citation; regenerate the inventory with
|
||||
-- tools/scan_pokered_delays.ps1.
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local T = require("tests.modkit")
|
||||
local Data = T.fixtures.fresh()
|
||||
local Font = require("src.render.Font")
|
||||
Font.load(Data)
|
||||
|
||||
local Timing = require("src.core.Timing")
|
||||
|
||||
-- ---------------------------------------------------------------- constants
|
||||
|
||||
-- home/fade.asm: four (or three) palette steps of `ld c, 8 / DelayFrames`
|
||||
T.eq(Timing.FADE_OUT_TO_BLACK, 32, "GBFadeOutToBlack is 4 x 8 frames")
|
||||
T.eq(Timing.FADE_IN_FROM_BLACK, 32, "GBFadeInFromBlack is 4 x 8 frames")
|
||||
T.eq(Timing.FADE_OUT_TO_WHITE, 24, "GBFadeOutToWhite is 3 x 8 frames")
|
||||
T.eq(Timing.FADE_IN_FROM_WHITE, 24, "GBFadeInFromWhite is 3 x 8 frames")
|
||||
|
||||
T.eq(Timing.DELAY3, 3, "Delay3 is three frames")
|
||||
T.eq(Timing.TEXT_SCROLL_PAIR, 10, "ScrollTextUpOneLine is 5 frames, run twice")
|
||||
T.eq(Timing.TEXT_CONT, 13, "<CONT>: ProtectedDelay3 + the two-line scroll")
|
||||
T.eq(Timing.TEXT_PARAGRAPH, 23, "<PARA>: ProtectedDelay3 + DelayFrames 20")
|
||||
T.eq(Timing.YES_NO_ANSWER, 15, "DisplayTwoOptionMenu holds 15 frames")
|
||||
T.eq(Timing.WARP_FADE_OUT, 32, "a map change fades out over 32 frames")
|
||||
T.eq(Timing.WARP_FADE_IN, 0, "there is no fade in: LoadGBPal restores in one write")
|
||||
|
||||
-- ---------------------------------------------------------------- HP bar
|
||||
|
||||
-- UpdateHPBar walks one HP point per iteration. On the player's HUD each
|
||||
-- point costs a frame (PrintHPNumber's DelayFrame, gated on wHPBarType) and
|
||||
-- each pixel of bar movement costs two more; on the enemy HUD only the
|
||||
-- pixels cost anything.
|
||||
T.eq(Timing.hpBarPixels(150, 150), 48, "a full bar is 48 px")
|
||||
T.eq(Timing.hpBarPixels(75, 150), 24, "half HP is half the bar")
|
||||
T.eq(Timing.hpBarPixels(0, 150), 0, "an empty bar is 0 px")
|
||||
T.eq(Timing.hpBarPixels(1, 150), 1, "GetHPBarLength clamps a sliver to 1 px")
|
||||
|
||||
T.eq(Timing.hpDrainFrames(150, 0, 150, true), 150 + 96 + 6,
|
||||
"a 150 HP player mon drains in D + 2P + 6 = 252 frames")
|
||||
T.eq(Timing.hpDrainFrames(150, 0, 150, false), 96 + 5,
|
||||
"the same drain on the enemy HUD costs only 2P + 5 = 101 frames")
|
||||
|
||||
-- The engine's per-frame stepper has to agree with that closed form, or the
|
||||
-- bar is animating at a rate nothing else measures.
|
||||
local BattleState = require("src.battle.BattleState")
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
local SaveData = require("src.core.SaveData")
|
||||
local TypeChart = require("src.battle.TypeChart")
|
||||
TypeChart.load(Data)
|
||||
|
||||
-- Each case gets its own party: draining a battler to 0 faints the very
|
||||
-- Pokemon object the save holds, and newWild refuses to start with no
|
||||
-- healthy party.
|
||||
local function newBattle()
|
||||
local save = SaveData.newGame()
|
||||
save.party = { Pokemon.new(Data, "FIXMON_A", 30) }
|
||||
local game = { data = Data, save = save,
|
||||
stack = { top = function() return nil end,
|
||||
push = function() end } }
|
||||
return BattleState.newWild(game, "FIXMON_C", 40)
|
||||
end
|
||||
|
||||
local function drainFrames(battle, battler, toHP)
|
||||
battler.mon.hp = toHP
|
||||
local frames = 0
|
||||
while battle:stepHPDrain() and frames < 20000 do frames = frames + 1 end
|
||||
return frames
|
||||
end
|
||||
|
||||
local battle = newBattle()
|
||||
local pMax = battle.player.mon.stats.hp
|
||||
T.eq(drainFrames(battle, battle.player, 0),
|
||||
Timing.hpDrainFrames(pMax, 0, pMax, true),
|
||||
"the player's bar steps at the hardware rate")
|
||||
|
||||
local battle2 = newBattle()
|
||||
local eMax = battle2.enemy.mon.stats.hp
|
||||
T.eq(drainFrames(battle2, battle2.enemy, 0),
|
||||
Timing.hpDrainFrames(eMax, 0, eMax, false),
|
||||
"the enemy's bar steps at the hardware rate")
|
||||
|
||||
-- a partial drain is exact too: every player-side HP step costs at least the
|
||||
-- number print, so the stepper never batches two into one frame
|
||||
local battle3 = newBattle()
|
||||
local start = battle3.player.mon.hp
|
||||
local target = math.max(1, start - 7)
|
||||
T.eq(drainFrames(battle3, battle3.player, target),
|
||||
Timing.hpDrainFrames(start, target, battle3.player.mon.stats.hp, true),
|
||||
"a partial player drain matches the closed form")
|
||||
|
||||
-- ---------------------------------------------------------------- text box
|
||||
|
||||
-- Both <CONT> and <PARA> print the down-arrow and run ProtectedDelay3 before
|
||||
-- ManualTextScroll starts watching the joypad, then pay the scroll or the
|
||||
-- box clear after the button. The port used to advance on the press frame
|
||||
-- with no cost on either side.
|
||||
local TextBox = require("src.render.TextBox")
|
||||
|
||||
local Input = { down = {}, pressed = {} }
|
||||
function Input:isDown(b) return self.down[b] or false end
|
||||
function Input:wasPressed(b) return self.pressed[b] or false end
|
||||
function Input:press(b) self.pressed[b] = true end
|
||||
function Input:release() self.pressed = {} end
|
||||
|
||||
local function textGame()
|
||||
local popped = false
|
||||
local g = { data = Data, save = SaveData.newGame(), input = Input }
|
||||
g.stack = { push = function() end,
|
||||
pop = function() popped = true end,
|
||||
top = function() return nil end }
|
||||
g.wasPopped = function() return popped end
|
||||
return g
|
||||
end
|
||||
|
||||
-- type a box out to its first wait, returning the frames that took
|
||||
local function typeToWait(box)
|
||||
local frames = 0
|
||||
while not box.waiting and not box.done and frames < 2000 do
|
||||
box:update(1 / 60)
|
||||
frames = frames + 1
|
||||
end
|
||||
return frames
|
||||
end
|
||||
|
||||
local g = textGame()
|
||||
g.save.options = g.save.options or {}
|
||||
g.save.options.textSpeed = 1 -- fastest, so the typewriter is not the subject
|
||||
local box = TextBox.new(g, "AB\fCD", {})
|
||||
typeToWait(box)
|
||||
T.check(box.waiting, "the box waits at the page break")
|
||||
|
||||
-- the three ProtectedDelay3 frames swallow the button
|
||||
local held = 0
|
||||
for _ = 1, Timing.TEXT_PRE_ADVANCE do
|
||||
Input:press("a")
|
||||
box:update(1 / 60)
|
||||
Input:release()
|
||||
held = held + 1
|
||||
T.check(box.waiting, "still waiting on pre-advance frame " .. held)
|
||||
end
|
||||
|
||||
-- the press now lands, and the box holds for the clear before typing again
|
||||
Input:press("a")
|
||||
box:update(1 / 60)
|
||||
Input:release()
|
||||
T.eq(box.holdFrames, Timing.TEXT_PAGE_CLEAR,
|
||||
"a page break holds DelayFrames 20 after the press")
|
||||
T.eq(#box.shown[1], 0, "the new page has not typed a character yet")
|
||||
|
||||
local blocked = 0
|
||||
while (box.holdFrames or 0) > 0 and blocked < 200 do
|
||||
box:update(1 / 60)
|
||||
blocked = blocked + 1
|
||||
T.eq(#box.shown[#box.shown], 0, "nothing types during the hold")
|
||||
end
|
||||
T.eq(blocked, Timing.TEXT_PAGE_CLEAR, "the hold is exactly 20 frames")
|
||||
|
||||
-- <CONT> pays the two-line scroll instead of the clear
|
||||
local g2 = textGame()
|
||||
g2.save.options = g2.save.options or {}
|
||||
g2.save.options.textSpeed = 1
|
||||
local box2 = TextBox.new(g2, "AB\vCD", {})
|
||||
typeToWait(box2)
|
||||
T.check(box2.waiting, "the box waits at the CONT marker")
|
||||
for _ = 1, Timing.TEXT_PRE_ADVANCE do box2:update(1 / 60) end
|
||||
Input:press("a")
|
||||
box2:update(1 / 60)
|
||||
Input:release()
|
||||
T.eq(box2.holdFrames, Timing.TEXT_SCROLL_PAIR,
|
||||
"a CONT advance holds for the two ScrollTextUpOneLine calls")
|
||||
|
||||
-- ---------------------------------------------------------------- yes/no
|
||||
|
||||
local ChoiceBox = require("src.ui.ChoiceBox")
|
||||
|
||||
local chosen, popped = nil, 0
|
||||
local g3 = { data = Data, save = SaveData.newGame(), input = Input }
|
||||
g3.stack = { push = function() end, pop = function() popped = popped + 1 end,
|
||||
top = function() return nil end }
|
||||
local choice = ChoiceBox.new(g3, function(yes) chosen = yes end)
|
||||
|
||||
Input:press("a")
|
||||
choice:update(1 / 60)
|
||||
Input:release()
|
||||
T.eq(chosen, nil, "the answer does not fire on the press frame")
|
||||
|
||||
-- count only the frames after the press: DelayFrames 15 runs between the
|
||||
-- press and TwoOptionMenu_RestoreScreenTiles handing control back
|
||||
local waited = 0
|
||||
while chosen == nil and waited < 200 do
|
||||
choice:update(1 / 60)
|
||||
waited = waited + 1
|
||||
end
|
||||
T.eq(waited, Timing.YES_NO_ANSWER, "the answer fires 15 frames after the press")
|
||||
T.eq(chosen, true, "A chose YES")
|
||||
T.eq(popped, 1, "the box popped itself once")
|
||||
|
||||
-- B picks the second option, and the cursor snaps to it for the hold
|
||||
local chosen2 = nil
|
||||
local g4 = { data = Data, save = SaveData.newGame(), input = Input }
|
||||
g4.stack = { push = function() end, pop = function() end,
|
||||
top = function() return nil end }
|
||||
local choice2 = ChoiceBox.new(g4, function(yes) chosen2 = yes end)
|
||||
Input:press("b")
|
||||
choice2:update(1 / 60)
|
||||
Input:release()
|
||||
T.eq(choice2.index, 2, "B moves the cursor to NO before the hold")
|
||||
local waited2 = 0
|
||||
while chosen2 == nil and waited2 < 200 do
|
||||
choice2:update(1 / 60)
|
||||
waited2 = waited2 + 1
|
||||
end
|
||||
T.eq(waited2, Timing.YES_NO_ANSWER, "the B answer holds 15 frames too")
|
||||
T.eq(chosen2, false, "B chose NO")
|
||||
|
||||
-- ---------------------------------------------------------------- warp fade
|
||||
|
||||
-- PlayMapChangeSound fades out over 32 frames and the new map simply
|
||||
-- appears; the port used to run a symmetric 12/12 fade, which is both too
|
||||
-- fast and a shape the hardware never had.
|
||||
local Transition = require("src.render.Transition")
|
||||
|
||||
local mid, done, tpopped = 0, 0, 0
|
||||
local g5 = { data = Data, save = SaveData.newGame() }
|
||||
g5.stack = { push = function() end, pop = function() tpopped = tpopped + 1 end,
|
||||
top = function() return nil end }
|
||||
local fade = Transition.new(g5, function() mid = mid + 1 end,
|
||||
function() done = done + 1 end)
|
||||
|
||||
local f = 0
|
||||
while done == 0 and f < 500 do
|
||||
fade:update(1 / 60)
|
||||
f = f + 1
|
||||
if mid == 1 and done == 0 then
|
||||
T.check(false, "the map switch and the hand-back must land together")
|
||||
end
|
||||
end
|
||||
T.eq(f, Timing.WARP_FADE_OUT, "the warp fade is 32 frames end to end")
|
||||
T.eq(mid, 1, "the map switched once")
|
||||
T.eq(done, 1, "the transition handed control back once")
|
||||
T.eq(tpopped, 1, "and popped itself once")
|
||||
|
||||
-- ------------------------------------------------------- battle turns
|
||||
|
||||
-- PlayApplyingAttackAnimation's six types (animations.asm:490-524). The
|
||||
-- slow shakes are c * 4b because AnimationShakeScreenHorizontallySlow pushes
|
||||
-- bc twice and runs two b-loops of DelayFrames 2 per outer pass.
|
||||
T.eq(Timing.SHAKE_VERTICAL, 48, "type 1 ShakeScreenVertically b=8")
|
||||
T.eq(Timing.SHAKE_HORIZ_HEAVY, 72, "type 2 fast horizontal b=8")
|
||||
T.eq(Timing.SHAKE_HORIZ_SLOW, 48, "type 3 slow horizontal, lb bc, 6, 2")
|
||||
T.eq(Timing.SHAKE_HORIZ_LIGHT, 18, "type 5 fast horizontal b=2")
|
||||
T.eq(Timing.SHAKE_HORIZ_SLOW2, 24, "type 6 slow horizontal, lb bc, 3, 2")
|
||||
|
||||
-- Type 4 is the player's plain damaging move -- the most-seen animation in
|
||||
-- the game. AnimationBlinkMon is `ld c, 6` of hide/DelayFrames 5/show/
|
||||
-- DelayFrames 5 (animations.asm:1360-1376): 60 frames, not the 20 the port
|
||||
-- used to run.
|
||||
T.eq(Timing.BLINK_MON, 60, "AnimationBlinkMon is 6 x (5 hidden + 5 shown)")
|
||||
T.eq(Timing.BLINK_MON % 10, 0,
|
||||
"and divides into whole 10-frame blinks, matching fxHidden's period")
|
||||
|
||||
-- SlideDownFaintedMonPic: b = PIC_HEIGHT slide steps of DelayFrames 2
|
||||
T.eq(Timing.FAINT_SLIDE, 14, "the faint slide is 7 steps x 2 frames")
|
||||
|
||||
T.eq(Timing.MOVE_STATUS_OR_MISS, 30,
|
||||
"a status move or a miss holds DelayFrames 30 before its text")
|
||||
|
||||
T.eq(Timing.MOVE_ANIM_PRE, 3,
|
||||
"PlayMoveAnimation calls Delay3 before handing off to MoveAnimation")
|
||||
T.eq(Timing.CRIT_OHKO_TEXT, 20,
|
||||
"PrintCriticalOHKOText closes with DelayFrames 20")
|
||||
T.eq(Timing.BATTLE_START_SENDOUT, 40, "StartBattle holds 40 after the send-out")
|
||||
|
||||
-- An animation row pays PlayMoveAnimation's Delay3 before the first frame
|
||||
-- of the animation, so the row goes back on the queue once (core.asm:6638).
|
||||
do
|
||||
local b = newBattle()
|
||||
b.queue, b.nextInsert, b.current = {}, 0, nil
|
||||
b.waitFrames, b.waitingSound = nil, nil
|
||||
b.draining, b.animPlaying, b.waitingUI = nil, nil, nil
|
||||
b.queue[1] = { anim = "FIX_TACKLE", attackerIsPlayer = true }
|
||||
b:updateQueue()
|
||||
T.eq(b.waitFrames, Timing.MOVE_ANIM_PRE,
|
||||
"the anim row pays Delay3 before it plays")
|
||||
T.eq(#b.queue, 1, "and is put back on the queue to run after the hold")
|
||||
T.check(b.queue[1].animDelayed, "flagged so the hold is paid only once")
|
||||
end
|
||||
|
||||
-- StartBattle's 40-frame hold is unconditional -- the `call nz` gates only
|
||||
-- EnemySendOutFirstMon -- so a wild battle queues it too, between
|
||||
-- "Wild X appeared!" and "Go! Y!" (core.asm:152-156).
|
||||
do
|
||||
-- the intro queue is built by enter(), not by newWild
|
||||
local b = newBattle()
|
||||
local ok = pcall(b.enter, b)
|
||||
T.check(ok, "a wild battle's intro builds")
|
||||
local found = false
|
||||
for _, row in ipairs(b.queue) do
|
||||
if row.wait == Timing.BATTLE_START_SENDOUT then found = true break end
|
||||
end
|
||||
T.check(found, "a wild battle's intro queues the 40-frame send-out hold")
|
||||
end
|
||||
|
||||
-- waitNext is what puts that hold in the turn queue
|
||||
do
|
||||
local b = newBattle()
|
||||
b.queue, b.nextInsert = {}, 0
|
||||
b:waitNext(Timing.MOVE_STATUS_OR_MISS)
|
||||
T.eq(#b.queue, 1, "waitNext queues one row")
|
||||
T.eq(b.queue[1].wait, Timing.MOVE_STATUS_OR_MISS, "carrying the hold length")
|
||||
b:waitNext(0)
|
||||
T.eq(#b.queue, 1, "a zero-length hold queues nothing")
|
||||
end
|
||||
|
||||
-- Battle text prints through the same PrintText path as overworld text, so
|
||||
-- it pays PrintLetterDelay per character (home/print_text.asm:4-45): one
|
||||
-- glyph per wOptions & $f frames, collapsing to one frame while A or B is
|
||||
-- held. It used to run a flat two glyphs per frame -- six times hardware
|
||||
-- speed at the default setting -- and ignored the text-speed option.
|
||||
local function typedFrames(speed, hold)
|
||||
local Btn = { down = {}, pressed = {} }
|
||||
function Btn:isDown(k) return self.down[k] or false end
|
||||
function Btn:wasPressed(k) return self.pressed[k] or false end
|
||||
if hold then Btn.down.a = true end
|
||||
|
||||
local b = newBattle()
|
||||
b.game.input = Btn
|
||||
b.game.save.options = b.game.save.options or {}
|
||||
b.game.save.options.textSpeed = speed
|
||||
b.queue, b.nextInsert = {}, 0
|
||||
b.waitFrames, b.waitingSound = nil, nil
|
||||
b.draining, b.animPlaying, b.waitingUI = nil, nil, nil
|
||||
b:startMessage({ text = "ABCDEF" })
|
||||
local frames = 0
|
||||
while (b.charIndex or 0) < 6 and frames < 400 do
|
||||
b:updateQueue()
|
||||
frames = frames + 1
|
||||
end
|
||||
return frames
|
||||
end
|
||||
|
||||
T.eq(typedFrames(3), 18, "six glyphs at MEDIUM take 3 frames each")
|
||||
T.eq(typedFrames(5), 30, "six glyphs at SLOW take 5 frames each")
|
||||
T.eq(typedFrames(1), 6, "six glyphs at FAST take 1 frame each")
|
||||
T.eq(typedFrames(3, true), 6,
|
||||
"holding A collapses the per-letter wait to a single frame")
|
||||
|
||||
-- WaitForSoundToFinish (home/delay.asm:15-20) is how the original gives a
|
||||
-- sound its own clear window. A trainer intro plays SFX_Silph_Scope --
|
||||
-- extracted here as "Trainer_Appeared" -- blocks on it, and only then pays
|
||||
-- the DelayFrames 20 before the balls and the text.
|
||||
do
|
||||
local b = newBattle()
|
||||
b.queue, b.nextInsert, b.current = {}, 0, nil
|
||||
b.waitFrames, b.waitingSound = nil, nil
|
||||
b.draining, b.animPlaying, b.waitingUI = nil, nil, nil
|
||||
local playing = true
|
||||
local src = { isPlaying = function() return playing end }
|
||||
b.queue[1] = { waitSound = function() return src end }
|
||||
T.check(b:updateQueue(), "the waitSound row is taken off the queue")
|
||||
T.eq(b.waitingSound, src, "and parks the queue on that source")
|
||||
T.check(b:updateQueue(), "the queue blocks while the sound is audible")
|
||||
playing = false
|
||||
b:updateQueue()
|
||||
T.eq(b.waitingSound, nil, "and releases the frame the sound stops")
|
||||
end
|
||||
|
||||
-- Every battle enters through the wipe, script-driven ones included.
|
||||
-- BattleTransition runs from DoBattleTransitionAndInitBattleVariables for
|
||||
-- all of them; start_battle used to push the BattleState straight onto the
|
||||
-- stack, so every scripted trainer -- gym leaders, the rival -- and every
|
||||
-- scripted wild battle cut to the battle screen with no transition at all.
|
||||
do
|
||||
local Commands = require("src.script.Commands")
|
||||
local route = {}
|
||||
local save = SaveData.newGame()
|
||||
save.party = { Pokemon.new(Data, "FIXMON_A", 30) }
|
||||
local ctx = {
|
||||
game = { data = Data, save = save,
|
||||
stack = { push = function() route[#route + 1] = "raw" end,
|
||||
top = function() return nil end } },
|
||||
runner = { yield = function() end, resume = function() end },
|
||||
overworld = { pushBattle = function() route[#route + 1] = "wipe" end },
|
||||
}
|
||||
Commands.start_battle(ctx, "wild", "FIXMON_C", 5)
|
||||
T.eq(#route, 1, "start_battle pushes the battle exactly once")
|
||||
T.eq(route[1], "wipe", "and routes it through the transition wipe")
|
||||
|
||||
-- with no overworld (a headless or menu-driven caller) it still works
|
||||
local route2 = {}
|
||||
local ctx2 = {
|
||||
game = { data = Data, save = save,
|
||||
stack = { push = function() route2[#route2 + 1] = "raw" end,
|
||||
top = function() return nil end } },
|
||||
runner = { yield = function() end, resume = function() end },
|
||||
}
|
||||
Commands.start_battle(ctx2, "wild", "FIXMON_C", 5)
|
||||
T.eq(route2[1], "raw", "and falls back to a direct push with no overworld")
|
||||
end
|
||||
|
||||
-- --------------------------------------------------- battle transition
|
||||
|
||||
-- The eight wipes, from pokered-c's battle_transition.c budget (derived from
|
||||
-- battle_transitions.asm, then checked against the ROM side by side). The
|
||||
-- port used a flat 40/24 for all eight.
|
||||
local BT = require("src.render.BattleTransition")
|
||||
local WIPES = {
|
||||
doublecircle = 30, -- 10 steps x 3 frames
|
||||
circle = 60, -- 20 x 3
|
||||
spiralout = 120, -- 360 fills / 3 per frame
|
||||
hstripes = 60, -- 20 x 3
|
||||
vstripes = 54, -- 18 x 3
|
||||
shrink = 54, -- 9 x 6
|
||||
split = 54, -- 9 x 6
|
||||
}
|
||||
for id, frames in pairs(WIPES) do
|
||||
T.eq(BT.STYLES[id].frames, frames, id .. " runs its asm frame budget")
|
||||
end
|
||||
|
||||
-- The inward spiral writes one tile per iteration and calls
|
||||
-- BattleTransition_TransferDelay3 every seventh -- and that helper is a
|
||||
-- Delay3, three frames, not a one-frame transfer. Reading it as one frame
|
||||
-- gives ~46 frames against the ROM's ~150, which is the exact mistake
|
||||
-- pokered-c caught on a live comparison.
|
||||
T.eq(BT.STYLES.spiralin.frames % 3, 0,
|
||||
"the inward spiral advances in whole Delay3 units")
|
||||
T.check(BT.STYLES.spiralin.frames >= 130 and BT.STYLES.spiralin.frames <= 170,
|
||||
"the inward spiral lands near the ROM's ~150 frames, not the ~46 a "
|
||||
.. "one-frame transfer would give (got "
|
||||
.. tostring(BT.STYLES.spiralin.frames) .. ")")
|
||||
|
||||
-- The flash belongs to the two wild wipes only: BattleTransition_FlashScreen
|
||||
-- is called from BattleTransition_Circle (:585) and _DoubleCircle (:628) and
|
||||
-- nowhere else. A trainer battle's transition is the spiral, inward against
|
||||
-- a weaker foe and outward against a stronger one
|
||||
-- (wBattleTransitionSpiralDirection, :119-126).
|
||||
do
|
||||
local fakeRenderer = {}
|
||||
local g = { data = Data, save = SaveData.newGame(), renderer = fakeRenderer,
|
||||
stack = { push = function() end, pop = function() end,
|
||||
top = function() return nil end } }
|
||||
|
||||
local wild = BT.new(g, function() end, { trainer = false, stronger = false })
|
||||
T.eq(wild.style, "doublecircle", "a weak wild foe gets the double circle")
|
||||
T.eq(wild.phase, "flash", "and flashes before the wipe")
|
||||
|
||||
local weak = BT.new(g, function() end, { trainer = true, stronger = false })
|
||||
T.eq(weak.style, "spiralin", "a weaker trainer gets the inward spiral")
|
||||
T.eq(weak.phase, "wipe", "with no flash in front of it")
|
||||
|
||||
local strong = BT.new(g, function() end, { trainer = true, stronger = true })
|
||||
T.eq(strong.style, "spiralout", "a stronger trainer gets the outward spiral")
|
||||
|
||||
-- the flash is a palette write, so it veils the whole surface: it is
|
||||
-- handed to the renderer in screen space rather than filling the 160x144
|
||||
-- UI canvas, which at any zoom above 1x left the surround unlit
|
||||
wild:draw()
|
||||
T.check(fakeRenderer.screenVeil ~= nil,
|
||||
"the flash publishes a screen-space veil to the renderer")
|
||||
T.eq(#fakeRenderer.screenVeil, 2, "as a {shade, alpha} pair")
|
||||
end
|
||||
|
||||
-- Returning to the overworld after a battle is a fade, not a cut:
|
||||
-- DelayFrames 10 (home/overworld.asm:351-352) with the palettes still white,
|
||||
-- then MapEntryAfterBattle's GBFadeInFromWhite (:749-753) = 24 frames.
|
||||
do
|
||||
local popped, done = 0, 0
|
||||
local r = {}
|
||||
local g = { data = Data, save = SaveData.newGame(), renderer = r,
|
||||
stack = { push = function() end,
|
||||
pop = function() popped = popped + 1 end,
|
||||
top = function() return nil end } }
|
||||
local fade = require("src.render.Transition").battleReturn(g,
|
||||
function() done = done + 1 end)
|
||||
|
||||
-- solid white through the 10-frame hold
|
||||
for i = 1, Timing.POST_BATTLE_RETURN do
|
||||
fade:draw()
|
||||
T.eq(r.screenVeil[1], 1, "the veil is white on hold frame " .. i)
|
||||
T.eq(r.screenVeil[2], 1, "and fully opaque on hold frame " .. i)
|
||||
fade:update(1 / 60)
|
||||
end
|
||||
|
||||
-- then it steps off in three palette stages of 8 frames, the way
|
||||
-- GBFadeIncCommon writes a palette and holds it (home/fade.asm:30-41)
|
||||
fade:draw()
|
||||
T.eq(r.screenVeil[2], 2 / 3, "the first fade step drops to two thirds")
|
||||
for _ = 1, 8 do fade:update(1 / 60) end
|
||||
fade:draw()
|
||||
T.eq(r.screenVeil[2], 1 / 3, "the second step to one third")
|
||||
for _ = 1, 8 do fade:update(1 / 60) end
|
||||
fade:draw()
|
||||
T.eq(r.screenVeil[2], 0, "the third clears it")
|
||||
|
||||
-- total duration, measured on a fresh instance (the staircase checks above
|
||||
-- advanced this one)
|
||||
local popped2, done2 = 0, 0
|
||||
local g2 = { data = Data, save = SaveData.newGame(), renderer = {},
|
||||
stack = { push = function() end,
|
||||
pop = function() popped2 = popped2 + 1 end,
|
||||
top = function() return nil end } }
|
||||
local fade2 = require("src.render.Transition").battleReturn(g2,
|
||||
function() done2 = done2 + 1 end)
|
||||
local frames = 0
|
||||
while done2 == 0 and frames < 500 do
|
||||
fade2:update(1 / 60)
|
||||
frames = frames + 1
|
||||
end
|
||||
T.eq(frames, Timing.POST_BATTLE_RETURN + Timing.FADE_IN_FROM_WHITE,
|
||||
"the whole return is the 10-frame hold plus a 24-frame fade")
|
||||
T.eq(popped2, 1, "and it pops itself exactly once")
|
||||
T.check(popped >= 0, "the staircase instance is independent")
|
||||
end
|
||||
|
||||
-- The spiral and circle walks generalise to an arbitrary grid, so a zoomed
|
||||
-- or windowed surface wipes as one figure instead of a spiral in a box
|
||||
-- surrounded by a square cascade.
|
||||
do
|
||||
local COLS_GB, ROWS_GB = 20, 18 -- the Game Boy's own tile grid
|
||||
for _, style in ipairs({ "spiralin", "spiralout", "circle", "doublecircle" }) do
|
||||
local order = BT.gridOrder(style, 40, 23)
|
||||
T.check(order ~= nil, style .. " builds an order for an arbitrary grid")
|
||||
T.eq(#order, 40 * 23, style .. " covers every tile of a 40x23 grid")
|
||||
local seen, dup = {}, false
|
||||
for _, t in ipairs(order) do
|
||||
local k = t[1] .. "," .. t[2]
|
||||
if seen[k] then dup = true end
|
||||
seen[k] = true
|
||||
end
|
||||
T.check(not dup, style .. " visits each tile exactly once")
|
||||
end
|
||||
T.eq(BT.gridOrder("shrink", 40, 23), nil,
|
||||
"geometry-shaped styles have no tile order and are extended as rects")
|
||||
-- At exactly the Game Boy's grid, gridOrder hands back the ROM's own walk
|
||||
-- rather than the generic one -- so an unzoomed window is the classic wipe.
|
||||
-- BattleTransition_InwardSpiral fills 359 of the 360 tiles and leaves the
|
||||
-- centre one to the final blackout, which is how you tell the two apart.
|
||||
T.eq(#BT.gridOrder("spiralin", COLS_GB, ROWS_GB), 359,
|
||||
"the classic grid gets the ROM's walk, not the generic spiral")
|
||||
T.check(#BT.gridOrder("spiralin", COLS_GB + 1, ROWS_GB)
|
||||
== (COLS_GB + 1) * ROWS_GB,
|
||||
"one tile wider and it is the generic spiral, covering everything")
|
||||
-- degenerate grids must not hang or error
|
||||
T.eq(#BT.gridOrder("spiralin", 1, 1), 1, "a 1x1 grid is one tile")
|
||||
T.eq(#BT.gridOrder("spiralout", 3, 1), 3, "a single-row grid walks straight")
|
||||
end
|
||||
|
||||
-- SlidePlayerAndEnemySilhouettesOnScreen: 144 px at 2 px/frame
|
||||
T.eq(Timing.BATTLE_SLIDE_IN_FRAMES, 72, "the silhouettes slide for 72 frames")
|
||||
T.eq(Timing.BATTLE_SLIDE_PX_PER_FRAME, 2, "at 2 px per frame")
|
||||
T.eq(Timing.BATTLE_SLIDE_IN_FRAMES * Timing.BATTLE_SLIDE_PX_PER_FRAME, 144,
|
||||
"which is the SCX $90 the enemy side scrolls through")
|
||||
|
||||
T.eq(Timing.TRAINER_INTRO_SFX_GAP, 20,
|
||||
"a trainer intro waits DelayFrames 20 before the balls and the text")
|
||||
|
||||
-- ------------------------------------------------------- catch-up clamping
|
||||
|
||||
-- Removing the warp fade in took away the counter that used to absorb the
|
||||
-- map-load hitch, so discardCatchup has to handle the oversized dt the hitch
|
||||
-- produces on the FOLLOWING frame -- otherwise the burst just moves one
|
||||
-- frame later and shows up as a walk-animation slide (issue #93).
|
||||
local FixedStep = require("src.core.FixedStep")
|
||||
|
||||
local steps = 0
|
||||
FixedStep:init(function() steps = steps + 1 end)
|
||||
FixedStep:update(1 / 60)
|
||||
T.eq(steps, 1, "an ordinary frame runs exactly one logic step")
|
||||
|
||||
-- what the hitch does when nothing is armed
|
||||
steps = 0
|
||||
FixedStep:update(0.25)
|
||||
T.check(steps > 10,
|
||||
"an unclamped hitch frame burns a burst of steps before the next draw")
|
||||
|
||||
-- and with the clamp armed
|
||||
FixedStep:init(function() steps = steps + 1 end)
|
||||
FixedStep:discardCatchup()
|
||||
steps = 0
|
||||
FixedStep:update(0.25)
|
||||
T.eq(steps, 1, "the frame after discardCatchup is clamped to one step")
|
||||
|
||||
steps = 0
|
||||
FixedStep:update(1 / 60)
|
||||
T.eq(steps, 1, "and the clamp expires after that one frame")
|
||||
|
||||
T.finish("timing parity")
|
||||
@@ -0,0 +1,50 @@
|
||||
-- The title screen and the intro fill the window (aspect preserved, bars on
|
||||
-- the long axis) instead of sitting at the fixed integer scale, and the
|
||||
-- overworld's survey zoom does not shrink them.
|
||||
--
|
||||
-- Both halves shipped broken together. Renderer:uiScale steps the UI down one
|
||||
-- whole integer per zoom-out step, which is right for the overworld -- a
|
||||
-- full-size dialogue box over a shrunken map looks wrong -- but it was applied
|
||||
-- unconditionally, so a saved zoom of -2 also drew the TITLE SCREEN at a
|
||||
-- reduced scale, in a window showing no map at all. The fix gates the
|
||||
-- step-down on a world actually being on screen, and opts these two states
|
||||
-- into the fill scale the battle "fill" size already uses.
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local T = require("tests.modkit")
|
||||
local Game = require("src.core.Game")
|
||||
local TitleState = require("src.ui.TitleState")
|
||||
local IntroMovie = require("src.ui.IntroMovie")
|
||||
|
||||
-- --------------------------------------------------------------- the opt-in
|
||||
|
||||
local title = setmetatable({}, { __index = TitleState })
|
||||
local intro = setmetatable({}, { __index = IntroMovie })
|
||||
|
||||
T.eq(title:wantsFillScale(), true, "the title screen asks for the fill scale")
|
||||
T.eq(intro:wantsFillScale(), true, "and so does the intro")
|
||||
|
||||
-- neither reads options, so this must hold with no game attached at all --
|
||||
-- the title screen is up before a save is loaded
|
||||
T.eq(TitleState.wantsFillScale(nil), true,
|
||||
"the title screen fills with no game or save behind it")
|
||||
T.eq(IntroMovie.wantsFillScale(nil), true, "and so does the intro")
|
||||
|
||||
-- ------------------------------------------------------------- the stack scan
|
||||
|
||||
local function stack(...) return { states = { ... } } end
|
||||
local overworld = {} -- no wantsFillScale at all, like every other state
|
||||
|
||||
T.eq(Game.fillScaleInStack(stack(title)), true,
|
||||
"the shared scan picks the title screen up")
|
||||
T.eq(Game.fillScaleInStack(stack(intro)), true, "and the intro")
|
||||
T.eq(Game.fillScaleInStack(stack(overworld)), false,
|
||||
"and the overworld still draws at the fixed scale")
|
||||
|
||||
-- the title screen opens the CONTINUE/NEW GAME menu and the options menu on
|
||||
-- top of itself; those must not snap the surface back for a frame, the same
|
||||
-- whole-stack rule a battle relies on
|
||||
T.eq(Game.fillScaleInStack(stack(title, {})), true,
|
||||
"a menu opened over the title screen keeps it filling")
|
||||
|
||||
T.finish("title fill scale")
|
||||
@@ -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": {
|
||||
|
||||
Reference in New Issue
Block a user