mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-13 17:00:58 +02:00
Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 078bb1ade6 | |||
| f205464aa4 | |||
| b820d3917c | |||
| 75aa71a886 | |||
| 398eefc380 | |||
| 1a57b67e14 | |||
| 41e326f376 | |||
| 6b012a31ce | |||
| f109530c5f | |||
| 4cb132199c | |||
| aedc63c40d |
@@ -32,7 +32,7 @@ jobs:
|
||||
outputs:
|
||||
changed: ${{ steps.paths.outputs.changed }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- id: paths
|
||||
@@ -58,7 +58,7 @@ jobs:
|
||||
outputs:
|
||||
ipa_url: ${{ steps.upload-ipa.outputs.artifact-url }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v7
|
||||
- name: import signing certificate
|
||||
if: github.repository == 'bryanthaboi/gen1recomp'
|
||||
run: |
|
||||
@@ -92,7 +92,7 @@ jobs:
|
||||
- name: upload iOS release artifact
|
||||
id: upload-ipa
|
||||
if: github.repository == 'bryanthaboi/gen1recomp'
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: gen1recomp-ios-ipa
|
||||
path: dist/ios/gen1recomp.ipa
|
||||
@@ -106,7 +106,7 @@ jobs:
|
||||
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
|
||||
@@ -124,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
|
||||
|
||||
@@ -166,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
|
||||
@@ -214,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
|
||||
|
||||
@@ -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
|
||||
|
||||
+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 = {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
Reference in New Issue
Block a user