mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-20 20:50:21 +02:00
Port timing/parity fixes, seamless battle transitions, faithful-res lock, and zoom-aware UI anchoring
Ports from a downstream fork, hand-surgered hunk-by-hunk to exclude the fork's randomizer/pokescript work and to skip a FixedStep jitter-tolerance attempt that never fixed the stutter it targeted. - src/core/Timing.lua: hardware-accurate frame-delay catalog ported from pret/pokered, feeding BattleState:waitNext, EffectRegistry's miss/crit beats, TextBox/ChoiceBox scroll and prompt holds, and the battle silhouette slide/shake/blink/faint timings. - Seamless battle transitions: Renderer:drawBattleWipe replaces the old 160x144-only cascade with one wipe drawn over the whole surface at any zoom or window size; BattleTransition's per-style frame lengths are corrected against pokered-c's derivation; Transition.battleReturn adds the post-battle GBFadeInFromWhite the port never had. - BATTLE SIZE / BATTLE BG options (BattleState:wantsFillScale/bgMode, Game.fillScaleInStack/worldBgBattleDim): battle surface can fill the window instead of the fixed integer letterbox, and the area around it can show white/black/the dimmed overworld instead of only white. - src/core/FaithfulRes.lua: locks the window to an exact 160x144 multiple. - Zoom-aware UI anchoring: Renderer:uiScale steps the UI down with survey zoom (gated to worldActive so the title/intro never shrink); Renderer:setUIAnchor lets TextBox, ChoiceBox, and an opted-in Menu (the START menu) pin themselves to a screen edge instead of the zoomed-out letterbox.
This commit is contained in:
@@ -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.
|
||||||
+199
-31
@@ -23,6 +23,7 @@ local Pokemon = require("src.pokemon.Pokemon")
|
|||||||
local Runtime = require("src.mods.Runtime")
|
local Runtime = require("src.mods.Runtime")
|
||||||
local Screens = require("src.ui.Screens")
|
local Screens = require("src.ui.Screens")
|
||||||
local Status = require("src.battle.Status")
|
local Status = require("src.battle.Status")
|
||||||
|
local Timing = require("src.core.Timing")
|
||||||
local TrainerAI = require("src.battle.TrainerAI")
|
local TrainerAI = require("src.battle.TrainerAI")
|
||||||
local TurnOrder = require("src.battle.TurnOrder")
|
local TurnOrder = require("src.battle.TurnOrder")
|
||||||
local TypeChart = require("src.battle.TypeChart")
|
local TypeChart = require("src.battle.TypeChart")
|
||||||
@@ -50,6 +51,41 @@ function BattleState:wideLayout()
|
|||||||
return self:isWideBattleLayout()
|
return self:isWideBattleLayout()
|
||||||
end
|
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
|
-- Renderer:setUISize asks the top state for its surface before anything draws
|
||||||
function BattleState:uiSize()
|
function BattleState:uiSize()
|
||||||
if self:wideLayout() then return WideBattle.WIDTH, WideBattle.HEIGHT end
|
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 })
|
{ drain = true, battler = battler, stopAt = stopAt })
|
||||||
end
|
end
|
||||||
|
|
||||||
-- One frame of the HP-bar drain (engine/gfx/hp_bar.asm UpdateHPBar):
|
-- Queue a pure frame hold at the current insert point, the way the original
|
||||||
-- the bar animates a pixel per two frames, so displayed HP moves at
|
-- spends DelayFrames between the beats of a turn. Mirrors sayNext/drainNext
|
||||||
-- maxHP/96 per frame (48-pixel bar). Returns true while animating.
|
-- 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()
|
function BattleState:stepHPDrain()
|
||||||
local busy = false
|
local busy = false
|
||||||
for _, b in ipairs({ self.player, self.enemy }) do
|
for _, b in ipairs({ self.player, self.enemy }) do
|
||||||
@@ -857,14 +914,30 @@ function BattleState:stepHPDrain()
|
|||||||
and b.shownHP >= b.drainFloor then
|
and b.shownHP >= b.drainFloor then
|
||||||
goal = b.drainFloor
|
goal = b.drainFloor
|
||||||
end
|
end
|
||||||
if b.shownHP ~= goal then
|
if (b.drainHold or 0) > 0 then
|
||||||
local step = math.max(1, b.mon.stats.hp) / 96
|
b.drainHold = b.drainHold - 1
|
||||||
if b.shownHP > goal then
|
busy = true
|
||||||
b.shownHP = math.max(goal, b.shownHP - step)
|
elseif b.shownHP ~= goal then
|
||||||
else
|
local maxHP = math.max(1, b.mon.stats.hp)
|
||||||
b.shownHP = math.min(goal, b.shownHP + step)
|
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
|
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
|
end
|
||||||
end
|
end
|
||||||
@@ -939,6 +1012,14 @@ function BattleState:updateQueue()
|
|||||||
self.waitFrames = self.waitFrames - 1
|
self.waitFrames = self.waitFrames - 1
|
||||||
return true
|
return true
|
||||||
end
|
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
|
-- an HP-bar drain holds the queue until the bar catches up
|
||||||
if self.draining then
|
if self.draining then
|
||||||
if self:stepHPDrain() then return true end
|
if self:stepHPDrain() then return true end
|
||||||
@@ -990,6 +1071,12 @@ function BattleState:updateQueue()
|
|||||||
self.waitFrames = item.wait
|
self.waitFrames = item.wait
|
||||||
return true
|
return true
|
||||||
end
|
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
|
if item.mimicSelect then
|
||||||
-- pause the queue on Mimic's copy menu (MoveSelectionMenu with
|
-- pause the queue on Mimic's copy menu (MoveSelectionMenu with
|
||||||
-- wMoveMenuType = 1 lists the enemy's moves; cursor starts on 1)
|
-- 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 --
|
-- the animation ends (hitRow rows carry a hit with no animation --
|
||||||
-- thrash/rage continuation turns that skip the announcement).
|
-- thrash/rage continuation turns that skip the announcement).
|
||||||
if item.anim or item.hitRow then
|
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 mdef = item.anim and self.data.moves[item.anim]
|
||||||
local anim = mdef and mdef.anim
|
local anim = mdef and mdef.anim
|
||||||
if item.anim == "POOF_ANIM" then
|
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
|
-- 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)
|
-- (home/text.asm ContText); this keeps a 3rd line on-screen (#216)
|
||||||
if self.msgWaiting then
|
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
|
if input:wasPressed("a") or input:wasPressed("b") then
|
||||||
self.msgWaiting = nil
|
self.msgWaiting = nil
|
||||||
self:beginMsgLine()
|
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
|
end
|
||||||
return true
|
return true
|
||||||
end
|
end
|
||||||
local cur = self.shown[#self.shown]
|
local cur = self.shown[#self.shown]
|
||||||
if #cur < #self.codes then
|
if #cur < #self.codes then
|
||||||
-- battle typewriter cadence: two glyphs per fixed step (as before)
|
-- Battle text prints through the same PrintText path as everything
|
||||||
for _ = 1, 2 do
|
-- else, so it pays PrintLetterDelay per character (home/print_text.asm:
|
||||||
if #cur >= #self.codes then break end
|
-- 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]
|
cur[#cur + 1] = self.codes[#cur + 1]
|
||||||
self.charIndex = self.charIndex + 1
|
self.charIndex = self.charIndex + 1
|
||||||
end
|
end
|
||||||
@@ -1100,6 +1218,7 @@ function BattleState:updateQueue()
|
|||||||
-- scrolling, \n advances now (beginMsgLine scrolls if the box is full)
|
-- scrolling, \n advances now (beginMsgLine scrolls if the box is full)
|
||||||
if self.lines[self.lineIndex + 1].cont then
|
if self.lines[self.lineIndex + 1].cont then
|
||||||
self.msgWaiting = true
|
self.msgWaiting = true
|
||||||
|
self.msgPreWait = Timing.TEXT_PRE_ADVANCE
|
||||||
else
|
else
|
||||||
self:beginMsgLine()
|
self:beginMsgLine()
|
||||||
end
|
end
|
||||||
@@ -1126,8 +1245,18 @@ function BattleState:updateQueue()
|
|||||||
-- only on a \v CONT hold (#317). A flag of its own, not msgWaiting:
|
-- 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
|
-- that branch above scrolls the NEXT line in, which this page has not
|
||||||
-- got, so reusing it would call beginMsgLine on a drained message.
|
-- got, so reusing it would call beginMsgLine on a drained message.
|
||||||
self.msgPrompt = true
|
if not self.msgPrompt then
|
||||||
if input:wasPressed("a") or input:wasPressed("b") 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.msgPrompt = nil
|
||||||
self.current = nil
|
self.current = nil
|
||||||
end
|
end
|
||||||
@@ -1248,11 +1377,12 @@ function BattleState:enter()
|
|||||||
-- without a transition (link battles, scripted pushes)
|
-- without a transition (link battles, scripted pushes)
|
||||||
Music.playBattle(self.data, self.musicKind)
|
Music.playBattle(self.data, self.musicKind)
|
||||||
-- intro presentation (SlidePlayerAndEnemySilhouettesOnScreen): both
|
-- intro presentation (SlidePlayerAndEnemySilhouettesOnScreen): both
|
||||||
-- sides slide in as black silhouettes. The original scrolls SCX from
|
-- sides slide in; the trainer pics stay up until the send-outs
|
||||||
-- $90 to 0 two pixels per frame (72 frames); the port covers the full
|
-- BATTLE BG "world" drops this battle's opacity so StateStack keeps drawing
|
||||||
-- 160px screen width, so 2px/frame is an 80-frame slide (slide offset is
|
-- the overworld underneath it (see bgMode). Per instance, so the class
|
||||||
-- introSlide*2 below). The trainer pics stay up until the send-outs.
|
-- default stays opaque for every other battle and for older saves.
|
||||||
self.introSlide = 80
|
self.isOpaque = self:bgMode() ~= "world"
|
||||||
|
self.introSlide = Timing.BATTLE_SLIDE_IN_FRAMES
|
||||||
self.showEnemyTrainer = self.kind == "trainer" and self.trainerPic ~= nil
|
self.showEnemyTrainer = self.kind == "trainer" and self.trainerPic ~= nil
|
||||||
-- DrawAllPokeballs (common_text.asm:27) puts the party ball rows AND the
|
-- DrawAllPokeballs (common_text.asm:27) puts the party ball rows AND the
|
||||||
-- HUD corner/underline tiles under them (PlacePlayerHUDTiles /
|
-- HUD corner/underline tiles under them (PlacePlayerHUDTiles /
|
||||||
@@ -1298,6 +1428,25 @@ function BattleState:enter()
|
|||||||
and not self.ghost and not self.scopeReveal then
|
and not self.ghost and not self.scopeReveal then
|
||||||
queueEnemyCry()
|
queueEnemyCry()
|
||||||
end
|
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)
|
self:say(self.introText)
|
||||||
-- the unveil rides on that same box, before _InitBattleCommon clears the
|
-- the unveil rides on that same box, before _InitBattleCommon clears the
|
||||||
-- intro chrome below (#492)
|
-- intro chrome below (#492)
|
||||||
@@ -1339,6 +1488,14 @@ function BattleState:enter()
|
|||||||
end)
|
end)
|
||||||
queueEnemyCry()
|
queueEnemyCry()
|
||||||
end
|
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
|
if not self.safari and not self.demo then
|
||||||
-- StartBattle .playerSendOutFirstMon (core.asm:236-240): the back pic
|
-- StartBattle .playerSendOutFirstMon (core.asm:236-240): the back pic
|
||||||
-- walks off the LEFT edge (SlideTrainerPicOffScreen, hlcoord 1,5,
|
-- walks off the LEFT edge (SlideTrainerPicOffScreen, hlcoord 1,5,
|
||||||
@@ -2510,24 +2667,27 @@ function BattleState:applyHitFx(hit)
|
|||||||
prog[#prog + 1] = { dy = 0, frames = 3 }
|
prog[#prog + 1] = { dy = 0, frames = 3 }
|
||||||
end
|
end
|
||||||
self.fx.shakeProg = prog
|
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
|
elseif t == 2 then
|
||||||
self.fx.shakeProg = fastShakeProg(8)
|
self.fx.shakeProg = fastShakeProg(8)
|
||||||
self.waitFrames = 72
|
self.waitFrames = Timing.SHAKE_HORIZ_HEAVY
|
||||||
elseif t == 3 then
|
elseif t == 3 then
|
||||||
self.fx.shakeProg = slowShakeProg(6, 2)
|
self.fx.shakeProg = slowShakeProg(6, 2)
|
||||||
self.waitFrames = 48
|
self.waitFrames = Timing.SHAKE_HORIZ_SLOW
|
||||||
elseif t == 4 then
|
elseif t == 4 then
|
||||||
if hit.blink then
|
if hit.blink then
|
||||||
self.fx.blink = { target = hit.blink, frames = 20 }
|
-- AnimationBlinkMon: 6 iterations of hide/5 frames/show/5 frames.
|
||||||
self.waitFrames = 20
|
-- 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
|
end
|
||||||
elseif t == 5 then
|
elseif t == 5 then
|
||||||
self.fx.shakeProg = fastShakeProg(2)
|
self.fx.shakeProg = fastShakeProg(2)
|
||||||
self.waitFrames = 18
|
self.waitFrames = Timing.SHAKE_HORIZ_LIGHT
|
||||||
elseif t == 6 then
|
elseif t == 6 then
|
||||||
self.fx.shakeProg = slowShakeProg(3, 2)
|
self.fx.shakeProg = slowShakeProg(3, 2)
|
||||||
self.waitFrames = 24
|
self.waitFrames = Timing.SHAKE_HORIZ_SLOW2
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -3305,10 +3465,13 @@ function BattleState:onFaint(battler)
|
|||||||
Sound.playCry(self.data, battler.mon.species)
|
Sound.playCry(self.data, battler.mon.species)
|
||||||
Sound.play(self.data, "Faint_Fall")
|
Sound.play(self.data, "Faint_Fall")
|
||||||
self.fx = self.fx or {}
|
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)
|
end)
|
||||||
self.nextInsert = (self.nextInsert or 0) + 1
|
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
|
if not battler.isPlayer and self.kind == "wild" then
|
||||||
-- FaintEnemyPokemon .wild_win (core.asm:792-795): beating a wild
|
-- FaintEnemyPokemon .wild_win (core.asm:792-795): beating a wild
|
||||||
-- mon calls EndLowHealthAlarm and starts MUSIC_DEFEATED_WILD_MON
|
-- mon calls EndLowHealthAlarm and starts MUSIC_DEFEATED_WILD_MON
|
||||||
@@ -4323,10 +4486,14 @@ function BattleState:growInScale(battler)
|
|||||||
end
|
end
|
||||||
|
|
||||||
-- battler hidden this frame? (damage blink)
|
-- 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)
|
function BattleState:fxHidden(battler)
|
||||||
local fx = self.fx
|
local fx = self.fx
|
||||||
if fx and fx.blink and fx.blink.target == battler and fx.blink.frames > 0 then
|
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
|
end
|
||||||
return false
|
return false
|
||||||
end
|
end
|
||||||
@@ -5230,7 +5397,8 @@ function BattleState:drawClassic()
|
|||||||
if sx == 0 and sy == 0 and fx and fx.shake and fx.shake > 0 then
|
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
|
sx = self.frame % 4 < 2 and 2 or -2
|
||||||
end
|
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
|
if self:colorMode() then
|
||||||
-- SGB pipeline: gray BG canvas -> (wavy) -> zone recolor with the
|
-- 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 Runtime = require("src.mods.Runtime")
|
||||||
local StatusRegistry = require("src.battle.StatusRegistry")
|
local StatusRegistry = require("src.battle.StatusRegistry")
|
||||||
local Strings = require("src.core.Strings")
|
local Strings = require("src.core.Strings")
|
||||||
|
local Timing = require("src.core.Timing")
|
||||||
|
|
||||||
local EffectRegistry = {}
|
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
|
-- pokered's <USER>/<TARGET> text macros print "Enemy " before the enemy
|
||||||
-- mon's nickname (home/text.asm PlaceMoveUsersName)
|
-- mon's nickname (home/text.asm PlaceMoveUsersName)
|
||||||
local function displayName(b)
|
local function displayName(b)
|
||||||
@@ -92,6 +105,7 @@ function EffectRegistry.runDamaging(battle, ctx, record)
|
|||||||
if target.invulnerable and not neverMiss then
|
if target.invulnerable and not neverMiss then
|
||||||
-- Explosion/Selfdestruct still animate on a miss (HandleIfPlayerMoveMissed)
|
-- Explosion/Selfdestruct still animate on a miss (HandleIfPlayerMoveMissed)
|
||||||
if not (record and record.explode) then battle:cancelMoveAnim() end
|
if not (record and record.explode) then battle:cancelMoveAnim() end
|
||||||
|
missBeat(battle, record)
|
||||||
battle:sayNext(Strings("%s's\nattack missed!", displayName(user)))
|
battle:sayNext(Strings("%s's\nattack missed!", displayName(user)))
|
||||||
-- MoveHitTest's INVULNERABLE branch sets the same wMoveMissed as a
|
-- MoveHitTest's INVULNERABLE branch sets the same wMoveMissed as a
|
||||||
-- failed accuracy roll (core.asm:5260), and the miss handler still
|
-- 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
|
if not battle:accuracyRoll(move, user, target) then
|
||||||
-- Explosion/Selfdestruct still animate on a miss (HandleIfPlayerMoveMissed)
|
-- Explosion/Selfdestruct still animate on a miss (HandleIfPlayerMoveMissed)
|
||||||
if not (record and record.explode) then battle:cancelMoveAnim() end
|
if not (record and record.explode) then battle:cancelMoveAnim() end
|
||||||
|
missBeat(battle, record)
|
||||||
battle:sayNext(Strings("%s's\nattack missed!", displayName(user)))
|
battle:sayNext(Strings("%s's\nattack missed!", displayName(user)))
|
||||||
-- Jump Kick crash, Explode self-destruct
|
-- Jump Kick crash, Explode self-destruct
|
||||||
if record and record.onMiss then record.onMiss(ctx, "accuracy") end
|
if record and record.onMiss then record.onMiss(ctx, "accuracy") end
|
||||||
@@ -147,6 +162,7 @@ function EffectRegistry.runDamaging(battle, ctx, record)
|
|||||||
end
|
end
|
||||||
if not counterable or (battle.lastDamage or 0) == 0 then
|
if not counterable or (battle.lastDamage or 0) == 0 then
|
||||||
battle:cancelMoveAnim()
|
battle:cancelMoveAnim()
|
||||||
|
missBeat(battle, record)
|
||||||
battle:sayNext(Strings("%s's\nattack missed!", displayName(user)))
|
battle:sayNext(Strings("%s's\nattack missed!", displayName(user)))
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
@@ -170,6 +186,7 @@ function EffectRegistry.runDamaging(battle, ctx, record)
|
|||||||
if info.typeMult == 0 then
|
if info.typeMult == 0 then
|
||||||
-- type immunity zeros damage and sets wMoveMissed in Gen 1, so no anim
|
-- type immunity zeros damage and sets wMoveMissed in Gen 1, so no anim
|
||||||
if not (record and record.explode) then battle:cancelMoveAnim() end
|
if not (record and record.explode) then battle:cancelMoveAnim() end
|
||||||
|
missBeat(battle, record)
|
||||||
battle:sayNext(Strings("It doesn't affect\n%s!", displayName(target)))
|
battle:sayNext(Strings("It doesn't affect\n%s!", displayName(target)))
|
||||||
if record and record.onMiss then record.onMiss(ctx, "immune") end
|
if record and record.onMiss then record.onMiss(ctx, "immune") end
|
||||||
return
|
return
|
||||||
@@ -177,6 +194,7 @@ function EffectRegistry.runDamaging(battle, ctx, record)
|
|||||||
if info.missed then
|
if info.missed then
|
||||||
-- 0.25x floored the damage to zero: the original registers a miss
|
-- 0.25x floored the damage to zero: the original registers a miss
|
||||||
if not (record and record.explode) then battle:cancelMoveAnim() end
|
if not (record and record.explode) then battle:cancelMoveAnim() end
|
||||||
|
missBeat(battle, record)
|
||||||
battle:sayNext(Strings("%s's\nattack missed!", displayName(user)))
|
battle:sayNext(Strings("%s's\nattack missed!", displayName(user)))
|
||||||
if record and record.onMiss then record.onMiss(ctx, "floored") end
|
if record and record.onMiss then record.onMiss(ctx, "floored") end
|
||||||
return
|
return
|
||||||
@@ -231,6 +249,13 @@ function EffectRegistry.runDamaging(battle, ctx, record)
|
|||||||
-- every strike -- damage was only rolled once
|
-- every strike -- damage was only rolled once
|
||||||
if info.crit then battle:sayNext(Strings("Critical hit!")) end
|
if info.crit then battle:sayNext(Strings("Critical hit!")) end
|
||||||
if info.ohko then battle:sayNext(Strings("One-hit KO!")) 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
|
if info.typeMult > 10 then
|
||||||
battle:sayNext(Strings("It's super\neffective!"))
|
battle:sayNext(Strings("It's super\neffective!"))
|
||||||
elseif info.typeMult < 10 then
|
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
|
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
|
sx = battle.frame % 4 < 2 and 2 or -2
|
||||||
end
|
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
|
-- Each side keeps its original sprite pixels and placement math: the two
|
||||||
-- 160x144 OAM regions are translated apart and clipped into the wider
|
-- 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
|
||||||
@@ -265,6 +265,38 @@ end
|
|||||||
-- exactly as the owning state computed it
|
-- exactly as the owning state computed it
|
||||||
local function sameZones(_, zones) return zones end
|
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,
|
-- 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,
|
-- bag, choice and text states it opens still draw their original 160px UI,
|
||||||
-- but the canvas must not snap to 160px between those states.
|
-- but the canvas must not snap to 160px between those states.
|
||||||
@@ -319,6 +351,14 @@ function Game:draw()
|
|||||||
else
|
else
|
||||||
Renderer:setUISize(Renderer.WIDTH, Renderer.HEIGHT)
|
Renderer:setUISize(Renderer.WIDTH, Renderer.HEIGHT)
|
||||||
end
|
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)
|
Renderer:beginFrame(worldBelow)
|
||||||
for i = self.stack:visibleBase(), #self.stack.states do
|
for i = self.stack:visibleBase(), #self.stack.states do
|
||||||
local state = self.stack.states[i]
|
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
|
-- returns true when a persisted GBC FX level was cleared on mobile
|
||||||
local gbcCleared = require("src.render.GBCFX").applyOptions(opts)
|
local gbcCleared = require("src.render.GBCFX").applyOptions(opts)
|
||||||
require("src.core.VideoMode").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
|
-- normalizes a nil/garbage cap to the 60 default, so old saves with no
|
||||||
-- fpsCap key pace at the standard rate (issue #88)
|
-- fpsCap key pace at the standard rate (issue #88)
|
||||||
require("src.core.FrameCap").applyOptions(opts)
|
require("src.core.FrameCap").applyOptions(opts)
|
||||||
|
|||||||
@@ -219,6 +219,15 @@ function SaveData.defaultOptions()
|
|||||||
-- battle screen composition: og (the 160x144 original) | wide
|
-- battle screen composition: og (the 160x144 original) | wide
|
||||||
-- (304x144, src/battle/WideBattle.lua)
|
-- (304x144, src/battle/WideBattle.lua)
|
||||||
battleLayout = "og",
|
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",
|
ruleset = "gen1_faithful",
|
||||||
-- 0-7 like the GB's NR50 master volume
|
-- 0-7 like the GB's NR50 master volume
|
||||||
musicVol = 7,
|
musicVol = 7,
|
||||||
@@ -239,6 +248,9 @@ function SaveData.defaultOptions()
|
|||||||
voidFill = "trees",
|
voidFill = "trees",
|
||||||
-- windowed | borderless (desktop fullscreen); ignored on mobile
|
-- windowed | borderless (desktop fullscreen); ignored on mobile
|
||||||
videoMode = "windowed",
|
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)
|
-- hard render frame-rate cap; render-only pacing (issue #88, FrameCap.lua)
|
||||||
fpsCap = 60,
|
fpsCap = 60,
|
||||||
-- graphics performance tier: auto | high | balanced | low. "auto"
|
-- 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
|
-- InitBattleCommon decompresses the front pic (core.asm:6694-6730), and
|
||||||
-- SlidePlayerAndEnemySilhouettesOnScreen rebuilds the whole tilemap between
|
-- SlidePlayerAndEnemySilhouettesOnScreen rebuilds the whole tilemap between
|
||||||
-- DisableLCD and EnableLCD (core.asm:9-49) before anything moves. This port
|
-- 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
|
-- has no load to hide behind, so the hold has to be explicit (#315). This is
|
||||||
-- frame budget for that work, not a number pokered states; retune this one
|
-- a frame budget for that work, not a number pokered states.
|
||||||
-- constant against a reference recording if it reads long or short.
|
--
|
||||||
local BLACK_HOLD = 30
|
-- 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 TILE = 8
|
||||||
local COLS, ROWS = 160 / TILE, 144 / TILE -- 20 x 18 tiles
|
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)).
|
-- HalfCircle2 continues (1,11) down under the bottom back to (18,11)).
|
||||||
-- arms = 1 (Circle, halves in sequence) or 2 (DoubleCircle, both halves
|
-- arms = 1 (Circle, halves in sequence) or 2 (DoubleCircle, both halves
|
||||||
-- at once, so opposite arms)
|
-- at once, so opposite arms)
|
||||||
local function sweepOrder(arms)
|
local function sweepOrder(arms, cols, rows)
|
||||||
local cx, cy = COLS / 2, ROWS / 2
|
cols, rows = cols or COLS, rows or ROWS
|
||||||
|
local cx, cy = cols / 2, rows / 2
|
||||||
local tiles = {}
|
local tiles = {}
|
||||||
for y = 0, ROWS - 1 do
|
for y = 0, rows - 1 do
|
||||||
for x = 0, COLS - 1 do
|
for x = 0, cols - 1 do
|
||||||
local a = math.atan2(cy - (y + 0.5), x + 0.5 - cx)
|
local a = math.atan2(cy - (y + 0.5), x + 0.5 - cx)
|
||||||
if a < 0 then a = a + 2 * math.pi end
|
if a < 0 then a = a + 2 * math.pi end
|
||||||
if arms == 2 then a = a % math.pi end
|
if arms == 2 then a = a % math.pi end
|
||||||
@@ -121,18 +135,116 @@ local function sweepOrder(arms)
|
|||||||
return tiles
|
return tiles
|
||||||
end
|
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
|
-- The eight wipes as records: frames is the wipe length, flash marks the
|
||||||
-- two circle wipes that call BattleTransition_FlashScreen first. new()
|
-- two circle wipes that call BattleTransition_FlashScreen first. new()
|
||||||
-- reads them, and the transitions registry serves the same table.
|
-- reads them, and the transitions registry serves the same table.
|
||||||
BattleTransition.STYLES = {
|
BattleTransition.STYLES = {
|
||||||
doublecircle = { kind = "wipe", frames = 40, flash = true },
|
doublecircle = { kind = "wipe", frames = 30, flash = true },
|
||||||
spiralin = { kind = "wipe", frames = 40 },
|
spiralin = { kind = "wipe", frames = SPIRAL_IN_FRAMES },
|
||||||
circle = { kind = "wipe", frames = 40, flash = true },
|
circle = { kind = "wipe", frames = 60, flash = true },
|
||||||
spiralout = { kind = "wipe", frames = 40 },
|
spiralout = { kind = "wipe", frames = 120 },
|
||||||
hstripes = { kind = "wipe", frames = 24 },
|
hstripes = { kind = "wipe", frames = 60 },
|
||||||
shrink = { kind = "wipe", frames = 24 },
|
shrink = { kind = "wipe", frames = 54 },
|
||||||
vstripes = { kind = "wipe", frames = 24 },
|
vstripes = { kind = "wipe", frames = 54 },
|
||||||
split = { kind = "wipe", frames = 24 },
|
split = { kind = "wipe", frames = 54 },
|
||||||
}
|
}
|
||||||
|
|
||||||
-- the eight wipes plus Transition's two warp fades: one registrant owns
|
-- 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
|
-- 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
|
-- function returning one); the four built-in orders are the defaults for
|
||||||
-- the styles that have always had them.
|
-- the styles that have always had them.
|
||||||
local function orderFor(style, def)
|
function orderFor(style, def)
|
||||||
if ORDERS[style] == nil then
|
if ORDERS[style] == nil then
|
||||||
local order = def and def.order
|
local order = def and def.order
|
||||||
if type(order) == "function" then
|
if type(order) == "function" then
|
||||||
@@ -235,6 +347,15 @@ function BattleTransition:draw()
|
|||||||
local v = FLASH_STEPS[step]
|
local v = FLASH_STEPS[step]
|
||||||
if v ~= 0 then
|
if v ~= 0 then
|
||||||
local shade = v > 0 and 0 or 1
|
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.setColor(shade, shade, shade, math.abs(v))
|
||||||
love.graphics.rectangle("fill", 0, 0, 160, 144)
|
love.graphics.rectangle("fill", 0, 0, 160, 144)
|
||||||
love.graphics.setColor(1, 1, 1, 1)
|
love.graphics.setColor(1, 1, 1, 1)
|
||||||
@@ -242,22 +363,35 @@ function BattleTransition:draw()
|
|||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
love.graphics.setColor(0, 0, 0, 1)
|
|
||||||
local prog = math.min(1, self.t / self.wipeLen)
|
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
|
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
|
if self.def and self.def.draw then
|
||||||
|
love.graphics.setColor(0, 0, 0, 1)
|
||||||
self.def.draw(self, prog)
|
self.def.draw(self, prog)
|
||||||
love.graphics.setColor(1, 1, 1, 1)
|
love.graphics.setColor(1, 1, 1, 1)
|
||||||
return
|
return
|
||||||
end
|
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)
|
local order = orderFor(style, self.def)
|
||||||
if order then
|
if order then
|
||||||
-- tile-order wipes: spiral / circle sweeps
|
-- 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)))
|
return math.max(1, math.floor(math.min(pw / w, ph / h)))
|
||||||
end
|
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
|
-- the native-pixel UI surface in use right now
|
||||||
function Renderer:uiSize()
|
function Renderer:uiSize()
|
||||||
return self.uiWidth or self.WIDTH, self.uiHeight or self.HEIGHT
|
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
|
-- warp-fade overlay from Transition (issue #121); cleared each frame so
|
||||||
-- a popped transition cannot leave a sticky black veil
|
-- a popped transition cannot leave a sticky black veil
|
||||||
self.worldFadeAlpha = nil
|
self.worldFadeAlpha = nil
|
||||||
-- battle-transition cascade outside the 160x144 wipe (BattleTransition)
|
-- battle-transition wipe, drawn over the whole surface (BattleTransition)
|
||||||
self.battleCascadeProg = nil
|
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
|
-- last frame's trueColor rects and sprite redraws go before anything
|
||||||
-- draws this one
|
-- draws this one
|
||||||
PaletteFX.clearTrueColor()
|
PaletteFX.clearTrueColor()
|
||||||
@@ -218,44 +253,94 @@ function Renderer:beginFrame(transparent)
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Black 8x8 (scaled) blocks cascading outward from the classic GB letterbox
|
-- The battle-transition wipe, drawn once over the WHOLE surface in screen
|
||||||
-- into the surrounding window, matching BattleTransition wipe progress.
|
-- space rather than as a 160x144 wipe plus a separate fill around it.
|
||||||
-- Tiles that sit entirely inside the 160x144 square are left to the OG wipe.
|
--
|
||||||
|
-- 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).
|
-- 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)
|
function Renderer:drawBattleWipe(wipe, ww, wh, ox, oy, vpw, vph, Sx, Sy)
|
||||||
if not prog or prog <= 0 then return end
|
if not wipe or not wipe.prog or wipe.prog <= 0 then return end
|
||||||
Sy = Sy or Sx
|
Sy = Sy or Sx
|
||||||
local TILE_W, TILE_H = 8 * Sx, 8 * Sy
|
local TW, TH = 8 * Sx, 8 * Sy
|
||||||
if TILE_W < 1 then TILE_W = 1 end
|
if TW < 1 then TW = 1 end
|
||||||
if TILE_H < 1 then TILE_H = 1 end
|
if TH < 1 then TH = 1 end
|
||||||
local cols = math.ceil(ww / TILE_W)
|
local prog = math.min(1, wipe.prog)
|
||||||
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
|
|
||||||
love.graphics.setColor(0, 0, 0, 1)
|
love.graphics.setColor(0, 0, 0, 1)
|
||||||
love.graphics.setScissor(0, 0, ww, wh)
|
love.graphics.setScissor(0, 0, ww, wh)
|
||||||
for i = 1, n do
|
|
||||||
local t = order[i]
|
if prog >= 1 then
|
||||||
love.graphics.rectangle("fill", t[1], t[2], TILE_W, TILE_H)
|
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
|
end
|
||||||
love.graphics.setScissor()
|
love.graphics.setScissor()
|
||||||
love.graphics.setColor(1, 1, 1, 1)
|
love.graphics.setColor(1, 1, 1, 1)
|
||||||
@@ -509,6 +594,38 @@ function Renderer:blitCanvas(canvas, sx, sy, zoneList, zoneSx, zoneSy,
|
|||||||
love.graphics.setShader()
|
love.graphics.setShader()
|
||||||
end
|
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
|
-- zones: optional list of SGB palette regions (see PaletteFX) in
|
||||||
-- 160x144 UI space, applied to the UI pass. worldZones: optional
|
-- 160x144 UI space, applied to the UI pass. worldZones: optional
|
||||||
-- regions in world-canvas pixels (overworld survey zoom colors each
|
-- 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.
|
-- Snap the letterbox origin to a framebuffer pixel, then convert to units.
|
||||||
local ox = math.floor((pw - uiw * Sp) / 2) / dpiX
|
local ox = math.floor((pw - uiw * Sp) / 2) / dpiX
|
||||||
local oy = math.floor((ph - uih * Sp) / 2) / dpiY
|
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")
|
local GBCFX = require("src.render.GBCFX")
|
||||||
-- Forced mono/Classic modes still need a whole-screen zone when a state
|
-- Forced mono/Classic modes still need a whole-screen zone when a state
|
||||||
-- exposes no SGB packets (raw DMG canvas), so sendColors can remap.
|
-- 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 stack = ok and Game and Game.stack
|
||||||
local base = stack and stack.visibleBase and stack:visibleBase()
|
local base = stack and stack.visibleBase and stack:visibleBase()
|
||||||
local state = base and stack.states and stack.states[base]
|
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)
|
clearR, clearG, clearB = PaletteFX.paperShade(Game and Game.data)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -655,9 +794,6 @@ function Renderer:endFrame(zones, worldZones)
|
|||||||
love.graphics.rectangle("fill", 0, 0, ww, wh)
|
love.graphics.rectangle("fill", 0, 0, ww, wh)
|
||||||
love.graphics.setColor(1, 1, 1, 1)
|
love.graphics.setColor(1, 1, 1, 1)
|
||||||
end
|
end
|
||||||
if self.battleCascadeProg then
|
|
||||||
self:drawBattleCascade(self.battleCascadeProg, ww, wh, ox, oy, vpw, vph, Sx, Sy)
|
|
||||||
end
|
|
||||||
elseif self.worldActive then
|
elseif self.worldActive then
|
||||||
local sp = Zoom.scale(Sp)
|
local sp = Zoom.scale(Sp)
|
||||||
local sx, sy = sp / dpiX, sp / dpiY
|
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.rectangle("fill", 0, 0, ww, wh)
|
||||||
love.graphics.setColor(1, 1, 1, 1)
|
love.graphics.setColor(1, 1, 1, 1)
|
||||||
end
|
end
|
||||||
-- Battle transition: cascade black blocks into the area outside the
|
end
|
||||||
-- classic 160x144 wipe square (world still shows through until filled).
|
-- BATTLE BG "world": the frozen overworld has just been composited and the
|
||||||
if self.battleCascadeProg then
|
-- battle is about to blit over it. Dim the world first, so the battle
|
||||||
self:drawBattleCascade(self.battleCascadeProg, ww, wh, ox, oy, vpw, vph, Sx, Sy)
|
-- 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
|
||||||
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
|
if present then
|
||||||
love.graphics.setCanvas()
|
love.graphics.setCanvas()
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
|
|
||||||
local Font = require("src.render.Font")
|
local Font = require("src.render.Font")
|
||||||
local Theme = require("src.ui.Theme")
|
local Theme = require("src.ui.Theme")
|
||||||
|
local Timing = require("src.core.Timing")
|
||||||
|
|
||||||
local TextBox = {}
|
local TextBox = {}
|
||||||
TextBox.__index = TextBox
|
TextBox.__index = TextBox
|
||||||
@@ -177,6 +178,13 @@ end
|
|||||||
function TextBox:update(dt)
|
function TextBox:update(dt)
|
||||||
local input = self.game.input
|
local input = self.game.input
|
||||||
self.blink = (self.blink + 1) % 60
|
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.done then
|
||||||
if self.auto then
|
if self.auto then
|
||||||
if not self.autoStarted then
|
if not self.autoStarted then
|
||||||
@@ -244,6 +252,13 @@ function TextBox:update(dt)
|
|||||||
return
|
return
|
||||||
end
|
end
|
||||||
if self.waiting then
|
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
|
if input:wasPressed("a") or input:wasPressed("b") then
|
||||||
require("src.core.Sound").play(self.game.data, "Press_AB")
|
require("src.core.Sound").play(self.game.data, "Press_AB")
|
||||||
self.waiting = false
|
self.waiting = false
|
||||||
@@ -252,11 +267,17 @@ function TextBox:update(dt)
|
|||||||
self.contAdvance = false
|
self.contAdvance = false
|
||||||
self.lineIndex = self.lineIndex + 1
|
self.lineIndex = self.lineIndex + 1
|
||||||
self:beginLine()
|
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
|
else
|
||||||
self.shown = {}
|
self.shown = {}
|
||||||
self.pageIndex = self.pageIndex + 1
|
self.pageIndex = self.pageIndex + 1
|
||||||
self.lineIndex = 1
|
self.lineIndex = 1
|
||||||
self:beginLine()
|
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
|
||||||
end
|
end
|
||||||
return
|
return
|
||||||
@@ -283,6 +304,7 @@ function TextBox:update(dt)
|
|||||||
if conts and conts[nextIdx] then
|
if conts and conts[nextIdx] then
|
||||||
-- pokered <CONT>: ▼ + WaitForTextScrollButtonPress before scroll
|
-- pokered <CONT>: ▼ + WaitForTextScrollButtonPress before scroll
|
||||||
self.waiting = true
|
self.waiting = true
|
||||||
|
self.preWait = Timing.TEXT_PRE_ADVANCE
|
||||||
self.contAdvance = true
|
self.contAdvance = true
|
||||||
else
|
else
|
||||||
self.lineIndex = nextIdx
|
self.lineIndex = nextIdx
|
||||||
@@ -290,6 +312,7 @@ function TextBox:update(dt)
|
|||||||
end
|
end
|
||||||
elseif self.pageIndex < #self.pages then
|
elseif self.pageIndex < #self.pages then
|
||||||
self.waiting = true
|
self.waiting = true
|
||||||
|
self.preWait = Timing.TEXT_PRE_ADVANCE
|
||||||
self.contAdvance = false
|
self.contAdvance = false
|
||||||
else
|
else
|
||||||
self.done = true
|
self.done = true
|
||||||
@@ -300,6 +323,15 @@ function TextBox:update(dt)
|
|||||||
end
|
end
|
||||||
|
|
||||||
function TextBox:draw()
|
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)
|
Font.drawBox(self.boxTx, self.boxTy, self.boxTw, self.boxTh)
|
||||||
love.graphics.setColor(0, 0, 0, 1)
|
love.graphics.setColor(0, 0, 0, 1)
|
||||||
if self.scrollPx and self.scrollPx > 0 then
|
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.
|
-- Screen fade used for warps: fade out, run a callback (map switch), fade in.
|
||||||
-- Pushed on the state stack above the overworld.
|
-- Pushed on the state stack above the overworld.
|
||||||
|
|
||||||
|
local Timing = require("src.core.Timing")
|
||||||
|
|
||||||
local Transition = {}
|
local Transition = {}
|
||||||
Transition.__index = 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
|
local FLASH_FRAMES = 7
|
||||||
|
|
||||||
-- The two fades as transitions records, so a mod retimes a warp fade the
|
-- The two fades as transitions records, so a mod retimes a warp fade the
|
||||||
-- same way it retimes a battle wipe. BattleTransition.registerInto pulls
|
-- same way it retimes a battle wipe. BattleTransition.registerInto pulls
|
||||||
-- these in with its eight wipes -- one registrant owns the registry.
|
-- these in with its eight wipes -- one registrant owns the registry.
|
||||||
Transition.STYLES = {
|
Transition.STYLES = {
|
||||||
warp_fade = { kind = "fade", frames = FRAMES },
|
warp_fade = { kind = "fade", frames = FRAMES, framesIn = FRAMES_IN },
|
||||||
white_flash = { kind = "fade", frames = FLASH_FRAMES },
|
white_flash = { kind = "fade", frames = FLASH_FRAMES },
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,26 +46,39 @@ function Transition.new(game, onMidpoint, onDone)
|
|||||||
self.onDone = onDone
|
self.onDone = onDone
|
||||||
self.t = 0
|
self.t = 0
|
||||||
self.phase = "out"
|
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
|
return self
|
||||||
end
|
end
|
||||||
|
|
||||||
|
function Transition:finish()
|
||||||
|
self.game.stack:pop()
|
||||||
|
if self.onDone then self.onDone() end
|
||||||
|
end
|
||||||
|
|
||||||
function Transition:update(dt)
|
function Transition:update(dt)
|
||||||
self.t = self.t + 1
|
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
|
self.t = 0
|
||||||
if self.phase == "out" then
|
if self.phase == "out" then
|
||||||
self.phase = "in"
|
self.phase = "in"
|
||||||
if self.onMidpoint then self.onMidpoint() end
|
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
|
else
|
||||||
self.game.stack:pop()
|
self:finish()
|
||||||
if self.onDone then self.onDone() end
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
function Transition:draw()
|
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
|
if self.phase == "in" then alpha = 1 - alpha end
|
||||||
-- Survey zoom draws the overworld into a window-filling world canvas
|
-- Survey zoom draws the overworld into a window-filling world canvas
|
||||||
-- while the UI pass stays the classic 160x144 letterbox. A rect on the
|
-- 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)
|
love.graphics.rectangle("fill", 0, 0, 160, 144)
|
||||||
end
|
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
|
return Transition
|
||||||
|
|||||||
+27
-4
@@ -3,6 +3,7 @@
|
|||||||
local Font = require("src.render.Font")
|
local Font = require("src.render.Font")
|
||||||
local Theme = require("src.ui.Theme")
|
local Theme = require("src.ui.Theme")
|
||||||
local Strings = require("src.core.Strings")
|
local Strings = require("src.core.Strings")
|
||||||
|
local Timing = require("src.core.Timing")
|
||||||
|
|
||||||
local ChoiceBox = {}
|
local ChoiceBox = {}
|
||||||
ChoiceBox.__index = ChoiceBox
|
ChoiceBox.__index = ChoiceBox
|
||||||
@@ -25,6 +26,19 @@ end
|
|||||||
|
|
||||||
function ChoiceBox:update(dt)
|
function ChoiceBox:update(dt)
|
||||||
local input = self.game.input
|
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
|
if input:wasPressed("up") or input:wasPressed("down") then
|
||||||
self.index = self.index == 1 and 2 or 1
|
self.index = self.index == 1 and 2 or 1
|
||||||
elseif input:wasPressed("a") then
|
elseif input:wasPressed("a") then
|
||||||
@@ -32,19 +46,28 @@ function ChoiceBox:update(dt)
|
|||||||
if not self.noSound then
|
if not self.noSound then
|
||||||
require("src.core.Sound").play(self.game.data, "Press_AB")
|
require("src.core.Sound").play(self.game.data, "Press_AB")
|
||||||
end
|
end
|
||||||
self.game.stack:pop()
|
self.pending = (self.index == 1)
|
||||||
self.onChoose(self.index == 1)
|
self.holdFrames = Timing.YES_NO_ANSWER
|
||||||
elseif input:wasPressed("b") then
|
elseif input:wasPressed("b") then
|
||||||
if not self.noSound then
|
if not self.noSound then
|
||||||
require("src.core.Sound").play(self.game.data, "Press_AB")
|
require("src.core.Sound").play(self.game.data, "Press_AB")
|
||||||
end
|
end
|
||||||
self.game.stack:pop()
|
-- .choseSecondMenuItem writes wCurrentMenuItem = 1 before the hold, so
|
||||||
self.onChoose(false)
|
-- the cursor visibly snaps to NO for those 15 frames
|
||||||
|
self.index = 2
|
||||||
|
self.pending = false
|
||||||
|
self.holdFrames = Timing.YES_NO_ANSWER
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
function ChoiceBox:draw()
|
function ChoiceBox:draw()
|
||||||
local tx, ty, tw, th = self.tx, self.ty, self.tw, self.th
|
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)
|
Font.drawBox(tx, ty, tw, th)
|
||||||
love.graphics.setColor(0, 0, 0, 1)
|
love.graphics.setColor(0, 0, 0, 1)
|
||||||
Font.draw(Strings("YES"), (tx + 2) * 8, (ty + 1) * 8)
|
Font.draw(Strings("YES"), (tx + 2) * 8, (ty + 1) * 8)
|
||||||
|
|||||||
@@ -33,6 +33,10 @@ local IntroMovie = {}
|
|||||||
IntroMovie.__index = IntroMovie
|
IntroMovie.__index = IntroMovie
|
||||||
IntroMovie.isOpaque = true
|
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
|
-- SGB intro palettes: the splash uses PalPacket_GameFreakIntro (logo
|
||||||
-- GAMEFREAK, falling star columns RED/VIRIDIAN/BLUEMON), the attract
|
-- GAMEFREAK, falling star columns RED/VIRIDIAN/BLUEMON), the attract
|
||||||
-- fight PalPacket_NidorinoIntro (PURPLEMON letterbox, BLACK bars)
|
-- 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
|
-- only menus whose real mask includes PAD_START -- the start menu
|
||||||
-- (engine/menus/draw_start_menu.asm) -- opt in here.
|
-- (engine/menus/draw_start_menu.asm) -- opt in here.
|
||||||
self.startCloses = opts.startCloses or false
|
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
|
self.onCancel = opts.onCancel
|
||||||
-- BIT_NO_MENU_BUTTON_SOUND (wMiscFlags): the PC session runs its
|
-- BIT_NO_MENU_BUTTON_SOUND (wMiscFlags): the PC session runs its
|
||||||
-- menus silent (home/window.asm HandleMenuInput_)
|
-- menus silent (home/window.asm HandleMenuInput_)
|
||||||
@@ -104,6 +107,14 @@ function Menu:update(dt)
|
|||||||
end
|
end
|
||||||
|
|
||||||
function Menu:draw()
|
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)
|
Font.drawBox(self.tx, self.ty, self.tw, self.th)
|
||||||
love.graphics.setColor(0, 0, 0, 1)
|
love.graphics.setColor(0, 0, 0, 1)
|
||||||
local visible = (self.maxVisible and math.min(self.maxVisible, #self.items))
|
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 GameSpeed = require("src.core.GameSpeed")
|
||||||
local GameVersion = require("src.core.GameVersion")
|
local GameVersion = require("src.core.GameVersion")
|
||||||
local VideoMode = require("src.core.VideoMode")
|
local VideoMode = require("src.core.VideoMode")
|
||||||
|
local FaithfulRes = require("src.core.FaithfulRes")
|
||||||
local FrameCap = require("src.core.FrameCap")
|
local FrameCap = require("src.core.FrameCap")
|
||||||
local Performance = require("src.core.Performance")
|
local Performance = require("src.core.Performance")
|
||||||
local Logger = require("src.core.Logger")
|
local Logger = require("src.core.Logger")
|
||||||
@@ -156,6 +157,40 @@ local function buildRows(game)
|
|||||||
o.battleLayout = o.battleLayout == "wide" and "og" or "wide"
|
o.battleLayout = o.battleLayout == "wide" and "og" or "wide"
|
||||||
return true
|
return true
|
||||||
end },
|
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"),
|
{ id = "ruleset", label = Strings("RULESET"),
|
||||||
value = function(g) return rulesetName(g) end,
|
value = function(g) return rulesetName(g) end,
|
||||||
step = function(g, dir)
|
step = function(g, dir)
|
||||||
@@ -298,6 +333,20 @@ local function buildRows(game)
|
|||||||
VideoMode.apply(o.videoMode)
|
VideoMode.apply(o.videoMode)
|
||||||
return true
|
return true
|
||||||
end },
|
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
|
-- hard render cap (issue #88): bounds the present rate so a
|
||||||
-- driver-forced vsync-off run cannot spin at thousands of FPS. Logic
|
-- driver-forced vsync-off run cannot spin at thousands of FPS. Logic
|
||||||
-- is fixed-step off dt, so this touches presentation only.
|
-- is fixed-step off dt, so this touches presentation only.
|
||||||
|
|||||||
@@ -133,7 +133,12 @@ function StartMenu.new(game)
|
|||||||
local rowStep = 2
|
local rowStep = 2
|
||||||
local maxVisible = math.floor((Renderer.HEIGHT / 8 - 2) / rowStep)
|
local maxVisible = math.floor((Renderer.HEIGHT / 8 - 2) / rowStep)
|
||||||
local menu = Menu.new(game, items,
|
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
|
-- the cursor position survives closing the menu
|
||||||
-- (wBattleAndStartSavedMenuItem, home/start_menu.asm)
|
-- (wBattleAndStartSavedMenuItem, home/start_menu.asm)
|
||||||
menu.index = math.min(game.save.startMenuIndex or 1, #items)
|
menu.index = math.min(game.save.startMenuIndex or 1, #items)
|
||||||
|
|||||||
@@ -14,6 +14,12 @@ local TitleState = {}
|
|||||||
TitleState.__index = TitleState
|
TitleState.__index = TitleState
|
||||||
TitleState.isOpaque = true
|
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,
|
-- SGB title zones (PalPacket_Titlescreen): the logo rows get LOGO2,
|
||||||
-- the version-ribbon band LOGO1, the rest MEWMON.
|
-- the version-ribbon band LOGO1, the rest MEWMON.
|
||||||
--
|
--
|
||||||
|
|||||||
@@ -717,6 +717,33 @@ function OverworldState:pushBattle(battle)
|
|||||||
if battle.computeMusicKind then
|
if battle.computeMusicKind then
|
||||||
require("src.core.Music").playBattle(Game.data, battle:computeMusicKind())
|
require("src.core.Music").playBattle(Game.data, battle:computeMusicKind())
|
||||||
end
|
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, behind the
|
||||||
|
-- `ld c, 10 / call DelayFrames` at :351-352.
|
||||||
|
--
|
||||||
|
-- It is wrapped around onFinish here, at the one funnel every battle goes
|
||||||
|
-- through, rather than inside afterBattle: a script-driven win defers
|
||||||
|
-- afterBattle into ctx.afterScript so an evolution screen cannot be buried
|
||||||
|
-- under the trainer's follow-up text (see Commands.start_battle), and the
|
||||||
|
-- fade inherited that deferral -- on a rival battle it fired after the
|
||||||
|
-- post-battle dialogue AND the walk-off, instead of 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 get to run. The overworld is frozen meanwhile --
|
||||||
|
-- StateStack updates the top state only -- so nothing moves under it.
|
||||||
|
local finish = battle.onFinish
|
||||||
|
battle.onFinish = function(result)
|
||||||
|
if result == "lose" then
|
||||||
|
-- the blackout path warps to the heal point with its own transition
|
||||||
|
if finish then finish(result) end
|
||||||
|
return
|
||||||
|
end
|
||||||
|
Game.stack:push(require("src.render.Transition").battleReturn(Game,
|
||||||
|
function() if finish then finish(result) end end))
|
||||||
|
end
|
||||||
Game.stack:push(BattleTransition.new(Game, function()
|
Game.stack:push(BattleTransition.new(Game, function()
|
||||||
Game.stack:push(battle)
|
Game.stack:push(battle)
|
||||||
end, {
|
end, {
|
||||||
|
|||||||
@@ -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")
|
||||||
@@ -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