mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-14 23:31:27 +02:00
Compare commits
57 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| efad8b325b | |||
| 4c5f776128 | |||
| c1495e0d5e | |||
| a97168a4f0 | |||
| a50ad30176 | |||
| c1f65106de | |||
| 4250e3aa32 | |||
| cecb24bb0e | |||
| b99b497893 | |||
| e16a45d6ba | |||
| 0e29046c31 | |||
| 5cbada493a | |||
| 4c79db3d5d | |||
| c7e46b6563 | |||
| fe1714d50e | |||
| 2765e3d145 | |||
| c62f1334bc | |||
| eea0dec4a6 | |||
| 25d241f12c | |||
| 0b55ab083a | |||
| 6b9d4f2470 | |||
| b9a89c1fd2 | |||
| 8a8340ffc8 | |||
| 0af69e2df6 | |||
| 4b9a326f6b | |||
| a92761dc13 | |||
| 8a649e8d95 | |||
| 5c5c970e81 | |||
| 7b208043b9 | |||
| 26dfad0682 | |||
| 902f0d73d4 | |||
| f6ae967796 | |||
| 29afbdaec7 | |||
| 350402b179 | |||
| 0f581e2f69 | |||
| 17eee1ca0e | |||
| 7aa06942be | |||
| d3010dc060 | |||
| 53e1a535dd | |||
| e63fa70700 | |||
| 27fb6eedc6 | |||
| 5c0a7d1456 | |||
| e10f6e2032 | |||
| c61643edaa | |||
| 0b7b0b48d1 | |||
| 2ff459e2c4 | |||
| 8c877a64f0 | |||
| 8fdfd5c843 | |||
| 579c1744bd | |||
| 118c45f8f6 | |||
| beaa502ff9 | |||
| 730b7b842c | |||
| 784832ca95 | |||
| bbe894fb4f | |||
| 4c17e606fd | |||
| 73d81a72b8 | |||
| 8b0f770c57 |
@@ -40,6 +40,7 @@ body:
|
||||
- Windows
|
||||
- Linux
|
||||
- Android
|
||||
- iOS
|
||||
- Multiple platforms
|
||||
validations:
|
||||
required: true
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
name: Release
|
||||
|
||||
# Builds the macOS, Windows, and Linux desktop apps, an Android APK, and the
|
||||
# Anbernic RG34XXSP (Stock OS 64-bit MOD / PortMaster) port on the self-hosted
|
||||
# Mac runner, and publishes them as a GitHub Release.
|
||||
# Builds the macOS, Windows, and Linux desktop apps, an Android APK, an iOS
|
||||
# IPA, and the Anbernic RG34XXSP (Stock OS 64-bit MOD / PortMaster) port on
|
||||
# the self-hosted Mac runner, and publishes them as a GitHub Release.
|
||||
#
|
||||
# Versioning:
|
||||
# - First ever release is 0.1.0.
|
||||
@@ -170,6 +170,15 @@ jobs:
|
||||
set -euo pipefail
|
||||
scripts/build_android.sh --version "${{ steps.ver.outputs.version }}"
|
||||
|
||||
- name: Build iOS
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Device Release IPA; signs with the Apple Development identity on
|
||||
# the runner (auto team detection). Users on other Apple IDs still
|
||||
# re-sign or build via docs/ios-install.md.
|
||||
scripts/build_ios.sh --fetch --device --release \
|
||||
--version "${{ steps.ver.outputs.version }}"
|
||||
|
||||
- name: Build Anbernic RG34XXSP port
|
||||
run: |
|
||||
set -euo pipefail
|
||||
@@ -226,6 +235,10 @@ jobs:
|
||||
[ -n "$apk" ] || { echo "::error::no Android APK found under dist/android/debug"; exit 1; }
|
||||
cp "$apk" "$outdir/gen1recomp-${v}-android.apk"
|
||||
|
||||
ipa="dist/ios/gen1recomp.ipa"
|
||||
[ -f "$ipa" ] || { echo "::error::$ipa not found (expected from scripts/build_ios.sh --device)"; exit 1; }
|
||||
cp "$ipa" "$outdir/gen1recomp-${v}-ios.ipa"
|
||||
|
||||
# Anbernic handheld port (suffix names the CFW it targets, so a
|
||||
# future RG35XX/other-CFW pack can ship alongside it).
|
||||
rg34="dist/rg34xxsp/gen1recomp-rg34xxsp-stockos64-mod.zip"
|
||||
@@ -344,6 +357,7 @@ jobs:
|
||||
"dist/release/gen1recomp-${v}-windows.zip" \
|
||||
"dist/release/gen1recomp-${v}-linux.zip" \
|
||||
"dist/release/gen1recomp-${v}-android.apk" \
|
||||
"dist/release/gen1recomp-${v}-ios.ipa" \
|
||||
"dist/release/gen1recomp-${v}-rg34xxsp-stockos64-mod.zip" \
|
||||
"dist/release/gen1recomp-${v}.love" \
|
||||
"dist/release/sha256sums.txt"
|
||||
|
||||
@@ -39,3 +39,6 @@ mobile/dist/
|
||||
# Tiled map-editing workspace (tools/tiled_export.py). Derived from the imported
|
||||
# ROM cache exactly like data/generated/, so it is never committable.
|
||||
/build/tiled/
|
||||
|
||||
# per-machine iOS bundle-id pin (see scripts/build_ios.sh)
|
||||
mobile/ios/bundle_id.local
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
Copyright 2026 BOIS CLUB GAMES, LLC
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
@@ -1,31 +1,75 @@
|
||||
# Gen1Recomp
|
||||
|
||||
A native LÖVE2D recreation of Poke Red and Blue. The engine and map
|
||||
A native LÖVE2D recreation of Poke Red, Blue and Yellow. The engine and map
|
||||
behavior are hand-written Lua; game data and graphics are decoded from a ROM
|
||||
supplied by the player.
|
||||
|
||||
SUPPORT AND ANNOUNCEMENTS: [Discord](https://bois.icu)
|
||||
<p align="center"><img src="https://raw.githubusercontent.com/bryanthaboi/gen1recomp/refs/heads/dev/assets/logo/logo.png"></p>
|
||||
|
||||
**SUPPORT / ANNOUNCEMENTS / MODS:** [Discord](https://bois.icu)
|
||||
|
||||
<p align="center">
|
||||
|
||||
<a href="https://www.youtube.com/@bryanthaboi">
|
||||
<img src="https://img.shields.io/badge/YouTube-FF0000?style=for-the-badge&logo=youtube&logoColor=white" alt="YouTube">
|
||||
</a>
|
||||
<a href="https://www.tiktok.com/@bryanthaboi">
|
||||
<img src="https://img.shields.io/badge/TikTok-000000?style=for-the-badge&logo=tiktok&logoColor=white" alt="TikTok">
|
||||
</a>
|
||||
<a href="https://x.com/bryanthaboi">
|
||||
<img src="https://img.shields.io/badge/X-000000?style=for-the-badge&logo=x&logoColor=white" alt="X">
|
||||
</a>
|
||||
<a href="https://bsky.app/profile/bryanthaboi.live">
|
||||
<img src="https://img.shields.io/badge/Bluesky-0285FF?style=for-the-badge&logo=bluesky&logoColor=white" alt="Bluesky">
|
||||
</a>
|
||||
<a href="https://www.instagram.com/bryanthaboi">
|
||||
<img src="https://img.shields.io/badge/Instagram-E4405F?style=for-the-badge&logo=instagram&logoColor=white" alt="Instagram">
|
||||
</a>
|
||||
|
||||
</p>
|
||||
|
||||
|
||||
<p align="center"> <a href="https://www.polygon.com/pokemon-red-blue-3d-voxel-mod-battle-pixels-gameplay-footage-remake/"> <img src="https://img.shields.io/badge/AS%20SEEN%20ON-POLYGON-ea2e49?style=for-the-badge" alt="As seen on Polygon"> </a>
|
||||
<a href="https://kotaku.com/pokemon-red-blue-recompilation-project-voxel-3d-mod-2000720281"> <img src="https://img.shields.io/badge/AS%20SEEN%20ON-KOTAKU-ea2e49?style=for-the-badge" alt="As seen on KOTAKU"> </a>
|
||||
|
||||
<a href="https://www.digitalfoundry.net/news/2026/07/pokemon-yellow-voxel-mod-turns-the-original-gameboy-code-into-a-stunning-world">
|
||||
<img src="https://img.shields.io/badge/AS%20SEEN%20ON-DIGITAL%20FOUNDRY-ea2e49?style=for-the-badge" alt="As seen on Digital Foundry">
|
||||
</a>
|
||||
<a href="https://www.androidauthority.com/unofficial-android-port-pokemon-red-blue-yellow-3692724/">
|
||||
<img src="https://img.shields.io/badge/AS%20SEEN%20ON-ANDROID%20AUTHORITY-ea2e49?style=for-the-badge" alt="As seen on Android Authority">
|
||||
</a>
|
||||
|
||||
<a href="https://www.xda-developers.com/this-amazing-pokemon-red-and-blue-voxel-mod-adds-a-3d-perspective-without-an-emulator/">
|
||||
<img src="https://img.shields.io/badge/AS%20SEEN%20ON-XDA%20DEVELOPERS-ea2e49?style=for-the-badge" alt="As seen on XDA Developers">
|
||||
</a>
|
||||
</p>
|
||||
|
||||
### Watch the latest update video
|
||||
|
||||
[](https://www.youtube.com/watch?v=8IOgqbe4YvA)
|
||||
|
||||
|
||||
This project does not include a ROM, emulate the Game Boy, transpile assembly,
|
||||
or download a disassembly. A canonical US Poke Red or Blue ROM is the only
|
||||
game content input.
|
||||
or download a disassembly. A canonical US Poke Red, Blue, or Yellow ROM is the
|
||||
only game content input.
|
||||
|
||||
The ROM is verified, used during import, and then released from memory. It is
|
||||
not copied into the cache. Later launches load the private generated cache and
|
||||
do not ask for the ROM again. Red and Blue can both be imported and played
|
||||
side by side.
|
||||
do not ask for the ROM again. Red, Blue, and Yellow can all be imported and
|
||||
played side by side.
|
||||
|
||||
## Quick Start
|
||||
|
||||
Open the desktop app. On first boot, choose your legally obtained `.gb` file
|
||||
or drop it onto the window. Import takes a few seconds and the game starts
|
||||
automatically.
|
||||
Open the desktop app. On first boot, choose your legally obtained `.gb` /
|
||||
`.gbc` file or drop it onto the window. Import takes a few seconds and the
|
||||
game starts automatically.
|
||||
|
||||
Only the canonical 1 MiB US Red and Blue ROMs are accepted. The importer
|
||||
verifies SHA-1 before creating any game data:
|
||||
Only the canonical 1 MiB US Red, Blue, and Yellow ROMs are accepted. The
|
||||
importer verifies SHA-1 before creating any game data:
|
||||
|
||||
- Red: `ea9bcae617fdf159b045185467ae58b2e4a48b9a`
|
||||
- Blue: `d7037c83e1ae5b39bde3c30787637ba1d4c48ce2`
|
||||
- Yellow: `cc7d03262ebfaf2f06772c1a480c7d9d5f4a38e1`
|
||||
|
||||
The packaged app contains neither a ROM nor pre-extracted game data. Music,
|
||||
sound effects, and cries are synthesized while the game runs from compact
|
||||
@@ -98,11 +142,11 @@ famous bugs included:
|
||||
|
||||
## Running From Source
|
||||
|
||||
Requires LÖVE 11.x. Place a Red or Blue ROM in the project folder and
|
||||
Requires LÖVE 11.x. Place a Red, Blue, or Yellow ROM in the project folder and
|
||||
double-click `Play-Mac.command` or `Play-Windows.bat`, or run:
|
||||
|
||||
```sh
|
||||
scripts/setup.sh --rom "/path/to/Poke Red.gb" # or Poke Blue.gb
|
||||
scripts/setup.sh --rom "/path/to/Poke Red.gb" # or Blue.gb / Yellow.gbc
|
||||
scripts/run.sh
|
||||
```
|
||||
|
||||
@@ -133,6 +177,13 @@ even on a different computer, as long as the same folder comes along.
|
||||
already written to either location is touched automatically, so copy files
|
||||
over yourself if you want to carry existing progress across the switch.
|
||||
|
||||
## iOS
|
||||
|
||||
Every release ships `gen1recomp-*-ios.ipa`. Sideload it with AltStore
|
||||
(Windows or Mac) — see [docs/ios-sideload.md](docs/ios-sideload.md). To
|
||||
build and install from source on a Mac instead, see
|
||||
[docs/ios-install.md](docs/ios-install.md).
|
||||
|
||||
## Handhelds
|
||||
|
||||
A PortMaster-style port for the **Anbernic RG34XXSP** on Stock OS 64-bit MOD
|
||||
@@ -185,4 +236,6 @@ request with real detail is one that can actually get built.
|
||||
|
||||
This project would not be possible without [pret](https://github.com/pret) >
|
||||
the pret band of decompiling maniacs > and their
|
||||
[pokered](https://github.com/pret/pokered) disassembly.
|
||||
[pokered](https://github.com/pret/pokered) disassembly.
|
||||
|
||||
<p align="center"><a href="https://boisclub.games"><img src="https://raw.githubusercontent.com/bryanthaboi/gen1recomp/refs/heads/dev/assets/logo/bcg.png"></a></p>
|
||||
|
||||
@@ -78,6 +78,14 @@ function love.conf(t)
|
||||
-- player to copy their ROM there instead of needing a native file
|
||||
-- picker (LOVE 11.5 on Android has none -- see src/import/RomImporter.lua).
|
||||
t.externalstorage = osName == "Android"
|
||||
-- LOVE 11.x exposes the phone's accelerometer as a 3-axis joystick by
|
||||
-- default. Gravity keeps one axis pinned near +/-1.0 whenever the phone
|
||||
-- is held upright, so it streams past-deadzone joystickaxis events that
|
||||
-- both hid the touch overlay every instant (it reads as "a controller is
|
||||
-- in use") and, via the generic-joystick axis mapping (#459), walked the
|
||||
-- player around by tilt. Nothing in the game reads the accelerometer,
|
||||
-- so drop the device entirely (#468).
|
||||
t.accelerometerjoystick = false
|
||||
else
|
||||
t.window.resizable = true
|
||||
end
|
||||
|
||||
@@ -11,8 +11,70 @@
|
||||
-- equivalent, kept for save compatibility: rows 1-4 also self-heal
|
||||
-- older saves (flag set before the port hid the ball) by hiding the
|
||||
-- leftover ball on the next interaction.
|
||||
--
|
||||
-- The room's two readables (data/events/hidden_events.asm,
|
||||
-- hidden_events_for CELADON_MANSION_ROOF_HOUSE):
|
||||
-- hidden_text_predef 3, 0 / 4, 0 PrintBlackboardLinkCableText, LinkCableHelp
|
||||
-- hidden_text_predef 3, 4 PrintNotebookText, TMNotebook
|
||||
-- tools/extract/field.py only parses `hidden_event` rows, so no
|
||||
-- hidden_text_predef row reaches data/generated/field.lua and both tiles
|
||||
-- were dead A presses (#391). Same hook shape as the bedroom SNES in
|
||||
-- data/scripts/flavor/reds_house_2f.lua (#135). hidden_text_predef puts
|
||||
-- the predef id in the facing byte, so neither tile gates on facing.
|
||||
|
||||
local Menu = require("src.ui.Menu")
|
||||
local TextBox = require("src.render.TextBox")
|
||||
|
||||
-- TMNotebookText (data/text/text_2.asm) has no leading underscore, so the
|
||||
-- extractor never collects it and the pamphlet's text is inlined.
|
||||
local TM_NOTEBOOK_TEXT = "It's a pamphlet\non TMs.\f...\f"
|
||||
.. "There are 50 TMs\nin all.\f"
|
||||
.. "There are also 5\nHMs that can be\vused repeatedly.\f"
|
||||
.. "SILPH CO."
|
||||
|
||||
-- LinkCableHelp (engine/events/hidden_events/school_blackboard.asm):
|
||||
-- HowToLinkText's four headings in the 15x10 box at the top left; picking
|
||||
-- a heading prints its LinkCableInfoText and returns to the menu, B or
|
||||
-- STOP READING closes.
|
||||
local LINK_HEADINGS = { "HOW TO LINK", "COLOSSEUM", "TRADE CENTER" }
|
||||
|
||||
local function linkCableHelp(game)
|
||||
local text = game.data.text or {}
|
||||
local items, showMenu, askHeading
|
||||
function showMenu()
|
||||
game.stack:push(Menu.new(game, items,
|
||||
{ tx = 0, ty = 0, tw = 15, th = 10, rowStep = 1 }))
|
||||
end
|
||||
function askHeading()
|
||||
game.stack:push(TextBox.new(game,
|
||||
text._LinkCableHelpText2 or "Which heading do\nyou want to read?",
|
||||
showMenu))
|
||||
end
|
||||
items = {}
|
||||
for i, label in ipairs(LINK_HEADINGS) do
|
||||
items[i] = { label = label, onSelect = function()
|
||||
game.stack:push(TextBox.new(game,
|
||||
text["_LinkCableInfoText" .. i] or label, askHeading))
|
||||
end }
|
||||
end
|
||||
items[#items + 1] = { label = "STOP READING" }
|
||||
game.stack:push(TextBox.new(game,
|
||||
text._LinkCableHelpText1 or "TRAINER TIPS\fUsing a Game Link\nCable",
|
||||
askHeading))
|
||||
end
|
||||
|
||||
return {
|
||||
onInteract = function(game, ow, fx, fy)
|
||||
if fy == 0 and (fx == 3 or fx == 4) then
|
||||
linkCableHelp(game)
|
||||
return true
|
||||
end
|
||||
if fx == 3 and fy == 4 then
|
||||
game.stack:push(TextBox.new(game, TM_NOTEBOOK_TEXT))
|
||||
return true
|
||||
end
|
||||
return false
|
||||
end,
|
||||
talk = {
|
||||
TEXT_CELADONMANSION_ROOF_HOUSE_EEVEE_POKEBALL = {
|
||||
{ "check_flag", "EVENT_GOT_EEVEE" }, -- 1
|
||||
@@ -22,11 +84,16 @@ return {
|
||||
{ "jump", 13 }, -- 4
|
||||
{ "give_pokemon", "EEVEE", 25 }, -- 5
|
||||
{ "jump_if_false", 12 }, -- 6 (party+box full)
|
||||
{ "play_sound", "Get_Item1" }, -- 7 (GotMonText jingle)
|
||||
{ "show_text", "_GotMonText", { RAM = "EEVEE" } }, -- 8
|
||||
{ "set_flag", "EVENT_GOT_EEVEE" }, -- 9
|
||||
-- flag + HideObject before the jingle and GotMonText: the nickname
|
||||
-- prompt inside give_pokemon and the text row both yield, and a
|
||||
-- script that dies there would leave the EEVEE taken with the ball
|
||||
-- still on the table and the gift claimable again (#426). The row
|
||||
-- count is unchanged, so the numeric jump targets still hold.
|
||||
{ "set_flag", "EVENT_GOT_EEVEE" }, -- 7
|
||||
{ "hide_object", "CELADON_MANSION_ROOF_HOUSE",
|
||||
"CELADONMANSION_ROOF_HOUSE_EEVEE_POKEBALL" }, -- 10
|
||||
"CELADONMANSION_ROOF_HOUSE_EEVEE_POKEBALL" }, -- 8
|
||||
{ "play_sound", "Get_Item1" }, -- 9 (GotMonText jingle)
|
||||
{ "show_text", "_GotMonText", { RAM = "EEVEE" } }, -- 10
|
||||
{ "jump", 13 }, -- 11
|
||||
{ "show_text", "_BoxIsFullText" }, -- 12
|
||||
},
|
||||
|
||||
+78
-16
@@ -183,6 +183,7 @@ M.BILLS_HOUSE = {
|
||||
overworld = ow },
|
||||
"BILLS_HOUSE", "BILLSHOUSE_BILL_POKEMON")
|
||||
game.save.flags.EVENT_BILL_SAID_USE_CELL_SEPARATOR = true
|
||||
require("src.world.PikachuFollower").onBillEnteredMachine(game, ow)
|
||||
done()
|
||||
end
|
||||
if ow.player.facing == "down" then
|
||||
@@ -273,6 +274,9 @@ M.BILLS_HOUSE = {
|
||||
Commands.hide_object(ctx, "BILLS_HOUSE", "BILLSHOUSE_BILL1")
|
||||
Commands.show_object(ctx, "BILLS_HOUSE", "BILLSHOUSE_BILL2")
|
||||
end
|
||||
if not flags.EVENT_MET_BILL_2 then
|
||||
require("src.world.PikachuFollower").onBillsHouseEnter(game, ow)
|
||||
end
|
||||
end,
|
||||
}
|
||||
|
||||
@@ -666,6 +670,48 @@ M.WARDENS_HOUSE = {
|
||||
-- generic OPP_GIOVANNI#2 battle)
|
||||
-- -------------------------------------------------------------------
|
||||
|
||||
-- SilphCo11FTeamRocketLeavesScript (scripts/SilphCo11F.asm) hides every
|
||||
-- ROCKET and rocket-aligned SCIENTIST toggle on 2F-11F the moment Giovanni
|
||||
-- falls; the item balls, the 2F/10F rescued workers and the 7F rival keep
|
||||
-- theirs. Names and order are data/maps/toggleable_objects.asm's
|
||||
-- TOGGLE_SILPH_CO_<floor>_n entries. Saffron City's half of the same
|
||||
-- script lives in M.SAFFRON_CITY (story4.lua) (#392).
|
||||
local SILPH_ROCKET_OBJECTS = {
|
||||
{ "SILPH_CO_2F", { "SILPHCO2F_SCIENTIST1", "SILPHCO2F_SCIENTIST2",
|
||||
"SILPHCO2F_ROCKET1", "SILPHCO2F_ROCKET2" } },
|
||||
{ "SILPH_CO_3F", { "SILPHCO3F_ROCKET", "SILPHCO3F_SCIENTIST" } },
|
||||
{ "SILPH_CO_4F", { "SILPHCO4F_ROCKET1", "SILPHCO4F_SCIENTIST",
|
||||
"SILPHCO4F_ROCKET2" } },
|
||||
{ "SILPH_CO_5F", { "SILPHCO5F_ROCKET1", "SILPHCO5F_SCIENTIST",
|
||||
"SILPHCO5F_ROCKER", "SILPHCO5F_ROCKET2" } },
|
||||
{ "SILPH_CO_6F", { "SILPHCO6F_ROCKET1", "SILPHCO6F_SCIENTIST",
|
||||
"SILPHCO6F_ROCKET2" } },
|
||||
{ "SILPH_CO_7F", { "SILPHCO7F_ROCKET1", "SILPHCO7F_SCIENTIST",
|
||||
"SILPHCO7F_ROCKET2", "SILPHCO7F_ROCKET3" } },
|
||||
{ "SILPH_CO_8F", { "SILPHCO8F_ROCKET1", "SILPHCO8F_SCIENTIST",
|
||||
"SILPHCO8F_ROCKET2" } },
|
||||
{ "SILPH_CO_9F", { "SILPHCO9F_ROCKET1", "SILPHCO9F_SCIENTIST",
|
||||
"SILPHCO9F_ROCKET2" } },
|
||||
{ "SILPH_CO_10F", { "SILPHCO10F_ROCKET", "SILPHCO10F_SCIENTIST" } },
|
||||
{ "SILPH_CO_11F", { "SILPHCO11F_GIOVANNI", "SILPHCO11F_ROCKET1",
|
||||
"SILPHCO11F_ROCKET2" } },
|
||||
}
|
||||
|
||||
-- hide_object writes save.objectToggles, so the single pass at the win
|
||||
-- covers floors the player is not standing on; onlyMap is the per-floor
|
||||
-- repair path below.
|
||||
local function silphRocketsLeave(game, ow, onlyMap)
|
||||
local Commands = require("src.script.Commands")
|
||||
local ctx = { game = game, save = game.save, overworld = ow }
|
||||
for _, floor in ipairs(SILPH_ROCKET_OBJECTS) do
|
||||
if not onlyMap or onlyMap == floor[1] then
|
||||
for _, name in ipairs(floor[2]) do
|
||||
Commands.hide_object(ctx, floor[1], name)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
M.SILPH_CO_11F = {
|
||||
-- Giovanni's battle is a COORDINATE TRIGGER, not a talk.
|
||||
-- SilphCo11FDefaultScript (scripts/SilphCo11F.asm) checks
|
||||
@@ -692,13 +738,11 @@ M.SILPH_CO_11F = {
|
||||
ow:scriptMove(gio, "down", 3, function()
|
||||
gio:facePlayer(ow.player)
|
||||
ow:engageTrainer(gio, function()
|
||||
-- SilphCo11FTeamRocketLeavesScript: Giovanni leaves the floor
|
||||
-- SilphCo11FTeamRocketLeavesScript: every Silph rocket leaves
|
||||
-- after the loss (the street rockets are handled by
|
||||
-- M.SAFFRON_CITY.onEnter in story4.lua).
|
||||
if game.save.flags.EVENT_BEAT_SILPH_CO_GIOVANNI then
|
||||
local Commands = require("src.script.Commands")
|
||||
local ctx = { game = game, save = game.save, overworld = ow }
|
||||
Commands.hide_object(ctx, "SILPH_CO_11F", "SILPHCO11F_GIOVANNI")
|
||||
silphRocketsLeave(game, ow)
|
||||
end
|
||||
end)
|
||||
end)
|
||||
@@ -706,28 +750,46 @@ M.SILPH_CO_11F = {
|
||||
end,
|
||||
onEnter = function(game, ow)
|
||||
if game.save.flags.EVENT_BEAT_SILPH_CO_GIOVANNI then
|
||||
local Commands = require("src.script.Commands")
|
||||
local ctx = { game = game, save = game.save, overworld = ow }
|
||||
Commands.hide_object(ctx, "SILPH_CO_11F", "SILPHCO11F_GIOVANNI")
|
||||
silphRocketsLeave(game, ow)
|
||||
end
|
||||
end,
|
||||
talk = {
|
||||
-- SilphCo11FSilphPresidentText branches on EVENT_GOT_MASTER_BALL only:
|
||||
-- the teleport pads reach him without passing Giovanni's trigger, and
|
||||
-- afterwards he describes the ball forever. The old rows fell off the
|
||||
-- end of the script on both branches, so a second talk was silence
|
||||
-- (#392).
|
||||
TEXT_SILPHCO11F_SILPH_PRESIDENT = {
|
||||
{ "face_player" }, -- 1
|
||||
{ "check_flag", "EVENT_BEAT_SILPH_CO_GIOVANNI" }, -- 2
|
||||
{ "jump_if_false", 10 }, -- 3
|
||||
{ "check_flag", "EVENT_GOT_MASTER_BALL" }, -- 4
|
||||
{ "jump_if_true", 10 }, -- 5
|
||||
{ "show_text", "_SilphCo11FSilphPresidentText" }, -- 6
|
||||
{ "check_flag", "EVENT_GOT_MASTER_BALL" }, -- 2
|
||||
{ "jump_if_true", 9 }, -- 3
|
||||
{ "show_text", "_SilphCo11FSilphPresidentText" }, -- 4
|
||||
-- give-then-print like scripts/SilphCo11F.asm
|
||||
{ "give_item", "MASTER_BALL", 1, false }, -- 7
|
||||
{ "show_text", "_SilphCo11FSilphPresidentReceivedMasterBallText" }, -- 8
|
||||
{ "set_flag", "EVENT_GOT_MASTER_BALL" }, -- 9
|
||||
{ "jump", 11 }, -- 10
|
||||
{ "give_item", "MASTER_BALL", 1, false }, -- 5
|
||||
{ "show_text", "_SilphCo11FSilphPresidentReceivedMasterBallText" }, -- 6
|
||||
{ "set_flag", "EVENT_GOT_MASTER_BALL" }, -- 7
|
||||
{ "jump", "end" }, -- 8
|
||||
{ "show_text",
|
||||
"_SilphCo11FSilphPresidentMasterBallDescriptionText" }, -- 9
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
-- Floors 2F-10F carry no other onEnter, so this repairs saves that beat
|
||||
-- Giovanni before the hide list existed (same shape as
|
||||
-- M.POKEMON_TOWER_7F.onEnter); 11F hides its own inside the table above.
|
||||
for _, floor in ipairs(SILPH_ROCKET_OBJECTS) do
|
||||
local mapId = floor[1]
|
||||
if mapId ~= "SILPH_CO_11F" then
|
||||
M[mapId] = M[mapId] or {}
|
||||
M[mapId].onEnter = function(game, ow)
|
||||
if game.save.flags.EVENT_BEAT_SILPH_CO_GIOVANNI then
|
||||
silphRocketsLeave(game, ow, mapId)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- -------------------------------------------------------------------
|
||||
-- Victory Road boulder switches (scripts/VictoryRoad1F/2F/3F.asm):
|
||||
-- a boulder resting on a switch removes a barrier block; the 3F hole
|
||||
|
||||
+108
-112
@@ -128,6 +128,11 @@ M.POKEMON_TOWER_6F = {
|
||||
t._PokemonTower6FBeGoneText or "Be gone...\nIntruders...", function()
|
||||
local BattleState = require("src.battle.BattleState")
|
||||
local battle = BattleState.newWild(game, "MAROWAK", 30)
|
||||
-- ItemUseBall .notOldManBattle (item_effects.asm:166-175): a ball
|
||||
-- thrown on POKEMON_TOWER_6F at the RESTLESS SOUL is dodged whether
|
||||
-- or not the scope revealed it, so the "can't be caught" state rides
|
||||
-- the battle instead of IsGhostBattle alone (#444)
|
||||
battle.noCatch = true
|
||||
if not game.save.inventory.SILPH_SCOPE then
|
||||
battle:makeGhost()
|
||||
end
|
||||
@@ -182,17 +187,16 @@ M.POKEMON_TOWER_6F = {
|
||||
-- preFrames: the shake's lead-in delays -- ShakeElevator's own Delay3s
|
||||
-- come to 9 frames; Silph/Rocket's ...ShakeScript prefixes another
|
||||
-- Delay3 (12) while CeladonMartElevatorShakeScript farjps straight in
|
||||
-- The panel is a bg_event, not a map-entry script: data/maps/objects/
|
||||
-- CeladonMartElevator.asm has `bg_event 3, 0, TEXT_CELADONMARTELEVATOR`
|
||||
-- and CeladonMartElevatorText is what runs DisplayElevatorFloorMenu, so
|
||||
-- the menu waits for the player to face the panel and press A (#395).
|
||||
-- Map entry only stores the car's exit warps (CeladonMartElevator
|
||||
-- StoreWarpEntriesScript), which is elevatorSeedExit below.
|
||||
-- After the ride the original does NOT jump-cut to the floor: choosing a
|
||||
-- floor in engine/events/elevator.asm DisplayElevatorFloorMenu rewrites
|
||||
-- the elevator car's own warp entries (wWarpEntries, via .UpdateWarp) to
|
||||
-- the chosen floor's exit warp, then the player walks out of the car onto
|
||||
-- that warp themselves (scripts/SilphCoElevator.asm etc.). Reproduce
|
||||
-- that here: rewrite the car's exit warps, then drive a short scripted
|
||||
-- walk-out onto an exit tile and take the (now rewritten) warp, reusing
|
||||
-- ow:scriptMove / ow:takeWarp (the Oak-escort primitives).
|
||||
local WALK_DIRVEC = { up = { 0, -1 }, down = { 0, 1 }, left = { -1, 0 }, right = { 1, 0 } }
|
||||
local WALK_OPP = { up = "down", down = "up", left = "right", right = "left" }
|
||||
local WALK_ORDER = { "up", "down", "left", "right" }
|
||||
-- floor rewrites the elevator car's own warp entries (wWarpEntries, via
|
||||
-- .UpdateWarp) to the chosen floor's exit warp and returns control, and
|
||||
-- the player walks out of the car onto that warp themselves.
|
||||
|
||||
-- .UpdateWarp: point EVERY car exit warp at the same floor's elevator
|
||||
-- door (warp id, map id). Shared generated map data, but the car's
|
||||
@@ -205,46 +209,6 @@ local function elevatorSetExit(ow, floor)
|
||||
end
|
||||
end
|
||||
|
||||
local function elevatorWalkOut(ow, floor)
|
||||
local m, p = ow.map, ow.player
|
||||
elevatorSetExit(ow, floor)
|
||||
-- leave by the exit tile under the player (they warped in onto one),
|
||||
-- else the nearest
|
||||
local door
|
||||
for _, w in ipairs(m.def.warps) do
|
||||
if w.x == p.cellX and w.y == p.cellY then door = w break end
|
||||
end
|
||||
if not door then
|
||||
local best
|
||||
for _, w in ipairs(m.def.warps) do
|
||||
local d = math.abs(w.x - p.cellX) + math.abs(w.y - p.cellY)
|
||||
if not best or d < best then best, door = d, w end
|
||||
end
|
||||
end
|
||||
-- the car interior sits on the door's walkable side; "out" is the
|
||||
-- doorway direction (the map edge for Silph/Celadon, the top doorway
|
||||
-- for the Rocket car). Fixed direction order keeps the step
|
||||
-- deterministic when a door tile has several walkable neighbours
|
||||
-- (Silph/Celadon step up into the car, the Rocket car steps down).
|
||||
local into
|
||||
for _, dir in ipairs(WALK_ORDER) do
|
||||
local v = WALK_DIRVEC[dir]
|
||||
local nx, ny = door.x + v[1], door.y + v[2]
|
||||
if m:inBounds(nx, ny) and m:isWalkableCell(nx, ny) then into = dir break end
|
||||
end
|
||||
into = into or "up"
|
||||
local out = WALK_OPP[into]
|
||||
local function leave()
|
||||
ow:takeWarp(door) -- door SFX + warp to the rewritten floor target
|
||||
end
|
||||
-- step one tile into the car, then walk back through the doorway onto
|
||||
-- the exit tile and take the warp: a visible walk-out on valid tiles
|
||||
-- for either door orientation, instead of a jump cut
|
||||
ow:scriptMove(p, into, 1, function()
|
||||
ow:scriptMove(p, out, 1, leave)
|
||||
end)
|
||||
end
|
||||
|
||||
local function elevatorFloors(elevatorMapId, game)
|
||||
local floors = {}
|
||||
for mapId, def in pairs(game.data.maps) do
|
||||
@@ -272,9 +236,9 @@ local function elevatorFloors(elevatorMapId, game)
|
||||
end
|
||||
|
||||
local function elevatorSeedExit(ow, floors, fromMapId)
|
||||
-- Seed a walk-out destination before the menu (or key-gate text):
|
||||
-- entry floor when known, else the first listed floor (1F). Choosing
|
||||
-- a floor still rewrites via elevatorWalkOut; B-cancel / no-key leave
|
||||
-- Seed a walk-out destination on entry (before the panel is ever
|
||||
-- read): entry floor when known, else the first listed floor (1F).
|
||||
-- Choosing a floor rewrites it again; B-cancel / no-key leave
|
||||
-- keeps this seed so walking out of the car cannot hit a missing ROM
|
||||
-- placeholder (#123) or the car's static default floor (#90: Rocket
|
||||
-- Hideout defaults to B1F even when entered from B2F/B4F).
|
||||
@@ -288,68 +252,68 @@ local function elevatorSeedExit(ow, floors, fromMapId)
|
||||
return exitFloor
|
||||
end
|
||||
|
||||
local function elevator(elevatorMapId, keyGate, preFrames)
|
||||
local function elevator(elevatorMapId, panelText, keyGate, preFrames)
|
||||
-- the panel bg_event: DisplayElevatorFloorMenu runs from this text
|
||||
-- script, never from map entry (#395)
|
||||
local function panel(game, ow, npc, done)
|
||||
done = done or function() end
|
||||
local floors = elevatorFloors(elevatorMapId, game)
|
||||
-- Rocket Hideout: without LIFT_KEY the panel only prints the need-
|
||||
-- a-key line and shows no floor menu
|
||||
-- (scripts/RocketHideoutElevator.asm).
|
||||
if keyGate and not game.save.inventory[keyGate.item] then
|
||||
local TextBox = require("src.render.TextBox")
|
||||
game.stack:push(TextBox.new(game,
|
||||
game.data.text[keyGate.text] or "It appears to\nneed a key.", done))
|
||||
return
|
||||
end
|
||||
local items = {}
|
||||
for _, f in ipairs(floors) do
|
||||
table.insert(items, { label = f.token, value = f })
|
||||
end
|
||||
local ListMenu = require("src.ui.ListMenu")
|
||||
game.stack:push(ListMenu.new(game, "WHICH FLOOR?", items, {
|
||||
onChoose = function(item, list)
|
||||
list:close()
|
||||
-- the whole ShakeElevator ride runs in place -- music stop, 100
|
||||
-- collision-thud scroll bounces, the PA chime -- and only then
|
||||
-- does .UpdateWarp's rewrite land, with the player still stood
|
||||
-- at the panel: they walk out to the car door themselves
|
||||
local ElevatorShake = require("src.world.ElevatorShake")
|
||||
game.stack:push(ElevatorShake.new(game, ow, {
|
||||
preFrames = preFrames,
|
||||
onDone = function()
|
||||
elevatorSetExit(ow, item.value)
|
||||
done()
|
||||
end,
|
||||
}))
|
||||
end,
|
||||
onCancel = function()
|
||||
-- DisplayElevatorFloorMenu: `ret c` on B -- no warp, nothing
|
||||
-- happens, the player just stays in the car (exit warps were
|
||||
-- already seeded on entry)
|
||||
done()
|
||||
end,
|
||||
}))
|
||||
end
|
||||
return {
|
||||
-- fromMapId: the floor the player just left (setMap passes it), so a
|
||||
-- B-cancel can still walk out onto a real map. Silph's ROM car warps
|
||||
-- default to UNUSED_MAP_ED, which is not in Data.maps -- Warp.resolve
|
||||
-- asserted and hard-crashed (#123).
|
||||
onEnter = function(game, ow, fromMapId)
|
||||
local floors = elevatorFloors(elevatorMapId, game)
|
||||
elevatorSeedExit(ow, floors, fromMapId)
|
||||
-- Rocket Hideout: without LIFT_KEY the panel only prints the need-
|
||||
-- a-key line (scripts/RocketHideoutElevator.asm). Exit warps are
|
||||
-- still seeded above so walking out returns to the entry floor
|
||||
-- instead of the car's ROM default (B1F) -- #90 / #105.
|
||||
if keyGate and not game.save.inventory[keyGate.item] then
|
||||
local TextBox = require("src.render.TextBox")
|
||||
game.stack:push(TextBox.new(game,
|
||||
game.data.text[keyGate.text] or "It appears to\nneed a key."))
|
||||
return
|
||||
end
|
||||
local items = {}
|
||||
for _, f in ipairs(floors) do
|
||||
table.insert(items, { label = f.token, value = f })
|
||||
end
|
||||
local ListMenu = require("src.ui.ListMenu")
|
||||
game.stack:push(ListMenu.new(game, "WHICH FLOOR?", items, {
|
||||
onChoose = function(item, list)
|
||||
list:close()
|
||||
-- the map-entry Transition (startWarpTo) calls onEnter from
|
||||
-- its OWN midpoint callback, so this menu was pushed on top
|
||||
-- of that still-active Transition; only the top state
|
||||
-- updates, so it froze mid-fade instead of finishing. Left
|
||||
-- alone it would resurface after the ride and play a stray
|
||||
-- fade at the wrong time -- pop it now, before it can happen.
|
||||
local Transition = require("src.render.Transition")
|
||||
if getmetatable(game.stack:top()) == Transition then
|
||||
game.stack:pop()
|
||||
end
|
||||
-- the whole ShakeElevator ride runs in place -- music stop,
|
||||
-- 100 collision-thud scroll bounces, the PA chime -- and only
|
||||
-- then does the player walk out of the car onto the chosen
|
||||
-- floor (elevatorWalkOut rewrites the car's exit warps first)
|
||||
local ElevatorShake = require("src.world.ElevatorShake")
|
||||
game.stack:push(ElevatorShake.new(game, ow, {
|
||||
preFrames = preFrames,
|
||||
onDone = function()
|
||||
elevatorWalkOut(ow, item.value)
|
||||
end,
|
||||
}))
|
||||
end,
|
||||
onCancel = function()
|
||||
-- DisplayElevatorFloorMenu: `ret c` on B -- no warp, nothing
|
||||
-- happens, the player just stays in the car (exit warps were
|
||||
-- already seeded to the entry floor above)
|
||||
end,
|
||||
}))
|
||||
elevatorSeedExit(ow, elevatorFloors(elevatorMapId, game), fromMapId)
|
||||
end,
|
||||
talk = { [panelText] = panel },
|
||||
}
|
||||
end
|
||||
|
||||
M.SILPH_CO_ELEVATOR = elevator("SILPH_CO_ELEVATOR")
|
||||
M.CELADON_MART_ELEVATOR = elevator("CELADON_MART_ELEVATOR", nil, 9)
|
||||
M.SILPH_CO_ELEVATOR = elevator("SILPH_CO_ELEVATOR",
|
||||
"TEXT_SILPHCOELEVATOR_ELEVATOR")
|
||||
M.CELADON_MART_ELEVATOR = elevator("CELADON_MART_ELEVATOR",
|
||||
"TEXT_CELADONMARTELEVATOR", nil, 9)
|
||||
M.ROCKET_HIDEOUT_ELEVATOR = elevator("ROCKET_HIDEOUT_ELEVATOR",
|
||||
"TEXT_ROCKETHIDEOUTELEVATOR",
|
||||
{ item = "LIFT_KEY", text = "_RocketHideoutElevatorAppearsToNeedKeyText" })
|
||||
|
||||
-- -------------------------------------------------------------------
|
||||
@@ -695,6 +659,16 @@ local DOCK_SHIP_BLOCKS = {
|
||||
{ bx = 7, by = 2, water = 13 }, { bx = 8, by = 2, water = 13 },
|
||||
}
|
||||
|
||||
-- her four hull columns bow-to-stern (upper-half / lower-half block ids)
|
||||
-- and the open-water ids of the rows she sits in
|
||||
local DOCK_SHIP_COLUMNS = {
|
||||
{ bx = 5, top = 4, bottom = 8 },
|
||||
{ bx = 6, top = 5, bottom = 9 },
|
||||
{ bx = 7, top = 6, bottom = 10 },
|
||||
{ bx = 8, top = 7, bottom = 11 },
|
||||
}
|
||||
local DOCK_WATER_TOP, DOCK_WATER_BOTTOM = 1, 13
|
||||
|
||||
M.VERMILION_DOCK = {
|
||||
onEnter = function(game, ow)
|
||||
local Flags = require("src.script.Flags")
|
||||
@@ -729,17 +703,39 @@ M.VERMILION_DOCK = {
|
||||
ow:startDustAnim(cx, 1, function() puff(n - 1, cx + 2) end)
|
||||
end
|
||||
puff(3, 15)
|
||||
-- VermilionDock_EraseSSAnne deliberately leaves the blocks under the
|
||||
-- player alone ("south of the player and won't be redrawn"), so skip
|
||||
-- his own block: he must not spend the walk-out standing on water
|
||||
local pbx = math.floor(ow.player.cellX / 2)
|
||||
local pby = math.floor(ow.player.cellY / 2)
|
||||
local rows = {}
|
||||
rows[#rows + 1] = { "wait", 100 }
|
||||
rows[#rows + 1] = { "play_sound", "SS_Anne_Horn" }
|
||||
for _, b in ipairs(DOCK_SHIP_BLOCKS) do
|
||||
rows[#rows + 1] = { "replace_block", b.bx, b.by, b.water }
|
||||
local function setBlock(bx, by, block)
|
||||
if bx < 1 or bx > 8 then return end
|
||||
if bx == pbx and by == pby then return end
|
||||
rows[#rows + 1] = { "replace_block", bx, by, block }
|
||||
end
|
||||
rows[#rows + 1] = { "wait", 30 }
|
||||
rows[#rows + 1] = { "wait", 120 }
|
||||
rows[#rows + 1] = { "play_sound", "SS_Anne_Horn" }
|
||||
-- .shift_columns_up slides her tile columns west behind a mid-frame
|
||||
-- rSCX split; with no split scroll here she sails one block per beat
|
||||
-- and the water closes in astern (#360)
|
||||
for step = 1, 8 do
|
||||
for _, col in ipairs(DOCK_SHIP_COLUMNS) do
|
||||
setBlock(col.bx - step, 1, col.top)
|
||||
setBlock(col.bx - step, 2, col.bottom)
|
||||
end
|
||||
setBlock(9 - step, 1, DOCK_WATER_TOP)
|
||||
setBlock(9 - step, 2, DOCK_WATER_BOTTOM)
|
||||
rows[#rows + 1] = { "wait", 20 }
|
||||
end
|
||||
-- the second horn as she clears the dock, then EraseSSAnne's 120
|
||||
-- frames before the walk out
|
||||
rows[#rows + 1] = { "play_sound", "SS_Anne_Horn" }
|
||||
rows[#rows + 1] = { "wait", 120 }
|
||||
rows[#rows + 1] = { "move_player", "up", 2 }
|
||||
-- keep Music_Surfing across the city warp (pokered stays on it
|
||||
-- through the exit-ship walk)
|
||||
rows[#rows + 1] = { "play_music", "Music_Surfing", { keep = true } }
|
||||
-- no keepMusic on this warp: Music_Surfing belongs to the dock's
|
||||
-- cutscene, and VERMILION_CITY's own theme has to take over as the
|
||||
-- player crosses in (EnterMap's PlayDefaultMusic)
|
||||
rows[#rows + 1] = { "warp", "VERMILION_CITY", 18, 31, "up" }
|
||||
rows[#rows + 1] = { "move_player", "up", 2 }
|
||||
ow:queueScript(rows)
|
||||
|
||||
+20
-3
@@ -135,6 +135,9 @@ M.SILPH_CO_2F = {
|
||||
talk = {
|
||||
TEXT_SILPHCO2F_SILPH_WORKER_F = gift({
|
||||
flag = "EVENT_GOT_TM36", item = "TM_SELFDESTRUCT",
|
||||
-- the label carries no leading underscore: pokered keeps this one in
|
||||
-- the script bank, not the far-text bank (#393)
|
||||
pre = "SilphCo2FSilphWorkerFPleaseTakeThisText",
|
||||
received = "_SilphCo2FSilphWorkerFReceivedTM36Text",
|
||||
explain = "_SilphCo2FSilphWorkerFTM36ExplanationText",
|
||||
noRoom = "_SilphCo2FSilphWorkerFTM36NoRoomText",
|
||||
@@ -836,6 +839,19 @@ M.SILPH_CO_7F = {
|
||||
end,
|
||||
}
|
||||
|
||||
-- SSAnne2FRivalAfterBattleScript's exit walk is keyed on the player's X,
|
||||
-- not on where the rival stopped: from (37,8) the rival stands below him on
|
||||
-- (36,8) and takes .RivalDownFourMovement straight down out of the room;
|
||||
-- from (36,8) he stands above him on (36,7) and takes
|
||||
-- .RivalWalkAroundPlayerMovement, which steps RIGHT and then falls through
|
||||
-- into the same four DOWNs, so five downs in all (#360).
|
||||
local function ssAnne2FRivalExitDirs(onLeft)
|
||||
if onLeft then
|
||||
return { "right", "down", "down", "down", "down", "down" }
|
||||
end
|
||||
return { "down", "down", "down", "down" }
|
||||
end
|
||||
|
||||
-- S.S. Anne 2F rival ambush (scripts/SSAnne2F.asm; coords 36/37,8)
|
||||
M.SS_ANNE_2F = {
|
||||
onStep = function(game, ow, x, y)
|
||||
@@ -848,11 +864,12 @@ M.SS_ANNE_2F = {
|
||||
{ "face_object", 2, onLeft and "down" or "right" }, -- 3
|
||||
{ "show_text", "_SSAnne2FRivalText" }, -- 4
|
||||
{ "rival_battle", "OPP_RIVAL2", 1 }, -- 5
|
||||
{ "jump_if_false", 10 }, -- 6
|
||||
{ "jump_if_false", 11 }, -- 6
|
||||
{ "set_flag", "EVENT_BEAT_SS_ANNE_RIVAL" }, -- 7
|
||||
{ "show_text", "_SSAnne2FRivalDefeatedText" }, -- 8
|
||||
{ "move_npc_to", 2, 36, 4 }, -- 9
|
||||
{ "hide_object", "SS_ANNE_2F", "SSANNE2F_RIVAL" }, -- 10
|
||||
{ "show_text", "_SSAnne2FRivalCutMasterText" }, -- 9
|
||||
{ "walk_npc", 2, ssAnne2FRivalExitDirs(onLeft) }, -- 10
|
||||
{ "hide_object", "SS_ANNE_2F", "SSANNE2F_RIVAL" }, -- 11
|
||||
}, onLeft and "up" or "left")
|
||||
end,
|
||||
}
|
||||
|
||||
@@ -23,10 +23,15 @@ M.CERULEAN_MELANIES_HOUSE = {
|
||||
rows[#rows + 1] = { "jump_if_false", "declined" }
|
||||
rows[#rows + 1] = { "give_pokemon", "BULBASAUR", 10 }
|
||||
rows[#rows + 1] = { "jump_if_false", "end" } -- party + box full
|
||||
rows[#rows + 1] = { "show_text", "MelanieText3" }
|
||||
-- Event and HideObject before MelanieText3: give_pokemon's nickname
|
||||
-- prompt and the received text both yield, and a script that dies
|
||||
-- in that window used to leave the BULBASAUR given with the event
|
||||
-- clear, so Melanie offered a second one (#426). The flag is only
|
||||
-- read at script entry, so the order is invisible in play.
|
||||
rows[#rows + 1] = { "set_flag", "EVENT_GOT_BULBASAUR_IN_CERULEAN" }
|
||||
rows[#rows + 1] = { "hide_object", "CERULEAN_MELANIES_HOUSE",
|
||||
"CERULEANMELANIESHOUSE_BULBASAUR" }
|
||||
rows[#rows + 1] = { "set_flag", "EVENT_GOT_BULBASAUR_IN_CERULEAN" }
|
||||
rows[#rows + 1] = { "show_text", "MelanieText3" }
|
||||
rows[#rows + 1] = { "jump", "end" }
|
||||
rows[#rows + 1] = { "label", "declined" }
|
||||
rows[#rows + 1] = { "show_text", "MelanieText5" }
|
||||
@@ -60,8 +65,13 @@ M.ROUTE_24 = {
|
||||
{ "jump_if_false", "declined" },
|
||||
{ "give_pokemon", "CHARMANDER", 10 },
|
||||
{ "jump_if_false", "end" },
|
||||
{ "show_text", "_Route24DamianText2" },
|
||||
-- SetEvent hoisted ahead of the received text (Route24.asm writes it
|
||||
-- after Route24Text_515e3, where nothing in between can fail): the
|
||||
-- nickname prompt and the text both yield, and a script death in that
|
||||
-- window left the CHARMANDER given with EVENT_54F clear, so Damian
|
||||
-- handed out a second one on the next talk (#426).
|
||||
{ "set_flag", "EVENT_54F" },
|
||||
{ "show_text", "_Route24DamianText2" },
|
||||
{ "jump", "end" },
|
||||
{ "label", "declined" },
|
||||
{ "show_text", "_Route24DamianText3" },
|
||||
@@ -87,9 +97,10 @@ M.VERMILION_CITY = {
|
||||
rows[#rows + 1] = { "jump_if_false", "declined" }
|
||||
rows[#rows + 1] = { "give_pokemon", "SQUIRTLE", 10 }
|
||||
rows[#rows + 1] = { "jump_if_false", "end" }
|
||||
rows[#rows + 1] = { "show_text", "_OfficerJennyText3" }
|
||||
-- event before the received text, as above (#426)
|
||||
rows[#rows + 1] = { "set_flag",
|
||||
"EVENT_GOT_SQUIRTLE_FROM_OFFICER_JENNY" }
|
||||
rows[#rows + 1] = { "show_text", "_OfficerJennyText3" }
|
||||
rows[#rows + 1] = { "jump", "end" }
|
||||
rows[#rows + 1] = { "label", "declined" }
|
||||
rows[#rows + 1] = { "show_text", "_OfficerJennyText4" }
|
||||
|
||||
@@ -48,7 +48,19 @@ M.MT_MOON_B2F = {
|
||||
{ "show_object", "MT_MOON_B2F", "MTMOONB2F_JESSIE" },
|
||||
{ "show_object", "MT_MOON_B2F", "MTMOONB2F_JAMES" },
|
||||
{ "show_text", "_MtMoonJessieJamesText1" },
|
||||
{ "face_player_dir", "right" },
|
||||
{ "face_player_dir", "up" },
|
||||
{ "emote", "player", "shock", 30 },
|
||||
-- MtMoonB2FScript_49e15 simulates PAD_UP for one player step, then
|
||||
-- Script6/Script9 walk Jessie (object 2, MovementData_f9e65: six
|
||||
-- $06) and James (object 6, f9e66: five $06); both objects' movement
|
||||
-- byte 2 is LEFT, so .determineDirection makes those LEFT steps --
|
||||
-- Jessie (9,3)->(3,3) above the player at (3,4), James (9,4)->(4,4)
|
||||
-- beside him. #423
|
||||
{ "walk_npc", "player", { "up" } },
|
||||
{ "walk_npc", 2, { "left", "left", "left", "left", "left", "left" } },
|
||||
{ "face_object", 2, "down" },
|
||||
{ "walk_npc", 6, { "left", "left", "left", "left", "left" } },
|
||||
{ "face_object", 6, "left" },
|
||||
{ "show_text", "_MtMoonJessieJamesText2" },
|
||||
{ "start_battle", "trainer", "OPP_ROCKET", 42 },
|
||||
{ "check_battle_result", "win" },
|
||||
@@ -57,8 +69,10 @@ M.MT_MOON_B2F = {
|
||||
{ "show_text", "_MtMoonJessieJamesText4" },
|
||||
{ "stop_music" },
|
||||
{ "play_music", "Music_MeetJessieJames" },
|
||||
{ "fade", "out" },
|
||||
{ "hide_object", "MT_MOON_B2F", "MTMOONB2F_JESSIE" },
|
||||
{ "hide_object", "MT_MOON_B2F", "MTMOONB2F_JAMES" },
|
||||
{ "fade", "in" },
|
||||
{ "play_default_music" },
|
||||
{ "set_flag", "EVENT_BEAT_MT_MOON_3_JESSIE_JAMES" },
|
||||
}, {})
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
# Build & install on your iPhone — step by step
|
||||
|
||||
Don't want to install Xcode? Download the release IPA and sideload it with
|
||||
AltStore instead — see [ios-sideload.md](ios-sideload.md).
|
||||
|
||||
This guide assumes **zero** programming experience. Follow it top to
|
||||
bottom and you'll have the game running on your own iPhone in roughly an
|
||||
hour (most of it is waiting for downloads).
|
||||
|
||||
## What you need
|
||||
|
||||
- A **Mac** (any Apple-silicon or recent Intel Mac on macOS 14 or newer)
|
||||
- An **iPhone** and its **charging cable**
|
||||
- A free **Apple ID** (the same account you use for the App Store)
|
||||
- Your **own, legally obtained** Pokémon Red or Blue ROM file (a 1 MB
|
||||
`.gb` file). This project ships no game data — the app rebuilds
|
||||
everything from *your* cartridge dump and verifies it before use.
|
||||
- About **15 GB free disk space** (Xcode is enormous; the game itself is
|
||||
tiny)
|
||||
|
||||
> **Free vs. paid Apple account:** a free Apple ID works. The only catch:
|
||||
> apps signed with a free account stop launching after **7 days** — just
|
||||
> re-run the install command (step 5) to re-sign; your save data is kept.
|
||||
> A paid Apple Developer account ($99/yr) extends that to a year.
|
||||
|
||||
---
|
||||
|
||||
## Step 1 — Install Xcode (once)
|
||||
|
||||
1. On the Mac, open the **App Store**, search **Xcode**, click **Get**.
|
||||
It's a ~10 GB download — go make tea.
|
||||
2. Open Xcode once. Accept the license. If it offers to install extra
|
||||
components or the **iOS platform**, say yes and let it finish.
|
||||
|
||||
## Step 2 — Sign Xcode into your Apple ID (once)
|
||||
|
||||
1. In Xcode's menu bar: **Xcode → Settings → Accounts**.
|
||||
2. Click the **+** in the bottom-left → **Apple Account** → sign in.
|
||||
3. Close the window. (This quietly creates the "signing certificate" the
|
||||
build uses — you never have to touch it again.)
|
||||
|
||||
## Step 3 — Get this project onto the Mac
|
||||
|
||||
If you received it as a folder, put it somewhere easy like your home
|
||||
folder. If it's on GitHub, click the green **Code** button → **Download
|
||||
ZIP**, then double-click the zip to unpack it.
|
||||
|
||||
## Step 4 — Build it (one command)
|
||||
|
||||
1. Open the **Terminal** app (press ⌘-space, type `terminal`, press
|
||||
return).
|
||||
2. Type `cd ` (c, d, space — don't press return yet), then **drag the
|
||||
project folder** from Finder onto the Terminal window — it fills in
|
||||
the path — and press **return**.
|
||||
3. Paste this and press return:
|
||||
|
||||
```sh
|
||||
scripts/build_ios.sh --fetch
|
||||
```
|
||||
|
||||
The first run downloads the LÖVE engine and compiles everything
|
||||
(5–15 minutes). Lines of build output scrolling by is normal. You're
|
||||
done when you see **`==> done`**.
|
||||
|
||||
## Step 5 — Put it on your iPhone
|
||||
|
||||
1. Plug the iPhone into the Mac with the cable. **Unlock it** and keep it
|
||||
unlocked. If it asks **"Trust This Computer?" → Trust**.
|
||||
2. Paste this and press return:
|
||||
|
||||
```sh
|
||||
scripts/build_ios.sh --device --install
|
||||
```
|
||||
|
||||
The script finds your signing identity and your phone by itself. If it
|
||||
complains, it tells you exactly what to fix (usually: the phone was
|
||||
locked — unlock and re-run).
|
||||
|
||||
3. First time only, the iPhone will want two approvals:
|
||||
- **Developer Mode**: Settings → **Privacy & Security** → scroll to
|
||||
**Developer Mode** → turn on → restart the phone → confirm.
|
||||
- **Trust the developer**: Settings → **General** → **VPN & Device
|
||||
Management** → tap the entry under *Developer App* → **Trust**.
|
||||
|
||||
Then re-run the command in step 5.2 if the install had failed.
|
||||
|
||||
## Step 6 — Give it your ROM and play
|
||||
|
||||
1. Get your `.gb` file onto the phone — AirDrop it to yourself, or save
|
||||
it in iCloud Drive / Files.
|
||||
2. Open the app. On the **RED** tab, tap **Import ROM** and pick your
|
||||
`.gb` file in the file browser that appears. (Alternative: in the
|
||||
**Files** app, drop the `.gb` into **On My iPhone → the game's
|
||||
folder** and just reopen the app — it imports automatically.)
|
||||
3. Import takes ~10 seconds, the button turns into **Play Red** — tap it.
|
||||
|
||||
## Optional goodies
|
||||
|
||||
- **Mods**: launcher → **MODS** tab → **Import mod .zip**.
|
||||
- **Save import/export**: buttons on each game's tab, using the normal
|
||||
iOS file picker.
|
||||
|
||||
## When things go wrong
|
||||
|
||||
| Symptom | Fix |
|
||||
|---|---|
|
||||
| `xcodebuild not found` | Xcode isn't installed or wasn't opened once — do Step 1 |
|
||||
| `no Apple signing identity found` | Do Step 2, then re-run |
|
||||
| `no iPhone/iPad found` | Cable in? Phone unlocked? Tapped "Trust"? |
|
||||
| Install fails with "locked" | Unlock the phone, keep it unlocked, re-run |
|
||||
| App icon appears then won't open | Do the two approvals in Step 5.3 |
|
||||
| App stops launching after a week | Free-account 7-day limit — re-run Step 5.2 |
|
||||
| "That ROM could not be imported" | The file isn't a canonical 1 MB US Red/Blue dump — the importer checks its fingerprint |
|
||||
|
||||
Every build/install command is safe to re-run; your saves live on the
|
||||
phone and survive reinstalls.
|
||||
@@ -0,0 +1,48 @@
|
||||
# Sideload the iOS build with AltStore
|
||||
|
||||
Every GitHub Release ships an IPA (`gen1recomp-*-ios.ipa`). Install it on
|
||||
your iPhone or iPad with [AltStore Classic](https://altstore.io/) — AltStore
|
||||
re-signs the app with **your** free Apple ID so you do not need a Mac or
|
||||
Xcode.
|
||||
|
||||
## 1. Install AltStore
|
||||
|
||||
Follow the official guide for your computer:
|
||||
|
||||
- [How to Install (Windows)](https://faq.altstore.io/altstore-classic/how-to-install-altstore-windows)
|
||||
- [How to Install (macOS)](https://faq.altstore.io/altstore-classic/how-to-install-altstore-macos)
|
||||
|
||||
You will install **AltServer** on the computer, then use it to put AltStore
|
||||
on the phone. What AltServer is and why it needs to stay running:
|
||||
|
||||
- [AltServer](https://faq.altstore.io/altstore-classic/altserver)
|
||||
|
||||
Stuck? Start here:
|
||||
|
||||
- [Troubleshooting Guide](https://faq.altstore.io/altstore-classic/troubleshooting-guide)
|
||||
|
||||
## 2. Install the game
|
||||
|
||||
1. Download `gen1recomp-*-ios.ipa` from
|
||||
[Releases](https://github.com/bryanthaboi/gen1recomp/releases).
|
||||
2. Open **AltStore** on the phone (AltServer must be running on the same
|
||||
Wi‑Fi, or keep the phone plugged into the computer).
|
||||
3. Tap **My Apps → +** (or share the IPA into AltStore) and pick the file.
|
||||
4. Sign in with your Apple ID when prompted. Wait for the install to finish.
|
||||
5. On first launch: Settings → **Privacy & Security → Developer Mode** (iOS
|
||||
16+), and Settings → **General → VPN & Device Management** → Trust your
|
||||
Apple ID if asked.
|
||||
|
||||
Then open the app, import your own legal `.gb` ROM on the Red/Blue tab, and
|
||||
play.
|
||||
|
||||
## Refresh / 7-day limit
|
||||
|
||||
With a free Apple ID, sideloaded apps stop launching after **7 days**. Keep
|
||||
AltServer running so AltStore can refresh them, or open AltStore and refresh
|
||||
manually before they expire. Saves on the phone are kept across refreshes.
|
||||
|
||||
## Prefer building it yourself?
|
||||
|
||||
Building from source on a Mac (no AltStore) is covered in
|
||||
[ios-install.md](ios-install.md).
|
||||
@@ -193,6 +193,11 @@ flushes the live options. Old saves that still embed an `options` table are
|
||||
migrated once into `options.lua` on load.
|
||||
|
||||
- Music / SFX volume
|
||||
- PIKACHU VOL (0-7, Yellow only): trims Pikachu's PCM voice clips under the
|
||||
SFX level, so the follower's constant chatter, the title-screen cry and
|
||||
every in-battle "Pika!" can be pulled down (or muted at 0) without
|
||||
quieting the rest of the sound effects. The row is hidden on Red/Blue,
|
||||
which have no voice clips.
|
||||
- Music Filter
|
||||
- OG GLITCHES on / off (Gen 1 quirks vs. modern-clean battle rules)
|
||||
- BATTLE LAYOUT (OG / WIDE); see "Widescreen battle layout" below
|
||||
|
||||
@@ -331,6 +331,30 @@ function love.gamepadaxis(joystick, axis, value)
|
||||
Game:gamepadaxis(joystick, axis, value)
|
||||
end
|
||||
|
||||
function love.joystickpressed(joystick, button)
|
||||
if editorMode then return end
|
||||
if Importer then return Importer:joystickpressed(joystick, button) end
|
||||
Game:joystickpressed(joystick, button)
|
||||
end
|
||||
|
||||
function love.joystickreleased(joystick, button)
|
||||
if editorMode then return end
|
||||
if Importer then return Importer:joystickreleased(joystick, button) end
|
||||
Game:joystickreleased(joystick, button)
|
||||
end
|
||||
|
||||
function love.joystickaxis(joystick, axis, value)
|
||||
if editorMode then return end
|
||||
if Importer then return Importer:joystickaxis(joystick, axis, value) end
|
||||
Game:joystickaxis(joystick, axis, value)
|
||||
end
|
||||
|
||||
function love.joystickhat(joystick, hat, direction)
|
||||
if editorMode then return end
|
||||
if Importer then return Importer:joystickhat(joystick, hat, direction) end
|
||||
Game:joystickhat(joystick, hat, direction)
|
||||
end
|
||||
|
||||
function love.joystickremoved(joystick)
|
||||
if editorMode then return end
|
||||
if Importer then return end
|
||||
@@ -358,7 +382,18 @@ end
|
||||
|
||||
function love.touchpressed(id, x, y, dx, dy, pressure)
|
||||
if editorMode then return end
|
||||
if Importer then return Importer:mousepressed(x, y, 1) end
|
||||
if Importer then
|
||||
-- iOS: LÖVE already synthesizes a mousepressed for the primary touch,
|
||||
-- and love.mousepressed below forwards that to the Importer, so
|
||||
-- forwarding here too fires every launcher button twice per tap. The
|
||||
-- resulting double-present was fatal for the document picker: the
|
||||
-- second sheet stole the first one's weakly-held delegate, so picking
|
||||
-- a file silently did nothing. Android keeps the forward for upstream
|
||||
-- parity (its SAF picker is a separate activity and tolerates the
|
||||
-- re-launch).
|
||||
if love.system.getOS() == "iOS" then return end
|
||||
return Importer:mousepressed(x, y, 1)
|
||||
end
|
||||
Game:touchpressed(id, x, y)
|
||||
end
|
||||
|
||||
@@ -424,6 +459,16 @@ function love.quit()
|
||||
pcall(function()
|
||||
require("src.core.DiscordPresence").shutdown()
|
||||
end)
|
||||
-- LOVE waits for every live love.thread before the process exits, and both
|
||||
-- background workers idle in a loop that only a "quit" command breaks, so
|
||||
-- without this the process outlived the window and the next launch re-entered
|
||||
-- the dead one instead of starting fresh (#339)
|
||||
if package.loaded["src.core.ChipAudio"] then
|
||||
pcall(package.loaded["src.core.ChipAudio"].shutdown)
|
||||
end
|
||||
if package.loaded["src.update.Check"] then
|
||||
pcall(package.loaded["src.update.Check"].shutdown)
|
||||
end
|
||||
end
|
||||
|
||||
function love.filedropped(file)
|
||||
@@ -461,6 +506,13 @@ function love.run()
|
||||
for name, a, b, c, d, e, f in love.event.poll() do
|
||||
if name == "quit" then
|
||||
if not love.quit or not love.quit() then
|
||||
-- Android keeps the process and its task alive after LOVE's own
|
||||
-- teardown, so the relaunched task re-enters an activity whose
|
||||
-- native main already returned; end the process outright once the
|
||||
-- love.quit hook has run (#339)
|
||||
if love.system and love.system.getOS() == "Android" then
|
||||
os.exit(a or 0)
|
||||
end
|
||||
return a or 0
|
||||
end
|
||||
end
|
||||
|
||||
+4
-1
@@ -70,7 +70,10 @@ The APK lands under `app/build/outputs/apk/embedNoRecord/debug/`.
|
||||
### Payload path
|
||||
|
||||
`app/src/embed/assets/game.love` - zip of `main.lua`, `conf.lua`, `src/`,
|
||||
`data/`, `assets/`, and `tools/rom_manifest.json`. Generated game data,
|
||||
`data/`, `assets/`, and the Red, Blue, and Yellow ROM manifests. The Android
|
||||
packer verifies the Yellow manifest before it packages; if a partial source
|
||||
export omitted it, it restores the file from this checkout's Git data and then
|
||||
falls back to the project's GitHub copy. Generated game data,
|
||||
scripts, tests, and mobile build sources are excluded.
|
||||
|
||||
## Branding (applied by the build script)
|
||||
|
||||
@@ -78,6 +78,10 @@ public class GameActivity extends SDLActivity {
|
||||
private static final String PICKED_SAVE_FILENAME = "picked_save.sav";
|
||||
private static final String PENDING_EXPORT_FILENAME = "pending_export.sav";
|
||||
private static final String EXPORT_DONE_FILENAME = "export_done.flag";
|
||||
// Written when a SAF pick cannot be read at all, with the destination
|
||||
// basename as its body, so RomImporter:focus can say so in the launcher
|
||||
// instead of leaving the player on "No ROM imported" (issue #442).
|
||||
private static final String PICK_ERROR_FILENAME = "pick_error.flag";
|
||||
// Destination basename for the in-flight SAF pick (set by showFilePicker).
|
||||
private String pendingPickFilename = PICKED_ROM_FILENAME;
|
||||
// Suggested download name for the in-flight SAF create (set by showCreateDocument).
|
||||
@@ -452,6 +456,17 @@ public class GameActivity extends SDLActivity {
|
||||
return new File(new File(getExternalFilesDir(null), "save"), ROM_SAVE_IDENTITY);
|
||||
}
|
||||
|
||||
/** Drops a small flag file in the save identity for Lua to consume on focus. */
|
||||
private void writeSaveDirFlag(String name, String body) {
|
||||
try {
|
||||
FileOutputStream fos = new FileOutputStream(new File(saveIdentityDir(), name), false);
|
||||
fos.write(body.getBytes());
|
||||
fos.close();
|
||||
} catch (IOException e) {
|
||||
Log.d("GameActivity", "could not write " + name + ": " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private boolean copyFileToUri(File source, Uri destUri) {
|
||||
InputStream in = null;
|
||||
OutputStream out = null;
|
||||
@@ -491,14 +506,7 @@ public class GameActivity extends SDLActivity {
|
||||
Uri uri = data.getData();
|
||||
if (copyFileToUri(source, uri)) {
|
||||
// Signal Lua on next focus that the SAF export finished.
|
||||
File flag = new File(saveIdentityDir(), EXPORT_DONE_FILENAME);
|
||||
try {
|
||||
FileOutputStream fos = new FileOutputStream(flag, false);
|
||||
fos.write("ok".getBytes());
|
||||
fos.close();
|
||||
} catch (IOException e) {
|
||||
Log.d("GameActivity", "could not write export_done flag: " + e.getMessage());
|
||||
}
|
||||
writeSaveDirFlag(EXPORT_DONE_FILENAME, "ok");
|
||||
// Keep pending_export.sav so a retry still works; Lua may remove it.
|
||||
} else {
|
||||
Log.d("GameActivity", "could not write export to " + uri);
|
||||
@@ -521,19 +529,36 @@ public class GameActivity extends SDLActivity {
|
||||
? pendingPickFilename : PICKED_ROM_FILENAME;
|
||||
File destFile = new File(destDir, destName);
|
||||
|
||||
InputStream source;
|
||||
// ACTION_OPEN_DOCUMENT is meant to land in the system documents UI, but
|
||||
// some OEM shells (ColorOS) offer third-party file managers in a
|
||||
// chooser, and those hand back either a provider URI this app has no
|
||||
// grant for (SecurityException / FileNotFoundException) or a bare
|
||||
// file:// path (unreadable without storage permission on targetSdk 34).
|
||||
// Try the resolver, then the path, then tell Lua why nothing imported.
|
||||
InputStream source = null;
|
||||
try {
|
||||
source = getContentResolver().openInputStream(uri);
|
||||
} catch (FileNotFoundException e) {
|
||||
} catch (Exception e) {
|
||||
Log.d("GameActivity", "could not open picked file: " + e.getMessage());
|
||||
return;
|
||||
}
|
||||
if (source == null && "file".equals(uri.getScheme()) && uri.getPath() != null) {
|
||||
try {
|
||||
source = new FileInputStream(uri.getPath());
|
||||
} catch (FileNotFoundException e) {
|
||||
Log.d("GameActivity", "could not open picked path: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
if (source == null) {
|
||||
Log.d("GameActivity", "ContentResolver returned no stream for picked file");
|
||||
Log.d("GameActivity", "no readable stream for picked file " + uri);
|
||||
writeSaveDirFlag(PICK_ERROR_FILENAME, destName);
|
||||
return;
|
||||
}
|
||||
if (!copyAssetFile(source, destFile.getPath())) {
|
||||
Log.d("GameActivity", "could not copy picked file to " + destFile);
|
||||
// A truncated pick would only fail verification later, so drop it
|
||||
// and report instead.
|
||||
destFile.delete();
|
||||
writeSaveDirFlag(PICK_ERROR_FILENAME, destName);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,25 @@
|
||||
# iOS build (LÖVE 11.5)
|
||||
|
||||
> **Native ROM/mod/save import.** The iOS build ships a Swift
|
||||
> document-picker bridge (`native/GRPickerBridge.swift` + `GRBootstrap.m`)
|
||||
> that `patch_love_src.py` wires into the LÖVE tree on every build:
|
||||
>
|
||||
> - `love.system.pickFile("rom"|"mod"|"sav")` and `love.system.createFile`
|
||||
> are exposed to Lua on iOS (same contract as love-android's SAF picker:
|
||||
> picks land in the save dir as `picked_rom.gb` / `picked_mod.zip` /
|
||||
> `picked_save.sav`; exports signal via `export_done.flag`).
|
||||
> - The Info.plist overlay enables `UIFileSharingEnabled` +
|
||||
> `LSSupportsOpeningDocumentsInPlace`, and `GRBootstrap.m` sweeps
|
||||
> `.gb/.gbc/.zip/.sav` files dropped in Documents (Files app / Finder)
|
||||
> into the LÖVE save dir on every activation — drop a ROM, open the app,
|
||||
> and it imports with no taps.
|
||||
> - `src/import/RomImporter.lua` treats iOS as a mobile platform and polls
|
||||
> for picker results (iOS pickers are in-process modals, so Android's
|
||||
> refocus rescan never fires).
|
||||
>
|
||||
> The note below about a missing "UIDocumentPicker handoff" is
|
||||
> resolved by this bridge.
|
||||
|
||||
macOS + Xcode only. Pins the official **LÖVE 11.5** iOS Xcode tree
|
||||
(`love-11.5-ios-source.zip` from [love2d/love releases](https://github.com/love2d/love/releases/tag/11.5)),
|
||||
matching `conf.lua`'s `t.version = "11.5"`.
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
// gen1recomp iOS native bridge bootstrap.
|
||||
//
|
||||
// Runs the Files-app inbox sweep (GRPickerBridge.sweepInbox) every time the
|
||||
// app becomes active, so ROMs/mods/saves dropped into the app's Documents
|
||||
// folder land in the LÖVE save directory before the Lua importer rescans.
|
||||
// Registered from a constructor so no LÖVE/SDL source needs to know about it.
|
||||
|
||||
#import <UIKit/UIKit.h>
|
||||
|
||||
__attribute__((constructor))
|
||||
static void GRBootstrapInstall(void)
|
||||
{
|
||||
[[NSNotificationCenter defaultCenter]
|
||||
addObserverForName:UIApplicationDidBecomeActiveNotification
|
||||
object:nil
|
||||
queue:[NSOperationQueue mainQueue]
|
||||
usingBlock:^(NSNotification *note) {
|
||||
Class bridge = NSClassFromString(@"GRPickerBridge");
|
||||
if ([bridge respondsToSelector:@selector(sweepInbox)]) {
|
||||
[bridge performSelector:@selector(sweepInbox)];
|
||||
}
|
||||
}];
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
// gen1recomp iOS native bridge: Apple Health step sync (Pokéwalker mode).
|
||||
//
|
||||
// Reached from liblove's wrap_System.cpp through the Objective-C runtime as
|
||||
// love.system.syncHealthSteps() — see mobile/ios/patch_love_src.py. The Lua
|
||||
// side (mods/pokewalker) opts in, calls sync, and later consumes
|
||||
// steps_pending.json from the LÖVE save directory:
|
||||
//
|
||||
// { "steps": 4312, "from": "<ISO8601>", "to": "<ISO8601>" }
|
||||
//
|
||||
// Steps are counted from a persisted anchor (last successful sync; first
|
||||
// run starts at midnight today) so the same walk is never credited twice.
|
||||
// An unconsumed pending file is merged, not overwritten, so steps survive
|
||||
// the player quitting before entering the overworld.
|
||||
|
||||
import Foundation
|
||||
import HealthKit
|
||||
|
||||
@objc(GRHealthBridge)
|
||||
public final class GRHealthBridge: NSObject {
|
||||
|
||||
private static let store = HKHealthStore()
|
||||
private static let anchorKey = "GRHealthLastSyncDate"
|
||||
private static let pendingName = "steps_pending.json"
|
||||
// Sanity clamp per sync: guards against absurd backlogs (device clock
|
||||
// changes, months-old anchors) turning into instant level-100 parties.
|
||||
private static let maxStepsPerSync = 50_000
|
||||
|
||||
/// love.system.syncHealthSteps() -> bool ("sync started").
|
||||
/// Requests read authorization on first use (system sheet appears over
|
||||
/// the game), then asynchronously writes the pending-steps file.
|
||||
@objc(syncStepsWithCommand:saveDir:)
|
||||
public static func syncSteps(command: UnsafePointer<CChar>?,
|
||||
saveDir: UnsafePointer<CChar>?) -> Bool {
|
||||
guard HKHealthStore.isHealthDataAvailable(),
|
||||
let stepType = HKQuantityType.quantityType(forIdentifier: .stepCount)
|
||||
else { return false }
|
||||
let dir = saveDir.map { String(cString: $0) } ?? ""
|
||||
guard !dir.isEmpty else { return false }
|
||||
|
||||
store.requestAuthorization(toShare: nil, read: [stepType]) { _, error in
|
||||
if let error = error {
|
||||
NSLog("GRHealthBridge: authorization error: \(error)")
|
||||
return
|
||||
}
|
||||
// Read authorization is intentionally opaque (a denied query
|
||||
// just returns no samples), so always run the query.
|
||||
runQuery(stepType: stepType, dir: dir)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// MARK: - Query
|
||||
|
||||
private static func runQuery(stepType: HKQuantityType, dir: String) {
|
||||
let defaults = UserDefaults.standard
|
||||
let now = Date()
|
||||
let anchor = (defaults.object(forKey: anchorKey) as? Date)
|
||||
?? Calendar.current.startOfDay(for: now)
|
||||
guard anchor < now else { return }
|
||||
|
||||
let predicate = HKQuery.predicateForSamples(withStart: anchor, end: now,
|
||||
options: .strictStartDate)
|
||||
let query = HKStatisticsQuery(quantityType: stepType,
|
||||
quantitySamplePredicate: predicate,
|
||||
options: .cumulativeSum) { _, stats, error in
|
||||
if let error = error {
|
||||
// Typical on first run before the user answers the sheet,
|
||||
// or when access is denied; the anchor is left untouched so
|
||||
// the next sync retries the same window.
|
||||
NSLog("GRHealthBridge: step query error: \(error)")
|
||||
return
|
||||
}
|
||||
let steps = Int(stats?.sumQuantity()?.doubleValue(for: .count()) ?? 0)
|
||||
defaults.set(now, forKey: anchorKey)
|
||||
deliver(steps: min(steps, maxStepsPerSync),
|
||||
from: anchor, to: now, dir: dir)
|
||||
}
|
||||
store.execute(query)
|
||||
}
|
||||
|
||||
// MARK: - Pending file
|
||||
|
||||
private static func deliver(steps: Int, from: Date, to: Date, dir: String) {
|
||||
let url = URL(fileURLWithPath: dir).appendingPathComponent(pendingName)
|
||||
|
||||
// Merge with an unconsumed earlier delivery so steps are never lost.
|
||||
var total = steps
|
||||
var fromDate = from
|
||||
if let data = try? Data(contentsOf: url),
|
||||
let old = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any] {
|
||||
total += (old["steps"] as? Int) ?? 0
|
||||
if let oldFrom = old["from"] as? String,
|
||||
let parsed = isoFormatter.date(from: oldFrom), parsed < fromDate {
|
||||
fromDate = parsed
|
||||
}
|
||||
}
|
||||
guard total > 0 else { return }
|
||||
|
||||
let payload: [String: Any] = [
|
||||
"steps": total,
|
||||
"from": isoFormatter.string(from: fromDate),
|
||||
"to": isoFormatter.string(from: to),
|
||||
]
|
||||
do {
|
||||
try FileManager.default.createDirectory(
|
||||
at: URL(fileURLWithPath: dir, isDirectory: true),
|
||||
withIntermediateDirectories: true)
|
||||
let data = try JSONSerialization.data(withJSONObject: payload)
|
||||
try data.write(to: url, options: .atomic)
|
||||
NSLog("GRHealthBridge: %d steps pending", total)
|
||||
} catch {
|
||||
NSLog("GRHealthBridge: could not write pending steps: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
private static let isoFormatter: ISO8601DateFormatter = {
|
||||
let f = ISO8601DateFormatter()
|
||||
f.formatOptions = [.withInternetDateTime]
|
||||
return f
|
||||
}()
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
// gen1recomp iOS native bridge: document picker + Files-app inbox sweep.
|
||||
//
|
||||
// liblove's wrap_System.cpp calls into this class through the Objective-C
|
||||
// runtime (objc_getClass / objc_msgSend), so nothing here may be renamed
|
||||
// without updating that patch (see mobile/ios/patch_love_src.py).
|
||||
//
|
||||
// Contract (mirrors love-android's GameActivity.showFilePicker):
|
||||
// love.system.pickFile("rom"|"mod"|"sav") -> copies the user's pick into
|
||||
// the LÖVE save directory as picked_rom.gb / picked_mod.zip /
|
||||
// picked_save.sav; RomImporter's pending-file scan consumes it.
|
||||
// love.system.createFile(name) -> exports save dir's pending_export.sav
|
||||
// through the system picker, then writes export_done.flag.
|
||||
|
||||
import UIKit
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
@objc(GRPickerBridge)
|
||||
public final class GRPickerBridge: NSObject {
|
||||
|
||||
// Every live picker keeps its own delegate here
|
||||
// (UIDocumentPickerViewController holds its delegate weakly). A single
|
||||
// "current delegate" slot would break under double activation: the
|
||||
// engine's touch handling can fire a button twice (touch + synthesized
|
||||
// mouse), the second present would replace the first picker's delegate,
|
||||
// and the sheet the user actually sees would then pick into nil —
|
||||
// silently doing nothing.
|
||||
private static var liveDelegates: [PickerDelegate] = []
|
||||
|
||||
// conf.lua t.identity — where LÖVE puts the fused save directory on iOS
|
||||
// (<sandbox>/Library/Application Support/<identity>).
|
||||
private static let loveIdentity = "pokemon-love2d"
|
||||
|
||||
// MARK: - Entry points called from liblove (C strings on purpose)
|
||||
|
||||
@objc(presentPickerWithKind:saveDir:)
|
||||
public static func presentPicker(kind: UnsafePointer<CChar>?,
|
||||
saveDir: UnsafePointer<CChar>?) -> Bool {
|
||||
let kindStr = kind.map { String(cString: $0) } ?? "rom"
|
||||
guard let dir = resolvedSaveDir(saveDir) else { return false }
|
||||
|
||||
let destName: String
|
||||
var types: [UTType] = []
|
||||
switch kindStr {
|
||||
case "mod":
|
||||
destName = "picked_mod.zip"
|
||||
types = [.zip]
|
||||
case "sav":
|
||||
destName = "picked_save.sav"
|
||||
default:
|
||||
destName = "picked_rom.gb"
|
||||
for ext in ["gb", "gbc"] {
|
||||
if let t = UTType(filenameExtension: ext) { types.append(t) }
|
||||
}
|
||||
}
|
||||
// .gb/.gbc/.sav resolve to dynamic UTTypes on most devices; offering
|
||||
// .data as well keeps every real file selectable. The importer
|
||||
// validates by size + SHA-1, so a wrong pick is rejected safely.
|
||||
types.append(.data)
|
||||
if !types.contains(.item) { types.append(.item) }
|
||||
|
||||
let picker = UIDocumentPickerViewController(forOpeningContentTypes: types,
|
||||
asCopy: true)
|
||||
picker.allowsMultipleSelection = false
|
||||
let delegate = PickerDelegate { urls in
|
||||
guard let src = urls.first else { return }
|
||||
copyItem(at: src, into: dir, named: destName)
|
||||
}
|
||||
return present(picker, with: delegate)
|
||||
}
|
||||
|
||||
@objc(presentExportWithName:saveDir:)
|
||||
public static func presentExport(name: UnsafePointer<CChar>?,
|
||||
saveDir: UnsafePointer<CChar>?) -> Bool {
|
||||
let suggested = name.map { String(cString: $0) } ?? "export.sav"
|
||||
guard let dir = resolvedSaveDir(saveDir) else { return false }
|
||||
let staged = dir.appendingPathComponent("pending_export.sav")
|
||||
guard FileManager.default.fileExists(atPath: staged.path) else { return false }
|
||||
|
||||
// Stage under the suggested name so the picker's filename field is
|
||||
// prefilled; forExporting moves/copies it to the user's destination.
|
||||
let tmp = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent(suggested)
|
||||
try? FileManager.default.removeItem(at: tmp)
|
||||
do {
|
||||
try FileManager.default.copyItem(at: staged, to: tmp)
|
||||
} catch {
|
||||
NSLog("GRPickerBridge: staging export failed: \(error)")
|
||||
return false
|
||||
}
|
||||
|
||||
let picker = UIDocumentPickerViewController(forExporting: [tmp], asCopy: true)
|
||||
let delegate = PickerDelegate { urls in
|
||||
guard !urls.isEmpty else { return }
|
||||
// Same completion signal love-android's GameActivity writes;
|
||||
// RomImporter:focus consumes it and clears pending_export.sav.
|
||||
let flag = dir.appendingPathComponent("export_done.flag")
|
||||
try? "ok".data(using: .utf8)?.write(to: flag)
|
||||
}
|
||||
return present(picker, with: delegate)
|
||||
}
|
||||
|
||||
// Moves ROM/mod/save files the user dropped into the app's Documents
|
||||
// folder (Files app / Finder file sharing) into the LÖVE save directory,
|
||||
// where the importer's pending-file scan looks. Called on every
|
||||
// UIApplicationDidBecomeActive (see GRBootstrap.m).
|
||||
@objc public static func sweepInbox() {
|
||||
let fm = FileManager.default
|
||||
guard let docs = fm.urls(for: .documentDirectory, in: .userDomainMask).first,
|
||||
let appSupport = fm.urls(for: .applicationSupportDirectory,
|
||||
in: .userDomainMask).first else { return }
|
||||
let saveDir = appSupport.appendingPathComponent(loveIdentity, isDirectory: true)
|
||||
let wanted: Set<String> = ["gb", "gbc", "zip", "sav"]
|
||||
guard let items = try? fm.contentsOfDirectory(at: docs,
|
||||
includingPropertiesForKeys: nil) else { return }
|
||||
for url in items where wanted.contains(url.pathExtension.lowercased()) {
|
||||
ensureDirectory(saveDir)
|
||||
let dest = saveDir.appendingPathComponent(url.lastPathComponent)
|
||||
try? fm.removeItem(at: dest)
|
||||
do {
|
||||
try fm.moveItem(at: url, to: dest)
|
||||
NSLog("GRPickerBridge: swept %@ into save dir", url.lastPathComponent)
|
||||
} catch {
|
||||
NSLog("GRPickerBridge: sweep failed for \(url.lastPathComponent): \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private static func resolvedSaveDir(_ cstr: UnsafePointer<CChar>?) -> URL? {
|
||||
var dir = cstr.map { String(cString: $0) } ?? ""
|
||||
if dir.isEmpty {
|
||||
guard let appSupport = FileManager.default
|
||||
.urls(for: .applicationSupportDirectory, in: .userDomainMask).first
|
||||
else { return nil }
|
||||
dir = appSupport.appendingPathComponent(loveIdentity).path
|
||||
}
|
||||
let url = URL(fileURLWithPath: dir, isDirectory: true)
|
||||
ensureDirectory(url)
|
||||
return url
|
||||
}
|
||||
|
||||
private static func ensureDirectory(_ url: URL) {
|
||||
try? FileManager.default.createDirectory(at: url,
|
||||
withIntermediateDirectories: true)
|
||||
}
|
||||
|
||||
private static func copyItem(at src: URL, into dir: URL, named name: String) {
|
||||
let scoped = src.startAccessingSecurityScopedResource()
|
||||
defer { if scoped { src.stopAccessingSecurityScopedResource() } }
|
||||
ensureDirectory(dir)
|
||||
let dest = dir.appendingPathComponent(name)
|
||||
try? FileManager.default.removeItem(at: dest)
|
||||
do {
|
||||
try FileManager.default.copyItem(at: src, to: dest)
|
||||
NSLog("GRPickerBridge: delivered %@", name)
|
||||
} catch {
|
||||
NSLog("GRPickerBridge: copy failed: \(error)")
|
||||
// Surface the failure in-game: the Lua pick poll turns this
|
||||
// into an on-screen notice instead of a silent no-op.
|
||||
let report = "Could not copy \(src.lastPathComponent): " +
|
||||
error.localizedDescription
|
||||
try? report.data(using: .utf8)?
|
||||
.write(to: dir.appendingPathComponent("pick_error.txt"))
|
||||
}
|
||||
}
|
||||
|
||||
private static func present(_ picker: UIDocumentPickerViewController,
|
||||
with delegate: PickerDelegate) -> Bool {
|
||||
let doPresent = { () -> Bool in
|
||||
// Double activation (touch + synthesized mouse) arrives within
|
||||
// one frame — before UIKit even exposes the first sheet via
|
||||
// presentedViewController — so gate on our own delegate list.
|
||||
// Without this the stacked present makes the picker auto-dismiss
|
||||
// with zero documents (observed as didPickDocumentsAt 0 urls)
|
||||
// and the user's pick silently does nothing.
|
||||
guard liveDelegates.isEmpty else {
|
||||
NSLog("GRPickerBridge: picker already active; ignoring re-present")
|
||||
return true
|
||||
}
|
||||
guard var top = UIApplication.shared.windows
|
||||
.first(where: { $0.isKeyWindow })?.rootViewController
|
||||
else { return false }
|
||||
while let presented = top.presentedViewController { top = presented }
|
||||
picker.delegate = delegate
|
||||
liveDelegates.append(delegate)
|
||||
delegate.onFinish = { [weak delegate] in
|
||||
liveDelegates.removeAll { $0 === delegate }
|
||||
}
|
||||
top.present(picker, animated: true)
|
||||
return true
|
||||
}
|
||||
if Thread.isMainThread { return doPresent() }
|
||||
var ok = false
|
||||
DispatchQueue.main.sync { ok = doPresent() }
|
||||
return ok
|
||||
}
|
||||
}
|
||||
|
||||
private final class PickerDelegate: NSObject, UIDocumentPickerDelegate {
|
||||
private let onPick: ([URL]) -> Void
|
||||
var onFinish: (() -> Void)?
|
||||
init(onPick: @escaping ([URL]) -> Void) { self.onPick = onPick }
|
||||
|
||||
func documentPicker(_ controller: UIDocumentPickerViewController,
|
||||
didPickDocumentsAt urls: [URL]) {
|
||||
NSLog("GRPickerBridge: didPickDocumentsAt %d url(s)", urls.count)
|
||||
onPick(urls)
|
||||
onFinish?()
|
||||
}
|
||||
|
||||
func documentPickerWasCancelled(_ controller: UIDocumentPickerViewController) {
|
||||
// No file was written; the Lua side's pending-file poll simply
|
||||
// never finds anything.
|
||||
NSLog("GRPickerBridge: picker cancelled")
|
||||
onFinish?()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<!-- gen1recomp iOS: read-only step counts for the Pokéwalker mod
|
||||
(GRHealthBridge.swift / mods/pokewalker). -->
|
||||
<key>com.apple.developer.healthkit</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -40,7 +40,7 @@
|
||||
<key>CFBundleSignature</key>
|
||||
<string>????</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1.0</string>
|
||||
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
<true/>
|
||||
<key>LSSupportsOpeningDocumentsInPlace</key>
|
||||
@@ -100,5 +100,16 @@
|
||||
</dict>
|
||||
</dict>
|
||||
</array>
|
||||
<!-- gen1recomp iOS: expose the app's Documents folder in the Files app /
|
||||
Finder so ROMs, mod .zips, and .sav files can be dropped in without
|
||||
the picker; GRBootstrap sweeps them into the LÖVE save dir. -->
|
||||
<key>UIFileSharingEnabled</key>
|
||||
<true/>
|
||||
<key>LSSupportsOpeningDocumentsInPlace</key>
|
||||
<true/>
|
||||
<!-- gen1recomp iOS: Pokéwalker mod reads step counts (opt-in, in the
|
||||
in-game mod manager) and converts them to Pokémon EXP. -->
|
||||
<key>NSHealthShareUsageDescription</key>
|
||||
<string>The Pokéwalker mod reads your step count to give your Pokémon party experience points for real-world walking.</string>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Applies gen1recomp's iOS native-bridge patches to the fetched LÖVE 11.5
|
||||
source tree (mobile/ios/love-src/). Idempotent AND re-appliable: the first
|
||||
run stashes a pristine `.orig` copy of every file it rewrites, and later
|
||||
runs always start over from that copy — so editing the patch content here
|
||||
just works on the next build, no manual restore needed.
|
||||
|
||||
What it does:
|
||||
1. Copies mobile/ios/native/ (GRPickerBridge.swift, GRHealthBridge.swift,
|
||||
GRBootstrap.m) and the HealthKit entitlements into the LÖVE tree.
|
||||
2. Patches liblove's wrap_System.cpp to expose love.system.pickFile,
|
||||
love.system.createFile, and love.system.syncHealthSteps on iOS (each
|
||||
calls a GR*Bridge Swift class through the Objective-C runtime, so
|
||||
liblove never links against Swift directly).
|
||||
3. Patches love.xcodeproj so the love-ios app target compiles the native
|
||||
files (Swift 5, iOS 14 deployment for UTType/forExporting APIs).
|
||||
"""
|
||||
|
||||
import re
|
||||
import shutil
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
IOS_DIR = Path(__file__).resolve().parent
|
||||
LOVE_SRC = IOS_DIR / "love-src"
|
||||
NATIVE_SRC = IOS_DIR / "native"
|
||||
NATIVE_DST = LOVE_SRC / "platform" / "xcode" / "ios" / "native"
|
||||
WRAP_SYSTEM = LOVE_SRC / "src" / "modules" / "system" / "wrap_System.cpp"
|
||||
PBXPROJ = LOVE_SRC / "platform" / "xcode" / "love.xcodeproj" / "project.pbxproj"
|
||||
ENTITLEMENTS_SRC = IOS_DIR / "overlays" / "love-ios.entitlements"
|
||||
|
||||
NATIVE_FILES = ("GRPickerBridge.swift", "GRHealthBridge.swift", "GRBootstrap.m")
|
||||
|
||||
MARKER = "gen1recomp iOS picker bridge"
|
||||
|
||||
# Headers must land outside `namespace love { namespace system {`.
|
||||
WRAP_INCLUDES = """
|
||||
// %s: headers for the native-bridge functions below.
|
||||
#ifdef LOVE_IOS
|
||||
#include <objc/runtime.h>
|
||||
#include <objc/message.h>
|
||||
#include "filesystem/Filesystem.h"
|
||||
#endif
|
||||
""" % MARKER
|
||||
|
||||
WRAP_FUNCS = """
|
||||
// --- %s -------------------------------------------------
|
||||
// love.system.pickFile / createFile / syncHealthSteps for iOS. pickFile and
|
||||
// createFile mirror the love-android extension this project's importer
|
||||
// already targets; syncHealthSteps feeds the Pokéwalker mod. Implemented in
|
||||
// Swift (GR*Bridge classes, love-ios app target); reached via the ObjC
|
||||
// runtime so liblove itself needs no Swift interop.
|
||||
#ifdef LOVE_IOS
|
||||
static const char *gr_saveDirectory()
|
||||
{
|
||||
auto fs = Module::getInstance<love::filesystem::Filesystem>(Module::M_FILESYSTEM);
|
||||
return fs != nullptr ? fs->getSaveDirectory() : "";
|
||||
}
|
||||
|
||||
static int gr_callBridge(lua_State *L, const char *className,
|
||||
const char *selector, const char *arg)
|
||||
{
|
||||
Class cls = objc_getClass(className);
|
||||
if (cls == nullptr)
|
||||
{
|
||||
lua_pushboolean(L, 0);
|
||||
return 1;
|
||||
}
|
||||
typedef signed char (*GRMsg)(Class, SEL, const char *, const char *);
|
||||
signed char ok = ((GRMsg)objc_msgSend)(cls, sel_registerName(selector),
|
||||
arg, gr_saveDirectory());
|
||||
lua_pushboolean(L, ok != 0);
|
||||
return 1;
|
||||
}
|
||||
|
||||
int w_pickFile(lua_State *L)
|
||||
{
|
||||
const char *kind = luaL_optstring(L, 1, "rom");
|
||||
return gr_callBridge(L, "GRPickerBridge", "presentPickerWithKind:saveDir:", kind);
|
||||
}
|
||||
|
||||
int w_createFile(lua_State *L)
|
||||
{
|
||||
const char *name = luaL_optstring(L, 1, "export.sav");
|
||||
return gr_callBridge(L, "GRPickerBridge", "presentExportWithName:saveDir:", name);
|
||||
}
|
||||
|
||||
int w_syncHealthSteps(lua_State *L)
|
||||
{
|
||||
return gr_callBridge(L, "GRHealthBridge", "syncStepsWithCommand:saveDir:", "sync");
|
||||
}
|
||||
#endif // LOVE_IOS
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
""" % MARKER
|
||||
|
||||
WRAP_REGISTRATION = """#ifdef LOVE_IOS
|
||||
{ "pickFile", w_pickFile },
|
||||
{ "createFile", w_createFile },
|
||||
{ "syncHealthSteps", w_syncHealthSteps },
|
||||
#endif
|
||||
"""
|
||||
|
||||
# Deterministic 24-hex-digit object IDs, chosen not to collide with the
|
||||
# upstream project (grep-verified against love-11.5's pbxproj).
|
||||
ID_FILE_PICKER = "6E1AC0DE0001000000000001"
|
||||
ID_FILE_OBJC = "6E1AC0DE0001000000000002"
|
||||
ID_FILE_HEALTH = "6E1AC0DE0001000000000003"
|
||||
ID_BUILD_PICKER = "6E1AC0DE0002000000000001"
|
||||
ID_BUILD_OBJC = "6E1AC0DE0002000000000002"
|
||||
ID_BUILD_HEALTH = "6E1AC0DE0002000000000003"
|
||||
SOURCES_PHASE_ID = "FA0B7F021A95AAF3000E1D17" # love-ios Sources phase
|
||||
IOS_APP_CONFIG_IDS = (
|
||||
"FA0B7F261A95AAF4000E1D17", # Debug
|
||||
"FA0B7F271A95AAF4000E1D17", # Release
|
||||
"FA0B7F281A95AAF4000E1D17", # Distribution
|
||||
)
|
||||
|
||||
PBX_SOURCES = (
|
||||
("GRPickerBridge.swift", ID_FILE_PICKER, ID_BUILD_PICKER, "sourcecode.swift"),
|
||||
("GRHealthBridge.swift", ID_FILE_HEALTH, ID_BUILD_HEALTH, "sourcecode.swift"),
|
||||
("GRBootstrap.m", ID_FILE_OBJC, ID_BUILD_OBJC, "sourcecode.c.objc"),
|
||||
)
|
||||
|
||||
|
||||
def fail(msg):
|
||||
print(f"patch_love_src: error: {msg}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def pristine(path: Path) -> str:
|
||||
"""Text of `path` before any of our patching: backed by a `.orig` stash.
|
||||
|
||||
The stash is only trusted if it is itself unpatched; that protects
|
||||
against a stash accidentally taken after an earlier patch run.
|
||||
"""
|
||||
orig = path.with_suffix(path.suffix + ".orig")
|
||||
if orig.is_file():
|
||||
text = orig.read_text()
|
||||
if MARKER not in text and ID_FILE_PICKER not in text:
|
||||
return text
|
||||
text = path.read_text()
|
||||
if MARKER in text or ID_FILE_PICKER in text:
|
||||
fail(f"{path} is already patched and no pristine .orig stash exists;\n"
|
||||
f" delete {LOVE_SRC} and re-run scripts/build_ios.sh --fetch")
|
||||
orig.write_text(text)
|
||||
return text
|
||||
|
||||
|
||||
def copy_native_files():
|
||||
NATIVE_DST.mkdir(parents=True, exist_ok=True)
|
||||
for name in NATIVE_FILES:
|
||||
src = NATIVE_SRC / name
|
||||
if not src.is_file():
|
||||
fail(f"missing {src}")
|
||||
shutil.copy2(src, NATIVE_DST / name)
|
||||
if not ENTITLEMENTS_SRC.is_file():
|
||||
fail(f"missing {ENTITLEMENTS_SRC}")
|
||||
shutil.copy2(ENTITLEMENTS_SRC, NATIVE_DST / "love-ios.entitlements")
|
||||
print(f"patch_love_src: native files -> {NATIVE_DST}")
|
||||
|
||||
|
||||
def patch_wrap_system():
|
||||
text = pristine(WRAP_SYSTEM)
|
||||
include_anchor = '#include "sdl/System.h"\n'
|
||||
if include_anchor not in text:
|
||||
fail(f"include anchor not found in {WRAP_SYSTEM}")
|
||||
text = text.replace(include_anchor, include_anchor + WRAP_INCLUDES, 1)
|
||||
anchor = "static const luaL_Reg functions[] ="
|
||||
if anchor not in text:
|
||||
fail(f"anchor not found in {WRAP_SYSTEM}")
|
||||
text = text.replace(anchor, WRAP_FUNCS + anchor, 1)
|
||||
reg_anchor = '\t{ "vibrate", w_vibrate },\n'
|
||||
if reg_anchor not in text:
|
||||
fail(f"registration anchor not found in {WRAP_SYSTEM}")
|
||||
text = text.replace(reg_anchor, reg_anchor + WRAP_REGISTRATION, 1)
|
||||
WRAP_SYSTEM.write_text(text)
|
||||
print("patch_love_src: wrap_System.cpp patched "
|
||||
"(pickFile/createFile/syncHealthSteps)")
|
||||
|
||||
|
||||
def patch_pbxproj():
|
||||
text = pristine(PBXPROJ)
|
||||
|
||||
build_files = "".join(
|
||||
f"\t\t{build_id} /* {name} in Sources */ = "
|
||||
f"{{isa = PBXBuildFile; fileRef = {file_id} /* {name} */; }};\n"
|
||||
for name, file_id, build_id, _ in PBX_SOURCES
|
||||
)
|
||||
anchor = "/* Begin PBXBuildFile section */\n"
|
||||
if anchor not in text:
|
||||
fail("PBXBuildFile section not found")
|
||||
text = text.replace(anchor, anchor + build_files, 1)
|
||||
|
||||
file_refs = "".join(
|
||||
f"\t\t{file_id} /* {name} */ = "
|
||||
f"{{isa = PBXFileReference; lastKnownFileType = {ftype}; "
|
||||
f"name = {name}; path = ios/native/{name}; "
|
||||
f"sourceTree = SOURCE_ROOT; }};\n"
|
||||
for name, file_id, _, ftype in PBX_SOURCES
|
||||
)
|
||||
anchor = "/* Begin PBXFileReference section */\n"
|
||||
if anchor not in text:
|
||||
fail("PBXFileReference section not found")
|
||||
text = text.replace(anchor, anchor + file_refs, 1)
|
||||
|
||||
# Add the files to the love-ios Sources phase.
|
||||
phase_re = re.compile(
|
||||
re.escape(SOURCES_PHASE_ID)
|
||||
+ r" /\* Sources \*/ = \{.*?files = \(\n", re.S)
|
||||
m = phase_re.search(text)
|
||||
if not m:
|
||||
fail("love-ios Sources phase not found")
|
||||
insertion = "".join(
|
||||
f"\t\t\t\t{build_id} /* {name} in Sources */,\n"
|
||||
for name, _, build_id, _ in PBX_SOURCES
|
||||
)
|
||||
text = text[: m.end()] + insertion + text[m.end():]
|
||||
|
||||
# Swift + modern deployment target + HealthKit entitlements on the
|
||||
# love-ios app target only (UTType and forExporting need iOS 14;
|
||||
# liblove stays as upstream).
|
||||
for config_id in IOS_APP_CONFIG_IDS:
|
||||
cfg_re = re.compile(
|
||||
re.escape(config_id) + r" /\* \w+ \*/ = \{.*?buildSettings = \{\n",
|
||||
re.S)
|
||||
m = cfg_re.search(text)
|
||||
if not m:
|
||||
fail(f"build configuration {config_id} not found")
|
||||
settings = (
|
||||
"\t\t\t\tSWIFT_VERSION = 5.0;\n"
|
||||
"\t\t\t\tIPHONEOS_DEPLOYMENT_TARGET = 14.0;\n"
|
||||
'\t\t\t\tCODE_SIGN_ENTITLEMENTS = "ios/native/love-ios.entitlements";\n'
|
||||
)
|
||||
text = text[: m.end()] + settings + text[m.end():]
|
||||
|
||||
PBXPROJ.write_text(text)
|
||||
print("patch_love_src: love.xcodeproj patched (native sources + Swift + entitlements)")
|
||||
|
||||
|
||||
def main():
|
||||
if not LOVE_SRC.is_dir():
|
||||
fail("love-src/ missing; run scripts/build_ios.sh --fetch first")
|
||||
copy_native_files()
|
||||
patch_wrap_system()
|
||||
patch_pbxproj()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -308,6 +308,9 @@ build_ios() {
|
||||
if [ "$IOS_RELEASE" = true ]; then
|
||||
args+=(--release)
|
||||
fi
|
||||
if [ "$VERSION_EXPLICIT" = true ]; then
|
||||
args+=(--version "$VERSION")
|
||||
fi
|
||||
"$ROOT/scripts/build_ios.sh" ${args[@]+"${args[@]}"}
|
||||
}
|
||||
|
||||
|
||||
@@ -26,6 +26,11 @@ APP_NAME="gen1recomp"
|
||||
APPLICATION_ID="com.theboisclub.pokemonred"
|
||||
LOVE_ANDROID_VERSION="11.5a"
|
||||
NDK_VERSION="25.2.9519653"
|
||||
YELLOW_MANIFEST_RELATIVE="tools/rom_manifest_yellow.json"
|
||||
# A fresh source checkout normally supplies this through Git. This URL is
|
||||
# deliberately only a last resort for incomplete source exports: the manifest
|
||||
# contains extraction metadata, never a ROM or extracted game data.
|
||||
YELLOW_MANIFEST_URL="${YELLOW_MANIFEST_URL:-https://raw.githubusercontent.com/bryanthaboi/gen1recomp/main/tools/rom_manifest_yellow.json}"
|
||||
|
||||
VERSION=""
|
||||
PACKAGE_ONLY=false
|
||||
@@ -72,6 +77,59 @@ if [ ! -d "$ANDROID_DIR/love/src/jni/love/src" ]; then
|
||||
Re-clone or 'git checkout -- mobile/android'. See mobile/ANDROID.md."
|
||||
fi
|
||||
|
||||
# ------------------------------------------------------- Yellow import metadata
|
||||
# Android packages game.love itself rather than reusing scripts/build.sh's
|
||||
# archive. Keep a partial source export from silently shipping an APK that can
|
||||
# list Yellow but cannot import it. Prefer the exact manifest from this
|
||||
# checkout's Git object database; only then fall back to the public repository.
|
||||
yellow_manifest_is_valid() {
|
||||
local path="$1"
|
||||
python3 - "$path" <<'PY'
|
||||
import json, pathlib, sys
|
||||
|
||||
try:
|
||||
manifest = json.loads(pathlib.Path(sys.argv[1]).read_text())
|
||||
except (OSError, ValueError):
|
||||
raise SystemExit(1)
|
||||
|
||||
raise SystemExit(0 if manifest.get("romSha1") ==
|
||||
"cc7d03262ebfaf2f06772c1a480c7d9d5f4a38e1" else 1)
|
||||
PY
|
||||
}
|
||||
|
||||
ensure_yellow_manifest() {
|
||||
local manifest="$ROOT/$YELLOW_MANIFEST_RELATIVE"
|
||||
local staged
|
||||
staged="$(mktemp)"
|
||||
|
||||
if yellow_manifest_is_valid "$manifest"; then
|
||||
rm -f "$staged"
|
||||
return
|
||||
fi
|
||||
|
||||
warn "Yellow import manifest is missing or invalid; recovering it before packaging"
|
||||
if git -C "$ROOT" show "HEAD:$YELLOW_MANIFEST_RELATIVE" > "$staged" 2>/dev/null \
|
||||
&& yellow_manifest_is_valid "$staged"; then
|
||||
mkdir -p "$(dirname "$manifest")"
|
||||
mv "$staged" "$manifest"
|
||||
say "restored Yellow import manifest from this checkout's Git data"
|
||||
return
|
||||
fi
|
||||
|
||||
if command -v curl >/dev/null 2>&1 \
|
||||
&& curl --fail --location --retry 2 --connect-timeout 15 \
|
||||
--output "$staged" "$YELLOW_MANIFEST_URL" \
|
||||
&& yellow_manifest_is_valid "$staged"; then
|
||||
mkdir -p "$(dirname "$manifest")"
|
||||
mv "$staged" "$manifest"
|
||||
say "downloaded Yellow import manifest from the project repository"
|
||||
return
|
||||
fi
|
||||
|
||||
rm -f "$staged"
|
||||
fail "Yellow import manifest is unavailable. Git recovery failed and could not download $YELLOW_MANIFEST_URL"
|
||||
}
|
||||
|
||||
# --------------------------------------------------------------- branding
|
||||
# love-android 11.5+ reads app id / name / orientation from gradle.properties.
|
||||
# Manifest still gets permission trims. Re-applied every build so refreshing
|
||||
@@ -135,6 +193,7 @@ PY
|
||||
# --------------------------------------------------------------- game.love
|
||||
pack_game_love() {
|
||||
say "packing game.love for love-android embed flavor"
|
||||
ensure_yellow_manifest
|
||||
mkdir -p "$EMBED_ASSETS"
|
||||
rm -f "$LOVE_FILE"
|
||||
# tools/save-editor ships with the app: the launcher's Edit button on a save
|
||||
@@ -142,14 +201,21 @@ pack_game_love() {
|
||||
(cd "$ROOT" && zip -q -9 -r "$LOVE_FILE" \
|
||||
main.lua conf.lua src data assets tools/save-editor \
|
||||
tools/rom_manifest.json tools/rom_manifest_blue.json \
|
||||
tools/rom_manifest_yellow.json \
|
||||
-x '*.DS_Store' -x '*/.git/*' -x '*/.DS_Store' \
|
||||
-x 'data/generated/*' -x 'assets/generated/*')
|
||||
if unzip -Z1 "$LOVE_FILE" \
|
||||
| grep -Eq '^(data|assets)/generated/[^/]+|^(data|assets)/generated/.+/'; then
|
||||
fail "game.love unexpectedly contains generated ROM data"
|
||||
fi
|
||||
unzip -Z1 "$LOVE_FILE" | grep -qx 'tools/save-editor/App.lua' \
|
||||
# Do not pipe unzip straight into grep here: on a large archive grep can
|
||||
# finish early and make unzip report SIGPIPE under `set -o pipefail`.
|
||||
local archive_entries
|
||||
archive_entries="$(unzip -Z1 "$LOVE_FILE")"
|
||||
grep -qx 'tools/save-editor/App.lua' <<< "$archive_entries" \
|
||||
|| fail "game.love is missing the save editor (Edit on a save row would crash)"
|
||||
grep -qx "$YELLOW_MANIFEST_RELATIVE" <<< "$archive_entries" \
|
||||
|| fail "game.love is missing the Yellow ROM import manifest"
|
||||
say "game.love: $(du -h "$LOVE_FILE" | cut -f1) -> $LOVE_FILE"
|
||||
|
||||
# This script packs its own game.love (it does not reuse build.sh's), so it
|
||||
|
||||
+164
-16
@@ -2,11 +2,16 @@
|
||||
# Packages the LÖVE2D Pokémon Red port into an iOS app via LÖVE 11.5's
|
||||
# official iOS Xcode project (love-11.5-ios-source.zip).
|
||||
#
|
||||
# Usage: scripts/build_ios.sh [--fetch] [--device] [--release] [--package-only]
|
||||
# Usage: scripts/build_ios.sh [--fetch] [--device] [--release] [--install]
|
||||
# [--version X.Y.Z] [--package-only]
|
||||
#
|
||||
# (default) Simulator Debug (CODE_SIGNING_ALLOWED=NO)
|
||||
# --device iphoneos SDK (needs signing / DEVELOPMENT_TEAM)
|
||||
# (default) Simulator Debug (ad-hoc signed)
|
||||
# --device iphoneos SDK; signing team auto-detected from the
|
||||
# keychain when DEVELOPMENT_TEAM is not set
|
||||
# --install after a --device build, install the app onto the
|
||||
# first connected iPhone/iPad (unlock it first)
|
||||
# --release Release configuration
|
||||
# --version X.Y.Z stamp MARKETING_VERSION / CURRENT_PROJECT_VERSION
|
||||
# --fetch Download love-11.5-ios-source.zip into mobile/ios/love-src/
|
||||
# --package-only Zip game.love + apply plist overlay; skip xcodebuild
|
||||
#
|
||||
@@ -16,6 +21,7 @@
|
||||
# - prebuilt iOS libraries under love-src/platform/xcode/ios/libraries/
|
||||
#
|
||||
# Output: dist/ios/<Config>-<sdk>/gen1recomp.app (convenience copy)
|
||||
# dist/ios/gen1recomp.ipa (device builds only)
|
||||
# mobile/ios/build/Build/Products/<Config>-<sdk>/gen1recomp.app
|
||||
|
||||
set -euo pipefail
|
||||
@@ -35,7 +41,19 @@ LIBS_DIR="$XCODE_DIR/ios/libraries"
|
||||
|
||||
APP_NAME="gen1recomp"
|
||||
DISPLAY_NAME="gen1recomp"
|
||||
BUNDLE_ID="com.theboisclub.pokemonred"
|
||||
# Bundle ID resolution, most specific wins:
|
||||
# 1. GEN1_BUNDLE_ID env var
|
||||
# 2. mobile/ios/bundle_id.local (one line, gitignored — pins YOUR install
|
||||
# so rebuilds keep updating the same app on your phone)
|
||||
# 3. device builds: com.gen1recomp.t<your team id> — explicit App IDs are
|
||||
# globally unique across ALL Apple accounts (and required once
|
||||
# capabilities like HealthKit are involved), so a per-team default
|
||||
# lets anyone build without colliding with someone else's app
|
||||
# 4. simulator: the project default (no App ID registration involved)
|
||||
BUNDLE_ID="${GEN1_BUNDLE_ID:-}"
|
||||
if [ -z "$BUNDLE_ID" ] && [ -f "$IOS_DIR/bundle_id.local" ]; then
|
||||
BUNDLE_ID="$(tr -d '[:space:]' < "$IOS_DIR/bundle_id.local")"
|
||||
fi
|
||||
LOVE_VERSION="$(tr -d '[:space:]' < "$IOS_DIR/LOVE_VERSION" 2>/dev/null || echo 11.5)"
|
||||
IOS_SOURCE_ZIP="love-${LOVE_VERSION}-ios-source.zip"
|
||||
APPLE_LIBS_ZIP="love-${LOVE_VERSION}-apple-libraries.zip"
|
||||
@@ -46,6 +64,8 @@ FETCH=false
|
||||
DEVICE=false
|
||||
RELEASE=false
|
||||
PACKAGE_ONLY=false
|
||||
INSTALL=false
|
||||
VERSION=""
|
||||
|
||||
say() { printf '\033[1;32m==>\033[0m %s\n' "$*"; }
|
||||
warn() { printf '\033[1;33mwarn:\033[0m %s\n' "$*" >&2; }
|
||||
@@ -57,15 +77,64 @@ while [ $# -gt 0 ]; do
|
||||
--device) DEVICE=true ;;
|
||||
--release) RELEASE=true ;;
|
||||
--package-only) PACKAGE_ONLY=true ;;
|
||||
--install) INSTALL=true ;;
|
||||
--version) VERSION="$2"; shift ;;
|
||||
-h|--help)
|
||||
sed -n '2,22p' "$0"
|
||||
sed -n '2,24p' "$0"
|
||||
exit 0
|
||||
;;
|
||||
*) fail "unknown argument: $1 (try --fetch, --device, --release, or --package-only)" ;;
|
||||
*) fail "unknown argument: $1 (try --fetch, --device, --release, --version, --install, or --package-only)" ;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
|
||||
VERSION_CODE=""
|
||||
if [ -n "$VERSION" ]; then
|
||||
if ! printf '%s' "$VERSION" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$'; then
|
||||
fail "invalid --version '$VERSION' (expected X.Y.Z)"
|
||||
fi
|
||||
major="${VERSION%%.*}"
|
||||
rest="${VERSION#*.}"
|
||||
minor="${rest%%.*}"
|
||||
patch="${rest##*.}"
|
||||
VERSION_CODE=$((major * 10000 + minor * 100 + patch))
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------- signing identity
|
||||
# Auto-detect the Apple Development team when the caller didn't set one.
|
||||
# Prefer a *valid* identity from `find-identity` (the parenthetical there is
|
||||
# the cert id, not the team), then read that cert's OU. Scanning every
|
||||
# "Apple Development" certificate picks expired personal/work certs first.
|
||||
detect_team() {
|
||||
local cn
|
||||
cn="$(security find-identity -v -p codesigning 2>/dev/null \
|
||||
| sed -n -E 's/.*"Apple Development: ([^"]+)".*/\1/p' \
|
||||
| head -1)"
|
||||
[ -n "$cn" ] || return 1
|
||||
security find-certificate -c "Apple Development: $cn" -p 2>/dev/null \
|
||||
| openssl x509 -noout -subject 2>/dev/null \
|
||||
| sed -n 's/.*OU *= *\([A-Z0-9]*\).*/\1/p' \
|
||||
| head -1
|
||||
}
|
||||
if $DEVICE && [ -z "${DEVELOPMENT_TEAM:-}" ]; then
|
||||
DEVELOPMENT_TEAM="$(detect_team || true)"
|
||||
if [ -n "$DEVELOPMENT_TEAM" ]; then
|
||||
say "signing team auto-detected from keychain: $DEVELOPMENT_TEAM"
|
||||
else
|
||||
fail "no Apple signing identity found.
|
||||
Open Xcode -> Settings -> Accounts, press +, and sign in with your
|
||||
Apple ID (a free account works). That creates the certificate this
|
||||
script signs with. Then re-run this command."
|
||||
fi
|
||||
fi
|
||||
if [ -z "$BUNDLE_ID" ]; then
|
||||
if $DEVICE; then
|
||||
BUNDLE_ID="com.gen1recomp.t$(printf '%s' "$DEVELOPMENT_TEAM" | tr '[:upper:]' '[:lower:]')"
|
||||
else
|
||||
BUNDLE_ID="com.theboisclub.pokemonred"
|
||||
fi
|
||||
fi
|
||||
|
||||
# --------------------------------------------------------------- host checks
|
||||
if [ "$(uname -s)" != "Darwin" ]; then
|
||||
fail "iOS builds require macOS (Darwin). This host is $(uname -s).
|
||||
@@ -166,16 +235,24 @@ pack_game_love() {
|
||||
rm -f "$LOVE_FILE"
|
||||
# Same payload as scripts/build.sh / build_android.sh: game sources plus
|
||||
# tools/save-editor, which the launcher's Edit button opens in-process.
|
||||
# Deliberately NO fused mods: a mod inside game.love sits in the
|
||||
# read-only app bundle, so the mod manager's Delete can't remove it and
|
||||
# it reappears every launch. Mods install as .zips at runtime instead
|
||||
# (launcher -> MODS -> Import mod .zip), the same lifecycle as every
|
||||
# other platform.
|
||||
(cd "$ROOT" && zip -q -9 -r "$LOVE_FILE" \
|
||||
main.lua conf.lua src data assets tools/save-editor \
|
||||
tools/rom_manifest.json tools/rom_manifest_blue.json \
|
||||
-x '*.DS_Store' -x '*/.git/*' -x '*/.DS_Store' \
|
||||
-x 'data/generated/*' -x 'assets/generated/*')
|
||||
# NOTE: grep -q here would race pipefail — it exits on first match, unzip
|
||||
# dies of SIGPIPE (141), and the pipeline "fails" nondeterministically.
|
||||
# >/dev/null keeps grep reading the whole stream instead.
|
||||
if unzip -Z1 "$LOVE_FILE" \
|
||||
| grep -Eq '^(data|assets)/generated/[^/]+|^(data|assets)/generated/.+/'; then
|
||||
| grep -E '^(data|assets)/generated/[^/]+|^(data|assets)/generated/.+/' >/dev/null; then
|
||||
fail "game.love unexpectedly contains generated ROM data"
|
||||
fi
|
||||
unzip -Z1 "$LOVE_FILE" | grep -qx 'tools/save-editor/App.lua' \
|
||||
unzip -Z1 "$LOVE_FILE" | grep -x 'tools/save-editor/App.lua' >/dev/null \
|
||||
|| fail "game.love is missing the save editor (Edit on a save row would crash)"
|
||||
say "game.love: $(du -h "$LOVE_FILE" | cut -f1) -> $LOVE_FILE"
|
||||
}
|
||||
@@ -291,6 +368,15 @@ run_xcodebuild() {
|
||||
|
||||
# Prefer -target + SYMROOT over -derivedDataPath: modern Xcode requires
|
||||
# -scheme whenever -derivedDataPath is set, and love-ios ships no shared schemes.
|
||||
# Always stamp both: the overlay plist expands $(MARKETING_VERSION) /
|
||||
# $(CURRENT_PROJECT_VERSION), and love-ios has no project-level defaults.
|
||||
local marketing_version="$LOVE_VERSION"
|
||||
local project_version="1"
|
||||
if [ -n "$VERSION" ]; then
|
||||
marketing_version="$VERSION"
|
||||
project_version="$VERSION_CODE"
|
||||
fi
|
||||
|
||||
local args=(
|
||||
-project "$PROJECT"
|
||||
-target love-ios
|
||||
@@ -300,18 +386,26 @@ run_xcodebuild() {
|
||||
SYMROOT="$BUILD_DIR/Build/Products"
|
||||
OBJROOT="$BUILD_DIR/Build/Intermediates"
|
||||
PRODUCT_BUNDLE_IDENTIFIER="$BUNDLE_ID"
|
||||
PRODUCT_NAME="$APP_NAME"
|
||||
MARKETING_VERSION="$LOVE_VERSION"
|
||||
MARKETING_VERSION="$marketing_version"
|
||||
CURRENT_PROJECT_VERSION="$project_version"
|
||||
ONLY_ACTIVE_ARCH=NO
|
||||
)
|
||||
|
||||
if ! $DEVICE; then
|
||||
# Simulator: no signing required
|
||||
args+=(CODE_SIGNING_ALLOWED=NO CODE_SIGNING_REQUIRED=NO CODE_SIGN_IDENTITY=)
|
||||
# Simulator: ad-hoc signing (no certificate needed). A plain unsigned
|
||||
# build would drop the entitlements file, and HealthKit refuses to run
|
||||
# without the com.apple.developer.healthkit entitlement even in the
|
||||
# simulator.
|
||||
args+=(CODE_SIGNING_ALLOWED=YES CODE_SIGNING_REQUIRED=NO CODE_SIGN_IDENTITY=-)
|
||||
else
|
||||
warn "device build: configure signing in Xcode or set DEVELOPMENT_TEAM / CODE_SIGN_IDENTITY"
|
||||
if [ -n "${DEVELOPMENT_TEAM:-}" ]; then
|
||||
args+=(DEVELOPMENT_TEAM="$DEVELOPMENT_TEAM")
|
||||
# Automatic signing + provisioning updates lets xcodebuild register the
|
||||
# bundle ID / create a development profile from the CLI, so a device
|
||||
# build works without ever opening the project in Xcode.
|
||||
args+=(DEVELOPMENT_TEAM="$DEVELOPMENT_TEAM"
|
||||
CODE_SIGN_STYLE=Automatic
|
||||
-allowProvisioningUpdates)
|
||||
fi
|
||||
if [ -n "${CODE_SIGN_IDENTITY:-}" ]; then
|
||||
args+=(CODE_SIGN_IDENTITY="$CODE_SIGN_IDENTITY")
|
||||
@@ -364,20 +458,74 @@ run_xcodebuild() {
|
||||
local dist_dir="$DIST/${config}-${sdk}"
|
||||
rm -rf "$dist_dir"
|
||||
mkdir -p "$dist_dir"
|
||||
cp -R "$app" "$dist_dir/"
|
||||
say "copied to $dist_dir/$(basename "$app")"
|
||||
cp -R "$app" "$dist_dir/$APP_NAME.app"
|
||||
say "copied to $dist_dir/$APP_NAME.app"
|
||||
|
||||
if $DEVICE; then
|
||||
package_ipa "$dist_dir/$APP_NAME.app"
|
||||
fi
|
||||
|
||||
say "iOS app: $app"
|
||||
say "bundle id: $BUNDLE_ID display: $DISPLAY_NAME"
|
||||
if $DEVICE; then
|
||||
warn "signing/provisioning is manual, see mobile/ios/README.md"
|
||||
if $INSTALL; then
|
||||
install_to_device "$app"
|
||||
else
|
||||
say "install with: scripts/build_ios.sh --device --install (iPhone plugged in + unlocked)"
|
||||
fi
|
||||
else
|
||||
say "simulator tip: xcrun simctl install booted \"$app\""
|
||||
fi
|
||||
}
|
||||
|
||||
# Pack Payload/<app>.app into dist/ios/gen1recomp.ipa for release / sideload tools.
|
||||
package_ipa() {
|
||||
local app="$1"
|
||||
local ipa="$DIST/$APP_NAME.ipa"
|
||||
local tmp
|
||||
tmp="$(mktemp -d "$DIST/ipa.XXXXXX")"
|
||||
mkdir -p "$tmp/Payload"
|
||||
cp -R "$app" "$tmp/Payload/$(basename "$app")"
|
||||
rm -f "$ipa"
|
||||
(cd "$tmp" && zip -q -r "$ipa" Payload)
|
||||
rm -rf "$tmp"
|
||||
say "ipa: $ipa ($(du -h "$ipa" | cut -f1))"
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------ device install
|
||||
# Installs the freshly built .app onto the first connected iPhone/iPad via
|
||||
# devicectl. The phone must be paired (plugged in at least once + "Trust
|
||||
# This Computer") and UNLOCKED during the install.
|
||||
install_to_device() {
|
||||
local app="$1"
|
||||
local line udid
|
||||
line="$(xcrun devicectl list devices 2>/dev/null \
|
||||
| grep -E 'iPhone|iPad' | grep -v 'Watch' | head -1 || true)"
|
||||
udid="$(printf '%s' "$line" \
|
||||
| grep -Eo '[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}' \
|
||||
| head -1 || true)"
|
||||
if [ -z "$udid" ]; then
|
||||
fail "no iPhone/iPad found.
|
||||
Plug the phone in with a cable, unlock it, tap 'Trust This Computer'
|
||||
if asked, then re-run: scripts/build_ios.sh --device --install"
|
||||
fi
|
||||
say "installing onto: $(printf '%s' "$line" | sed 's/ .*//') ($udid)"
|
||||
if xcrun devicectl device install app --device "$udid" "$app"; then
|
||||
say "installed. On the phone: tap the new app on your Home Screen."
|
||||
say "first launch may ask you to enable Developer Mode (Settings ->"
|
||||
say "Privacy & Security -> Developer Mode) and to trust the developer"
|
||||
say "(Settings -> General -> VPN & Device Management)."
|
||||
else
|
||||
fail "install failed. Most common cause: the phone was locked.
|
||||
Unlock it, keep it plugged in, and re-run:
|
||||
scripts/build_ios.sh --device --install"
|
||||
fi
|
||||
}
|
||||
|
||||
# --------------------------------------------------------------- main
|
||||
apply_ios_branding
|
||||
say "applying iOS native bridge patches (picker/Files support)"
|
||||
python3 "$IOS_DIR/patch_love_src.py" || fail "patch_love_src.py failed"
|
||||
pack_game_love
|
||||
ensure_game_love_in_xcode
|
||||
|
||||
|
||||
+216
-55
@@ -4,7 +4,7 @@
|
||||
-- Flow: intro -> menu (FIGHT/PKMN/ITEM/RUN) -> move select -> turn
|
||||
-- resolution (a queue of messages/actions/UI pushes) -> back to menu,
|
||||
-- until one side is out, then finish. Pops itself and calls
|
||||
-- onFinish("win"|"lose"|"run"|"caught"|"skipped").
|
||||
-- onFinish("win"|"lose"|"run"|"caught").
|
||||
--
|
||||
-- The Gen 1 move-effect pipeline (multi-hit, charge, trapping, thrash,
|
||||
-- bide, recharge, confusion, screens, substitute, transform, ...) is
|
||||
@@ -38,15 +38,16 @@ BattleState.letterboxWhite = true
|
||||
|
||||
-- BATTLE LAYOUT: the classic 160x144 arrangement, or the widescreen one on
|
||||
-- a 304x144 surface (src/battle/WideBattle.lua). Only the composition
|
||||
-- differs; every battler, queue and animation below is shared. The wide
|
||||
-- layout is live only while this battle is the state being drawn on top --
|
||||
-- a party menu or bag pushed over it is a 160x144 screen, so the surface
|
||||
-- goes back with it and the battle underneath is not drawn at all.
|
||||
function BattleState:wideLayout()
|
||||
-- differs; every battler, queue and animation below is shared. Menus and
|
||||
-- prompts pushed during a wide battle keep its wide canvas, while drawing
|
||||
-- their classic 160px UI centred within it (Game:draw).
|
||||
function BattleState:isWideBattleLayout()
|
||||
local options = self.game and self.game.save and self.game.save.options
|
||||
if not options or options.battleLayout ~= "wide" then return false end
|
||||
local stack = self.game.stack
|
||||
return (stack and stack.top and stack:top()) == self
|
||||
return options and options.battleLayout == "wide" or false
|
||||
end
|
||||
|
||||
function BattleState:wideLayout()
|
||||
return self:isWideBattleLayout()
|
||||
end
|
||||
|
||||
-- Renderer:setUISize asks the top state for its surface before anything draws
|
||||
@@ -187,6 +188,36 @@ local function namedPalette(data, name)
|
||||
return { name = key, colors = colors }
|
||||
end
|
||||
|
||||
-- Custom trainer portraits can opt into the same Advanced OBJ palette source
|
||||
-- as their overworld walker. Vanilla trainers preserve the hardware-faithful
|
||||
-- MEWMON fallback used during the battle introduction.
|
||||
function BattleState.trainerPalette(data, trainer)
|
||||
local source = trainer and trainer.paletteSource
|
||||
if source then
|
||||
local PaletteFX = require("src.render.PaletteFX")
|
||||
local colors, group = PaletteFX.spriteObp({ paletteSource = source }, trainer.id)
|
||||
if colors then
|
||||
return { name = "trainer:" .. source .. ":" .. tostring(group), colors = colors }
|
||||
end
|
||||
end
|
||||
return namedPalette(data, "MEWMON")
|
||||
end
|
||||
|
||||
-- Yellow only: ROCKET with wTrainerNo >= $2a is Jessie & James, who share
|
||||
-- the class and the name "ROCKET" but battle behind their own pic
|
||||
-- (home/trainers2.asm IsFightingJessieJames). picJessieJames exists only
|
||||
-- in a Yellow cache extracted after #439, so an older cache keeps the
|
||||
-- grunt pic until it is re-imported.
|
||||
function BattleState.trainerPicPath(data, trainer, oppClass, partyIndex)
|
||||
if oppClass == "OPP_ROCKET" and (partyIndex or 1) >= 42
|
||||
and trainer and trainer.picJessieJames then
|
||||
return trainer.picJessieJames
|
||||
end
|
||||
if trainer and trainer.pic then return trainer.pic end
|
||||
local base = trainer and trainer.basePic and data.trainers[trainer.basePic]
|
||||
return base and base.pic or nil
|
||||
end
|
||||
|
||||
-- The battle-BGP fade variant of a pic (AnimationFlashScreen and the
|
||||
-- SetAnimationBGPalette effects remap the four BG shades; on the SGB
|
||||
-- the colorizer then colors the REMAPPED shade, so a faded pic shows
|
||||
@@ -607,7 +638,9 @@ function BattleState.newTrainer(game, oppClass, partyIndex)
|
||||
-- MonsterPalettes[0] = PAL_MEWMON -- InitBattleCommon zeroes
|
||||
-- wEnemyMonSpecies2 before the intro's SET_PAL_BATTLE
|
||||
-- (engine/battle/core.asm:6682, engine/gfx/palettes.asm SetPal_Battle)
|
||||
self.trainerPic = getImage(self.trainer.pic, namedPalette(game.data, "MEWMON"))
|
||||
self.trainerPic = getImage(
|
||||
BattleState.trainerPicPath(game.data, self.trainer, oppClass, partyIndex),
|
||||
BattleState.trainerPalette(game.data, self.trainer))
|
||||
self.introText = Strings("%s wants\nto fight!", self.trainer.name)
|
||||
return self
|
||||
end
|
||||
@@ -739,10 +772,17 @@ function BattleState:buildScreen(id, ...)
|
||||
end
|
||||
|
||||
-- insert a wait for the HP bars to finish draining (UpdateHPBar):
|
||||
-- the queue holds until every battler's displayed HP catches up
|
||||
function BattleState:drainNext()
|
||||
-- the queue holds until every battler's displayed HP catches up.
|
||||
-- `stopAt` pins how far that battler's bar may drain on this row. A
|
||||
-- multi-hit move takes every strike off the model while the turn is still
|
||||
-- being queued, so an unpinned row would drain straight to the
|
||||
-- post-last-hit HP and the later strikes would animate nothing (#394);
|
||||
-- ApplyDamageToEnemyPokemon runs UpdateHPBar2 once per strike inside the
|
||||
-- wNumAttacksLeft loop (engine/battle/core.asm:4727).
|
||||
function BattleState:drainNext(battler, stopAt)
|
||||
self.nextInsert = (self.nextInsert or 0) + 1
|
||||
table.insert(self.queue, self.nextInsert, { drain = true })
|
||||
table.insert(self.queue, self.nextInsert,
|
||||
{ drain = true, battler = battler, stopAt = stopAt })
|
||||
end
|
||||
|
||||
-- One frame of the HP-bar drain (engine/gfx/hp_bar.asm UpdateHPBar):
|
||||
@@ -751,14 +791,22 @@ end
|
||||
function BattleState:stepHPDrain()
|
||||
local busy = false
|
||||
for _, b in ipairs({ self.player, self.enemy }) do
|
||||
if b and b.shownHP and b.shownHP ~= b.mon.hp then
|
||||
local step = math.max(1, b.mon.stats.hp) / 96
|
||||
if b.shownHP > b.mon.hp then
|
||||
b.shownHP = math.max(b.mon.hp, b.shownHP - step)
|
||||
else
|
||||
b.shownHP = math.min(b.mon.hp, b.shownHP + step)
|
||||
if b and b.shownHP then
|
||||
-- drainFloor is the stop the running row carries (see drainNext)
|
||||
local goal = b.mon.hp
|
||||
if b.drainFloor and b.drainFloor > goal
|
||||
and b.shownHP >= b.drainFloor then
|
||||
goal = b.drainFloor
|
||||
end
|
||||
if b.shownHP ~= goal then
|
||||
local step = math.max(1, b.mon.stats.hp) / 96
|
||||
if b.shownHP > goal then
|
||||
b.shownHP = math.max(goal, b.shownHP - step)
|
||||
else
|
||||
b.shownHP = math.min(goal, b.shownHP + step)
|
||||
end
|
||||
busy = busy or b.shownHP ~= goal
|
||||
end
|
||||
busy = busy or b.shownHP ~= b.mon.hp
|
||||
end
|
||||
end
|
||||
return busy
|
||||
@@ -836,6 +884,8 @@ function BattleState:updateQueue()
|
||||
if self.draining then
|
||||
if self:stepHPDrain() then return true end
|
||||
self.draining = nil
|
||||
if self.player then self.player.drainFloor = nil end
|
||||
if self.enemy then self.enemy.drainFloor = nil end
|
||||
end
|
||||
-- a move animation holds the queue until it finishes; its screen
|
||||
-- effects (SE_*) and per-row sounds route into the fx layer as they
|
||||
@@ -874,6 +924,7 @@ function BattleState:updateQueue()
|
||||
end
|
||||
if item.drain then
|
||||
self.draining = true
|
||||
if item.battler then item.battler.drainFloor = item.stopAt end
|
||||
return true
|
||||
end
|
||||
if item.wait then
|
||||
@@ -1096,10 +1147,34 @@ function BattleState:battleKind()
|
||||
end
|
||||
|
||||
function BattleState:enter()
|
||||
if self.dead then
|
||||
-- Out of useable POKéMON before the battle even starts. pokered does not
|
||||
-- skip the battle: .checkAnyPartyAlive (engine/battle/core.asm:158-162)
|
||||
-- runs right after the intro and jumps to HandlePlayerBlackOut, so the
|
||||
-- player blacks out and afterBattle's "lose" path revives the party at the
|
||||
-- last heal point. Handing the map back with a 0 HP party instead bricked
|
||||
-- the save: every later encounter aborted here too and sighted trainers
|
||||
-- re-engaged forever (#425). self.dead alone is not the test --
|
||||
-- makeOldManDemo and LinkBattle install a player battler after the
|
||||
-- constructor flagged the battle dead.
|
||||
if self.dead and not self.player then
|
||||
local name = self.game.save.player.name
|
||||
self.result = "lose"
|
||||
self.game.stack:pop()
|
||||
Runtime.emit("battle.ended", { battle = self, result = "skipped" })
|
||||
if self.onFinish then self.onFinish("skipped") end
|
||||
require("src.core.Music").restoreMap(self.data)
|
||||
Runtime.emit("battle.ended", { battle = self, result = "lose", skipped = true })
|
||||
local onFinish = self.onFinish
|
||||
local function blackedOut()
|
||||
if onFinish then onFinish("lose") end
|
||||
end
|
||||
-- the Oak's Lab starter rival returns above PlayerBlackedOutText2 and
|
||||
-- afterBattle keeps the player in the lab, so print nothing there
|
||||
if BattleState.isOaksLabStarterRival(self) then return blackedOut() end
|
||||
-- _PlayerBlackedOutText2 (data/text/text_2.asm:896): the two paragraphs
|
||||
-- playerMonFainted queues on the battle screen; there is no battle
|
||||
-- screen to queue them on here, so they print over the map.
|
||||
self.game.stack:push(require("src.render.TextBox").new(self.game,
|
||||
Strings("%s is out of\nuseable POKéMON!", name) .. "\f"
|
||||
.. Strings("%s blacked\nout!", name), blackedOut))
|
||||
return
|
||||
end
|
||||
local Music = require("src.core.Music")
|
||||
@@ -1379,6 +1454,7 @@ function BattleState:update(dt)
|
||||
for _, b in ipairs({ self.player, self.enemy }) do
|
||||
if b then
|
||||
if b.shownHP then b.shownHP = b.mon.hp end
|
||||
b.drainFloor = nil
|
||||
b.shownStatus = b.mon.status
|
||||
end
|
||||
end
|
||||
@@ -1936,6 +2012,15 @@ function BattleState:resolveSwitch(newMon)
|
||||
end
|
||||
|
||||
function BattleState:endOfTurn()
|
||||
-- the same ret: a decided battle never reaches HandlePoisonBurnLeechSeed
|
||||
-- or CheckNumAttacksLeft (core.asm:417-421, 456-460), so the residual
|
||||
-- sweep and the trapping-counter release are skipped on the turn a
|
||||
-- Teleport escape (or a win/loss/capture) settles it (#441). The
|
||||
-- turn_ended hook still fires: mods count turns, not residuals.
|
||||
if self.result then
|
||||
Runtime.emit("battle.turn_ended", { battle = self, turn = self.turnCount or 0 })
|
||||
return
|
||||
end
|
||||
-- sideToxic mirrors w*ToxicCounter: it advances only while the
|
||||
-- battler's badly-poisoned flag (toxicCounter) is set, an item/AI
|
||||
-- cure clears the flag but NOT the side counter, and a fresh Toxic
|
||||
@@ -2153,6 +2238,31 @@ local function startPicKind(pf, kind)
|
||||
pf.hidden = nil
|
||||
end
|
||||
|
||||
-- PredefShakeScreenHorizontally (engine/gfx/screen_effects.asm): the window
|
||||
-- jumps right by b for 5 frames then home for 4, b counting down to 1.
|
||||
-- b = 8 for SE_SHAKE_SCREEN and the heavy applying-attack shake, b = 2 for
|
||||
-- the light one.
|
||||
local function fastShakeProg(b)
|
||||
local prog = {}
|
||||
for i = b, 1, -1 do
|
||||
prog[#prog + 1] = { dx = i, frames = 5 }
|
||||
prog[#prog + 1] = { dx = 0, frames = 4 }
|
||||
end
|
||||
return prog
|
||||
end
|
||||
|
||||
-- AnimationShakeScreenHorizontallySlow (engine/battle/animations.asm:526):
|
||||
-- rWX creeps 1px right every 2 frames b times, then back down to 0, c times
|
||||
-- over. Silent -- this is the non-damaging move's feedback.
|
||||
local function slowShakeProg(b, c)
|
||||
local prog = {}
|
||||
for _ = 1, c do
|
||||
for i = 1, b do prog[#prog + 1] = { dx = i, frames = 2 } end
|
||||
for i = b - 1, 0, -1 do prog[#prog + 1] = { dx = i, frames = 2 } end
|
||||
end
|
||||
return prog
|
||||
end
|
||||
|
||||
-- Route one AnimPlayer event into the fx layer. Frame counts and
|
||||
-- amplitudes are the routines' own (engine/battle/animations.asm;
|
||||
-- shakes: engine/gfx/screen_effects.asm).
|
||||
@@ -2194,14 +2304,7 @@ function BattleState:applyAnimEffect(ev)
|
||||
|
||||
-- ---------------------------------------------- screen shakes
|
||||
elseif e == "SE_SHAKE_SCREEN" then
|
||||
-- PredefShakeScreenHorizontally b=8: the window jumps right by b
|
||||
-- for 5 frames then home for 4, b counting down 8..1
|
||||
local prog = {}
|
||||
for b = 8, 1, -1 do
|
||||
prog[#prog + 1] = { dx = b, frames = 5 }
|
||||
prog[#prog + 1] = { dx = 0, frames = 4 }
|
||||
end
|
||||
fx.shakeProg = prog
|
||||
fx.shakeProg = fastShakeProg(8)
|
||||
elseif e == "SE_ROCK_SLIDE_SHAKE" then
|
||||
-- DoRockSlideSpecialEffects: 1px horizontal then vertical rumble
|
||||
fx.shakeProg = { { dx = 1, frames = 5 }, { dx = 0, frames = 4 },
|
||||
@@ -2298,34 +2401,77 @@ function BattleState:applyAnimEffect(ev)
|
||||
-- battler.substituteHP is set (MoveEffects raises it with the move)
|
||||
end
|
||||
|
||||
-- The target's post-animation hit feedback (PlayApplyingAttackAnimation,
|
||||
-- engine/battle/animations.asm:475): the player's damaging moves blink
|
||||
-- the ENEMY pic; the enemy's damaging moves shake the screen vertically
|
||||
-- (ShakeScreenVertically -> PredefShakeScreenVertically b=8: the window
|
||||
-- drops by b for 3 frames then home for 3, b counting down) -- the
|
||||
-- player's pic never blinks. Damage sound with either. A hold keeps
|
||||
-- The post-animation applying-attack feedback (PlayApplyingAttackAnimation
|
||||
-- -> AnimationTypePointerTable, engine/battle/animations.asm:475-524).
|
||||
-- hit.animType is wAnimationType, 1..6:
|
||||
-- 1 enemy damaging, no added effect ShakeScreenVertically (b=8)
|
||||
-- 2 enemy damaging, added effect fast horizontal shake, b=8
|
||||
-- 3 enemy non-damaging slow horizontal shake, b=6, c=2
|
||||
-- 4 player damaging, no added effect BlinkEnemyMonSprite
|
||||
-- 5 player damaging, added effect fast horizontal shake, b=2
|
||||
-- 6 player non-damaging slow horizontal shake, b=3, c=2
|
||||
-- Types 3 and 6 are silent; the rest open with PlayApplyingAttackSound,
|
||||
-- which is the damage sound hit.sfx already carries. Only 1 and 4 were
|
||||
-- implemented, so every move with an added effect blinked (or shook
|
||||
-- vertically) instead of shaking sideways and every status move showed
|
||||
-- nothing at all -- Bubblebeam, Confusion, Hypnosis (#354). A hold keeps
|
||||
-- the queue still until the effect finishes.
|
||||
function BattleState:applyHitFx(hit)
|
||||
if hit.blink then
|
||||
self.fx = self.fx or {}
|
||||
if hit.blink.isPlayer then
|
||||
local prog = {}
|
||||
for b = 8, 1, -1 do
|
||||
prog[#prog + 1] = { dy = b, frames = 3 }
|
||||
prog[#prog + 1] = { dy = 0, frames = 3 }
|
||||
end
|
||||
self.fx.shakeProg = prog
|
||||
self.waitFrames = 48 -- the predef blocks until the shake settles
|
||||
else
|
||||
self.fx.blink = { target = hit.blink, frames = 20 }
|
||||
self.waitFrames = 20
|
||||
end
|
||||
end
|
||||
self.fx = self.fx or {}
|
||||
-- rows queued before animType existed carry only the blink target
|
||||
local t = hit.animType
|
||||
if not t and hit.blink then t = hit.blink.isPlayer and 1 or 4 end
|
||||
if hit.sfx then
|
||||
require("src.core.Sound").play(self.data, hit.sfx)
|
||||
end
|
||||
if not t or not self:animationsOn() then return end
|
||||
if t == 1 then
|
||||
-- PredefShakeScreenVertically b=8: the window drops by b for 3 frames
|
||||
-- then home for 3, b counting down
|
||||
local prog = {}
|
||||
for b = 8, 1, -1 do
|
||||
prog[#prog + 1] = { dy = b, frames = 3 }
|
||||
prog[#prog + 1] = { dy = 0, frames = 3 }
|
||||
end
|
||||
self.fx.shakeProg = prog
|
||||
self.waitFrames = 48 -- the predef blocks until the shake settles
|
||||
elseif t == 2 then
|
||||
self.fx.shakeProg = fastShakeProg(8)
|
||||
self.waitFrames = 72
|
||||
elseif t == 3 then
|
||||
self.fx.shakeProg = slowShakeProg(6, 2)
|
||||
self.waitFrames = 48
|
||||
elseif t == 4 then
|
||||
if hit.blink then
|
||||
self.fx.blink = { target = hit.blink, frames = 20 }
|
||||
self.waitFrames = 20
|
||||
end
|
||||
elseif t == 5 then
|
||||
self.fx.shakeProg = fastShakeProg(2)
|
||||
self.waitFrames = 18
|
||||
elseif t == 6 then
|
||||
self.fx.shakeProg = slowShakeProg(3, 2)
|
||||
self.waitFrames = 24
|
||||
end
|
||||
end
|
||||
|
||||
-- Primary status effects whose pokered handler ends in
|
||||
-- PlayCurrentMoveAnimation2 (engine/battle/effects.asm:1448), which sets
|
||||
-- wAnimationType 6 on the player's turn and 3 on the enemy's: sleep,
|
||||
-- poison, confuse, disable and the primary stat-down effects. Every other
|
||||
-- primary effect goes through PlayCurrentMoveAnimation and leaves the type
|
||||
-- at 0 (no applying animation): paralysis (FreezeBurnParalyzeEffect),
|
||||
-- leech seed, the stat-UP effects, Splash. Side-effect stat drops are
|
||||
-- skipped too -- UpdateLoweredStatDone bails out for them because the
|
||||
-- damaging move's own type 2/5 shake already played.
|
||||
local SLOW_SHAKE_EFFECTS = {
|
||||
SLEEP_EFFECT = true, POISON_EFFECT = true, CONFUSION_EFFECT = true,
|
||||
DISABLE_EFFECT = true,
|
||||
ATTACK_DOWN1_EFFECT = true, DEFENSE_DOWN1_EFFECT = true,
|
||||
DEFENSE_DOWN2_EFFECT = true, SPEED_DOWN1_EFFECT = true,
|
||||
ACCURACY_DOWN1_EFFECT = true,
|
||||
}
|
||||
|
||||
-- AnimateSendingOutMon (core.asm:6801-6838): the mon grows out of the
|
||||
-- ball -- a 3-frame ball beat, 4 frames of the pic at 3/7 scale (a 3x3
|
||||
-- block of its 7x7 tiles), 5 frames at 5/7 (5x5), then full size.
|
||||
@@ -2545,6 +2691,12 @@ function BattleState:syncShownStatus()
|
||||
end
|
||||
|
||||
function BattleState:executeAction(user, target, action)
|
||||
-- MainInBattleLoop reads wEscapedFromBattle right after Execute*Move and
|
||||
-- rets (core.asm:417-421, 456-460): a Teleport/Roar/Whirlwind escape ends
|
||||
-- the turn where it lands and the second mover never moves. self.result
|
||||
-- is only ever set once the battle is over (run/win/lose/caught), and the
|
||||
-- faint cases are already covered by the HP guard below (#441)
|
||||
if self.result then return end
|
||||
if user.mon.hp <= 0 or target.mon.hp <= 0 then return end
|
||||
if not action then return end
|
||||
|
||||
@@ -2909,6 +3061,8 @@ function BattleState:performMove(user, target, moveInst, isCalled)
|
||||
-- (AlreadyAsleep / NothingHappened / ButItFailed print with no anim)
|
||||
if primaryEffectFailed(msgs) then
|
||||
self:cancelMoveAnim()
|
||||
elseif SLOW_SHAKE_EFFECTS[move.effect] and self.moveAnimRow then
|
||||
self.moveAnimRow.hit = { animType = user.isPlayer and 6 or 3 }
|
||||
end
|
||||
for _, m in ipairs(msgs) do
|
||||
self:sayNext(m)
|
||||
@@ -2962,6 +3116,10 @@ function BattleState:continueBide(user, target)
|
||||
self:sayNext(Strings("But, it failed!"))
|
||||
return
|
||||
end
|
||||
-- .UnleashEnergy (core.asm:3501-3529) re-points wPlayerMoveNum at BIDE
|
||||
-- and rejoins HandleIfPlayerMoveMissed, so BIDE's own animation plays
|
||||
-- here, after UnleashedEnergyText and before the damage (#375)
|
||||
self:animNext("BIDE", user.isPlayer)
|
||||
self:applyDamage(target, dmg)
|
||||
if target.mon.hp <= 0 then self:onFaint(target) end
|
||||
end
|
||||
@@ -2986,7 +3144,7 @@ function BattleState:applyDamage(target, dmg)
|
||||
end
|
||||
local dealt = math.min(dmg, target.mon.hp)
|
||||
target.mon.hp = target.mon.hp - dealt
|
||||
if dealt > 0 then self:drainNext() end -- animate the bar down
|
||||
if dealt > 0 then self:drainNext(target, target.mon.hp) end -- animate the bar down
|
||||
if target.bideTurns then
|
||||
target.bideDamage = (target.bideDamage or 0) + dealt
|
||||
end
|
||||
@@ -3833,12 +3991,15 @@ function BattleState:throwBall(ball)
|
||||
self:act(function() self:endOfTurn() end)
|
||||
return
|
||||
end
|
||||
if self.ghost then
|
||||
if self.ghost or self.noCatch then
|
||||
-- ItemUseBall's can't-be-caught path (item_effects.asm:149-153):
|
||||
-- the ball is thrown (TossBallAnimation still picks the arc from
|
||||
-- wCurItem, so a Master/Ultra toss keeps its flicker), dodged
|
||||
-- ($10 anim data, no wobbles), and the turn is spent like any
|
||||
-- failed throw
|
||||
-- failed throw. battle.noCatch is the .notOldManBattle half of the
|
||||
-- same check (item_effects.asm:166-175): the POKEMON_TOWER_6F
|
||||
-- RESTLESS SOUL dodges balls even once the scope has revealed it,
|
||||
-- so it is not a ghost battle any more (#444)
|
||||
self:animNext(self:tossAnimFor(ball), true, nil, ball)
|
||||
self:sayNext(Strings("It dodged the\nthrown BALL!"))
|
||||
self:sayNext(Strings("This POKéMON\ncan't be caught!"))
|
||||
|
||||
@@ -186,7 +186,15 @@ function EffectRegistry.runDamaging(battle, ctx, record)
|
||||
-- hitRow carries the blink instead.
|
||||
local hitSfx = info.typeMult > 10 and "Super_Effective"
|
||||
or info.typeMult < 10 and "Not_Very_Effective" or "Damage"
|
||||
-- GetPlayerAnimationType / GetEnemyAnimationType (engine/battle/core.asm
|
||||
-- :3159 / :5555): wAnimationType is 4 (blink the enemy pic) or 1 (shake
|
||||
-- the screen vertically) for a damaging move with no added effect, and
|
||||
-- 5 / 2 (a horizontal shake) as soon as the move HAS one -- which is why
|
||||
-- Bubblebeam and Confusion shake instead of blinking (#354)
|
||||
local added = move.effect ~= nil and move.effect ~= "NO_ADDITIONAL_EFFECT"
|
||||
local hitFx = { sfx = hitSfx,
|
||||
animType = user.isPlayer and (added and 5 or 4)
|
||||
or (added and 2 or 1),
|
||||
blink = battle:animationsOn() and target or nil }
|
||||
|
||||
local totalDealt = 0
|
||||
|
||||
@@ -609,10 +609,16 @@ MoveEffects.full = {
|
||||
},
|
||||
|
||||
BIDE_EFFECT = {
|
||||
-- BideEffect (effects.asm:764-789) is a ResidualEffects2 entry: the
|
||||
-- storing turn plays XSTATITEM_ANIM (XSTATITEM_DUPLICATE_ANIM on the
|
||||
-- enemy side) and never BIDE's own animation, which belongs to the
|
||||
-- release turn in .UnleashEnergy (#375)
|
||||
perform = function(ctx)
|
||||
local user = ctx.user
|
||||
user.bideTurns = ctx.rng(2, 3)
|
||||
user.bideDamage = 0
|
||||
ctx.battle:cancelMoveAnim()
|
||||
ctx.anim(user.isPlayer and "XSTATITEM_ANIM" or "XSTATITEM_DUPLICATE_ANIM")
|
||||
ctx.say(Strings("%s\nis storing energy!", displayName(user)))
|
||||
end,
|
||||
},
|
||||
|
||||
+118
-4
@@ -25,6 +25,32 @@ local SAMPLE_RATE = ChipSynth.SAMPLE_RATE
|
||||
local MUSIC_BUFFER_SAMPLES = ChipSynth.MUSIC_BUFFER_SAMPLES
|
||||
local MUSIC_BUFFER_COUNT = ChipSynth.MUSIC_BUFFER_COUNT
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Per-channel mix (edit these)
|
||||
-- Applied on load and whenever this file hot-reloads.
|
||||
-- Runtime: ChipAudio.setChannelVolume / setChannelPitch.
|
||||
-- [1] pulse 1 [2] pulse 2 [3] wave [4] noise / drums
|
||||
-- Volume: 1 = authentic, 0 = mute, >1 boosts
|
||||
-- Pitch: 1 = authentic, 2 = +1 octave, 0.5 = -1 octave
|
||||
-- The shipped values stay at 1: 0.25 / 0.5 on the wave channel buried the Ch3
|
||||
-- countermelodies an octave low (#429), and ChipSynth already applies the
|
||||
-- wave channel's own hardware octave (frequency * 0.5).
|
||||
-- ---------------------------------------------------------------------------
|
||||
local CHANNEL_VOLUME = {
|
||||
[1] = 1, -- pulse 1
|
||||
[2] = 1, -- pulse 2
|
||||
[3] = 1, -- wave
|
||||
[4] = 1, -- noise / drums
|
||||
}
|
||||
local CHANNEL_PITCH = {
|
||||
[1] = 1, -- pulse 1
|
||||
[2] = 1, -- pulse 2
|
||||
[3] = 1, -- wave
|
||||
[4] = 1, -- noise / drums
|
||||
}
|
||||
ChipSynth.setChannelVolumes(CHANNEL_VOLUME)
|
||||
ChipSynth.setChannelPitches(CHANNEL_PITCH)
|
||||
|
||||
-- currentMusic: { source, gen, threaded, started, finished, engine }
|
||||
-- threaded songs stream from the worker (engine is nil here);
|
||||
-- the fallback path owns a local engine and fills the source itself.
|
||||
@@ -32,6 +58,14 @@ local currentMusic
|
||||
local pendingBuf -- a current-gen buffer popped from the worker but not yet
|
||||
-- queued because the Source was momentarily full
|
||||
|
||||
-- Music holds playback while a fanfare owns the music channels (#398).
|
||||
-- Pausing the Source is not enough on its own: this module is what starts a
|
||||
-- chip song (immediately on the sync path, on the first worker buffer on the
|
||||
-- threaded one), so a song that begins during a jingle would come up
|
||||
-- underneath it. Music.duckForFanfare sets the hold, Music releases it when
|
||||
-- the jingle ends.
|
||||
local musicHeld = false
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- worker management
|
||||
-- ---------------------------------------------------------------------------
|
||||
@@ -125,7 +159,7 @@ local function playMusicSync(data, header, allowLoops)
|
||||
currentMusic = { source = source, engine = engine, threaded = false,
|
||||
started = true, finished = false }
|
||||
fillSync(MUSIC_FILL_INITIAL)
|
||||
source:play()
|
||||
if not musicHeld then source:play() end
|
||||
return source
|
||||
end
|
||||
|
||||
@@ -152,13 +186,23 @@ function ChipAudio.playMusic(data, header, allowLoops)
|
||||
musicGen = musicGen + 1
|
||||
local gen = musicGen
|
||||
cmdCh:push({ cmd = "play", gen = gen, header = header,
|
||||
allowLoops = allowLoops, audio = slimAudio(data) })
|
||||
allowLoops = allowLoops, audio = slimAudio(data),
|
||||
channelVolumes = ChipSynth.getChannelVolumes(),
|
||||
channelPitches = ChipSynth.getChannelPitches() })
|
||||
currentMusic = { source = source, gen = gen, threaded = true,
|
||||
started = false, finished = false }
|
||||
-- playback starts in update() once the first buffer arrives (~1 frame)
|
||||
return source
|
||||
end
|
||||
|
||||
local function pushChannelMix()
|
||||
if workerReady and cmdCh then
|
||||
cmdCh:push({ cmd = "channelMix",
|
||||
volumes = ChipSynth.getChannelVolumes(),
|
||||
pitches = ChipSynth.getChannelPitches() })
|
||||
end
|
||||
end
|
||||
|
||||
-- move finished buffers from the worker into the Source; start playback once
|
||||
-- the first one lands
|
||||
local function updateThreaded()
|
||||
@@ -189,7 +233,7 @@ local function updateThreaded()
|
||||
end
|
||||
end
|
||||
end
|
||||
if not m.started then
|
||||
if not m.started and not musicHeld then
|
||||
if (MUSIC_BUFFER_COUNT - m.source:getFreeBufferCount()) > 0 then
|
||||
pcall(function() m.source:play() end)
|
||||
m.started = true
|
||||
@@ -212,7 +256,7 @@ end
|
||||
-- pause/resume behavior.
|
||||
function ChipAudio.ensureMusicPlaying()
|
||||
local m = currentMusic
|
||||
if not m or m.finished then return end
|
||||
if not m or m.finished or musicHeld then return end
|
||||
if m.threaded then
|
||||
if not m.started then return end
|
||||
local ok, playing = pcall(function() return m.source:isPlaying() end)
|
||||
@@ -230,6 +274,18 @@ function ChipAudio.ensureMusicPlaying()
|
||||
end
|
||||
end
|
||||
|
||||
-- Silence the song for the length of a fanfare and start whatever was held
|
||||
-- back once it ends. Held state outlives a song change: Music.play may swap
|
||||
-- songs while the jingle is still sounding.
|
||||
function ChipAudio.holdMusic(held)
|
||||
held = not not held
|
||||
if held == musicHeld then return end
|
||||
musicHeld = held
|
||||
if held then return end
|
||||
ChipAudio.update()
|
||||
ChipAudio.ensureMusicPlaying()
|
||||
end
|
||||
|
||||
-- Threaded playMusic returns an empty QueueableSource and only calls
|
||||
-- Source:play once the first worker buffer lands (~1 frame later). Until
|
||||
-- then Source:isPlaying is false -- callers that treat that as "song over"
|
||||
@@ -270,6 +326,64 @@ function ChipAudio.invalidate()
|
||||
if workerReady and cmdCh then cmdCh:push({ cmd = "invalidate" }) end
|
||||
end
|
||||
|
||||
-- End the worker thread. LOVE waits for every live love.thread before the
|
||||
-- process exits and the worker's command loop only returns on "quit", so
|
||||
-- skipping this leaves the process running after the window is gone (#339).
|
||||
function ChipAudio.shutdown()
|
||||
ChipAudio.stopMusic()
|
||||
if workerReady and cmdCh then cmdCh:push({ cmd = "quit" }) end
|
||||
if worker then pcall(function() worker:wait() end) end
|
||||
worker, cmdCh, outCh = nil, nil, nil
|
||||
workerReady = false
|
||||
end
|
||||
|
||||
-- Runtime mix for one hardware channel (1..4). Takes effect on the next
|
||||
-- synthesized buffer (live music) and on any SFX/cry rendered after the call.
|
||||
function ChipAudio.setChannelVolume(hw, scale)
|
||||
ChipSynth.setChannelVolume(hw, scale)
|
||||
pushChannelMix()
|
||||
end
|
||||
|
||||
function ChipAudio.getChannelVolume(hw)
|
||||
return ChipSynth.getChannelVolume(hw)
|
||||
end
|
||||
|
||||
function ChipAudio.setChannelVolumes(volumes)
|
||||
ChipSynth.setChannelVolumes(volumes)
|
||||
pushChannelMix()
|
||||
end
|
||||
|
||||
function ChipAudio.getChannelVolumes()
|
||||
return ChipSynth.getChannelVolumes()
|
||||
end
|
||||
|
||||
function ChipAudio.setChannelPitch(hw, scale)
|
||||
ChipSynth.setChannelPitch(hw, scale)
|
||||
pushChannelMix()
|
||||
end
|
||||
|
||||
function ChipAudio.getChannelPitch(hw)
|
||||
return ChipSynth.getChannelPitch(hw)
|
||||
end
|
||||
|
||||
function ChipAudio.setChannelPitches(pitches)
|
||||
ChipSynth.setChannelPitches(pitches)
|
||||
pushChannelMix()
|
||||
end
|
||||
|
||||
function ChipAudio.getChannelPitches()
|
||||
return ChipSynth.getChannelPitches()
|
||||
end
|
||||
|
||||
-- aliases for channel 4 (noise / drums)
|
||||
function ChipAudio.setNoiseVolume(scale)
|
||||
ChipAudio.setChannelVolume(4, scale)
|
||||
end
|
||||
|
||||
function ChipAudio.getNoiseVolume()
|
||||
return ChipAudio.getChannelVolume(4)
|
||||
end
|
||||
|
||||
-- a stale song must not keep sounding past the flush that replaced its
|
||||
-- program (20 §2 cache contract, chip music row)
|
||||
Assets.register(ChipAudio.invalidate)
|
||||
|
||||
+77
-7
@@ -29,6 +29,71 @@ ChipSynth.SAMPLE_RATE = SAMPLE_RATE
|
||||
ChipSynth.MUSIC_BUFFER_SAMPLES = MUSIC_BUFFER_SAMPLES
|
||||
ChipSynth.MUSIC_BUFFER_COUNT = MUSIC_BUFFER_COUNT
|
||||
|
||||
-- Runtime mix per hardware channel (1 pulse, 2 pulse, 3 wave, 4 noise).
|
||||
-- Volume: 1 = authentic GB, 0 = mute. Pitch: 1 = authentic, 2 = +1 octave,
|
||||
-- 0.5 = -1 octave. Applied at sample time so a live change reaches the next
|
||||
-- buffer on both the sync path and the worker (via ChipAudio).
|
||||
local channelVolume = { 1, 1, 1, 1 }
|
||||
local channelPitch = { 1, 1, 1, 1 }
|
||||
|
||||
local function clampScale(scale)
|
||||
return math.max(0, tonumber(scale) or 0)
|
||||
end
|
||||
|
||||
local function setChannelTable(table, hw, scale)
|
||||
hw = tonumber(hw)
|
||||
if not hw or hw < 1 or hw > 4 then return end
|
||||
table[hw] = clampScale(scale)
|
||||
end
|
||||
|
||||
local function setChannelTables(table, values)
|
||||
if type(values) ~= "table" then return end
|
||||
for hw = 1, 4 do
|
||||
if values[hw] ~= nil then table[hw] = clampScale(values[hw]) end
|
||||
end
|
||||
end
|
||||
|
||||
function ChipSynth.setChannelVolume(hw, scale)
|
||||
setChannelTable(channelVolume, hw, scale)
|
||||
end
|
||||
|
||||
function ChipSynth.getChannelVolume(hw)
|
||||
return channelVolume[tonumber(hw) or 0] or 1
|
||||
end
|
||||
|
||||
function ChipSynth.setChannelVolumes(volumes)
|
||||
setChannelTables(channelVolume, volumes)
|
||||
end
|
||||
|
||||
function ChipSynth.getChannelVolumes()
|
||||
return { channelVolume[1], channelVolume[2], channelVolume[3], channelVolume[4] }
|
||||
end
|
||||
|
||||
function ChipSynth.setChannelPitch(hw, scale)
|
||||
setChannelTable(channelPitch, hw, scale)
|
||||
end
|
||||
|
||||
function ChipSynth.getChannelPitch(hw)
|
||||
return channelPitch[tonumber(hw) or 0] or 1
|
||||
end
|
||||
|
||||
function ChipSynth.setChannelPitches(pitches)
|
||||
setChannelTables(channelPitch, pitches)
|
||||
end
|
||||
|
||||
function ChipSynth.getChannelPitches()
|
||||
return { channelPitch[1], channelPitch[2], channelPitch[3], channelPitch[4] }
|
||||
end
|
||||
|
||||
-- aliases for the noise/drum layer
|
||||
function ChipSynth.setNoiseVolume(scale)
|
||||
ChipSynth.setChannelVolume(4, scale)
|
||||
end
|
||||
|
||||
function ChipSynth.getNoiseVolume()
|
||||
return ChipSynth.getChannelVolume(4)
|
||||
end
|
||||
|
||||
local PITCHES = {
|
||||
0xF82C, 0xF89D, 0xF907, 0xF96B, 0xF9CA, 0xFA23,
|
||||
0xFA77, 0xFAC7, 0xFB12, 0xFB58, 0xFB9B, 0xFBDA,
|
||||
@@ -430,7 +495,8 @@ function Channel:sampleNoise(parameter)
|
||||
local divisor = NOISE_DIVISORS[bit.band(parameter, 7)]
|
||||
local shift = bit.rshift(parameter, 4)
|
||||
if shift < 14 then
|
||||
local cycles = GB_CLOCK / divisor / (2 ^ shift) / SAMPLE_RATE
|
||||
local pitch = channelPitch[self.hardware] or 1
|
||||
local cycles = GB_CLOCK / divisor / (2 ^ shift) / SAMPLE_RATE * pitch
|
||||
local width7 = bit.band(parameter, 8) ~= 0
|
||||
local remaining = cycles
|
||||
while remaining > 0 do
|
||||
@@ -500,11 +566,14 @@ function Channel:sample()
|
||||
event.sample = sampleIndex + 1
|
||||
if event.silence then return 0 end
|
||||
|
||||
if event.drum then return self:sampleDrum(event, sampleIndex) end
|
||||
local gain = channelVolume[self.hardware] or 1
|
||||
if event.drum then
|
||||
return self:sampleDrum(event, sampleIndex) * gain
|
||||
end
|
||||
local volume = envelopeVolume(
|
||||
event.volume or 0, event.fade or 0, event.elapsed)
|
||||
if event.noise then
|
||||
return self:sampleNoise(event.noiseParameter) * volume / 15
|
||||
return self:sampleNoise(event.noiseParameter) * volume / 15 * gain
|
||||
end
|
||||
|
||||
local register = event.register
|
||||
@@ -529,7 +598,8 @@ function Channel:sample()
|
||||
end
|
||||
end
|
||||
end
|
||||
local frequency = 131072 / (2048 - math.min(register, 2047))
|
||||
local pitch = channelPitch[self.hardware] or 1
|
||||
local frequency = 131072 / (2048 - math.min(register, 2047)) * pitch
|
||||
if event.wave then frequency = frequency * 0.5 end
|
||||
local phase = self.phase
|
||||
self.phase = (phase + frequency / SAMPLE_RATE) % 1
|
||||
@@ -539,7 +609,7 @@ function Channel:sample()
|
||||
-- a def-local program may omit its wave table entirely
|
||||
if not wave then return 0 end
|
||||
local index = math.min(32, math.floor(phase * 32) + 1)
|
||||
return wave[index] * event.waveLevel
|
||||
return wave[index] * event.waveLevel * gain
|
||||
end
|
||||
local duty = event.duty
|
||||
if type(duty) == "table" then
|
||||
@@ -548,9 +618,9 @@ function Channel:sample()
|
||||
local pattern = WAVE_PATTERN_TABLES[duty or 2] or WAVE_PATTERN_TABLES[2]
|
||||
local step = math.floor(phase * 8) % 8
|
||||
if pattern[step + 1] == 0 then
|
||||
return -volume / 15
|
||||
return -volume / 15 * gain
|
||||
end
|
||||
return volume / 15
|
||||
return volume / 15 * gain
|
||||
end
|
||||
|
||||
local Engine = {}
|
||||
|
||||
@@ -38,6 +38,22 @@ local CONSTANT_DEFAULTS = {
|
||||
},
|
||||
}
|
||||
|
||||
-- data/events/trades.asm in Pokemon Yellow has its own TradeMons table.
|
||||
-- Old Yellow imports were built from the Red manifest, so correct that
|
||||
-- table after loading as well as in the fixed import manifest below.
|
||||
local YELLOW_TRADES = {
|
||||
{ give = "LICKITUNG", get = "DUGTRIO", dialogset = 1, nickname = "GURIO" },
|
||||
{ give = "CLEFAIRY", get = "MR_MIME", dialogset = 1, nickname = "MILES" },
|
||||
{ give = "BUTTERFREE", get = "BEEDRILL", dialogset = 3, nickname = "STINGER" },
|
||||
{ give = "KANGASKHAN", get = "MUK", dialogset = 1, nickname = "STICKY" },
|
||||
{ give = "MEW", get = "MEW", dialogset = 3, nickname = "BART" },
|
||||
{ give = "TANGELA", get = "PARASECT", dialogset = 1, nickname = "SPIKE" },
|
||||
{ give = "PIDGEOT", get = "PIDGEOT", dialogset = 2, nickname = "MARTY" },
|
||||
{ give = "GOLDUCK", get = "RHYDON", dialogset = 2, nickname = "BUFFY" },
|
||||
{ give = "GROWLITHE", get = "DEWGONG", dialogset = 3, nickname = "CEZANNE" },
|
||||
{ give = "CUBONE", get = "MACHOKE", dialogset = 3, nickname = "RICKY" },
|
||||
}
|
||||
|
||||
-- field.boot is the total-conversion override point for the new game; the
|
||||
-- values match what SaveData.newGame and the Oak speech used to inline.
|
||||
local BOOT_DEFAULTS = {
|
||||
@@ -58,6 +74,12 @@ local function copy(value)
|
||||
return out
|
||||
end
|
||||
|
||||
function Data:applyVersionedFieldData()
|
||||
if require("src.core.GameVersion").isYellow() then
|
||||
self.field.trades = copy(YELLOW_TRADES)
|
||||
end
|
||||
end
|
||||
|
||||
-- Fills only what the cache is missing, so an importer that learns to
|
||||
-- stamp one of these keys silently takes over from the engine.
|
||||
function Data:seedDefaults()
|
||||
@@ -77,6 +99,7 @@ function Data:seedDefaults()
|
||||
if constants.dexDigits == nil then
|
||||
constants.dexDigits = math.max(3, #tostring(constants.dexSize))
|
||||
end
|
||||
self:applyVersionedFieldData()
|
||||
local boot = self.field.boot
|
||||
if boot == nil then
|
||||
boot = {}
|
||||
|
||||
+99
-7
@@ -244,35 +244,93 @@ end
|
||||
-- exactly as the owning state computed it
|
||||
local function sameZones(_, zones) return zones end
|
||||
|
||||
-- A wide battle owns the surface until it leaves the stack. The party,
|
||||
-- bag, choice and text states it opens still draw their original 160px UI,
|
||||
-- but the canvas must not snap to 160px between those states.
|
||||
function Game.wideBattleInStack(stack)
|
||||
for i = #(stack and stack.states or {}), 1, -1 do
|
||||
local state = stack.states[i]
|
||||
if state and state.isWideBattleLayout and state:isWideBattleLayout() then
|
||||
return state
|
||||
end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
-- Shift classic SGB zones to the centred UI. A full-width base zone extends
|
||||
-- into both margins, keeping the canvas' paper color continuous; narrower
|
||||
-- sprite and status zones move with the classic UI content.
|
||||
local function centerClassicZones(zones, offset)
|
||||
if not zones or offset == 0 then return zones end
|
||||
local shifted = {}
|
||||
for i, zone in ipairs(zones) do
|
||||
local copy = {}
|
||||
for key, value in pairs(zone) do copy[key] = value end
|
||||
if copy.x == 0 and copy.w == Renderer.WIDTH then
|
||||
copy.w = copy.w + offset * 2
|
||||
else
|
||||
copy.x = (copy.x or 0) + offset
|
||||
end
|
||||
shifted[i] = copy
|
||||
end
|
||||
return shifted
|
||||
end
|
||||
|
||||
function Game:draw()
|
||||
-- the UI canvas clears transparent when the overworld's world pass
|
||||
-- shows through beneath it; opaque full-screen states get the classic
|
||||
-- white clear
|
||||
local base = self.stack:visibleBase()
|
||||
local worldBelow = self.stack.states[base] == self.overworld
|
||||
-- The UI surface is resolved once, before any state draws: the top state
|
||||
-- may want more than the Game Boy's 160x144 (the widescreen battle layout
|
||||
-- asks for 304x144). Anything else keeps the classic surface, so a menu
|
||||
-- pushed over a wide battle brings the screen straight back to 160x144.
|
||||
-- A wide battle holds its 304px surface through every menu or prompt it
|
||||
-- opens. States that do not draw the wide battle composition are centred
|
||||
-- in that surface below, so their classic coordinates and hit testing stay
|
||||
-- unchanged. Outside a battle, including the title screen, the option is
|
||||
-- intentionally inactive because it is a battle-layout setting.
|
||||
local top = self.stack:top()
|
||||
if top and top.uiSize then
|
||||
local wideBattle = Game.wideBattleInStack(self.stack)
|
||||
local classicOffset = 0
|
||||
if wideBattle and wideBattle.uiSize then
|
||||
Renderer:setUISize(wideBattle:uiSize())
|
||||
classicOffset = math.floor((select(1, Renderer:uiSize()) - Renderer.WIDTH) / 2)
|
||||
elseif top and top.uiSize then
|
||||
Renderer:setUISize(top:uiSize())
|
||||
else
|
||||
Renderer:setUISize(Renderer.WIDTH, Renderer.HEIGHT)
|
||||
end
|
||||
Renderer:beginFrame(worldBelow)
|
||||
self.stack:draw()
|
||||
for i = self.stack:visibleBase(), #self.stack.states do
|
||||
local state = self.stack.states[i]
|
||||
local wideState = state and state.isWideBattleLayout
|
||||
and state:isWideBattleLayout()
|
||||
if state and state.draw then
|
||||
if classicOffset ~= 0 and not wideState then
|
||||
love.graphics.push()
|
||||
love.graphics.translate(classicOffset, 0)
|
||||
state:draw()
|
||||
love.graphics.pop()
|
||||
else
|
||||
state:draw()
|
||||
end
|
||||
end
|
||||
end
|
||||
-- SGB colorization: the topmost state that knows its palette owns the
|
||||
-- screen (overlays like text boxes inherit from what's beneath them);
|
||||
-- the overworld's world pass colors each visible map area separately
|
||||
local zones, worldZones
|
||||
local zones, worldZones, zoneOwner
|
||||
for i = #self.stack.states, 1, -1 do
|
||||
local s = self.stack.states[i]
|
||||
if s.sgbPalettes then
|
||||
zones = s:sgbPalettes(self)
|
||||
zoneOwner = s
|
||||
break
|
||||
end
|
||||
end
|
||||
if classicOffset ~= 0 and zoneOwner
|
||||
and not (zoneOwner.isWideBattleLayout
|
||||
and zoneOwner:isWideBattleLayout()) then
|
||||
zones = centerClassicZones(zones, classicOffset)
|
||||
end
|
||||
-- 14's render.zones: weather/lighting overlays and custom colorization
|
||||
-- recolor or add zones before the blit
|
||||
if ModRuntime.wantsHook("render.zones") then
|
||||
@@ -433,6 +491,40 @@ function Game:gamepadaxis(joystick, axis, value)
|
||||
Input:gamepadaxis(joystick, axis, value)
|
||||
end
|
||||
|
||||
-- conf.lua turns the mobile accelerometer-joystick off (#468), but guard the
|
||||
-- generic joystick path anyway: any sensor-style device that still reaches us
|
||||
-- has gravity pinning an axis past the deadzone, which would hide the touch
|
||||
-- overlay every instant and steer the player by tilt through the axis-1/2
|
||||
-- mapping (#459). Real controllers arrive as SDL gamepads or named sticks,
|
||||
-- never as "* Accelerometer".
|
||||
local function isAccelerometer(joystick)
|
||||
local name = joystick and joystick.getName and joystick:getName()
|
||||
return name ~= nil and name:lower():find("accelerometer", 1, true) ~= nil
|
||||
end
|
||||
|
||||
function Game:joystickpressed(joystick, button)
|
||||
if isAccelerometer(joystick) then return end
|
||||
TouchControls:noteGamepad()
|
||||
Input:joystickpressed(joystick, button)
|
||||
end
|
||||
|
||||
function Game:joystickreleased(joystick, button)
|
||||
if isAccelerometer(joystick) then return end
|
||||
Input:joystickreleased(joystick, button)
|
||||
end
|
||||
|
||||
function Game:joystickaxis(joystick, axis, value)
|
||||
if isAccelerometer(joystick) then return end
|
||||
if math.abs(value) > 0.5 then TouchControls:noteGamepad() end
|
||||
Input:joystickaxis(joystick, axis, value)
|
||||
end
|
||||
|
||||
function Game:joystickhat(joystick, hat, direction)
|
||||
if isAccelerometer(joystick) then return end
|
||||
if direction ~= "c" then TouchControls:noteGamepad() end
|
||||
Input:joystickhat(joystick, hat, direction)
|
||||
end
|
||||
|
||||
-- Window focus/visibility flips: a release due while unfocused/hidden can
|
||||
-- be swallowed by the OS. Reset on both edges -- gaining focus with a
|
||||
-- physically held key won't re-fire keypressed, so trusting leftover
|
||||
|
||||
@@ -39,6 +39,20 @@ local DEFAULT_GAMEPAD_BINDINGS = {
|
||||
local STICK_ON = 0.5
|
||||
local STICK_OFF = 0.3
|
||||
|
||||
-- Generic SDL joysticks expose the left stick as the first two numbered
|
||||
-- axes and the D-pad as a hat. This is common on Linux handhelds whose
|
||||
-- controller has no game-controller database entry.
|
||||
local RAW_BUTTON_BINDINGS = {
|
||||
[1] = "a", [2] = "b",
|
||||
[7] = "select", [8] = "start", [9] = "select", [10] = "start",
|
||||
}
|
||||
|
||||
local HAT_DIRECTIONS = {
|
||||
u = { "up" }, d = { "down" }, l = { "left" }, r = { "right" },
|
||||
lu = { "left", "up" }, ru = { "right", "up" },
|
||||
ld = { "left", "down" }, rd = { "right", "down" },
|
||||
}
|
||||
|
||||
function Input:init()
|
||||
self:applyBindings(nil)
|
||||
self:reset()
|
||||
@@ -79,6 +93,7 @@ function Input:reset()
|
||||
self.sources = {}
|
||||
self.stickAxis = { x = 0, y = 0 }
|
||||
self.stickDir = nil
|
||||
self.hatDirs = {}
|
||||
end
|
||||
|
||||
-- Multiple physical sources (W + Up, d-pad + stick, etc.) can claim the
|
||||
@@ -179,6 +194,16 @@ function Input:gamepadreleased(joystick, button)
|
||||
end
|
||||
end
|
||||
|
||||
function Input:joystickpressed(joystick, button)
|
||||
local btn = RAW_BUTTON_BINDINGS[button]
|
||||
if btn then press(self, btn, "joy:" .. button) end
|
||||
end
|
||||
|
||||
function Input:joystickreleased(joystick, button)
|
||||
local btn = RAW_BUTTON_BINDINGS[button]
|
||||
if btn then release(self, btn, "joy:" .. button) end
|
||||
end
|
||||
|
||||
-- left stick treated as a continuous held direction, same 4-way rule as
|
||||
-- the touch swipe d-pad: whichever axis has the larger magnitude wins.
|
||||
function Input:gamepadaxis(joystick, axis, value)
|
||||
@@ -214,6 +239,26 @@ function Input:gamepadaxis(joystick, axis, value)
|
||||
end
|
||||
end
|
||||
|
||||
function Input:joystickaxis(joystick, axis, value)
|
||||
if axis == 1 then
|
||||
self:gamepadaxis(joystick, "leftx", value)
|
||||
elseif axis == 2 then
|
||||
self:gamepadaxis(joystick, "lefty", value)
|
||||
end
|
||||
end
|
||||
|
||||
function Input:joystickhat(joystick, hat, direction)
|
||||
local source = "hat:" .. hat
|
||||
for _, btn in ipairs(self.hatDirs[hat] or {}) do
|
||||
release(self, btn, source)
|
||||
end
|
||||
local dirs = HAT_DIRECTIONS[direction] or {}
|
||||
for _, btn in ipairs(dirs) do
|
||||
press(self, btn, source)
|
||||
end
|
||||
self.hatDirs[hat] = dirs
|
||||
end
|
||||
|
||||
function Input:isDown(btn)
|
||||
return self.state[btn] or false
|
||||
end
|
||||
|
||||
@@ -85,6 +85,7 @@ local function fanfareActive()
|
||||
local ok, playing = pcall(src.isPlaying, src)
|
||||
if ok and playing then return true end
|
||||
state.fanfare = nil
|
||||
require("src.core.ChipAudio").holdMusic(false)
|
||||
return false
|
||||
end
|
||||
|
||||
@@ -94,6 +95,9 @@ end
|
||||
function Music.duckForFanfare(src)
|
||||
if not src then return end
|
||||
state.fanfare = src
|
||||
-- ChipAudio is what starts a chip song, so the pause below cannot hold one
|
||||
-- that has not started yet (nor one Music.play swaps in mid-jingle) (#398)
|
||||
require("src.core.ChipAudio").holdMusic(true)
|
||||
if state.source then
|
||||
local ok, playing = pcall(state.source.isPlaying, state.source)
|
||||
if ok and playing then
|
||||
|
||||
@@ -223,6 +223,9 @@ function SaveData.defaultOptions()
|
||||
-- 0-7 like the GB's NR50 master volume
|
||||
musicVol = 7,
|
||||
sfxVol = 7,
|
||||
-- Yellow only: 0-7 trim for Pikachu's PCM voice clips on top of sfxVol
|
||||
-- (the PIKACHU VOL row appears only on Yellow; see Sound.lua)
|
||||
pikaVol = 7,
|
||||
musicFilter = 0,
|
||||
-- logic fast-forward multiplier; audio is unaffected (GameSpeed.lua)
|
||||
speed = 1,
|
||||
|
||||
+51
-10
@@ -16,6 +16,30 @@ local cache = {}
|
||||
-- scaling the 0.8 base every source gets
|
||||
local BASE_VOLUME = 0.8
|
||||
local volumeScale = 1
|
||||
-- port addition (Yellow only): 0-7 trim from save.options.pikaVol on top of
|
||||
-- the SFX level, for Pikachu's voice clips alone. Yellow voices every
|
||||
-- Pikachu cry with PCM samples that are far louder and far more frequent
|
||||
-- than the chip cries around them (the follower alone talks on every
|
||||
-- interaction), so this is the one source players want to pull down
|
||||
-- without muting the rest of the SFX bus. 7 = untouched, 0 = silent.
|
||||
local pikaScale = 1
|
||||
|
||||
-- cache keys whose volume the Pikachu trim applies to: the PCM clips, plus
|
||||
-- the chip PIKACHU cry that a Yellow cache without extracted clips falls
|
||||
-- back to (playCry). Red/Blue never reach the second branch, so a shared
|
||||
-- options.lua carrying a low pikaVol cannot quiet their Pikachu.
|
||||
local function isPikaKey(key)
|
||||
if type(key) ~= "string" then return false end
|
||||
if key:sub(1, 8) == "pikacry:" then return true end
|
||||
return key == "cry:PIKACHU"
|
||||
and require("src.core.GameVersion").isYellow()
|
||||
end
|
||||
|
||||
local function volumeFor(key)
|
||||
local scale = volumeScale
|
||||
if isPikaKey(key) then scale = scale * pikaScale end
|
||||
return BASE_VOLUME * scale
|
||||
end
|
||||
|
||||
-- Fanfares occupy the music's tone channels on the Game Boy: their sfx
|
||||
-- headers claim channels 5-7 (= hardware channels 1-3), silencing the
|
||||
@@ -91,7 +115,7 @@ local function playPath(data, key, def, pitch, tempo)
|
||||
reportBadDef("sfx", key, owner(data, "sfx", key), err)
|
||||
return nil
|
||||
end
|
||||
s:setVolume(BASE_VOLUME * volumeScale)
|
||||
s:setVolume(volumeFor(key))
|
||||
cache[key] = s
|
||||
src = s
|
||||
end
|
||||
@@ -111,6 +135,9 @@ local function played(kind, name, species)
|
||||
Runtime.emit("sound.played", { kind = kind, name = name, species = species })
|
||||
end
|
||||
|
||||
-- returns the started source (nil headless, or when the def failed to load)
|
||||
-- so callers that block on a fanfare like the original's
|
||||
-- PlaySoundWaitForCurrent -> WaitForSoundToFinish can poll it
|
||||
function Sound.play(data, name)
|
||||
local sfx = data.audio and data.audio.sfx
|
||||
local def = sfx and sfx[name]
|
||||
@@ -120,6 +147,7 @@ function Sound.play(data, name)
|
||||
require("src.core.Music").duckForFanfare(src)
|
||||
end
|
||||
played("sfx", name)
|
||||
return src
|
||||
end
|
||||
|
||||
-- Play a move's sound with its MoveSoundTable pitch/tempo modifiers
|
||||
@@ -211,7 +239,7 @@ function Sound.playPikaCry(data, n)
|
||||
cache[key] = false
|
||||
return nil
|
||||
end
|
||||
s:setVolume(BASE_VOLUME * volumeScale)
|
||||
s:setVolume(volumeFor(key))
|
||||
cache[key] = s
|
||||
src = s
|
||||
end
|
||||
@@ -245,7 +273,7 @@ function Sound.playCry(data, species)
|
||||
owner(data, "cries", species), err)
|
||||
return nil
|
||||
end
|
||||
s:setVolume(BASE_VOLUME * volumeScale)
|
||||
s:setVolume(volumeFor(key))
|
||||
cache[key] = s
|
||||
src = s
|
||||
end
|
||||
@@ -321,7 +349,7 @@ function Sound.startLoop(data, name)
|
||||
return
|
||||
end
|
||||
s:setLooping(true)
|
||||
s:setVolume(BASE_VOLUME * volumeScale)
|
||||
s:setVolume(volumeFor(name))
|
||||
loopCache[name] = s
|
||||
src = s
|
||||
end
|
||||
@@ -345,14 +373,26 @@ end
|
||||
-- 0-7 SFX volume level (0 mutes); cached sources (menu beeps, cries,
|
||||
-- the low-health alarm loop) update immediately so the change is heard
|
||||
-- on the next play
|
||||
local function reapplyVolumes()
|
||||
for key, src in pairs(cache) do
|
||||
if src then pcall(src.setVolume, src, volumeFor(key)) end
|
||||
end
|
||||
for key, src in pairs(loopCache) do
|
||||
if src then pcall(src.setVolume, src, volumeFor(key)) end
|
||||
end
|
||||
end
|
||||
|
||||
function Sound.setVolumeLevel(level)
|
||||
volumeScale = math.max(0, math.min(7, level or 7)) / 7
|
||||
for _, src in pairs(cache) do
|
||||
if src then pcall(src.setVolume, src, BASE_VOLUME * volumeScale) end
|
||||
end
|
||||
for _, src in pairs(loopCache) do
|
||||
if src then pcall(src.setVolume, src, BASE_VOLUME * volumeScale) end
|
||||
end
|
||||
reapplyVolumes()
|
||||
end
|
||||
|
||||
-- 0-7 Pikachu-voice trim on top of the SFX level (7 = no trim, 0 mutes the
|
||||
-- clips while the rest of the SFX bus keeps its level). Yellow only: on
|
||||
-- Red/Blue no cached key answers isPikaKey, so this is inert there.
|
||||
function Sound.setPikaVolumeLevel(level)
|
||||
pikaScale = math.max(0, math.min(7, level or 7)) / 7
|
||||
reapplyVolumes()
|
||||
end
|
||||
|
||||
-- hot reload / jukebox A-B: drop one key's sources (its pitch-tempo
|
||||
@@ -386,6 +426,7 @@ Assets.register(Sound.invalidate)
|
||||
-- loading a save)
|
||||
function Sound.applyOptions(opts)
|
||||
Sound.setVolumeLevel(opts and opts.sfxVol or 7)
|
||||
Sound.setPikaVolumeLevel(opts and opts.pikaVol or 7)
|
||||
end
|
||||
|
||||
return Sound
|
||||
|
||||
@@ -5,8 +5,10 @@
|
||||
--
|
||||
-- Protocol -- main thread pushes command tables onto the "chipaudio_cmd"
|
||||
-- channel and drains produced buffers off "chipaudio_out":
|
||||
-- cmd = "play" { gen, header, allowLoops, audio } start a song
|
||||
-- cmd = "play" { gen, header, allowLoops, audio,
|
||||
-- channelVolumes?, channelPitches? }
|
||||
-- cmd = "stop" halt production
|
||||
-- cmd = "channelMix" { volumes, pitches } per-hw volume/pitch
|
||||
-- cmd = "invalidate" drop the bank cache
|
||||
-- cmd = "quit" end the thread
|
||||
-- out buffers are tagged with the play's `gen` so the main thread can
|
||||
@@ -45,6 +47,12 @@ local function handle(cmd)
|
||||
engine = nil
|
||||
outCh:clear() -- drop any buffers left from the previous song
|
||||
data = { audio = cmd.audio }
|
||||
if cmd.channelVolumes ~= nil then
|
||||
ChipSynth.setChannelVolumes(cmd.channelVolumes)
|
||||
end
|
||||
if cmd.channelPitches ~= nil then
|
||||
ChipSynth.setChannelPitches(cmd.channelPitches)
|
||||
end
|
||||
local ok, eng = pcall(ChipSynth.newEngine, data, cmd.header,
|
||||
{ allowLoops = cmd.allowLoops })
|
||||
if ok then
|
||||
@@ -58,6 +66,9 @@ local function handle(cmd)
|
||||
engine = nil
|
||||
finished = false
|
||||
outCh:clear()
|
||||
elseif cmd.cmd == "channelMix" then
|
||||
if cmd.volumes ~= nil then ChipSynth.setChannelVolumes(cmd.volumes) end
|
||||
if cmd.pitches ~= nil then ChipSynth.setChannelPitches(cmd.pitches) end
|
||||
elseif cmd.cmd == "invalidate" then
|
||||
ChipSynth.invalidateBanks()
|
||||
elseif cmd.cmd == "quit" then
|
||||
|
||||
+42
-3
@@ -179,6 +179,38 @@ local function resolveUnmount()
|
||||
return physfsUnmountFn
|
||||
end
|
||||
|
||||
-- PHYSFS_getMountPoint, resolved the same way: a non-NULL return means `dir`
|
||||
-- is already somewhere in the search path. withMounted needs it because
|
||||
-- PHYSFS_mount reports success for an already-mounted directory without
|
||||
-- adding a second entry, so its unmount would drop a mount it did not make
|
||||
-- (#413).
|
||||
local physfsMountPointFn = nil
|
||||
local function resolveMountPoint()
|
||||
if physfsMountPointFn ~= nil then return physfsMountPointFn end
|
||||
physfsMountPointFn = false
|
||||
local ok, ffi = pcall(require, "ffi")
|
||||
if not ok then return physfsMountPointFn end
|
||||
pcall(ffi.cdef, "const char *PHYSFS_getMountPoint(const char *dir);")
|
||||
local libs = {
|
||||
function() return ffi.C end,
|
||||
function() return ffi.load("love") end,
|
||||
}
|
||||
for _, getlib in ipairs(libs) do
|
||||
local okl, lib = pcall(getlib)
|
||||
if okl and lib then
|
||||
local oks, fn = pcall(function() return lib.PHYSFS_getMountPoint end)
|
||||
if oks and fn then
|
||||
physfsMountPointFn = function(d)
|
||||
local okr, ret = pcall(fn, d)
|
||||
return okr and ret ~= nil and ret ~= ffi.NULL
|
||||
end
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
return physfsMountPointFn
|
||||
end
|
||||
|
||||
-- The portable game folder when the cache should live there, else nil.
|
||||
-- Resolved (and, for a fused build, mounted) once and cached. Requires a
|
||||
-- desktop portable install (SaveData) and a working windowless mkdir.
|
||||
@@ -398,13 +430,20 @@ end
|
||||
-- resolves -- which is what makes it safe to point at a folder whose contents
|
||||
-- nobody has validated.
|
||||
--
|
||||
-- Returns nil when the mount is unavailable (no ffi, no PHYSFS symbol, or the
|
||||
-- mount was refused), which callers must treat as "could not look", not as
|
||||
-- "nothing there". An error inside fn still unmounts before it propagates.
|
||||
-- Returns nil when the mount is unavailable (no ffi, no PHYSFS symbol, the
|
||||
-- mount was refused, or `dir` is already on the read path), which callers must
|
||||
-- treat as "could not look", not as "nothing there". An error inside fn still
|
||||
-- unmounts before it propagates.
|
||||
function CacheFs.withMounted(dir, mountPoint, fn)
|
||||
if not dir or dir == "" then return nil end
|
||||
local mount, unmount = resolveMount(), resolveUnmount()
|
||||
if not (mount and unmount) then return nil end
|
||||
-- A directory already in the search path cannot be borrowed: PHYSFS_mount
|
||||
-- returns success without adding an entry, and the unmount below would then
|
||||
-- remove the mount somebody else is relying on -- for the portable game
|
||||
-- folder, the one that makes its cache and mods readable at all (#413)
|
||||
local mountedAt = resolveMountPoint()
|
||||
if mountedAt and mountedAt(dir) then return nil end
|
||||
if not mount(dir, mountPoint, true) then return nil end
|
||||
local ok, res = pcall(fn)
|
||||
unmount(dir)
|
||||
|
||||
@@ -1307,6 +1307,16 @@ function RomExtractor:extractTrainers()
|
||||
}
|
||||
self:tick("Trainers", index, #order)
|
||||
end
|
||||
-- Yellow's Jessie & James fight as OPP_ROCKET but carry their own pic
|
||||
-- (home/trainers2.asm IsFightingJessieJames); only Yellow's manifest has
|
||||
-- the symbol, so Red and Blue skip this (#439).
|
||||
if self.symbols.JessieJamesPic then
|
||||
local relative = "battle/trainers/jessie_james.png"
|
||||
self:writeCompressedPic("JessieJamesPic", relative)
|
||||
if out.OPP_ROCKET then
|
||||
out.OPP_ROCKET.picJessieJames = "assets/generated/" .. relative
|
||||
end
|
||||
end
|
||||
self:write("trainers", out)
|
||||
return out
|
||||
end
|
||||
|
||||
+271
-50
@@ -6,7 +6,7 @@ local RomImporter = {}
|
||||
RomImporter.__index = RomImporter
|
||||
|
||||
-- Cache generation tag; bump to force every imported version to re-extract.
|
||||
local CACHE_FORMAT = "rom-cache-v7:"
|
||||
local CACHE_FORMAT = "rom-cache-v8:"
|
||||
-- The completion marker is written under each version's cache prefix
|
||||
-- (rom-cache.complete for Red, blue/rom-cache.complete for Blue).
|
||||
local MARKER_PATH = "rom-cache.complete"
|
||||
@@ -35,6 +35,13 @@ local REQUIRED_FILES = {
|
||||
"assets/generated/audio/programs.bin",
|
||||
}
|
||||
|
||||
-- Files only one version's cache carries. A version that predates one of
|
||||
-- them re-imports on its own, without dragging the other versions through a
|
||||
-- CACHE_FORMAT bump.
|
||||
local VERSION_REQUIRED_FILES = {
|
||||
yellow = { "assets/generated/battle/trainers/jessie_james.png" }, -- #439
|
||||
}
|
||||
|
||||
-- "Split-screen ROM selector" first-run palette (matches FirstRun.dc.html from
|
||||
-- the Claude Design project): a dark neon arcade panel, one column per game.
|
||||
-- Red, Blue, and Yellow share the same importer flow once listed in
|
||||
@@ -99,6 +106,9 @@ local function allRequiredFilesExist(version)
|
||||
for _, path in ipairs(REQUIRED_FILES) do
|
||||
if not CacheFs.exists(path) then ok = false; break end
|
||||
end
|
||||
for _, path in ipairs(ok and VERSION_REQUIRED_FILES[version] or {}) do
|
||||
if not CacheFs.exists(path) then ok = false; break end
|
||||
end
|
||||
CacheFs.prefix = saved
|
||||
return ok
|
||||
end
|
||||
@@ -321,18 +331,46 @@ local function findPendingRom(ready)
|
||||
return nil
|
||||
end
|
||||
|
||||
-- GameActivity always writes the SAF pick to picked_rom.gb, so a leftover
|
||||
-- under that exact basename is the file the player just chose and
|
||||
-- findPendingRom silently refused: wrong size, or a hacked/overdumped cart
|
||||
-- whose SHA-1 matches no known version ([b]/[BF] dumps never will). Route it
|
||||
-- through startData so the launcher says which of the two it was instead of
|
||||
-- staying on "No ROM imported" with no message at all (issue #442), and drop
|
||||
-- the file so the next tap starts from a clean slate. A cart that is simply
|
||||
-- already imported is not an error -- #167 skips it on purpose -- so leave
|
||||
-- that one alone.
|
||||
local function consumePickedRomError(self)
|
||||
local preferred = "picked_rom.gb"
|
||||
if not love.filesystem.getInfo(preferred, "file") then return false end
|
||||
local data = love.filesystem.read(preferred)
|
||||
if type(data) == "string" and #data == 1024 * 1024 then
|
||||
local version = GameVersion.forSha1(sha1(data))
|
||||
if version and self.ready[version] then return false end
|
||||
end
|
||||
love.filesystem.remove(preferred)
|
||||
if type(data) ~= "string" then
|
||||
self:setError("The picked file could not be read. Reopen the picker and "
|
||||
.. "choose the ROM with the Files (Documents) app.")
|
||||
return true
|
||||
end
|
||||
self:startData(data, preferred)
|
||||
return true
|
||||
end
|
||||
|
||||
-- Android SAF writes mod picks to picked_mod.zip; USB copies may use any
|
||||
-- .zip basename at the save-dir root. preferAny=true also accepts those USB
|
||||
-- copies (Choose / Import); focus only consumes the SAF basename so a random
|
||||
-- leftover archive is never auto-installed on every refocus.
|
||||
local function findPendingMod(preferAny)
|
||||
local function findPendingMod(preferAny, skip)
|
||||
local preferred = "picked_mod.zip"
|
||||
if love.filesystem.getInfo(preferred, "file") then
|
||||
return preferred
|
||||
end
|
||||
if not preferAny then return nil end
|
||||
for _, name in ipairs(love.filesystem.getDirectoryItems("")) do
|
||||
if name:lower():match("%.zip$") and love.filesystem.getInfo(name, "file") then
|
||||
if name:lower():match("%.zip$") and not (skip and skip[name])
|
||||
and love.filesystem.getInfo(name, "file") then
|
||||
return name
|
||||
end
|
||||
end
|
||||
@@ -340,20 +378,36 @@ local function findPendingMod(preferAny)
|
||||
end
|
||||
|
||||
-- Same pattern as findPendingMod for battery saves (picked_save.sav / *.sav).
|
||||
local function findPendingSav(preferAny)
|
||||
local function findPendingSav(preferAny, skip)
|
||||
local preferred = "picked_save.sav"
|
||||
if love.filesystem.getInfo(preferred, "file") then
|
||||
return preferred
|
||||
end
|
||||
if not preferAny then return nil end
|
||||
for _, name in ipairs(love.filesystem.getDirectoryItems("")) do
|
||||
if name:lower():match("%.sav$") and love.filesystem.getInfo(name, "file") then
|
||||
if name:lower():match("%.sav$") and not (skip and skip[name])
|
||||
and love.filesystem.getInfo(name, "file") then
|
||||
return name
|
||||
end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
-- Retire an Android pick once it has been through the installer / importer,
|
||||
-- whether or not it worked: a pick left on disk wins the scans above forever,
|
||||
-- so the next tap re-runs the same failing file and the picker never reopens
|
||||
-- (#420). The SAF basename is GameActivity's own copy of the pick and is
|
||||
-- always deleted; a USB copy is the player's file, so a failed one is only
|
||||
-- skipped for the rest of the session.
|
||||
local function consumePick(self, name, safName, ok)
|
||||
if ok or name == safName then
|
||||
love.filesystem.remove(name)
|
||||
return
|
||||
end
|
||||
self.pickSkip = self.pickSkip or {}
|
||||
self.pickSkip[name] = true
|
||||
end
|
||||
|
||||
local function chooseRom(promptName)
|
||||
promptName = promptName or "Pokemon"
|
||||
local prompt = "Choose your " .. promptName .. " ROM"
|
||||
@@ -482,7 +536,12 @@ end
|
||||
-- supplied the Edit label is not drawn at all).
|
||||
function RomImporter.new(onComplete, opts)
|
||||
opts = opts or {}
|
||||
local android = love.system.getOS() == "Android"
|
||||
-- iOS rides the same mobile import flows as Android: the save-dir
|
||||
-- pending-file scan plus love.system.pickFile / createFile, provided
|
||||
-- natively by the Swift GRPickerBridge (mobile/ios/native/). The flag
|
||||
-- keeps its historical name so every Android call site stays untouched.
|
||||
local mobileOS = love.system.getOS()
|
||||
local android = mobileOS == "Android" or mobileOS == "iOS"
|
||||
local CacheFs = require("src.import.CacheFs")
|
||||
local self = setmetatable({
|
||||
onComplete = onComplete,
|
||||
@@ -490,6 +549,11 @@ function RomImporter.new(onComplete, opts)
|
||||
forceImport = opts.forceImport or false,
|
||||
onEditSave = opts.onEditSave,
|
||||
android = android,
|
||||
ios = mobileOS == "iOS",
|
||||
-- One startup poll pass: files dropped through the Files app are swept
|
||||
-- into the save dir before Lua boots (GRBootstrap), but no love.focus
|
||||
-- event necessarily follows, so consume them via the first poll tick.
|
||||
pickPending = mobileOS == "iOS" or nil,
|
||||
-- Android drag: the launcher is handed no move events at all (main.lua
|
||||
-- forwards neither touchmoved nor mousemoved while it is up), and its mouse
|
||||
-- emulation is what "no reliable pointer polling" below refers to.
|
||||
@@ -540,6 +604,7 @@ function RomImporter.new(onComplete, opts)
|
||||
_padCursorActive = false,
|
||||
_padAxis = { leftx = 0, lefty = 0, righty = 0 },
|
||||
_padDir = {},
|
||||
_rawHatDirs = {},
|
||||
_padInited = false,
|
||||
}, RomImporter)
|
||||
|
||||
@@ -568,7 +633,13 @@ function RomImporter.new(onComplete, opts)
|
||||
end
|
||||
if android and needRom then
|
||||
local name, data = findPendingRom(self.ready)
|
||||
if name then self:startData(data, name) end
|
||||
if name then
|
||||
self:startData(data, name)
|
||||
else
|
||||
-- The picker runs as its own activity and Android may kill us while it
|
||||
-- is up, so a rejected pick can outlive the focus handler (#442).
|
||||
consumePickedRomError(self)
|
||||
end
|
||||
end
|
||||
|
||||
-- Mouse-wheel scroll for the save-slot / mods lists. main.lua (off limits)
|
||||
@@ -617,6 +688,11 @@ end
|
||||
-- up without the player needing to tap the button again. Mod and save SAF
|
||||
-- drops (picked_mod.zip / picked_save.sav) are consumed first so a leftover
|
||||
-- ROM pick cannot steal the focus path when both games are already ready.
|
||||
-- NOTE (iOS): do NOT clear pickPending here. The picker's dismissal focus
|
||||
-- event can arrive before the Swift delegate has finished copying the pick
|
||||
-- into the save dir; if this scan runs early and finds nothing, the poll in
|
||||
-- _pollPickedFiles must stay armed so it consumes the file when it lands
|
||||
-- moments later (it clears pickPending itself once something is found).
|
||||
function RomImporter:focus(f)
|
||||
if not (f and self.android and self.workState ~= "working") then return end
|
||||
-- SAF create-document finished: GameActivity wrote export_done.flag.
|
||||
@@ -629,28 +705,53 @@ function RomImporter:focus(f)
|
||||
if self.tab == "mods" then self.tab = version end
|
||||
return
|
||||
end
|
||||
local modName = findPendingMod(false)
|
||||
if modName then
|
||||
self:_installMod(modName)
|
||||
if self.modNotice and self.modNotice.ok then
|
||||
love.filesystem.remove(modName)
|
||||
-- The SAF pick failed inside GameActivity, which wrote pick_error.flag with
|
||||
-- the destination basename in it: some OEM shells (ColorOS) let a third-party
|
||||
-- archive manager win the ACTION_OPEN_DOCUMENT chooser and hand back a URI
|
||||
-- this app has no permission to read, and until #442 that returned to a
|
||||
-- launcher that said nothing at all.
|
||||
local pickError = love.filesystem.getInfo("pick_error.flag", "file")
|
||||
and love.filesystem.read("pick_error.flag")
|
||||
if pickError then
|
||||
love.filesystem.remove("pick_error.flag")
|
||||
local text = "Could not read the picked file. Reopen the picker and choose "
|
||||
.. "it with the Files (Documents) app, or copy it into: "
|
||||
.. love.filesystem.getSaveDirectory()
|
||||
if pickError:find("picked_mod", 1, true) then
|
||||
self.modNotice = { ok = false, text = text }
|
||||
elseif pickError:find("picked_save", 1, true) then
|
||||
local version = self.androidPendingVersion or self:_savedropTarget()
|
||||
self.androidPendingVersion = nil
|
||||
self.saveNotice[version] = { ok = false, text = text }
|
||||
else
|
||||
self:setError(text)
|
||||
end
|
||||
return
|
||||
end
|
||||
local savName = findPendingSav(false)
|
||||
local modName = findPendingMod(false, self.pickSkip)
|
||||
if modName then
|
||||
self:_installMod(modName)
|
||||
consumePick(self, modName, "picked_mod.zip",
|
||||
self.modNotice and self.modNotice.ok)
|
||||
return
|
||||
end
|
||||
local savName = findPendingSav(false, self.pickSkip)
|
||||
if savName then
|
||||
local version = self.androidPendingVersion or self:_savedropTarget()
|
||||
self.androidPendingVersion = nil
|
||||
self:_importSave(version, savName)
|
||||
if self.saveNotice[version] and self.saveNotice[version].ok then
|
||||
love.filesystem.remove(savName)
|
||||
end
|
||||
consumePick(self, savName, "picked_save.sav",
|
||||
self.saveNotice[version] and self.saveNotice[version].ok)
|
||||
return
|
||||
end
|
||||
for _, v in ipairs(GameVersion.ORDER) do
|
||||
if not self.ready[v] then
|
||||
local name, data = findPendingRom(self.ready)
|
||||
if name then self:startData(data, name) end
|
||||
if name then
|
||||
self:startData(data, name)
|
||||
else
|
||||
consumePickedRomError(self)
|
||||
end
|
||||
return
|
||||
end
|
||||
end
|
||||
@@ -698,8 +799,9 @@ function RomImporter:startData(data, displayName)
|
||||
local actualHash = sha1(data)
|
||||
local version = GameVersion.forSha1(actualHash)
|
||||
if not version then
|
||||
self:setError(("Unsupported ROM (SHA-1 %s). Use an unmodified US Pokemon "
|
||||
.. "Red, Blue, or Yellow ROM."):format(actualHash))
|
||||
self:setError(("Unsupported ROM (SHA-1 %s). This needs a clean US Pokemon "
|
||||
.. "Red, Blue, or Yellow dump; patched, trimmed or \"fixed\" dumps "
|
||||
.. "(tagged [b] or [BF]) never verify."):format(actualHash))
|
||||
return
|
||||
end
|
||||
local info = GameVersion.info(version)
|
||||
@@ -849,17 +951,19 @@ end
|
||||
function RomImporter:chooseMod()
|
||||
if self.workState == "working" then return end
|
||||
if self.android then
|
||||
local name = findPendingMod(true)
|
||||
local name = findPendingMod(true, self.pickSkip)
|
||||
if name then
|
||||
self:_installMod(name)
|
||||
if self.modNotice and self.modNotice.ok then
|
||||
love.filesystem.remove(name)
|
||||
end
|
||||
consumePick(self, name, "picked_mod.zip",
|
||||
self.modNotice and self.modNotice.ok)
|
||||
return
|
||||
end
|
||||
if not love.system.pickFile("mod") then
|
||||
self.modNotice = { ok = false,
|
||||
text = "Could not open the file picker. Copy a mod .zip via USB." }
|
||||
else
|
||||
self.pickPending = true
|
||||
self.pickTimer = 0
|
||||
end
|
||||
return
|
||||
end
|
||||
@@ -908,13 +1012,12 @@ end
|
||||
function RomImporter:chooseSaveImport(version)
|
||||
if self.workState == "working" then return end
|
||||
if self.android then
|
||||
local name = findPendingSav(true)
|
||||
local name = findPendingSav(true, self.pickSkip)
|
||||
if name then
|
||||
self.androidPendingVersion = version
|
||||
self:_importSave(version, name)
|
||||
if self.saveNotice[version] and self.saveNotice[version].ok then
|
||||
love.filesystem.remove(name)
|
||||
end
|
||||
consumePick(self, name, "picked_save.sav",
|
||||
self.saveNotice[version] and self.saveNotice[version].ok)
|
||||
return
|
||||
end
|
||||
self.androidPendingVersion = version
|
||||
@@ -922,6 +1025,9 @@ function RomImporter:chooseSaveImport(version)
|
||||
self.androidPendingVersion = nil
|
||||
self.saveNotice[version] = { ok = false,
|
||||
text = "Could not open the file picker. Copy a .sav via USB." }
|
||||
else
|
||||
self.pickPending = true
|
||||
self.pickTimer = 0
|
||||
end
|
||||
return
|
||||
end
|
||||
@@ -958,6 +1064,8 @@ function RomImporter:exportSave(version)
|
||||
end
|
||||
self.androidPendingExportVersion = version
|
||||
if love.system.createFile and love.system.createFile(suggested) then
|
||||
self.pickPending = true
|
||||
self.pickTimer = 0
|
||||
self.saveNotice[version] = { ok = true,
|
||||
text = "Pick where to save " .. suggested .. "..." }
|
||||
else
|
||||
@@ -999,6 +1107,8 @@ function RomImporter:choose(version)
|
||||
local name, data = findPendingRom(self.ready)
|
||||
if name then
|
||||
self:startData(data, name)
|
||||
elseif consumePickedRomError(self) then
|
||||
return -- a rejected pick explains itself instead of silently reopening
|
||||
elseif not love.system.pickFile() then
|
||||
-- Picker unavailable (API < 19, or no document-picker app installed):
|
||||
-- fall back to the USB folder-drop path as a friendly notice, not an
|
||||
@@ -1008,6 +1118,9 @@ function RomImporter:choose(version)
|
||||
status = "No picker available, copy your ROM into:",
|
||||
detail = love.filesystem.getSaveDirectory(),
|
||||
}
|
||||
else
|
||||
self.pickPending = true
|
||||
self.pickTimer = 0
|
||||
end
|
||||
return
|
||||
end
|
||||
@@ -1042,9 +1155,49 @@ function RomImporter:choose(version)
|
||||
end
|
||||
end
|
||||
|
||||
-- iOS: the document picker is an in-process modal sheet, so unlike Android's
|
||||
-- separate SAF activity there is no love.focus(true) when it dismisses.
|
||||
-- While a pick is outstanding, poll the save dir for the bridge's delivered
|
||||
-- file (picked_rom.gb / picked_mod.zip / picked_save.sav / export_done.flag)
|
||||
-- and run the same refocus import path Android uses.
|
||||
function RomImporter:_pollPickedFiles(dt)
|
||||
if not (self.ios and self.pickPending) then return end
|
||||
if self.workState == "working" then return end
|
||||
self.pickTimer = (self.pickTimer or 0) + dt
|
||||
if self.pickTimer < 0.5 then return end
|
||||
self.pickTimer = 0
|
||||
-- The Swift bridge reports a failed pick copy through pick_error.txt;
|
||||
-- surface it on whichever tab the player is looking at rather than
|
||||
-- letting the pick silently do nothing.
|
||||
local pickError = love.filesystem.read("pick_error.txt")
|
||||
if pickError then
|
||||
love.filesystem.remove("pick_error.txt")
|
||||
self.pickPending = nil
|
||||
self.modNotice = { ok = false, text = pickError }
|
||||
self.notice = { version = self.chooseVersion or "red",
|
||||
status = "File import failed:", detail = pickError }
|
||||
return
|
||||
end
|
||||
local found = love.filesystem.getInfo("export_done.flag", "file") ~= nil
|
||||
if not found then
|
||||
for _, name in ipairs(love.filesystem.getDirectoryItems("")) do
|
||||
local n = name:lower()
|
||||
if n:match("%.gbc?$") or n == "picked_mod.zip" or n == "picked_save.sav" then
|
||||
found = true
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
if found then
|
||||
self.pickPending = nil
|
||||
self:focus(true)
|
||||
end
|
||||
end
|
||||
|
||||
function RomImporter:update(dt)
|
||||
self.pulse = self.pulse + dt
|
||||
self:_updatePadCursor(dt)
|
||||
self:_pollPickedFiles(dt)
|
||||
if self.workState ~= "working" or not self.worker then return end
|
||||
local started = love.timer.getTime()
|
||||
repeat
|
||||
@@ -1184,6 +1337,36 @@ function RomImporter:gamepadaxis(_, axis, value)
|
||||
end
|
||||
end
|
||||
|
||||
function RomImporter:joystickpressed(joystick, button)
|
||||
if button == 1 then self:gamepadpressed(joystick, "a") end
|
||||
end
|
||||
|
||||
function RomImporter:joystickreleased(joystick, button)
|
||||
if button == 1 then self:gamepadreleased(joystick, "a") end
|
||||
end
|
||||
|
||||
function RomImporter:joystickaxis(joystick, axis, value)
|
||||
if axis == 1 then
|
||||
self:gamepadaxis(joystick, "leftx", value)
|
||||
elseif axis == 2 then
|
||||
self:gamepadaxis(joystick, "lefty", value)
|
||||
end
|
||||
end
|
||||
|
||||
function RomImporter:joystickhat(_, hat, direction)
|
||||
for _, dir in ipairs(self._rawHatDirs[hat] or {}) do
|
||||
self._padDir[dir] = nil
|
||||
end
|
||||
local dirs = ({
|
||||
u = { "dpup" }, d = { "dpdown" }, l = { "dpleft" }, r = { "dpright" },
|
||||
lu = { "dpleft", "dpup" }, ru = { "dpright", "dpup" },
|
||||
ld = { "dpleft", "dpdown" }, rd = { "dpright", "dpdown" },
|
||||
})[direction] or {}
|
||||
for _, dir in ipairs(dirs) do self._padDir[dir] = true end
|
||||
self._rawHatDirs[hat] = dirs
|
||||
if #dirs > 0 then self:_activatePadCursor() end
|
||||
end
|
||||
|
||||
-- Player pressed Play on a game whose ROM is imported: hand off to boot.
|
||||
function RomImporter:play(version)
|
||||
if self.workState == "working" then return end
|
||||
@@ -1415,6 +1598,32 @@ function RomImporter.pageScrollFor(naturalH, viewportH, scroll)
|
||||
return maxPage > 0, clamp(scroll or 0, 0, maxPage), maxPage
|
||||
end
|
||||
|
||||
-- Every hit rect mousepressed dispatches on, cleared before any panel draws:
|
||||
-- each rect is rebuilt only by the panel that draws its control, so whatever a
|
||||
-- frame does not draw must not stay clickable. Missing the Delete rects here
|
||||
-- let a click on the mods tab land on the game tab's save Delete label (#433).
|
||||
function RomImporter:_resetFrameRects()
|
||||
self.romButtonRect = nil
|
||||
self.playButtonRect = nil
|
||||
self.tabRects = {}
|
||||
-- Rebuilt only by the active version's SAVE SLOT panel, so the mods tab (or a
|
||||
-- version with no panel drawn this frame) cannot inherit last frame's rows.
|
||||
self.slotRects = nil
|
||||
self.slotEditRects = nil
|
||||
self.slotDeleteRects = nil
|
||||
self.newSlotRect = nil
|
||||
-- Rebuilt only by the mods panel; nil elsewhere so a game tab cannot inherit
|
||||
-- last frame's mod toggles / Delete labels / import button.
|
||||
self.modRects = nil
|
||||
self.modDeleteRects = nil
|
||||
self.modImportRect = nil
|
||||
-- Rebuilt only by the active game panel's SAVE FILES card; nil elsewhere so
|
||||
-- the mods tab cannot inherit last frame's save Import/Export/open-folder hits.
|
||||
self.saveImportRect = nil
|
||||
self.saveExportRect = nil
|
||||
self.saveFolderRect = nil
|
||||
end
|
||||
|
||||
function RomImporter:draw()
|
||||
local width, height = love.graphics.getDimensions()
|
||||
local s = clamp(height / 768, 0.7, 1.6)
|
||||
@@ -1433,23 +1642,7 @@ function RomImporter:draw()
|
||||
end
|
||||
self._hoverEnabled = self._padCursorActive or not self.android
|
||||
self._anyHover = false
|
||||
self.romButtonRect = nil
|
||||
self.playButtonRect = nil
|
||||
self.tabRects = {}
|
||||
-- Rebuilt only by the active version's SAVE SLOT panel, so the mods tab (or a
|
||||
-- version with no panel drawn this frame) cannot inherit last frame's rows.
|
||||
self.slotRects = nil
|
||||
self.slotEditRects = nil
|
||||
self.newSlotRect = nil
|
||||
-- Rebuilt only by the mods panel; nil elsewhere so a game tab cannot inherit
|
||||
-- last frame's mod toggles / import button.
|
||||
self.modRects = nil
|
||||
self.modImportRect = nil
|
||||
-- Rebuilt only by the active game panel's SAVE FILES card; nil elsewhere so
|
||||
-- the mods tab cannot inherit last frame's save Import/Export/open-folder hits.
|
||||
self.saveImportRect = nil
|
||||
self.saveExportRect = nil
|
||||
self.saveFolderRect = nil
|
||||
self:_resetFrameRects()
|
||||
|
||||
-- Fonts + size-dependent scenery, rebuilt only when the window size changes.
|
||||
local fontKey = ("%dx%d"):format(width, height)
|
||||
@@ -1936,6 +2129,16 @@ local function inside(r, x, y)
|
||||
return true
|
||||
end
|
||||
|
||||
-- A Delete label is armed by one click and commits on a second one on the same
|
||||
-- target; it disarms on any other press and after this many seconds, because
|
||||
-- nothing in the launcher can undo a delete (#433).
|
||||
local DELETE_CONFIRM_SECONDS = 4
|
||||
|
||||
local function armedDelete(a, kind, id, version)
|
||||
return a ~= nil and a.kind == kind and a.id == id and a.version == version
|
||||
and (love.timer.getTime() - a.t) <= DELETE_CONFIRM_SECONDS
|
||||
end
|
||||
|
||||
function RomImporter:mousepressed(x, y, button)
|
||||
if self._rename then return end -- the rename modal swallows all clicks
|
||||
-- Whether a press can be ARMED and resolved on release, which needs a
|
||||
@@ -1955,6 +2158,10 @@ function RomImporter:mousepressed(x, y, button)
|
||||
return
|
||||
end
|
||||
if button ~= 1 then return end
|
||||
-- Any press that is not the second click on an armed Delete disarms it, so
|
||||
-- take the arm off self up front and let the Delete loops below re-arm.
|
||||
local armed = self._confirmDelete
|
||||
self._confirmDelete = nil
|
||||
if inside(self.bcgButton, x, y) or inside(self.linkUrlRect, x, y) then
|
||||
love.system.openURL(COMMUNITY_URL)
|
||||
return
|
||||
@@ -2019,7 +2226,12 @@ function RomImporter:mousepressed(x, y, button)
|
||||
-- targets, no scroll conflict).
|
||||
for _, r in ipairs(self.slotDeleteRects or {}) do
|
||||
if inside(r, x, y) then
|
||||
self:_deleteSlot(self.panelVersion, r.id)
|
||||
if armedDelete(armed, "slot", r.id, self.panelVersion) then
|
||||
self:_deleteSlot(self.panelVersion, r.id)
|
||||
else
|
||||
self._confirmDelete = { kind = "slot", id = r.id,
|
||||
version = self.panelVersion, t = love.timer.getTime() }
|
||||
end
|
||||
return
|
||||
end
|
||||
end
|
||||
@@ -2053,7 +2265,11 @@ function RomImporter:mousepressed(x, y, button)
|
||||
end
|
||||
for _, r in ipairs(self.modDeleteRects or {}) do
|
||||
if inside(r, x, y) then
|
||||
self:_deleteMod(r.id)
|
||||
if armedDelete(armed, "mod", r.id, nil) then
|
||||
self:_deleteMod(r.id)
|
||||
else
|
||||
self._confirmDelete = { kind = "mod", id = r.id, t = love.timer.getTime() }
|
||||
end
|
||||
return
|
||||
end
|
||||
end
|
||||
@@ -2792,8 +3008,11 @@ function RomImporter:_drawSaveSlotPanel(version, x, y, w, h, paged)
|
||||
local drect = { x = delX - 6 * s, y = delY - 4 * s,
|
||||
width = delW + 12 * s, height = delH + 8 * s, id = slot.id }
|
||||
local dhot = self:_hover(drect)
|
||||
col(dhot and PAL.red or PAL.warning)
|
||||
love.graphics.print(delText, delX, delY)
|
||||
-- Armed by a first click, the label asks before a second one commits;
|
||||
-- the box stays sized to "Delete" so the row never reflows (#433).
|
||||
local darmed = armedDelete(self._confirmDelete, "slot", slot.id, version)
|
||||
col((darmed or dhot) and PAL.red or PAL.warning)
|
||||
love.graphics.printf(darmed and "Sure?" or delText, delX, delY, delW, "center")
|
||||
local rightReserve = delW + 18 * s
|
||||
|
||||
-- Edit label, immediately left of Delete: opens the bundled save
|
||||
@@ -3150,8 +3369,10 @@ function RomImporter:_drawModsPanel(x, y, w, h, paged)
|
||||
width = L.delW + 12 * s, height = delH + 4 * s, id = m.id }
|
||||
local dhot = self:_hover(drect)
|
||||
love.graphics.setFont(self.hintFont)
|
||||
col(dhot and PAL.red or PAL.warning)
|
||||
love.graphics.print("Delete", delX, delY)
|
||||
-- Same two-click arm as a save slot's Delete (#433).
|
||||
local darmed = armedDelete(self._confirmDelete, "mod", m.id, nil)
|
||||
col((darmed or dhot) and PAL.red or PAL.warning)
|
||||
love.graphics.printf(darmed and "Sure?" or "Delete", delX, delY, L.delW, "center")
|
||||
|
||||
-- hit rects clipped to the visible list band
|
||||
local vy = math.max(trect.y, top)
|
||||
|
||||
@@ -75,7 +75,10 @@ function SaveFileIO.importToSlot(source, version)
|
||||
return false, ("A save file must be %d bytes (32 KB); this one is %d.")
|
||||
:format(SAVE_SIZE, #bytes)
|
||||
end
|
||||
local save, convertErr = SaveConvert.importSav(bytes, version)
|
||||
-- 3rd arg: the crosswalk has to come from THIS game's ROM cache. The
|
||||
-- launcher imports before the cache is mounted on the un-prefixed paths, so
|
||||
-- SaveConvert cannot find the generated tables by itself here (#420).
|
||||
local save, convertErr = SaveConvert.importSav(bytes, version, version)
|
||||
if not save then return false, convertErr end
|
||||
-- Tag the game version and normalize the meta stamp: SaveConvert leaves
|
||||
-- meta.format = "gen1_import", but SaveData.load's migration pass compares
|
||||
@@ -103,7 +106,7 @@ function SaveFileIO.exportActiveSlot(version)
|
||||
version = version or GameVersion.get()
|
||||
local save = SaveData.load(version)
|
||||
if not save then return false, "this game has no save to export yet" end
|
||||
local bytes, exportErr = SaveConvert.exportSav(save)
|
||||
local bytes, exportErr = SaveConvert.exportSav(save, version)
|
||||
if not bytes then return false, exportErr end
|
||||
local slotId = SaveData.activeSlot(version) or "save"
|
||||
local fs = love and love.filesystem
|
||||
|
||||
@@ -168,6 +168,21 @@ function LauncherMods.pickStrays(found, installed)
|
||||
return out
|
||||
end
|
||||
|
||||
-- isReadableRoot(folder, source, cacheRoot) -> is folder already on the
|
||||
-- physfs read path, pure. source is love.filesystem.getSource(), cacheRoot
|
||||
-- the mounted portable game folder (CacheFs.root()); a fused build has both,
|
||||
-- and they are different paths (the archive inside the executable vs the
|
||||
-- folder beside it). Either one's mods/ is readable already, so nothing in
|
||||
-- it is a stray -- and the portable folder must additionally never be handed
|
||||
-- to CacheFs.withMounted: PHYSFS_mount reports success for a directory
|
||||
-- already in the search path WITHOUT adding a second entry, so the paired
|
||||
-- unmount tears down the one real mount and the panel loses every mod in the
|
||||
-- game folder (#413).
|
||||
function LauncherMods.isReadableRoot(folder, source, cacheRoot)
|
||||
if not folder or folder == "" then return false end
|
||||
return folder == source or folder == cacheRoot
|
||||
end
|
||||
|
||||
-- ------- discovery (love.filesystem)
|
||||
|
||||
local function decodeManifest(raw, path)
|
||||
@@ -349,17 +364,20 @@ end
|
||||
local STRAY_MOUNT = "stray_scan"
|
||||
|
||||
-- Run fn(mountedModsRoot) for each game folder that has a readable mods/
|
||||
-- directory, one mount at a time. Folders that are already the physfs source
|
||||
-- are skipped: their mods/ is discoverable by definition, so anything there is
|
||||
-- installed already and not a stray (this is every `love <gamedir>` dev run).
|
||||
-- directory, one mount at a time. Folders already on the read path are
|
||||
-- skipped (isReadableRoot): the physfs source, which is every `love <gamedir>`
|
||||
-- dev run, and the portable game folder CacheFs mounted, where re-mounting is
|
||||
-- what used to drop the mount (#413).
|
||||
local function eachStrayRoot(fn)
|
||||
local SaveData_ = require("src.core.SaveData")
|
||||
local fs = love and love.filesystem
|
||||
if not fs then return end
|
||||
local source = fs.getSource and fs.getSource()
|
||||
local cacheRoot = CacheFs.root()
|
||||
local seen = {}
|
||||
for _, folder in ipairs(SaveData_.gameFolders() or {}) do
|
||||
if not seen[folder] and folder ~= source then
|
||||
if not seen[folder]
|
||||
and not LauncherMods.isReadableRoot(folder, source, cacheRoot) then
|
||||
seen[folder] = true
|
||||
CacheFs.withMounted(folder, STRAY_MOUNT, function()
|
||||
local root = STRAY_MOUNT .. "/mods"
|
||||
|
||||
@@ -428,6 +428,7 @@ R.pokemon = {
|
||||
spriteFront = f.path, spriteBack = f.path, frontSize = f.int(1, 7),
|
||||
dexEntry = f.opt(f.rec{ kind = f.str, heightFt = f.int(0),
|
||||
heightIn = f.int(0, 11), weight = f.num,
|
||||
heightM = f.opt(f.num), weightKg = f.opt(f.num),
|
||||
text = f.str }),
|
||||
icon = f.opt(f.union{ f.str, f.rec{ image = f.path,
|
||||
frames = f.opt(f.int(1)) } }),
|
||||
@@ -561,6 +562,11 @@ R.trainers = {
|
||||
index = f.opt(f.int(0, 255)),
|
||||
-- unused vanilla classes ship without a pic, so it cannot be required
|
||||
pic = f.opt(f.path),
|
||||
-- Optional Advanced-mode OBJ palette source for a custom trainer portrait.
|
||||
-- It follows the same ROM crosswalk form as sprites.paletteSource.
|
||||
paletteSource = f.opt(f.str),
|
||||
-- Reuse a base trainer class's portrait without redistributing its asset.
|
||||
basePic = f.opt(f.id("trainers")),
|
||||
baseMoney = f.opt(f.int(0)),
|
||||
parties = f.list(f.list(f.rec{ level = f.int(1),
|
||||
species = f.id("pokemon") })),
|
||||
@@ -580,6 +586,10 @@ R.sprites = {
|
||||
frames = f.int(1),
|
||||
walker = f.opt(f.bool),
|
||||
trueColor = f.opt(f.bool),
|
||||
-- Mod art can opt into an existing ROM sprite's Advanced-mode OBJ
|
||||
-- palette assignment without claiming that the image itself came from
|
||||
-- the ROM (which is what `source` documents on imported records).
|
||||
paletteSource = f.opt(f.str),
|
||||
},
|
||||
example = 'mod.content.sprites:register("SPRITE_HERO", { image = "...", frames = 6 })',
|
||||
}
|
||||
|
||||
@@ -18,7 +18,8 @@ local function samePath(path) return path end
|
||||
-- side: "front" | "back"
|
||||
-- opts.mon: the live mon when available (per-instance skins)
|
||||
-- opts.kind: "battle" | "summary" | "dex" | "evolution" | "hof" | "trade"
|
||||
-- | "title" | "oak" | "credits" (informational for wrappers)
|
||||
-- | "title" | "oak" | "credits" | "overworld" (informational
|
||||
-- for wrappers)
|
||||
-- Returns path, trueColor.
|
||||
function Sprites.path(data, species, side, opts)
|
||||
opts = opts or {}
|
||||
|
||||
@@ -35,6 +35,48 @@ PaletteFX.MODE_LABELS = {
|
||||
}
|
||||
PaletteFX.mode = "gbc"
|
||||
|
||||
-- ------- dark-cave state (wMapPalOffset)
|
||||
--
|
||||
-- Unlike the per-frame shadeMap further down, this outlives a frame: ADVANCED
|
||||
-- resolves real colour per tile and BAKES it (tileset atlas, sprite sheets),
|
||||
-- so FadePal2's shift has to reach those bakes and their cache keys rather
|
||||
-- than a shader (#383). OverworldState owns it: armed before the map's atlas
|
||||
-- is built, cleared by FLASH.
|
||||
local darkWorld = false
|
||||
|
||||
-- returns true when the flag actually changed, so the caller can rebuild
|
||||
function PaletteFX.setDarkWorld(on)
|
||||
on = on and true or false
|
||||
if darkWorld == on then return false end
|
||||
darkWorld = on
|
||||
return true
|
||||
end
|
||||
|
||||
function PaletteFX.darkWorld() return darkWorld end
|
||||
|
||||
-- cache-key suffix for anything baked under the dark shift
|
||||
function PaletteFX.darkKey() return darkWorld and "#dark" or "" end
|
||||
|
||||
-- FadePal2 sets rOBP0 = `dc 3,3,3,2` as well as rBGP, so EVERY OBJ colour a
|
||||
-- sprite can carry lands on shade 3: the player, trainers and item balls are
|
||||
-- black silhouettes until FLASH (#383). Applied to whatever 4-colour OBP the
|
||||
-- active mode resolved, with a distinct cache group so the lit and dark bakes
|
||||
-- of one sheet never collide in SpriteRenderer's obpCache.
|
||||
function PaletteFX.darkObp(colors, group)
|
||||
if not (colors and darkWorld) then return colors, group end
|
||||
return PaletteFX.permute(colors, PaletteFX.DARK_BGP), tostring(group) .. "dark"
|
||||
end
|
||||
|
||||
-- the same shift folded into a baked 8-group world palette array (ADVANCED)
|
||||
local function darkGroups(groups)
|
||||
if not (groups and darkWorld) then return groups end
|
||||
local out = {}
|
||||
for i = 1, #groups do
|
||||
out[i] = PaletteFX.permute(groups[i], PaletteFX.DARK_BGP)
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
-- Classic DMG pea-soup greens (#9BBC0F / #8BAC0F / #306230 / #0F380F)
|
||||
PaletteFX.CLASSIC = {
|
||||
{ 155, 188, 15 }, { 139, 172, 15 }, { 48, 98, 48 }, { 15, 56, 15 },
|
||||
@@ -93,9 +135,13 @@ end
|
||||
-- Red bake with a Blue one and one version would show the other's colors.
|
||||
-- Yellow: same Red OBJ green as above until Yellow-specific tables land.
|
||||
function PaletteFX.ogObj()
|
||||
if GameVersion.isBlue() then return PaletteFX.GBC_OBJ_BLUE, "gbcobj_blue" end
|
||||
if GameVersion.isYellow() then return PaletteFX.GBC_OBJ, "gbcobj" end
|
||||
return PaletteFX.GBC_OBJ, "gbcobj"
|
||||
if GameVersion.isBlue() then
|
||||
return PaletteFX.darkObp(PaletteFX.GBC_OBJ_BLUE, "gbcobj_blue")
|
||||
end
|
||||
if GameVersion.isYellow() then
|
||||
return PaletteFX.darkObp(PaletteFX.GBC_OBJ, "gbcobj")
|
||||
end
|
||||
return PaletteFX.darkObp(PaletteFX.GBC_OBJ, "gbcobj")
|
||||
end
|
||||
|
||||
-- The DMG object ramp every mode except OG RED bakes onto overworld sprites,
|
||||
@@ -114,7 +160,7 @@ PaletteFX.OBP0_SHADES = {
|
||||
}
|
||||
|
||||
function PaletteFX.dmgObj()
|
||||
return PaletteFX.OBP0_SHADES, "obp0"
|
||||
return PaletteFX.darkObp(PaletteFX.OBP0_SHADES, "obp0")
|
||||
end
|
||||
|
||||
local INV_MAP = { [0] = 3, [1] = 2, [2] = 1, [3] = 0 }
|
||||
@@ -503,7 +549,7 @@ function PaletteFX.worldGroupColors(data, tileset, mapId, playerCellY)
|
||||
local w = pack and pack.world
|
||||
local base = w and w.groupColors[tileset]
|
||||
if not base then return nil end
|
||||
if not w.roofGroup[tileset] then return base end
|
||||
if not w.roofGroup[tileset] then return darkGroups(base) end
|
||||
local roofMapId = mapId
|
||||
if mapId == ROUTE_6_SAFFRON.mapId and playerCellY
|
||||
and playerCellY < ROUTE_6_SAFFRON.cellYBelow then
|
||||
@@ -511,7 +557,7 @@ function PaletteFX.worldGroupColors(data, tileset, mapId, playerCellY)
|
||||
end
|
||||
local roofMap = data and data.maps and data.maps[roofMapId]
|
||||
local roof = roofMap and w.roofByMapIndex[roofMap.index]
|
||||
if not roof then return base end
|
||||
if not roof then return darkGroups(base) end
|
||||
local out = {}
|
||||
for i = 1, 8 do out[i] = base[i] end
|
||||
-- LoadTownPalette only overwrites W2_BgPaletteData + $32, i.e. colors 1
|
||||
@@ -521,7 +567,7 @@ function PaletteFX.worldGroupColors(data, tileset, mapId, playerCellY)
|
||||
-- material's 2 middle shades are town-specific
|
||||
local base4 = base[ROOF_GROUP + 1]
|
||||
out[ROOF_GROUP + 1] = { base4[1], roof[1], roof[2], base4[4] }
|
||||
return out
|
||||
return darkGroups(out)
|
||||
end
|
||||
|
||||
-- an overworld sprite's resolved 4-color OBJ palette (ColorOverworldSprite),
|
||||
@@ -537,7 +583,7 @@ end
|
||||
function PaletteFX.spriteObp(spriteDef, seed)
|
||||
local pack = PaletteFX.gbcPack()
|
||||
local w = pack and pack.world
|
||||
local src = spriteDef and spriteDef.source
|
||||
local src = spriteDef and (spriteDef.paletteSource or spriteDef.source)
|
||||
if not (w and src) then return nil end
|
||||
local idx = tonumber(src:match("%[(%d+)%]"))
|
||||
-- RedBikeSprite loads outside SpriteSheetPointerTable
|
||||
@@ -552,7 +598,7 @@ function PaletteFX.spriteObp(spriteDef, seed)
|
||||
for i = 1, #seed do h = (h * 31 + seed:byte(i)) % 4294967296 end
|
||||
group = h % 4
|
||||
end
|
||||
return w.spritePalettes[group], group
|
||||
return PaletteFX.darkObp(w.spritePalettes[group], group)
|
||||
end
|
||||
|
||||
-- GetHealthBarColor (home/palettes.asm) on the standard 48px bar
|
||||
|
||||
+18
-4
@@ -437,12 +437,26 @@ function Renderer:drawTiltedWorld(zoneList, sx, sy, wox, woy, target)
|
||||
return true
|
||||
end
|
||||
|
||||
-- clamp a scissor rect to the viewport box
|
||||
local function scissorClamped(x, y, w, h, ox, oy, vpw, vph)
|
||||
-- Clamp a scissor rect to the viewport box, then round it outward to whole
|
||||
-- framebuffer pixels. love.graphics.setScissor truncates x, y, w and h to
|
||||
-- pixels independently, so a rect with fractional unit edges (Android's
|
||||
-- non-integer DPI puts fitScale/dpi in Sx/Sy) loses up to a pixel per side
|
||||
-- and two adjacent SGB zones stop sharing an edge: the letterbox clear shows
|
||||
-- through as a horizontal seam at every zone boundary (#373). Rounding
|
||||
-- outward makes neighbours overlap by at most one row instead -- the overlap
|
||||
-- redraws the same canvas pixels one palette later, and past the canvas edge
|
||||
-- there is nothing to draw. The half pixel keeps LOVE's truncation on the
|
||||
-- snapped edge rather than one short of it.
|
||||
local function scissorClamped(x, y, w, h, ox, oy, vpw, vph, dpiX, dpiY)
|
||||
local x2, y2 = math.min(x + w, ox + vpw), math.min(y + h, oy + vph)
|
||||
x, y = math.max(x, ox), math.max(y, oy)
|
||||
if x2 <= x or y2 <= y then return false end
|
||||
love.graphics.setScissor(x, y, x2 - x, y2 - y)
|
||||
dpiX, dpiY = dpiX or 1, dpiY or 1
|
||||
local px1, py1 = math.floor(x * dpiX), math.floor(y * dpiY)
|
||||
local px2, py2 = math.ceil(x2 * dpiX), math.ceil(y2 * dpiY)
|
||||
love.graphics.setScissor((px1 + 0.5) / dpiX, (py1 + 0.5) / dpiY,
|
||||
(px2 - px1 + 0.5) / dpiX,
|
||||
(py2 - py1 + 0.5) / dpiY)
|
||||
return true
|
||||
end
|
||||
|
||||
@@ -572,7 +586,7 @@ function Renderer:endFrame(zones, worldZones)
|
||||
if not plain then PaletteFX.sendColors(shader, z.colors) end
|
||||
if scissorClamped(bx + z.x * zoneSx, by + z.y * zoneSy,
|
||||
z.w * zoneSx, z.h * zoneSy,
|
||||
boxX, boxY, boxW, boxH) then
|
||||
boxX, boxY, boxW, boxH, dpiX, dpiY) then
|
||||
love.graphics.draw(canvas, bx, by, 0, sx, sy)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -135,7 +135,10 @@ local function blitFrame(image, quad, x, y, flip, redraw)
|
||||
end
|
||||
end
|
||||
|
||||
function SpriteRenderer:draw(px, py, camX, camY, facing, walkPhase, stepFlip)
|
||||
-- topHalf blits only the upper 8 rows of the frame: FishingAnim overwrites the
|
||||
-- bottom tile row of the standing frames with the fishing pose art, which the
|
||||
-- caller then draws itself through :drawTile (Player:draw, #384)
|
||||
function SpriteRenderer:draw(px, py, camX, camY, facing, walkPhase, stepFlip, topHalf)
|
||||
local x = math.floor(px - camX)
|
||||
local y = math.floor(py - camY) - 4
|
||||
local image = self.image
|
||||
@@ -190,7 +193,39 @@ function SpriteRenderer:draw(px, py, camX, camY, facing, walkPhase, stepFlip)
|
||||
flip = true
|
||||
end
|
||||
local quad = self.frames[frame] or self.frames[0]
|
||||
if topHalf then
|
||||
self.halfFrames = self.halfFrames or {}
|
||||
if not self.halfFrames[frame] then
|
||||
local iw, ih = self.image:getDimensions()
|
||||
self.halfFrames[frame] = love.graphics.newQuad(0, frame * 16, 16, 8, iw, ih)
|
||||
end
|
||||
quad = self.halfFrames[frame]
|
||||
end
|
||||
blitFrame(image, quad, x, y, flip, redraw)
|
||||
end
|
||||
|
||||
-- Blit a loose 16-wide fx tile at screen (x, y) wearing THIS sprite's OBJ
|
||||
-- palette, mirroring the mode branches in :draw above. The fishing pose row
|
||||
-- overwrites the sheet's own tiles in VRAM in the original, so it has to be
|
||||
-- recolored and OG-RED-redrawn exactly like the sheet rather than blitted as
|
||||
-- raw DMG shades (#384).
|
||||
function SpriteRenderer:drawTile(path, x, y, flip)
|
||||
local image, redraw = getImage(path), false
|
||||
if self.def.trueColor then
|
||||
PaletteFX.markTrueColor(x, y, 16, 8)
|
||||
elseif PaletteFX.usesGbcPack() then
|
||||
local colors, group = PaletteFX.spriteObp(self.def, self.seed)
|
||||
if colors then image = getObpImage(path, colors, group) end
|
||||
elseif PaletteFX.usesSpriteObp() and PaletteFX.spriteRedrawPassActive() then
|
||||
image, redraw = getObpImage(path, PaletteFX.ogObj()), true
|
||||
else
|
||||
image = getObpImage(path, PaletteFX.dmgObj())
|
||||
end
|
||||
local iw, ih = image:getDimensions()
|
||||
self.tileQuads = self.tileQuads or {}
|
||||
self.tileQuads[path] = self.tileQuads[path]
|
||||
or love.graphics.newQuad(0, 0, iw, ih, iw, ih)
|
||||
blitFrame(image, self.tileQuads[path], x, y, flip, redraw)
|
||||
end
|
||||
|
||||
return SpriteRenderer
|
||||
|
||||
@@ -402,8 +402,15 @@ end
|
||||
-- the route's own default roof (Vermilion's) throughout.
|
||||
local gbcAtlasCache = {}
|
||||
|
||||
-- Cache suffix for a map's RED++ bake. A dark cave folds FadePal2 into the
|
||||
-- palette worldGroupColors hands the bake (#383), so the lit and dark bakes of
|
||||
-- one map are different images and must not share a key.
|
||||
local function gbcKeyFor(mapId)
|
||||
return "#gbc:" .. mapId .. PaletteFX.darkKey()
|
||||
end
|
||||
|
||||
local function getGbcAtlas(imagePath, tilesetId, mapId, perRow, data)
|
||||
local key = imagePath .. "#gbc:" .. mapId
|
||||
local key = imagePath .. gbcKeyFor(mapId)
|
||||
if gbcAtlasCache[key] ~= nil then return gbcAtlasCache[key] or nil end
|
||||
local img = false
|
||||
if love.image and love.image.newImageData then
|
||||
@@ -473,7 +480,7 @@ function TileRenderer.new(map, data)
|
||||
self.gbcAtlas = true
|
||||
-- also recolors the animated water/flower entries below, so they
|
||||
-- match the atlas's static tiles instead of showing raw grayscale
|
||||
gbcCtx = { tilesetId = map.tileset.id, mapId = map.id, key = "#gbc:" .. map.id,
|
||||
gbcCtx = { tilesetId = map.tileset.id, mapId = map.id, key = gbcKeyFor(map.id),
|
||||
groupColors = PaletteFX.worldGroupColors(data, map.tileset.id, map.id, nil) }
|
||||
-- ...and feeds the color-0-keyed single tiles the feet overdraw needs
|
||||
-- (see getKeyedTile): same source image and palette groups, so keep the
|
||||
@@ -481,6 +488,7 @@ function TileRenderer.new(map, data)
|
||||
gbcCtx.imagePath = map.tileset.image
|
||||
gbcCtx.perRow = map.tileset.tilesPerRow
|
||||
self.gbcCtx = gbcCtx
|
||||
self.gbcAtlasKey = map.tileset.image .. gbcCtx.key
|
||||
self.gbcKeyed = {}
|
||||
end
|
||||
end
|
||||
@@ -582,7 +590,7 @@ local function ensureWaterBorderFill(self)
|
||||
local groupColors = PaletteFX.worldGroupColors(
|
||||
self.data, map.tileset.id, map.id, nil)
|
||||
colors = group and groupColors and groupColors[group + 1] or nil
|
||||
gbcKey = "#gbc:" .. map.id
|
||||
gbcKey = gbcKeyFor(map.id)
|
||||
end
|
||||
local textures = getShiftVariants(map.tileset.image, perRow, WATER_TILE,
|
||||
colors, gbcKey)
|
||||
@@ -913,8 +921,9 @@ function TileRenderer:release()
|
||||
self.gbcCtx = nil
|
||||
end
|
||||
if self.gbcAtlas and self.image then
|
||||
local key = self.map.tileset.image .. "#gbc:" .. self.map.id
|
||||
local key = self.gbcAtlasKey or (self.map.tileset.image .. gbcKeyFor(self.map.id))
|
||||
if gbcAtlasCache[key] == self.image then gbcAtlasCache[key] = nil end
|
||||
self.gbcAtlasKey = nil
|
||||
safeRelease(self.image)
|
||||
self.image = nil
|
||||
self.gbcAtlas = nil
|
||||
|
||||
@@ -84,6 +84,15 @@ O.coins = O.mainData + 685 -- 2B BCD
|
||||
-- (wWalkBikeSurfState) + 10 = 359, so 685 + 359 = 1044 as well.
|
||||
O.townVisited = O.mainData + 1044 -- 2B (flag_array NUM_CITY_MAPS)
|
||||
O.eventFlags = O.mainData + 1104 -- 320B (flag_array NUM_EVENTS = 2560 bits)
|
||||
-- Progress bits vanilla keeps OUTSIDE wEventFlags that this port still spells
|
||||
-- as save.flags entries (#396). Offsets walk forward from wTownVisitedFlag
|
||||
-- over the same ram/wram.asm declaration run the 60-byte gap above sums:
|
||||
-- +29 wStatusFlags1, +35 wStatusFlags4, +41 wElite4Flags,
|
||||
-- +44 wCompletedInGameTradeFlags.
|
||||
O.statusFlags1 = O.townVisited + 29 -- 1B
|
||||
O.statusFlags4 = O.townVisited + 35 -- 1B
|
||||
O.elite4Flags = O.townVisited + 41 -- 1B
|
||||
O.tradeFlags = O.townVisited + 44 -- 2B (flag_array NUM_NPC_TRADES)
|
||||
-- Play time (wPlayTimeHours/Maxed/Minutes/Seconds/Frames) lives INSIDE the
|
||||
-- sMainData window (wMainDataStart..wMainDataEnd is copied verbatim into
|
||||
-- SRAM), 1866 bytes past wMainDataStart -- reached from the checksum-verified
|
||||
@@ -314,6 +323,34 @@ local BADGE_BY_BIT = {
|
||||
local BADGE_BY_BIT_SET = {}
|
||||
for _, name in pairs(BADGE_BY_BIT) do BADGE_BY_BIT_SET[name] = true end
|
||||
|
||||
-- save.flags names whose vanilla home is NOT wEventFlags (#396: exporting a
|
||||
-- save and importing it back made the Saffron gate guards thirsty again,
|
||||
-- because BIT_GAVE_SAFFRON_GUARDS_DRINK is a wStatusFlags1 bit and nothing
|
||||
-- carried it). Bit numbers are constants/ram_constants.asm; the trade bits
|
||||
-- are wWhichTrade, which engine/events/in_game_trades.asm uses to index
|
||||
-- wCompletedInGameTradeFlags, i.e. the data/events/trades.asm row order the
|
||||
-- port's `trade` command takes 1-based.
|
||||
local EXTRA_FLAG_BITS = {
|
||||
EVENT_GOT_OLD_ROD = { O.statusFlags1, 3 },
|
||||
EVENT_GOT_GOOD_ROD = { O.statusFlags1, 4 },
|
||||
EVENT_GOT_SUPER_ROD = { O.statusFlags1, 5 },
|
||||
EVENT_GAVE_GUARDS_DRINK = { O.statusFlags1, 6 },
|
||||
EVENT_GOT_LAPRAS = { O.statusFlags4, 0 },
|
||||
EVENT_STARTED_ELITE_4 = { O.elite4Flags, 1 },
|
||||
EVENT_TRADED_NIDORINO_FOR_NIDORINA = { O.tradeFlags, 0 },
|
||||
EVENT_TRADED_ABRA_FOR_MR_MIME = { O.tradeFlags, 1 },
|
||||
EVENT_TRADED_PONYTA_FOR_SEEL = { O.tradeFlags, 3 },
|
||||
EVENT_TRADED_SPEAROW_FOR_FARFETCHD = { O.tradeFlags, 4 },
|
||||
EVENT_TRADED_SLOWBRO_FOR_LICKITUNG = { O.tradeFlags, 5 },
|
||||
EVENT_TRADED_POLIWHIRL_FOR_JYNX = { O.tradeFlags, 6 },
|
||||
EVENT_TRADED_RAICHU_FOR_ELECTRODE = { O.tradeFlags, 7 },
|
||||
EVENT_TRADED_VENONAT_FOR_TANGELA = { O.tradeFlags, 8 },
|
||||
EVENT_TRADED_NIDORAN_M_FOR_NIDORAN_F = { O.tradeFlags, 9 },
|
||||
}
|
||||
|
||||
-- port-local name -> the wEventFlags name it means (#396)
|
||||
local FLAG_ALIAS = { EVENT_RECEIVED_BIKE_VOUCHER = "EVENT_GOT_BIKE_VOUCHER" }
|
||||
|
||||
-- STATUS_* bits (constants/battle_constants.asm): 0-2 sleep-turns-left,
|
||||
-- 3 PSN, 4 BRN, 5 FRZ, 6 PAR
|
||||
local STATUS_BIT = { PSN = 3, BRN = 4, FRZ = 5, PAR = 6 }
|
||||
@@ -671,6 +708,14 @@ function GenSave.decode(bytes, data, opts)
|
||||
end
|
||||
end
|
||||
|
||||
-- the same progress under names that are not wEventFlags bits (#396)
|
||||
for name, spec in pairs(EXTRA_FLAG_BITS) do
|
||||
if bitGet(bytes, spec[1], spec[2]) then save.flags[name] = true end
|
||||
end
|
||||
for portName, vanillaName in pairs(FLAG_ALIAS) do
|
||||
if save.flags[vanillaName] then save.flags[portName] = true end
|
||||
end
|
||||
|
||||
-- FLY destinations. wTownVisitedFlag's bit index IS the town's map index:
|
||||
-- engine/items/town_map.asm BuildFlyLocationsList loads the 16-bit value
|
||||
-- into de and rotates it right one bit per iteration with b counting up
|
||||
@@ -794,6 +839,19 @@ function GenSave.encode(save, data, template)
|
||||
local bitIdx = events.byName[name]
|
||||
if bitIdx then bitSet(buf, O.eventFlags, bitIdx, true) end
|
||||
end
|
||||
for portName, vanillaName in pairs(FLAG_ALIAS) do
|
||||
local bitIdx = events.byName[vanillaName]
|
||||
if bitIdx and save.flags[portName] then bitSet(buf, O.eventFlags, bitIdx, true) end
|
||||
end
|
||||
end
|
||||
|
||||
-- Non-wEventFlags progress, written both ways: this port's save is the only
|
||||
-- authority for these names, so a flag it does not hold must clear the
|
||||
-- template's bit rather than survive in the export (#396).
|
||||
if save.flags then
|
||||
for name, spec in pairs(EXTRA_FLAG_BITS) do
|
||||
bitSet(buf, spec[1], spec[2], save.flags[name] and true or false)
|
||||
end
|
||||
end
|
||||
|
||||
-- FLY destinations back into wTownVisitedFlag (see the decode note), so a
|
||||
|
||||
@@ -11,9 +11,14 @@
|
||||
-- tables load through `require`, exactly how src/core/Data.lua pulls the
|
||||
-- generated modules -- which resolves under both plain luajit (package.path
|
||||
-- "./?.lua") for the headless CLI/tests and love.filesystem for a fused
|
||||
-- build, with an OS-path fallback for odd working directories. The only
|
||||
-- place `love` is referenced is inside a guarded fallback, so running under
|
||||
-- stock Lua never touches it.
|
||||
-- build, with an OS-path fallback for odd working directories. `love` is only
|
||||
-- ever referenced inside guarded fallbacks, so running under stock Lua never
|
||||
-- touches it.
|
||||
--
|
||||
-- When a caller names the game a save belongs to, the generated tables come
|
||||
-- out of that version's ROM cache through CacheFs instead: the launcher does
|
||||
-- its importing before the cache is mounted onto the un-prefixed paths, so
|
||||
-- require alone cannot see them there (#420).
|
||||
|
||||
local GenSave = require("src.save_convert.GenSave")
|
||||
|
||||
@@ -63,20 +68,59 @@ local function loadTable(requirePath, filePath)
|
||||
:format(requirePath, requirePath, filePath)
|
||||
end
|
||||
|
||||
local crosswalk -- { pokemon=, moves=, items=, maps=, eventFlags= }
|
||||
-- The four generated tables live in one game's ROM cache, and the launcher
|
||||
-- reaches this code before that cache is on the un-prefixed read path:
|
||||
-- CacheFs.mountVersion only runs from main.lua's bootGame (after Play), and
|
||||
-- Blue/Yellow keep their cache under GameVersion.cachePrefix. So a bare
|
||||
-- require sees Red's copy at best, and nothing at all in a fused portable
|
||||
-- build (the game folder is only readable through CacheFs's PhysFS mount) --
|
||||
-- read the tables out of the cache whenever the caller names the game the
|
||||
-- save belongs to, and let the require path above cover everything else
|
||||
-- (#420).
|
||||
local function loadCacheTable(gameVersion, filePath)
|
||||
if not (gameVersion and love and love.filesystem) then return nil end
|
||||
if not filePath:match("^data/generated/") then return nil end
|
||||
local okc, CacheFs = pcall(require, "src.import.CacheFs")
|
||||
local okg, GameVersion = pcall(require, "src.core.GameVersion")
|
||||
if not (okc and okg and type(CacheFs) == "table") then return nil end
|
||||
local info = GameVersion.VERSIONS[gameVersion]
|
||||
if not info then return nil end
|
||||
-- CacheFs.prefix is launcher-owned global state (it points at whatever
|
||||
-- import last ran), so borrow it for this read and put it back.
|
||||
local saved = CacheFs.prefix
|
||||
CacheFs.prefix = info.cachePrefix
|
||||
local okr, bytes = pcall(CacheFs.read, filePath)
|
||||
CacheFs.prefix = saved
|
||||
if not (okr and type(bytes) == "string") then return nil end
|
||||
local chunk = loadstring(bytes, "@" .. info.cachePrefix .. filePath)
|
||||
if not chunk then return nil end
|
||||
local okx, mod = pcall(chunk)
|
||||
if okx and type(mod) == "table" then return mod end
|
||||
return nil
|
||||
end
|
||||
|
||||
-- Crosswalk sets keyed by the game whose cache they came from ("*" for the
|
||||
-- require-resolved set): Yellow's tables are not Red's, so one import must
|
||||
-- never be handed the previous import's data (#420).
|
||||
local crosswalks = {} -- [key] = { pokemon=, moves=, items=, maps=, eventFlags= }
|
||||
local charmapReady
|
||||
|
||||
local function ensureData()
|
||||
if not crosswalk then
|
||||
local function ensureData(gameVersion)
|
||||
local key = gameVersion or "*"
|
||||
if not crosswalks[key] then
|
||||
local data = {}
|
||||
for key, spec in pairs(DATA_MODULES) do
|
||||
if key ~= "charmap" then
|
||||
local mod, e = loadTable(spec[1], spec[2])
|
||||
if not mod then return nil, e end
|
||||
data[key] = mod
|
||||
for name, spec in pairs(DATA_MODULES) do
|
||||
if name ~= "charmap" then
|
||||
local mod = loadCacheTable(gameVersion, spec[2])
|
||||
if not mod then
|
||||
local e
|
||||
mod, e = loadTable(spec[1], spec[2])
|
||||
if not mod then return nil, e end
|
||||
end
|
||||
data[name] = mod
|
||||
end
|
||||
end
|
||||
crosswalk = data
|
||||
crosswalks[key] = data
|
||||
end
|
||||
if not charmapReady then
|
||||
local cm, err = loadTable(DATA_MODULES.charmap[1], DATA_MODULES.charmap[2])
|
||||
@@ -84,13 +128,14 @@ local function ensureData()
|
||||
GenSave.setCharmap(cm)
|
||||
charmapReady = true
|
||||
end
|
||||
return crosswalk
|
||||
return crosswalks[key]
|
||||
end
|
||||
|
||||
-- Exposed for the CLI/tests so they can share the exact data set the codec
|
||||
-- uses (and so a caller can pre-warm the cache). Returns data, err.
|
||||
function SaveConvert.loadData()
|
||||
return ensureData()
|
||||
-- uses (and so a caller can pre-warm the cache). gameVersion picks whose ROM
|
||||
-- cache the generated tables come from. Returns data, err.
|
||||
function SaveConvert.loadData(gameVersion)
|
||||
return ensureData(gameVersion)
|
||||
end
|
||||
|
||||
-- ------------------------------------------------------------------
|
||||
@@ -108,7 +153,8 @@ local function defaultsSave()
|
||||
options = {
|
||||
textSpeed = 3, animations = true, battleStyle = "shift",
|
||||
battleLayout = "og",
|
||||
ruleset = "gen1_faithful", musicVol = 7, sfxVol = 7, musicFilter = 0,
|
||||
ruleset = "gen1_faithful", musicVol = 7, sfxVol = 7, pikaVol = 7,
|
||||
musicFilter = 0,
|
||||
speed = 1, colors = "gbc", tilt = 0, gbcfx = 0,
|
||||
videoMode = "windowed", mods = {},
|
||||
},
|
||||
@@ -116,13 +162,12 @@ local function defaultsSave()
|
||||
end
|
||||
|
||||
-- Merge a GenSave.decode() result over the new-game defaults, exactly the
|
||||
-- way convert.lua did, then stamp the requested version. The 32768-byte
|
||||
-- import template GenSave stashes as `rawImport` and the decode `warnings`
|
||||
-- are dropped here: neither belongs in a serialized slot file (a fresh
|
||||
-- export always starts zero-filled -- see GenSave.lua's header).
|
||||
-- way convert.lua did, then stamp the requested version. Keep the imported
|
||||
-- SRAM image with the slot: Pokémon Red restores its saved current-map cache
|
||||
-- before Continue, and an export needs that unmodeled data to remain bootable.
|
||||
-- Decode warnings are only import diagnostics and do not belong in the slot.
|
||||
local function mergeDefaults(decoded, version)
|
||||
decoded.warnings = nil
|
||||
decoded.rawImport = nil
|
||||
local save = defaultsSave()
|
||||
for k, v in pairs(decoded) do save[k] = v end
|
||||
save.lastHeal = { map = save.player.map, x = save.player.x, y = save.player.y }
|
||||
@@ -139,20 +184,22 @@ SaveConvert.mergeDefaults = mergeDefaults
|
||||
-- Public API
|
||||
-- ------------------------------------------------------------------
|
||||
|
||||
-- importSav(bytes, version) -> saveTable, err
|
||||
-- importSav(bytes, version, gameVersion) -> saveTable, err
|
||||
-- bytes: the raw 32768-byte SRAM string. Validates size and the main-data
|
||||
-- checksum, decodes through GenSave, and returns a save table fully merged
|
||||
-- over the new-game defaults and tagged with `version`, ready to hand to
|
||||
-- SaveSerializer.encode for a slot file. On any failure returns nil + a
|
||||
-- message (never raises).
|
||||
function SaveConvert.importSav(bytes, version)
|
||||
-- SaveSerializer.encode for a slot file. gameVersion ("red"/"blue"/"yellow")
|
||||
-- names the game the save is being imported for, which is what selects the
|
||||
-- crosswalk tables; omit it to take whatever `require` resolves. On any
|
||||
-- failure returns nil + a message (never raises).
|
||||
function SaveConvert.importSav(bytes, version, gameVersion)
|
||||
if type(bytes) ~= "string" then
|
||||
return nil, "expected raw save bytes as a string"
|
||||
end
|
||||
if #bytes ~= GenSave.SAVE_SIZE then
|
||||
return nil, ("save must be %d bytes, got %d"):format(GenSave.SAVE_SIZE, #bytes)
|
||||
end
|
||||
local data, derr = ensureData()
|
||||
local data, derr = ensureData(gameVersion)
|
||||
if not data then return nil, derr end
|
||||
|
||||
local ok, decoded = pcall(GenSave.decode, bytes, data)
|
||||
@@ -171,16 +218,17 @@ function SaveConvert.importSav(bytes, version)
|
||||
return mergeDefaults(decoded, version)
|
||||
end
|
||||
|
||||
-- exportSav(saveTable) -> bytes, err
|
||||
-- exportSav(saveTable, gameVersion) -> bytes, err
|
||||
-- Encodes a save table back to a raw 32768-byte SRAM image. Template-aware:
|
||||
-- if the table still carries the stashed import template (saveTable.rawImport)
|
||||
-- GenSave reproduces every unmodeled region from it; otherwise those regions
|
||||
-- are zero-filled. On failure returns nil + a message (never raises).
|
||||
function SaveConvert.exportSav(saveTable)
|
||||
-- are zero-filled. gameVersion selects the crosswalk tables exactly as in
|
||||
-- importSav. On failure returns nil + a message (never raises).
|
||||
function SaveConvert.exportSav(saveTable, gameVersion)
|
||||
if type(saveTable) ~= "table" then
|
||||
return nil, "expected a save table"
|
||||
end
|
||||
local data, derr = ensureData()
|
||||
local data, derr = ensureData(gameVersion)
|
||||
if not data then return nil, derr end
|
||||
local ok, bytes = pcall(GenSave.encode, saveTable, data, nil)
|
||||
if not ok then return nil, "encode failed: " .. tostring(bytes) end
|
||||
|
||||
+26
-3
@@ -225,11 +225,23 @@ function Commands.give_item(ctx, itemId, count, gotText)
|
||||
ctx.game.stringBuffer = def and def.name or itemId
|
||||
-- the jingle rides the box -- Sound.play routes fanfares through
|
||||
-- Music.duckForFanfare, like PlaySoundWaitForCurrent
|
||||
require("src.core.Sound").play(ctx.game.data,
|
||||
(def and def.keyItem) and "Get_Key_Item" or "Get_Item1")
|
||||
local Sound = require("src.core.Sound")
|
||||
local jingle = (def and def.keyItem) and "Get_Key_Item" or "Get_Item1"
|
||||
if gotText ~= false then
|
||||
-- the gift texts carry the jingle as a trailing text command
|
||||
-- (sound_get_item_1 / sound_get_key_item -> home/text.asm
|
||||
-- TextCommand_SOUND), so it only fires once the last page has typed
|
||||
-- out, blocks on WaitForSoundToFinish, and AfterDisplayingTextID's
|
||||
-- button wait runs after it (#374)
|
||||
ctx.textOpts = ctx.textOpts or {}
|
||||
ctx.textOpts.auto = {
|
||||
sound = function() return Sound.play(ctx.game.data, jingle) end,
|
||||
wait = true,
|
||||
}
|
||||
Commands.show_text(ctx, gotText
|
||||
or Strings("{PLAYER} got\n%s!", ctx.game.stringBuffer))
|
||||
else
|
||||
Sound.play(ctx.game.data, jingle)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -253,7 +265,18 @@ function Commands.start_battle(ctx, kind, a, b)
|
||||
ctx.lastBattleResult = result
|
||||
ctx.lastCheck = result == "win"
|
||||
if ctx.overworld then
|
||||
ctx.overworld:afterBattle(result, battle)
|
||||
-- A map script often follows a trainer battle with its own text.
|
||||
-- Keep a level evolution behind that text: otherwise afterBattle
|
||||
-- pushes the evolution screen, then this runner resumes and pushes
|
||||
-- the trainer's text on top of it.
|
||||
if result == "win" then
|
||||
ctx.afterScript = ctx.afterScript or {}
|
||||
table.insert(ctx.afterScript, function()
|
||||
ctx.overworld:afterBattle(result, battle)
|
||||
end)
|
||||
else
|
||||
ctx.overworld:afterBattle(result, battle)
|
||||
end
|
||||
end
|
||||
runner:resume()
|
||||
end
|
||||
|
||||
@@ -125,6 +125,10 @@ function ScriptRunner:run(script, extra)
|
||||
end
|
||||
self.co = coroutine.create(function()
|
||||
self:exec(script, ctx)
|
||||
-- Commands can defer a game action until the script's own dialogue is
|
||||
-- done. start_battle uses this for win-path evolutions, which must not
|
||||
-- be covered by post-battle trainer text.
|
||||
for _, callback in ipairs(ctx.afterScript or {}) do callback() end
|
||||
if ctx.onDone then ctx.onDone() end
|
||||
if Runtime.wants("script.ended") then
|
||||
Runtime.emit("script.ended", { ctx = ctx, completed = true })
|
||||
|
||||
+5
-3
@@ -305,9 +305,11 @@ local function useOn(game, battle, id, target, list, moveIndex, picker)
|
||||
-- HP medicine: fill the bar in the still-open picker first, then print
|
||||
-- and close, the order item_effects.asm .doneHealing runs in
|
||||
-- (SFX_HEAL_HP -> UpdateHPBar2 -> RedrawPartyMenu prints the message).
|
||||
-- picker is nil for every other item and for in-battle use, which keeps
|
||||
-- the pop-then-print path below. #252
|
||||
if picker and extra and extra.healedFrom and target then
|
||||
-- Only a keepOpen picker is still on the stack to animate: every other
|
||||
-- item, and every in-battle use, popped it in PartyMenu before onSwitch,
|
||||
-- and takes the pop-then-print path below -- which is the path that
|
||||
-- spends the battle turn. #252, #379
|
||||
if picker and picker.keepOpen and extra and extra.healedFrom and target then
|
||||
picker:animateTo(target, extra.healedFrom, function()
|
||||
showMessages(game, payload, closePicker)
|
||||
end)
|
||||
|
||||
+22
-7
@@ -35,14 +35,15 @@ function DexEntryMenu.new(game, speciesOrOpts)
|
||||
local species, forceOwned = resolveArgs(speciesOrOpts)
|
||||
local self = setmetatable({ game = game, forceOwned = forceOwned }, DexEntryMenu)
|
||||
self.def = game.data.pokemon[species]
|
||||
local path = require("src.pokemon.Sprites").path(game.data, species, "front",
|
||||
{ kind = "dex" })
|
||||
local path, trueColor = require("src.pokemon.Sprites").path(
|
||||
game.data, species, "front", { kind = "dex" })
|
||||
-- `path and pcall(...)` truncates to one value, so img was always nil and
|
||||
-- every dex page drew without its pic (#307); the guard has to be a
|
||||
-- statement for pcall's second return to survive.
|
||||
local ok, img = false, nil
|
||||
if path then ok, img = pcall(love.graphics.newImage, path) end
|
||||
self.sprite = ok and img or nil
|
||||
self.spriteTrueColor = self.sprite and trueColor or false
|
||||
require("src.core.Sound").playCry(game.data, species)
|
||||
return self
|
||||
end
|
||||
@@ -55,17 +56,26 @@ function DexEntryMenu:update(dt)
|
||||
end
|
||||
|
||||
function DexEntryMenu:draw()
|
||||
DexEntryMenu.render(self.game, self.def, self.sprite, self.forceOwned)
|
||||
DexEntryMenu.render(self.game, self.def, self.sprite, self.forceOwned,
|
||||
self.spriteTrueColor)
|
||||
end
|
||||
|
||||
-- Static entry-page renderer, shared with the printer stand-in
|
||||
-- (src/core/Printer.lua renders the same page into a PNG the way
|
||||
-- PrintPokedexEntry rendered it to the Game Boy Printer).
|
||||
function DexEntryMenu.render(game, def, sprite, forceOwned)
|
||||
function DexEntryMenu.render(game, def, sprite, forceOwned, trueColor)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
love.graphics.rectangle("fill", 0, 0, 160, 144)
|
||||
if sprite then
|
||||
love.graphics.draw(sprite, 8, math.max(0, 60 - sprite:getHeight()))
|
||||
local y = math.max(0, 60 - sprite:getHeight())
|
||||
love.graphics.draw(sprite, 8, y)
|
||||
-- a full-color pic has to sit out the SGB recolor, so mark its bounds
|
||||
-- for the unshaded pass (#350). The printer path leaves trueColor nil:
|
||||
-- it renders to its own PNG canvas, and a mark left behind there would
|
||||
-- bleed into the next real frame.
|
||||
if trueColor then
|
||||
require("src.render.PaletteFX").markTrueColor(8, y, sprite:getDimensions())
|
||||
end
|
||||
end
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
Font.draw(def.name, 72, 8)
|
||||
@@ -87,8 +97,13 @@ function DexEntryMenu.render(game, def, sprite, forceOwned)
|
||||
-- feet/inches use the dex screen's ′/″ glyphs ("HT ?′??″" in
|
||||
-- pokedex.asm; the tiles come from gfx/pokedex/pokedex.png via
|
||||
-- engine/gfx/load_pokedex_tiles.asm)
|
||||
Font.draw(Strings("HT %d′%02d″", e.heightFt, e.heightIn or 0), 72, 44)
|
||||
Font.draw(Strings("WT %.1flb", (e.weight or 0) / 10), 72, 54)
|
||||
if e.heightM then
|
||||
Font.draw((("GR. %.1fm"):format(e.heightM):gsub("(%d)%.(%d)", "%1,%2")), 64, 44)
|
||||
Font.draw((("GEW. %.1fkg"):format(e.weightKg or 0):gsub("(%d)%.(%d)", "%1,%2")), 64, 54)
|
||||
else
|
||||
Font.draw(Strings("HT %d′%02d″", e.heightFt, e.heightIn or 0), 72, 44)
|
||||
Font.draw(Strings("WT %.1flb", (e.weight or 0) / 10), 72, 54)
|
||||
end
|
||||
end
|
||||
local text = owned and e.text and game.data.text[e.text] or nil
|
||||
local y = 72
|
||||
|
||||
+1
-1
@@ -191,7 +191,7 @@ function ListMenu:draw()
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
love.graphics.rectangle("fill", 0, 0, 160, 144)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
Font.draw(self.title, 8, 4)
|
||||
Font.draw(Strings(self.title), 8, 4)
|
||||
if #self.items == 0 then
|
||||
Font.draw(Strings("Nothing here."), 16, 64)
|
||||
end
|
||||
|
||||
@@ -19,6 +19,20 @@ function Menu.new(game, items, opts)
|
||||
self.tx = opts.tx or 10
|
||||
self.ty = opts.ty or 0
|
||||
self.tw = opts.tw or 10
|
||||
-- grow the box to the widest label so longer (e.g. localized) labels don't
|
||||
-- overflow the frame; nudge tx left to keep the box on-screen (20 tiles).
|
||||
do
|
||||
local widest = 0
|
||||
for _, it in ipairs(items) do
|
||||
if it.label then
|
||||
local n = #Font.split(it.label)
|
||||
if n > widest then widest = n end
|
||||
end
|
||||
end
|
||||
local needed = widest + 3
|
||||
if needed > self.tw then self.tw = needed end
|
||||
if self.tx + self.tw > 20 then self.tx = math.max(0, 20 - self.tw) end
|
||||
end
|
||||
self.rowStep = opts.rowStep or 2
|
||||
-- maxVisible: cap the box to this many rows and scroll the rest instead
|
||||
-- of growing past it (e.g. the start menu, whose row count varies with
|
||||
|
||||
@@ -183,7 +183,7 @@ function NamingScreen:draw()
|
||||
end
|
||||
for r, row in ipairs(self:grid()) do
|
||||
for c, cell in ipairs(row) do
|
||||
Font.draw(cell, c * 16, 32 + r * 16)
|
||||
Font.draw(Strings(cell), c * 16, 32 + r * 16)
|
||||
end
|
||||
end
|
||||
Font.drawCode(Theme.cursor, self.col * 16 - 8, 32 + self.row * 16)
|
||||
|
||||
+38
-20
@@ -61,7 +61,7 @@ local function tryImage(path)
|
||||
return ok and img or nil
|
||||
end
|
||||
|
||||
-- Resolve a pic descriptor to (image, flip).
|
||||
-- Resolve a pic descriptor to (image, flip, trueColor).
|
||||
-- Descriptors:
|
||||
-- "oak" | "rival" | "player" shorthand
|
||||
-- { type = "trainer", id = "OPP_PROF_OAK" }
|
||||
@@ -70,7 +70,7 @@ end
|
||||
-- { type = "image", path = "..." }
|
||||
-- { type = "sprite", id = "SPRITE_RED" }
|
||||
function OakSpeech.resolvePic(game, desc, speech)
|
||||
if desc == nil then return nil, false end
|
||||
if desc == nil then return nil, false, false end
|
||||
if type(desc) == "string" then
|
||||
if desc == "oak" then
|
||||
desc = { type = "trainer", id = "OPP_PROF_OAK" }
|
||||
@@ -86,35 +86,37 @@ function OakSpeech.resolvePic(game, desc, speech)
|
||||
local t = desc.type
|
||||
if t == "trainer" then
|
||||
if speech and desc.id == "OPP_PROF_OAK" and speech.oakPic then
|
||||
return speech.oakPic, false
|
||||
return speech.oakPic, false, false
|
||||
end
|
||||
if speech and desc.id == "OPP_RIVAL1" and speech.rivalPic then
|
||||
return speech.rivalPic, false
|
||||
return speech.rivalPic, false, false
|
||||
end
|
||||
local trainers = game.data.trainers or {}
|
||||
local tr = trainers[desc.id]
|
||||
return tryImage(tr and tr.pic), false
|
||||
return tryImage(tr and tr.pic), false, false
|
||||
elseif t == "pokemon" then
|
||||
if speech and desc.id == speech.demoSpecies and speech.demoPic then
|
||||
return speech.demoPic, desc.flip and true or false
|
||||
return speech.demoPic, desc.flip and true or false, speech.demoTrueColor
|
||||
end
|
||||
local path = require("src.pokemon.Sprites").path(
|
||||
local path, trueColor = require("src.pokemon.Sprites").path(
|
||||
game.data, desc.id, "front", { kind = "oak" })
|
||||
return tryImage(path), desc.flip and true or false
|
||||
return tryImage(path), desc.flip and true or false, trueColor
|
||||
elseif t == "player" then
|
||||
if speech and speech.playerPic and not desc.path then
|
||||
return speech.playerPic, false
|
||||
return speech.playerPic, false, speech.playerTrueColor
|
||||
end
|
||||
if desc.path then return tryImage(desc.path), false end
|
||||
return tryImage(require("src.pokemon.Sprites").playerPath(
|
||||
game.data, "front", { kind = "intro" })), false
|
||||
if desc.path then return tryImage(desc.path), false, false end
|
||||
local path, trueColor = require("src.pokemon.Sprites").playerPath(
|
||||
game.data, "front", { kind = "intro" })
|
||||
return tryImage(path), false, trueColor
|
||||
elseif t == "image" then
|
||||
return tryImage(desc.path), desc.flip and true or false
|
||||
return tryImage(desc.path), desc.flip and true or false, false
|
||||
elseif t == "sprite" then
|
||||
local sp = game.data.sprites and game.data.sprites[desc.id]
|
||||
return tryImage(sp and sp.image), desc.flip and true or false
|
||||
return tryImage(sp and sp.image), desc.flip and true or false,
|
||||
sp and sp.trueColor or false
|
||||
end
|
||||
return nil, false
|
||||
return nil, false, false
|
||||
end
|
||||
|
||||
-- Vanilla step list. Ids are the stable anchors mods insert around.
|
||||
@@ -219,15 +221,18 @@ function OakSpeech.new(game, onDone)
|
||||
-- the show-off mon and the name length cap come from data; the vanilla
|
||||
-- literals stay as the fallbacks
|
||||
self.demoSpecies = oakGfx.demoSpecies or "NIDORINO"
|
||||
local demoPath = require("src.pokemon.Sprites").path(
|
||||
local demoPath, demoTrueColor = require("src.pokemon.Sprites").path(
|
||||
game.data, self.demoSpecies, "front", { kind = "oak" })
|
||||
self.demoPic = tryImage(demoPath)
|
||||
self.demoTrueColor = self.demoPic and demoTrueColor or false
|
||||
local constants = game.data.constants or {}
|
||||
self.nameLen = constants.playerNameLength or 7
|
||||
-- RedPicFront (gfx/player/red.png, shared with the trainer card) and
|
||||
-- the ShrinkPic1/ShrinkPic2 frames (gfx/player/shrink{1,2}.png)
|
||||
self.playerPic = tryImage(require("src.pokemon.Sprites").playerPath(
|
||||
game.data, "front", { kind = "intro" }))
|
||||
local playerPath, playerTrueColor = require("src.pokemon.Sprites").playerPath(
|
||||
game.data, "front", { kind = "intro" })
|
||||
self.playerPic = tryImage(playerPath)
|
||||
self.playerTrueColor = self.playerPic and playerTrueColor or false
|
||||
self.shrinkPic1 = tryImage(oakGfx.shrink1
|
||||
or "assets/generated/intro/shrink1.png")
|
||||
self.shrinkPic2 = tryImage(oakGfx.shrink2
|
||||
@@ -278,14 +283,17 @@ end
|
||||
|
||||
function OakSpeech:applyPic(step)
|
||||
if step.pic == nil then return end
|
||||
local img, flip = OakSpeech.resolvePic(self.game, step.pic, self)
|
||||
local img, flip, trueColor = OakSpeech.resolvePic(self.game, step.pic, self)
|
||||
if img then
|
||||
self.pic = img
|
||||
self.picFlip = flip or false
|
||||
self.picTrueColor = trueColor or false
|
||||
elseif step.pic == "player" or (type(step.pic) == "table" and step.pic.type == "player") then
|
||||
-- mirror the old fallback: player pic missing → oak
|
||||
self.pic = self.playerPic or self.oakPic
|
||||
self.picFlip = false
|
||||
self.picTrueColor = self.pic == self.playerPic and self.playerTrueColor
|
||||
or false
|
||||
end
|
||||
end
|
||||
|
||||
@@ -342,6 +350,7 @@ function OakSpeech:runStep(step)
|
||||
-- NIDORINO show-off: mirrored front sprite + wipe + cry + text 2A
|
||||
self.pic = self.demoPic
|
||||
self.picFlip = true
|
||||
self.picTrueColor = self.demoTrueColor
|
||||
self:revealPic("wipe", function()
|
||||
Sound.playCry(self.game.data, self.demoSpecies)
|
||||
self:say(Strings("_OakSpeechText2A"), function() self:advance() end)
|
||||
@@ -477,7 +486,10 @@ end
|
||||
|
||||
function OakSpeech:advance()
|
||||
self.step = self.step + 1
|
||||
self.picFlip = false
|
||||
-- picFlip belongs to the pic, not to the step: OakSpeechText2 prints 2A
|
||||
-- and 2B over one flipped NIDORINO with no redraw between them
|
||||
-- (oak_speech.asm:80-83), so a pic-less step must not un-mirror what is
|
||||
-- still on screen; only applyPic and the demo step may change it (#397)
|
||||
local steps = self.steps
|
||||
if not steps then
|
||||
-- enter() builds steps; keep a path for callers that advance early
|
||||
@@ -536,8 +548,10 @@ function OakSpeech:update(dt)
|
||||
s.frame = s.frame + 1
|
||||
if s.frame == 5 then
|
||||
self.pic = self.shrinkPic1 or self.pic
|
||||
self.picTrueColor = false
|
||||
elseif s.frame == 9 then
|
||||
self.pic = self.shrinkPic2 or self.pic
|
||||
self.picTrueColor = false
|
||||
-- wAudioFadeOutControl = 10: the music ramps to silence over ~70
|
||||
-- frames (7 levels x 10), reaching 0 just as the fade-to-white
|
||||
-- begins at frame 79, instead of a hard cut (oak_speech.asm:145-149,
|
||||
@@ -545,6 +559,7 @@ function OakSpeech:update(dt)
|
||||
Music.fadeOut(10)
|
||||
elseif s.frame == 29 then
|
||||
self.pic = nil
|
||||
self.picTrueColor = false
|
||||
self.walkVisible = true
|
||||
elseif s.frame >= 79 and s.frame <= 102 then
|
||||
self.fadeLevel = math.floor((s.frame - 79) / 8) + 1
|
||||
@@ -584,6 +599,9 @@ function OakSpeech:draw()
|
||||
else
|
||||
love.graphics.draw(self.pic, x + off, y)
|
||||
end
|
||||
if self.picTrueColor then
|
||||
require("src.render.PaletteFX").markTrueColor(x + off, y, w, h)
|
||||
end
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
end
|
||||
if self.walkVisible and self.walkSheet then
|
||||
|
||||
+31
-6
@@ -2,7 +2,8 @@
|
||||
-- (engine/menus/main_menu.asm DisplayOptionMenu), the battle ruleset
|
||||
-- (cycles the merged rulesets registry; gen1_faithful keeps the original
|
||||
-- quirks), plus the port's audio rows and display rows: music/SFX
|
||||
-- volume (0-7), music low-pass filter (OFF/1X/2X/3X), COLORS / TILT /
|
||||
-- volume (0-7), PIKACHU VOL (0-7, Yellow only: trims the PCM voice clips
|
||||
-- under SFX VOL), music low-pass filter (OFF/1X/2X/3X), COLORS / TILT /
|
||||
-- GBC FX / ZOOM / VOID FILL / VIDEO MODE, and the MODS row that opens
|
||||
-- the mod manager.
|
||||
-- Rows are descriptors fed through the ui.options.rows hook, so mods can
|
||||
@@ -16,6 +17,7 @@ local GBCFX = require("src.render.GBCFX")
|
||||
local Zoom = require("src.render.Zoom")
|
||||
local TileRenderer = require("src.render.TileRenderer")
|
||||
local GameSpeed = require("src.core.GameSpeed")
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
local VideoMode = require("src.core.VideoMode")
|
||||
local FrameCap = require("src.core.FrameCap")
|
||||
local Logger = require("src.core.Logger")
|
||||
@@ -82,7 +84,7 @@ local function rulesetName(game)
|
||||
local ids = rulesetIds(game)
|
||||
local id = ids[rulesetIndex(game, ids)] or game.save.options.ruleset
|
||||
local record = id and rulesets[id]
|
||||
return record and record.name or id or "----"
|
||||
return Strings(record and record.name or id or "----")
|
||||
end
|
||||
|
||||
-- 0-7 volume level display (0 = OFF)
|
||||
@@ -118,7 +120,7 @@ local function sameRows(_, rows) return rows end
|
||||
local function buildRows(game)
|
||||
local rows = {
|
||||
{ id = "textSpeed", label = Strings("TEXT SPEED"),
|
||||
value = function(g) return SPEEDS[speedIndex(g)][2] end,
|
||||
value = function(g) return Strings(SPEEDS[speedIndex(g)][2]) end,
|
||||
step = function(g)
|
||||
local i = speedIndex(g) % #SPEEDS + 1
|
||||
g.save.options.textSpeed = SPEEDS[i][1]
|
||||
@@ -126,7 +128,7 @@ local function buildRows(game)
|
||||
end },
|
||||
{ id = "animations", label = Strings("BATTLE ANIMATION"),
|
||||
value = function(g)
|
||||
return g.save.options.animations == false and "OFF" or "ON"
|
||||
return g.save.options.animations == false and Strings("OFF") or Strings("ON")
|
||||
end,
|
||||
step = function(g)
|
||||
local o = g.save.options
|
||||
@@ -135,7 +137,7 @@ local function buildRows(game)
|
||||
end },
|
||||
{ id = "battleStyle", label = Strings("BATTLE STYLE"),
|
||||
value = function(g)
|
||||
return g.save.options.battleStyle == "set" and "SET" or "SHIFT"
|
||||
return g.save.options.battleStyle == "set" and Strings("SET") or Strings("SHIFT")
|
||||
end,
|
||||
step = function(g)
|
||||
local o = g.save.options
|
||||
@@ -146,7 +148,7 @@ local function buildRows(game)
|
||||
-- widescreen composition (src/battle/WideBattle.lua)
|
||||
{ id = "battleLayout", label = Strings("BATTLE LAYOUT"),
|
||||
value = function(g)
|
||||
return g.save.options.battleLayout == "wide" and "WIDE" or "OG"
|
||||
return g.save.options.battleLayout == "wide" and Strings("WIDE") or Strings("OG")
|
||||
end,
|
||||
step = function(g)
|
||||
local o = g.save.options
|
||||
@@ -178,6 +180,18 @@ local function buildRows(game)
|
||||
require("src.core.Sound").setVolumeLevel(o.sfxVol)
|
||||
return true
|
||||
end },
|
||||
-- Yellow only (filtered out below on Red/Blue): trims Pikachu's voice
|
||||
-- clips under the SFX level, since Yellow plays them constantly (every
|
||||
-- follower interaction, the title screen, the dex) and they are mixed
|
||||
-- much hotter than the chip cries.
|
||||
{ id = "pikaVol", label = Strings("PIKACHU VOL"),
|
||||
value = function(g) return volLabel(g.save.options.pikaVol) end,
|
||||
step = function(g, dir)
|
||||
local o = g.save.options
|
||||
o.pikaVol = stepVolume(o.pikaVol, dir)
|
||||
require("src.core.Sound").setPikaVolumeLevel(o.pikaVol)
|
||||
return true
|
||||
end },
|
||||
{ id = "musicFilter", label = Strings("MUSIC FILTER"),
|
||||
value = function(g)
|
||||
return FILTERS[(g.save.options.musicFilter or 0) + 1]
|
||||
@@ -317,6 +331,17 @@ local function buildRows(game)
|
||||
end
|
||||
rows = filtered
|
||||
end
|
||||
-- PIKACHU VOL only means something where the voice clips exist: Yellow
|
||||
-- (data.audio.pikaCries is the clip count the importer wrote). Red/Blue
|
||||
-- keep the row list they always had.
|
||||
local audio = game and game.data and game.data.audio
|
||||
if not (GameVersion.isYellow() and audio and audio.pikaCries) then
|
||||
local filtered = {}
|
||||
for _, row in ipairs(rows) do
|
||||
if row.id ~= "pikaVol" then filtered[#filtered + 1] = row end
|
||||
end
|
||||
rows = filtered
|
||||
end
|
||||
-- A mod's render pipelines are display modes like TILT, so their rows sit
|
||||
-- with it rather than at the end of the list where a mod's own
|
||||
-- ui.options.rows additions land. Nothing registered means nothing
|
||||
|
||||
+24
-15
@@ -351,18 +351,23 @@ function PartyMenu:update(dt)
|
||||
end })
|
||||
return
|
||||
elseif action == "flash" then -- FLASH lights dark tunnels
|
||||
-- start_sub_menus.asm .flash: PrintText _FlashLightsAreaText, then
|
||||
-- GBPalWhiteOutWithDelay3 + jp .goBackToMap
|
||||
-- start_sub_menus.asm .flash: PrintText _FlashLightsAreaText runs
|
||||
-- with the party menu still on screen, and only then
|
||||
-- GBPalWhiteOutWithDelay3 + jp .goBackToMap. So the message reads
|
||||
-- over the menu, and the cave is lit when the blink hands the
|
||||
-- screen back, never under the text (#385).
|
||||
local ow = self.game.overworld
|
||||
local TextBox = require("src.render.TextBox")
|
||||
local Transition = require("src.render.Transition")
|
||||
self.game.stack:pop()
|
||||
ow.dark = false
|
||||
self.game.save.flashLit = true
|
||||
self.game.stack:push(TextBox.new(self.game,
|
||||
self.game.data.text._FlashLightsAreaText
|
||||
or Strings("A blinding FLASH\nlights the area!"), function()
|
||||
self.game.stack:push(Transition.whiteFlash(self.game))
|
||||
self:close()
|
||||
-- setDark, not a bare field write: ADVANCED carries the darkness
|
||||
-- in a baked atlas, so lighting the cave rebuilds it (#383)
|
||||
self.game.stack:push(Transition.whiteFlash(self.game, nil,
|
||||
function() ow:setDark(false) end))
|
||||
end))
|
||||
return
|
||||
elseif action == "surf" then
|
||||
@@ -377,9 +382,11 @@ function PartyMenu:update(dt)
|
||||
local reason = ow:useSurfFieldMove()
|
||||
local Transition = require("src.render.Transition")
|
||||
if reason == "ok" then
|
||||
self.game.stack:pop() -- close the party menu (jp .goBackToMap)
|
||||
-- UseItem prints _SurfingGotOnText with the party menu still up;
|
||||
-- GBPalWhiteOutWithDelay3 + jp .goBackToMap only follow it, so
|
||||
-- trySurf closes this menu when its text does (#385)
|
||||
local fx, fy = ow.player:facingCell()
|
||||
ow:trySurf(fx, fy)
|
||||
ow:trySurf(fx, fy, function() self:close() end)
|
||||
return
|
||||
end
|
||||
if reason == "dismount" then
|
||||
@@ -410,9 +417,10 @@ function PartyMenu:update(dt)
|
||||
-- .cannotStopSurfing prints _SurfingNoPlaceToGetOffText but
|
||||
-- never zeroes wActionResultOrTookBattleTurn, so unlike the
|
||||
-- other refusals the menu still closes afterwards
|
||||
-- (GBPalWhiteOutWithDelay3 + .goBackToMap)
|
||||
self.game.stack:pop()
|
||||
-- (GBPalWhiteOutWithDelay3 + .goBackToMap), and the text prints
|
||||
-- over the still-open menu like every other .loop refusal (#385)
|
||||
self.game.stack:push(TextBox.new(self.game, txt, function()
|
||||
self:close()
|
||||
self.game.stack:push(Transition.whiteFlash(self.game))
|
||||
end))
|
||||
return
|
||||
@@ -454,18 +462,19 @@ function PartyMenu:update(dt)
|
||||
local Transition = require("src.render.Transition")
|
||||
local def = self.game.data.pokemon[mon.species]
|
||||
local name = mon.nickname or def.name
|
||||
self.game.stack:pop() -- close the party menu (jp .goBackToMap)
|
||||
ow.strengthActive = true
|
||||
local t1 = (self.game.data.text._UsedStrengthText
|
||||
or Strings("{RAM:wNameBuffer} used\nSTRENGTH.")):gsub("{RAM:wNameBuffer}", name)
|
||||
local t2 = (self.game.data.text._CanMoveBouldersText
|
||||
or Strings("{RAM:wNameBuffer} can\nmove boulders.")):gsub("{RAM:wNameBuffer}", name)
|
||||
-- like surf (#320): the blink belongs UNDER the texts, not as a
|
||||
-- flashbang on the empty map after them; the stack only updates
|
||||
-- the top state, so the flash holds until the texts close
|
||||
self.game.stack:push(Transition.whiteFlash(self.game))
|
||||
-- like surf (#320, #385): both texts print with the party menu
|
||||
-- still on screen, and the blink IS the menu closing afterwards,
|
||||
-- not a flashbang on the empty map
|
||||
self.game.stack:push(TextBox.new(self.game, t1, function()
|
||||
self.game.stack:push(TextBox.new(self.game, t2))
|
||||
self.game.stack:push(TextBox.new(self.game, t2, function()
|
||||
self:close()
|
||||
self.game.stack:push(Transition.whiteFlash(self.game))
|
||||
end))
|
||||
end, { auto = { sound = function()
|
||||
return require("src.core.Sound").playCry(self.game.data, mon.species)
|
||||
end } }))
|
||||
|
||||
+11
-4
@@ -43,12 +43,13 @@ function SummaryMenu.new(game, mon)
|
||||
Stats.ensure(game.data.pokemon[mon.species], mon)
|
||||
local self = setmetatable({ game = game, mon = mon, page = 1 }, SummaryMenu)
|
||||
local Sprites = require("src.pokemon.Sprites")
|
||||
local path = Sprites.path(game.data, mon.species, "front",
|
||||
local path, trueColor = Sprites.path(game.data, mon.species, "front",
|
||||
{ mon = mon, kind = "summary" })
|
||||
if path then
|
||||
local ok, img = pcall(love.graphics.newImage, path)
|
||||
self.sprite = ok and img or nil
|
||||
end
|
||||
self.spriteTrueColor = self.sprite and trueColor or false
|
||||
require("src.core.Sound").playCry(game.data, mon.species)
|
||||
return self
|
||||
end
|
||||
@@ -111,8 +112,14 @@ function SummaryMenu:draw()
|
||||
-- the same routine the intro's NIDORINO show-off uses (OakSpeech picFlip:
|
||||
-- negative x scale anchored at the pic's right edge). #280
|
||||
if self.sprite then
|
||||
love.graphics.draw(self.sprite, 8 + self.sprite:getWidth(),
|
||||
math.max(0, 56 - self.sprite:getHeight()), 0, -1, 1)
|
||||
local pw, ph = self.sprite:getDimensions()
|
||||
local py = math.max(0, 56 - ph)
|
||||
love.graphics.draw(self.sprite, 8 + pw, py, 0, -1, 1)
|
||||
-- a full-color pic has to sit out the SGB monPal recolor, so mark the
|
||||
-- rect the mirrored draw covers for the unshaded pass (#430)
|
||||
if self.spriteTrueColor then
|
||||
require("src.render.PaletteFX").markTrueColor(8, py, pw, ph)
|
||||
end
|
||||
end
|
||||
local HudTiles = require("src.render.HudTiles")
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
@@ -146,7 +153,7 @@ function SummaryMenu:draw()
|
||||
}
|
||||
for i, s in ipairs(stats) do
|
||||
local y = 72 + (i - 1) * 16
|
||||
Font.draw(s[1], 8, y)
|
||||
Font.draw(Strings(s[1]), 8, y)
|
||||
Font.draw(("%3d"):format(s[2]), 48, y + 8)
|
||||
end
|
||||
|
||||
|
||||
+61
-17
@@ -22,13 +22,17 @@ TitleState.isOpaque = true
|
||||
-- LOGO2 blue / LOGO1 red (issue #133). A trailing trueColor zone leaves the
|
||||
-- overlay's DMG black unshaded while the logo and title mon keep title pals.
|
||||
--
|
||||
-- ROM SuperPal whites are often {255,239,255}. Under RED++, LOGO2/MEWMON
|
||||
-- come from the GBC pack (pure white) while Blue's LOGO1 stays on the ROM
|
||||
-- pack (#128), so the version-ribbon row reads as a pink band. Force that
|
||||
-- slot to pure white; ink colors (Blue/Red "Version" text) stay intact.
|
||||
local function withPureWhite(pal)
|
||||
-- Every title SuperPal shares color 0 on hardware (sgb_palettes.asm: LOGO1,
|
||||
-- LOGO2 and MEWMON all start RGB 31,29,31), so the ribbon band's white has to
|
||||
-- be the neighbouring zones' white. Under RED++, LOGO2/MEWMON come from the
|
||||
-- GBC pack (pure white) while Blue's LOGO1 stays on the ROM pack (#128) and
|
||||
-- the row reads as a pink band; taking LOGO2's white fixes that without
|
||||
-- forcing pure white in SGB, where a brighter band drew two faint lines
|
||||
-- across the title (#373). Ink colors (Blue/Red "Version" text) stay intact.
|
||||
local function withWhiteOf(pal, ref)
|
||||
if not pal then return nil end
|
||||
return { { 255, 255, 255 }, pal[2], pal[3], pal[4] }
|
||||
if not (ref and ref[1]) then return pal end
|
||||
return { ref[1], pal[2], pal[3], pal[4] }
|
||||
end
|
||||
|
||||
function TitleState:sgbPalettes(game)
|
||||
@@ -46,9 +50,10 @@ function TitleState:sgbPalettes(game)
|
||||
P.zone(logoPal, 9, 8, 10, 8),
|
||||
}
|
||||
else
|
||||
local logoPal = P.pal(game.data, "LOGO2")
|
||||
z = {
|
||||
P.zone(P.pal(game.data, "LOGO2"), 0, 0, 19, 7),
|
||||
P.zone(withPureWhite(P.pal(game.data, "LOGO1")), 0, 8, 19, 9),
|
||||
P.zone(logoPal, 0, 0, 19, 7),
|
||||
P.zone(withWhiteOf(P.pal(game.data, "LOGO1"), logoPal), 0, 8, 19, 9),
|
||||
P.zone(P.pal(game.data, "MEWMON"), 0, 10, 19, 17),
|
||||
}
|
||||
end
|
||||
@@ -98,6 +103,30 @@ local function imagePath(entry)
|
||||
return entry
|
||||
end
|
||||
|
||||
-- PaletteFX redraws a true-color rectangle after the palette pass. Red's
|
||||
-- title art is drawn on top of the title mon, so leave its bounds out of the
|
||||
-- rectangle rather than redrawing that art without its title palette.
|
||||
local function markVisibleTrueColor(x, y, w, h, cover)
|
||||
local P = require("src.render.PaletteFX")
|
||||
if not cover then
|
||||
P.markTrueColor(x, y, w, h)
|
||||
return
|
||||
end
|
||||
local cx, cy, cw, ch = cover[1], cover[2], cover[3], cover[4]
|
||||
local right, bottom = x + w, y + h
|
||||
local cright, cbottom = cx + cw, cy + ch
|
||||
local ix1, iy1 = math.max(x, cx), math.max(y, cy)
|
||||
local ix2, iy2 = math.min(right, cright), math.min(bottom, cbottom)
|
||||
if ix1 >= ix2 or iy1 >= iy2 then
|
||||
P.markTrueColor(x, y, w, h)
|
||||
return
|
||||
end
|
||||
if y < iy1 then P.markTrueColor(x, y, w, iy1 - y) end
|
||||
if iy2 < bottom then P.markTrueColor(x, iy2, w, bottom - iy2) end
|
||||
if x < ix1 then P.markTrueColor(x, iy1, ix1 - x, iy2 - iy1) end
|
||||
if ix2 < right then P.markTrueColor(ix2, iy1, right - ix2, iy2 - iy1) end
|
||||
end
|
||||
|
||||
function TitleState.new(game, opts)
|
||||
opts = opts or {}
|
||||
local self = setmetatable({}, TitleState)
|
||||
@@ -148,7 +177,7 @@ function TitleState.new(game, opts)
|
||||
self.cycleSpecies = (type(title.cycleSpecies) == "table"
|
||||
and #title.cycleSpecies > 0)
|
||||
and title.cycleSpecies or defaultCycle
|
||||
self.sprites = {} -- species -> image or false (load failed)
|
||||
self.sprites = {} -- species -> { image, trueColor } or false (load failed)
|
||||
self.cycleIndex = 1
|
||||
self.timer = 0
|
||||
self.blink = 0
|
||||
@@ -252,12 +281,13 @@ function TitleState:currentSprite()
|
||||
local species = self.cycleSpecies[self.cycleIndex]
|
||||
local cached = self.sprites[species]
|
||||
if cached == nil then
|
||||
local path = require("src.pokemon.Sprites").path(
|
||||
local path, trueColor = require("src.pokemon.Sprites").path(
|
||||
self.game.data, species, "front", { kind = "title" })
|
||||
cached = tryImage(path) or false
|
||||
local image = tryImage(path)
|
||||
cached = image and { image = image, trueColor = trueColor } or false
|
||||
self.sprites[species] = cached
|
||||
end
|
||||
return cached or nil
|
||||
return cached and cached.image or nil, cached and cached.trueColor or false
|
||||
end
|
||||
|
||||
local function hasSave()
|
||||
@@ -402,7 +432,7 @@ end
|
||||
-- the version ribbon at (7,8), Red's title art as OAM at px (82,80),
|
||||
-- the title mon in the 7x7 box at tile (5,10), copyright on row 17.
|
||||
-- Yellow (title_yellow.asm): logo (2,1), speech bubble (6,4), Pikachu
|
||||
-- (4,8) 12x9 — no version ribbon, no cycling mon, no Red OAM.
|
||||
-- (4,8) 12x9 -- no version ribbon, no cycling mon, no Red OAM.
|
||||
function TitleState:draw()
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
love.graphics.rectangle("fill", 0, 0, 160, 144)
|
||||
@@ -446,13 +476,27 @@ function TitleState:draw()
|
||||
love.graphics.newQuad(40, 0, 40, 8, iw, ih), 80, 64)
|
||||
end
|
||||
end
|
||||
local sprite = self:currentSprite()
|
||||
local sprite, spriteTrueColor = self:currentSprite()
|
||||
if sprite then
|
||||
local w, h = sprite:getDimensions()
|
||||
local slide = (self.slideIn or 0) * 8
|
||||
love.graphics.draw(sprite, 40 + math.floor((56 - w) / 2) + slide,
|
||||
136 - h)
|
||||
local slide = (self.slideIn or 0) * 8 -- scroll in from the right
|
||||
-- bottom-aligned and centered in the (5,10)-(11,16) tile box
|
||||
local x = 40 + math.floor((56 - w) / 2) + slide
|
||||
local y = 136 - h
|
||||
love.graphics.draw(sprite, x, y)
|
||||
-- a full-color mon keeps its own palette through the SGB pass, minus
|
||||
-- the strip Red's OAM covers (#350). Yellow never reaches here: its
|
||||
-- layout has no cycling mon and no Red art (title_yellow.asm).
|
||||
if spriteTrueColor then
|
||||
local cover
|
||||
if self.player then
|
||||
local pw, ph = self.player:getDimensions()
|
||||
cover = { 82, 80, pw, ph }
|
||||
end
|
||||
markVisibleTrueColor(x, y, w, h, cover)
|
||||
end
|
||||
end
|
||||
-- Red is OAM in the original: he draws over the mon's box edge
|
||||
if self.player then
|
||||
love.graphics.draw(self.player, 82, 80)
|
||||
end
|
||||
|
||||
@@ -574,10 +574,14 @@ function YellowIntro:beginScenes()
|
||||
self:startScene(0)
|
||||
end
|
||||
|
||||
-- The movie's exit never stops the song (intro_yellow.asm PlayIntroScene
|
||||
-- .go_to_title_screen, where the A/B/START skip lands too): Music_YellowIntro
|
||||
-- outlasts the scenes and plays on into the title setup until title.asm's
|
||||
-- StopAllMusic before MUSIC_TITLE_SCREEN, which TitleState:startMusic stands
|
||||
-- in for (#436)
|
||||
function YellowIntro:finish()
|
||||
if self.finished then return end
|
||||
self.finished = true
|
||||
pcall(Music.stop)
|
||||
self.game.stack:pop()
|
||||
if self.onDone then self.onDone() end
|
||||
end
|
||||
|
||||
+3
-1
@@ -50,7 +50,9 @@ local CALLBACK_NAMES = {
|
||||
"keypressed", "keyreleased", "textinput",
|
||||
"mousepressed", "mousereleased", "mousemoved", "wheelmoved",
|
||||
"touchpressed", "touchmoved", "touchreleased",
|
||||
"gamepadpressed", "gamepadreleased", "gamepadaxis", "joystickremoved",
|
||||
"gamepadpressed", "gamepadreleased", "gamepadaxis",
|
||||
"joystickpressed", "joystickreleased", "joystickaxis", "joystickhat",
|
||||
"joystickremoved",
|
||||
"focus", "visible", "resize", "filedropped", "directorydropped",
|
||||
"errorhandler", "threaderror", "lowmemory",
|
||||
}
|
||||
|
||||
@@ -174,4 +174,14 @@ function Check.download()
|
||||
cmdCh:push({ cmd = "download" })
|
||||
end
|
||||
|
||||
-- End the worker thread. Its command loop sits in Channel:demand(), which
|
||||
-- never returns on its own, and LOVE waits for every live love.thread before
|
||||
-- the process exits (#339).
|
||||
function Check.shutdown()
|
||||
if cmdCh then cmdCh:push({ cmd = "quit" }) end
|
||||
if worker then pcall(function() worker:wait() end) end
|
||||
worker, cmdCh, stateCh = nil, nil, nil
|
||||
workerReady = false
|
||||
end
|
||||
|
||||
return Check
|
||||
|
||||
@@ -128,6 +128,23 @@ FieldDefaults.FIELD = {
|
||||
-- the one-shot flag the gate's pass text is gated on; a gate a mod adds
|
||||
-- gets "PASSED_<mapId>" instead of this pre-v2 spelling
|
||||
badgeGates = { ROUTE_22_GATE = { passedFlag = "PASSED_ROUTE22_GATE" } },
|
||||
-- the Rocket Hideout lift gates the floor callbacks stamp shut
|
||||
-- (scripts/RocketHideoutB1F.asm / RocketHideoutB4F.asm
|
||||
-- ...DoorCallbackScript); seeds caches imported before the manifest
|
||||
-- carried them, which left both gates standing open (#372)
|
||||
cardKeyDoors = {
|
||||
closedDoors = {
|
||||
ROCKET_HIDEOUT_B1F = {
|
||||
{ block = 0x54, bx = 12, by = 8, open = 0x0e,
|
||||
event = "EVENT_BEAT_ROCKET_HIDEOUT_1_TRAINER_4" },
|
||||
},
|
||||
ROCKET_HIDEOUT_B4F = {
|
||||
{ block = 0x2d, bx = 12, by = 5, open = 0x0e,
|
||||
events = { "EVENT_BEAT_ROCKET_HIDEOUT_4_TRAINER_0",
|
||||
"EVENT_BEAT_ROCKET_HIDEOUT_4_TRAINER_1" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
-- VermilionGymSetDoorTile opens the motorized door once both locks are hit
|
||||
hiddenExtras = {
|
||||
-- PrintTrashText bins (#188); seeds stale caches missing the key
|
||||
|
||||
+20
-4
@@ -52,13 +52,24 @@ function NPC:facePlayer(player)
|
||||
end
|
||||
|
||||
function NPC:update(map, entities)
|
||||
-- self.stepFrames overrides the shared 16-frame walk for an object whose
|
||||
-- step has to stay in phase with something else: Yellow's follower
|
||||
-- Pikachu takes the player's own step length, halved while it is more
|
||||
-- than a cell behind (FastPikachuFollow, engine/pikachu/
|
||||
-- pikachu_follow.asm). self.hopStep is the same file's $5-$8 hop
|
||||
-- command: two cells of travel inside one step's frames
|
||||
-- (DoubleAddPikachuStepVectorToScreenPixelCoords), which is why the
|
||||
-- pixel span doubles while the frame count does not. Nothing else sets
|
||||
-- either field, so every other object keeps the constant (#410, #409).
|
||||
local stepLen = self.stepFrames or STEP_FRAMES
|
||||
local span = self.hopStep and 2 or 1
|
||||
if self.moving then
|
||||
self.progress = self.progress + 1
|
||||
-- NPC_CHANGE_FACING: animate the walk cycle in place, no translation
|
||||
-- (movement.asm ChangeFacingDirection zeroes the delta); px/py stay
|
||||
-- pinned to the current cell while walkPhase() cycles.
|
||||
if self.marching then
|
||||
if self.progress >= STEP_FRAMES then
|
||||
if self.progress >= stepLen then
|
||||
self.progress = 0
|
||||
self.moving = false
|
||||
self.marching = false
|
||||
@@ -67,13 +78,18 @@ function NPC:update(map, entities)
|
||||
return
|
||||
end
|
||||
local d = Collision.DELTA[self.facing]
|
||||
self.px = self.cellX * 16 + d[1] * self.progress
|
||||
self.py = self.cellY * 16 + d[2] * self.progress
|
||||
if self.progress >= STEP_FRAMES then
|
||||
-- 1px per frame at the default length; a shortened step scales instead,
|
||||
-- so the cell still lands on a 16px boundary (Player:update does the
|
||||
-- same for the bicycle)
|
||||
local moved = math.floor(self.progress * 16 * span / stepLen)
|
||||
self.px = self.cellX * 16 + d[1] * moved
|
||||
self.py = self.cellY * 16 + d[2] * moved
|
||||
if self.progress >= stepLen then
|
||||
self.cellX, self.cellY = self.targetX, self.targetY
|
||||
self.targetX, self.targetY = nil, nil
|
||||
self.px, self.py = self.cellX * 16, self.cellY * 16
|
||||
self.moving = false
|
||||
self.hopStep = nil
|
||||
self.stepFlip = not self.stepFlip
|
||||
end
|
||||
return
|
||||
|
||||
+272
-183
@@ -65,6 +65,16 @@ local ROD_OAM = {
|
||||
right = { dx = 16, dy = 4, tile = 1, flip = true }, -- dbsprite 11, 10, 0, 0, $fe, XFLIP
|
||||
}
|
||||
|
||||
-- field.darkMaps (home/overworld.asm's dark-map check): the floors that run
|
||||
-- with wMapPalOffset = 6 until FLASH
|
||||
local function isDarkMap(mapId)
|
||||
local darkDef = Game.data.field.darkMaps
|
||||
for _, m in ipairs(darkDef and darkDef.maps or {}) do
|
||||
if m == mapId then return true end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
-- object_event spawn filter (toggleable_objects, items taken, beaten
|
||||
-- static encounters), shared by the current map's real NPCs and the
|
||||
-- visual-only ghosts on connected neighbor maps
|
||||
@@ -188,8 +198,50 @@ function OverworldState:enter(mapId, x, y, facing)
|
||||
-- survives save/load: a loaded game may start inside a building whose
|
||||
-- exit mat is a LAST_MAP warp
|
||||
self.lastOutdoor = Game.save.lastOutdoor
|
||||
self.justWarped = false
|
||||
self:setMap(mapId, x, y, facing, { via = "boot" })
|
||||
-- boot/load: derive the flag from the tile the save left us standing on,
|
||||
-- like MapEntryAfterBattle's IsPlayerStandingOnWarp, so a game saved on a
|
||||
-- door mat can still walk straight back out (issue #378)
|
||||
self:refreshStandingOnWarp()
|
||||
end
|
||||
|
||||
-- Silph Co card key doors + Rocket Hideout elevator gates: the .blk
|
||||
-- layouts ship with the doorways open; each floor's map script stamps
|
||||
-- the closed door block on load until its unlock event is set
|
||||
-- (scripts/SilphCo2F.asm SilphCo2FGateCallbackScript et al., closed
|
||||
-- blocks $54/$5f/$20; scripts/RocketHideoutB1F.asm +
|
||||
-- RocketHideoutB4F.asm ...DoorCallbackScript, closed blocks $54/$2d over
|
||||
-- the lift doorway). A door opens on its single `event`, or on `events`
|
||||
-- when every listed flag must be set (Rocket Hideout B4F's lift gate
|
||||
-- needs both guard trainers beaten -- CheckBothEventsSet). The callbacks
|
||||
-- run whenever BIT_CUR_MAP_LOADED_1 is set, which is map load AND the end
|
||||
-- of a battle on that map (home/trainers.asm EndTrainerBattle), so the
|
||||
-- gate opens with SFX_GO_INSIDE the moment the last guard falls (#372).
|
||||
function OverworldState:stampClosedDoors()
|
||||
local closedDoors = FieldDefaults.fieldValue(Game.data, "cardKeyDoors",
|
||||
"closedDoors")
|
||||
local floorDoors = self.map and closedDoors and closedDoors[self.map.id]
|
||||
if not floorDoors then return end
|
||||
local stamped, unlocked = false, false
|
||||
for _, door in ipairs(floorDoors) do
|
||||
local open
|
||||
if door.events then
|
||||
open = true
|
||||
for _, ev in ipairs(door.events) do
|
||||
if not Game.save.flags[ev] then open = false break end
|
||||
end
|
||||
else
|
||||
open = Game.save.flags[door.event]
|
||||
end
|
||||
local want = open and door.open or door.block
|
||||
if self.map:blockAt(door.bx, door.by) ~= want then
|
||||
self.map:setBlock(door.bx, door.by, want)
|
||||
stamped = true
|
||||
if open then unlocked = true end
|
||||
end
|
||||
end
|
||||
if stamped then self.map.renderer:rebuild() end
|
||||
if unlocked then require("src.core.Sound").play(Game.data, "Go_Inside") end
|
||||
end
|
||||
|
||||
function OverworldState:setMap(mapId, x, y, facing, opts)
|
||||
@@ -222,6 +274,13 @@ function OverworldState:setMap(mapId, x, y, facing, opts)
|
||||
self.tileAnimOverride.tileset.animation = self.tileAnimOverride.animation
|
||||
self.tileAnimOverride = nil
|
||||
end
|
||||
-- ADVANCED bakes colour into the tileset atlas, so the dark-cave shift has
|
||||
-- to be armed before that atlas is built for this map (#383); self.dark is
|
||||
-- settled below, once the map record is in hand.
|
||||
if PaletteFX.setDarkWorld(isDarkMap(mapId) and not Game.save.flashLit)
|
||||
and PaletteFX.usesGbcPack() then
|
||||
MapLoader.invalidateAll()
|
||||
end
|
||||
self.map = MapLoader.load(Game.data, mapId)
|
||||
-- STRENGTH deactivates on every real map load (home/overworld.asm
|
||||
-- EnterMap -> ResetUsingStrengthOutOfBattleBit clears BIT_STRENGTH_ACTIVE
|
||||
@@ -241,38 +300,7 @@ function OverworldState:setMap(mapId, x, y, facing, opts)
|
||||
self.map.renderer:rebuild()
|
||||
self.cutBlocks[mapId] = nil
|
||||
end
|
||||
-- Silph Co card key doors + Rocket Hideout elevator gates: the .blk
|
||||
-- layouts ship with the doorways open; each floor's map script stamps
|
||||
-- the closed door block on load until its unlock event is set
|
||||
-- (scripts/SilphCo2F.asm SilphCo2FGateCallbackScript et al., closed
|
||||
-- blocks $54/$5f/$20; scripts/RocketHideoutB1F.asm +
|
||||
-- RocketHideoutB4F.asm ...DoorCallbackScript, closed blocks $54/$2d over
|
||||
-- the lift doorway). A door opens on its single `event`, or on `events`
|
||||
-- when every listed flag must be set (Rocket Hideout B4F's lift gate
|
||||
-- needs both guard trainers beaten -- CheckBothEventsSet).
|
||||
local closedDoors = FieldDefaults.fieldValue(Game.data, "cardKeyDoors",
|
||||
"closedDoors")
|
||||
local floorDoors = closedDoors and closedDoors[mapId]
|
||||
if floorDoors then
|
||||
local stamped = false
|
||||
for _, door in ipairs(floorDoors) do
|
||||
local open
|
||||
if door.events then
|
||||
open = true
|
||||
for _, ev in ipairs(door.events) do
|
||||
if not Game.save.flags[ev] then open = false break end
|
||||
end
|
||||
else
|
||||
open = Game.save.flags[door.event]
|
||||
end
|
||||
local want = open and door.open or door.block
|
||||
if self.map:blockAt(door.bx, door.by) ~= want then
|
||||
self.map:setBlock(door.bx, door.by, want)
|
||||
stamped = true
|
||||
end
|
||||
end
|
||||
if stamped then self.map.renderer:rebuild() end
|
||||
end
|
||||
self:stampClosedDoors()
|
||||
-- forced dismount only where riding is disallowed (IsBikeRidingAllowed,
|
||||
-- home/overworld.asm: bike_riding_tilesets.asm tilesets plus the
|
||||
-- ROUTE_23/INDIGO_PLATEAU map exceptions)
|
||||
@@ -294,18 +322,11 @@ function OverworldState:setMap(mapId, x, y, facing, opts)
|
||||
-- Rock Tunnel darkness (wMapPalOffset, home/overworld.asm): dark
|
||||
-- until FLASH is used; the light persists between the tunnel floors
|
||||
-- and resets once outside
|
||||
local darkDef = Game.data.field.darkMaps
|
||||
self.dark = false
|
||||
if darkDef then
|
||||
local isDark = false
|
||||
for _, m in ipairs(darkDef.maps) do
|
||||
if m == mapId then isDark = true break end
|
||||
end
|
||||
if isDark then
|
||||
self.dark = not Game.save.flashLit
|
||||
else
|
||||
Game.save.flashLit = nil
|
||||
end
|
||||
if isDarkMap(mapId) then
|
||||
self:setDark(not Game.save.flashLit)
|
||||
else
|
||||
Game.save.flashLit = nil
|
||||
self:setDark(false)
|
||||
end
|
||||
if Game.data.field.flyWarps[mapId] then
|
||||
Game.save.visited = Game.save.visited or {}
|
||||
@@ -345,7 +366,7 @@ function OverworldState:setMap(mapId, x, y, facing, opts)
|
||||
for _, n in ipairs(self.npcs) do table.insert(self.entities, n) end
|
||||
-- Yellow's companion Pikachu trails the player (never in
|
||||
-- self.entities: it does not block movement, pikachu_follow.asm)
|
||||
require("src.world.PikachuFollower").onMapEntered(Game, self)
|
||||
require("src.world.PikachuFollower").onMapEntered(Game, self, opts)
|
||||
|
||||
-- opts.keepMusic: the Oak-escort warp keeps MUSIC_MEET_PROF_OAK
|
||||
-- playing into the lab (BIT_NO_MAP_MUSIC in wStatusFlags7);
|
||||
@@ -576,17 +597,19 @@ function OverworldState:sgbWorldZones()
|
||||
return zones
|
||||
end
|
||||
|
||||
-- Whether the dark-map shade shift (PaletteFX.DARK_BGP, armed in drawWorld)
|
||||
-- can actually reach this frame's world pass. It cannot in RED++: that mode
|
||||
-- bakes real per-tile colour into the tileset atlas and sgbWorldZones returns
|
||||
-- an EMPTY zone list above, so the world blits with no shade-remap shader at
|
||||
-- all and there is no palette left to permute. Only then does fxDark
|
||||
-- composite the darkness by hand (#322).
|
||||
function OverworldState:darkNeedsOverlay()
|
||||
if not self.dark then return false end
|
||||
local renderer = self.map and self.map.renderer
|
||||
return PaletteFX.usesGbcPack() and renderer ~= nil
|
||||
and renderer.gbcAtlas ~= nil
|
||||
-- wMapPalOffset, the one piece of state both halves of the darkness read:
|
||||
-- drawWorld arms PaletteFX.DARK_BGP off self.dark for the shade-remapped
|
||||
-- modes, and PaletteFX.setDarkWorld feeds the bakes ADVANCED does instead of
|
||||
-- shading (tileset atlas, sprite sheets) plus their cache keys. A bake cannot
|
||||
-- be re-shaded in place, so a change there rebuilds every resident map --
|
||||
-- every dark floor, not just this one, since FLASH lights them all (#383).
|
||||
function OverworldState:setDark(on)
|
||||
on = on and true or false
|
||||
self.dark = on
|
||||
if PaletteFX.setDarkWorld(on) and PaletteFX.usesGbcPack() and self.map then
|
||||
MapLoader.invalidateAll()
|
||||
self:reloadMap(self.map.id, "dark")
|
||||
end
|
||||
end
|
||||
|
||||
function OverworldState:npcByIndex(index)
|
||||
@@ -795,6 +818,21 @@ function OverworldState:update(dt)
|
||||
if ca.onDone then ca.onDone() end
|
||||
end
|
||||
end
|
||||
-- fishing pose tail: the rod is already gone, the pose holds for the
|
||||
-- frames the original spends unwinding the item menu (#384)
|
||||
if self.fishPose then
|
||||
self.fishPose = self.fishPose - 1
|
||||
if self.fishPose <= 0 then
|
||||
self.fishPose = nil
|
||||
self.player.fishing = nil
|
||||
end
|
||||
end
|
||||
-- Yellow's companion hopping up onto the Poke Center counter owns the
|
||||
-- world for its arc, the same way the heal machine below does (#417)
|
||||
if self.pikaHop then
|
||||
require("src.world.PikachuFollower").updateHop(self)
|
||||
return
|
||||
end
|
||||
if self.healAnim then
|
||||
local ha = self.healAnim
|
||||
local ev = OverworldState.stepHealAnim(ha)
|
||||
@@ -861,7 +899,12 @@ function OverworldState:update(dt)
|
||||
-- the emotion-bubble pause holds the world for a beat
|
||||
if self.emote then
|
||||
self.emote.frames = self.emote.frames - 1
|
||||
if self.emote.frames <= 0 then
|
||||
-- PikaPicAnimTimerAndJoypad (engine/pikachu/pikachu_pic_animation.asm)
|
||||
-- cuts a pikapic beat short on A or B; the "!" bubble hold has no such
|
||||
-- check, so only the pikapic marks itself skippable (#424)
|
||||
local cut = self.emote.skippable
|
||||
and (Game.input:wasPressed("a") or Game.input:wasPressed("b"))
|
||||
if cut or self.emote.frames <= 0 then
|
||||
local done = self.emote.onDone
|
||||
self.emote = nil
|
||||
if done then done() end
|
||||
@@ -955,19 +998,33 @@ function OverworldState:dirHeld()
|
||||
or input:isDown("left") or input:isDown("right")
|
||||
end
|
||||
|
||||
-- The warp cell the player WARPED IN on is inert until they physically step
|
||||
-- off it: standing on it, or bonking a wall/edge from it, must not re-fire a
|
||||
-- warp (CheckWarpsNoCollision's arrival-disable; see setMap where
|
||||
-- warpEntryCell/justWarped are set and onStepComplete where they clear).
|
||||
-- Both stand-still warp triggers -- the map-edge exit (checkEdgeExit) and the
|
||||
-- blocked-step collision warp (handleInput) -- must consult this, or a corner
|
||||
-- staircase whose warp tile sits on the map edge (Red's-house (7,1)) bounces
|
||||
-- floors every input frame (issue #230).
|
||||
function OverworldState:onWarpArrivalCell()
|
||||
if self.justWarped then return true end
|
||||
local entry = self.warpEntryCell
|
||||
return entry ~= nil and self.player.cellX == entry.x
|
||||
and self.player.cellY == entry.y
|
||||
-- BIT_STANDING_ON_WARP (wMovementFlags): the warp under the player's feet may
|
||||
-- only fire from a collision -- the blocked-step warp (handleInput) and the
|
||||
-- map-edge exit (checkEdgeExit) -- while this flag is set. pokered clears it
|
||||
-- on every completed step, sets it again when that step lands on a warp
|
||||
-- square, then clears it once more when the square is a warp-activating tile
|
||||
-- that is not also a door tile (CheckWarpsNoCollisionLoop ->
|
||||
-- IsPlayerStandingOnDoorTileOrWarpTile, engine/overworld/player_state.asm);
|
||||
-- onStepComplete maintains it. ClearVariablesOnEnterMap does not clear
|
||||
-- wMovementFlags, so the flag rides through the warp itself: a house door
|
||||
-- tile ($1B) leaves it set, so you land on the interior mat still able to
|
||||
-- walk back out on that same tile (issue #378), while a staircase tile
|
||||
-- ($1A/$1C) clears it and cannot bounce you between floors (issue #230).
|
||||
function OverworldState:canCollisionWarp()
|
||||
return self.standingOnWarp == true
|
||||
end
|
||||
|
||||
-- Re-derive the flag from the tile under the player, the way a completed step
|
||||
-- does (and the way MapEntryAfterBattle's IsPlayerStandingOnWarp does after a
|
||||
-- battle): a door tile keeps it, a stair/ladder warp tile clears it.
|
||||
function OverworldState:refreshStandingOnWarp()
|
||||
local p = self.player
|
||||
self.standingOnWarp = false
|
||||
if self.map:warpAtCell(p.cellX, p.cellY)
|
||||
and not (self.map:isWarpTileCell(p.cellX, p.cellY)
|
||||
and not self.map:isDoorTileCell(p.cellX, p.cellY)) then
|
||||
self.standingOnWarp = true
|
||||
end
|
||||
end
|
||||
|
||||
function OverworldState:handleInput()
|
||||
@@ -1011,9 +1068,9 @@ function OverworldState:handleInput()
|
||||
local result, why = self.player:tryMove(dir, self.map, self.entities)
|
||||
-- a collision while standing on a warp square fires the warp when the
|
||||
-- extra check passes (CheckWarpsCollision: route-gate doorways, dock
|
||||
-- entrances, ...) -- but never on the inert cell we just warped in on
|
||||
-- (issue #230), which the completed-step path guards the same way.
|
||||
if result == "blocked" and not self:onWarpArrivalCell() then
|
||||
-- entrances, ...), and only while BIT_STANDING_ON_WARP is set (issue
|
||||
-- #230), which the map-edge path guards the same way.
|
||||
if result == "blocked" and self:canCollisionWarp() then
|
||||
local w = Warp.onCollision(self.map, Game.data.field.warpCarpets,
|
||||
self.player.cellX, self.player.cellY, dir)
|
||||
if w then
|
||||
@@ -1169,11 +1226,11 @@ function OverworldState:checkEdgeExit(dir)
|
||||
|
||||
local w = Warp.onEdge(self.map, p.cellX, p.cellY, dir)
|
||||
if w then
|
||||
-- ...but not while still standing on the warp cell we just arrived on
|
||||
-- (issue #230): fall through so pushing into the edge bonks (SFX +
|
||||
-- walk-in-place) instead of instantly re-warping. A real step onto an
|
||||
-- exit-carpet edge cleared warpEntryCell first, so those still fire.
|
||||
if self:onWarpArrivalCell() then return false end
|
||||
-- ...but only with BIT_STANDING_ON_WARP set: a staircase tile clears it,
|
||||
-- so pushing into the edge beside one bonks (SFX + walk-in-place) instead
|
||||
-- of bouncing floors (issue #230), while the door mat you warped in on
|
||||
-- keeps it and exits on that same tile (issue #378).
|
||||
if not self:canCollisionWarp() then return false end
|
||||
self:takeWarp(w.def)
|
||||
return true
|
||||
end
|
||||
@@ -1239,7 +1296,14 @@ function OverworldState:crossConnection(dir, conn)
|
||||
-- still protects the visible step when neighbor rebuild or the sync
|
||||
-- fallback stalls, and avoids the rare one-frame volume spike from a
|
||||
-- song swap mid-step.
|
||||
self:setMap(conn.map, x, y, p.facing, { seamless = true, keepMusic = true })
|
||||
-- Yellow's follower crosses the seam as one continuous walk, so hand the
|
||||
-- live instance through setMap (which rebuilds self.npcs) instead of
|
||||
-- letting it respawn behind the player (#427)
|
||||
local PikachuFollower = require("src.world.PikachuFollower")
|
||||
local pika = PikachuFollower.current(self)
|
||||
local fromX, fromY = p.cellX, p.cellY
|
||||
self:setMap(conn.map, x, y, p.facing,
|
||||
{ seamless = true, keepMusic = true, keepPikachu = pika })
|
||||
self.pendingSeamMusic = conn.map
|
||||
-- place the player one cell before the seam (their old world spot,
|
||||
-- which the neighbor strip renders identically) and start the step
|
||||
@@ -1249,6 +1313,8 @@ function OverworldState:crossConnection(dir, conn)
|
||||
local d = DIRVEC[dir]
|
||||
p.cellX, p.cellY = x - d[1], y - d[2]
|
||||
p.px, p.py = p.cellX * 16, p.cellY * 16
|
||||
-- same translation for the follower and the cell it is chasing
|
||||
PikachuFollower.rebase(self, p.cellX - fromX, p.cellY - fromY)
|
||||
self.camera:follow(p.px, p.py)
|
||||
p.facing = dir
|
||||
p.targetX, p.targetY = x, y
|
||||
@@ -1399,6 +1465,7 @@ function OverworldState:goFishing(rod)
|
||||
-- the bobber waits a beat before the verdict (the original's
|
||||
-- FishingInit dot animation); the rod pose draws in the meantime
|
||||
self.fishing = { facing = self.player.facing }
|
||||
self.player.fishing = true
|
||||
Game.stack:push(TextBox.new(Game, ". . .", function()
|
||||
-- FishingAnim (engine/overworld/player_animations.asm) holds
|
||||
-- BIT_LEDGE_OR_FISHING -- the rod OAM and the fishing pose -- through
|
||||
@@ -1406,12 +1473,20 @@ function OverworldState:goFishing(rod)
|
||||
-- must NOT vanish with the dots box (#321).
|
||||
if not enc then
|
||||
Game.stack:push(TextBox.new(Game, Strings("Not even a nibble!"), function()
|
||||
-- the rod OAM goes out with the verdict box (res BIT_LEDGE_OR_FISHING
|
||||
-- straight after PrintText) but the player keeps the patched tiles
|
||||
-- until the overworld reloads them a few frames later
|
||||
-- (RestoreScreenTilesAndReloadTilePatterns, home/palettes.asm ->
|
||||
-- ReloadMapSpriteTilePatterns, home/reload_sprites.asm) -- #384
|
||||
self.fishing = nil
|
||||
self.fishPose = 10
|
||||
end))
|
||||
return
|
||||
end
|
||||
Game.stack:push(TextBox.new(Game, Strings("Oh!\nIt's a bite!"), function()
|
||||
-- the bite goes straight into battle, which reloads the sprite tiles
|
||||
self.fishing = nil
|
||||
self.player.fishing = nil
|
||||
local BattleState = require("src.battle.BattleState")
|
||||
local battle = BattleState.newWild(Game, enc.species, enc.level, { hooked = true })
|
||||
if Game.save.safari and Map.inRegion(self.map.def, "SAFARI", "SAFARI_ZONE") then
|
||||
@@ -1497,13 +1572,18 @@ function OverworldState:interact()
|
||||
npc = self:npcAtCell(fx2, fy2)
|
||||
end
|
||||
if npc then
|
||||
if not npc.moving then
|
||||
if npc.pikachuFollower then
|
||||
-- the companion answers directly (TalkToPikachu), no map text id
|
||||
require("src.world.PikachuFollower").talk(Game, self, npc)
|
||||
else
|
||||
self:talkTo(npc)
|
||||
end
|
||||
if npc.pikachuFollower then
|
||||
-- the companion answers directly (TalkToPikachu), no map text id --
|
||||
-- and it answers mid-step too. pikachu_follow.asm walks the follower
|
||||
-- on the player's own step clock, so the original never has it
|
||||
-- mid-tile while the player stands; this port's follow is a frame
|
||||
-- late (the npc loop runs before Player:update lands the step), so
|
||||
-- the not-moving gate used to eat the A press in the frames right
|
||||
-- after landing -- exactly when you turn round to face it (#407).
|
||||
-- talk() lands the follower on its cell first.
|
||||
require("src.world.PikachuFollower").talk(Game, self, npc)
|
||||
elseif not npc.moving then
|
||||
self:talkTo(npc)
|
||||
end
|
||||
interacted(self, fx, fy, "npc", npc)
|
||||
return
|
||||
@@ -2013,6 +2093,7 @@ function OverworldState:billsHouseBillExits()
|
||||
local Commands = require("src.script.Commands")
|
||||
local ctx = { game = Game, save = Game.save, overworld = self }
|
||||
Commands.show_object(ctx, "BILLS_HOUSE", "BILLSHOUSE_BILL1")
|
||||
require("src.world.PikachuFollower").onBillExitedMachine(Game, self)
|
||||
local function done()
|
||||
Game.save.flags.EVENT_MET_BILL = true
|
||||
Game.save.flags.EVENT_MET_BILL_2 = true
|
||||
@@ -2087,25 +2168,26 @@ end
|
||||
-- Gen 1 has no confirmation prompt: using SURF gets straight on
|
||||
-- (_SurfingGotOnText, item_effects.asm .surf). Called from the party
|
||||
-- menu's SURF action (via useSurfFieldMove) once the facing tile has been
|
||||
-- confirmed to be water -- there is no overworld A-press hook.
|
||||
function OverworldState:trySurf(fx, fy)
|
||||
-- confirmed to be water -- there is no overworld A-press hook. onClose is
|
||||
-- that menu's own close, called when the got-on text ends (see below).
|
||||
function OverworldState:trySurf(fx, fy, onClose)
|
||||
local mon = self:partyKnows("SURF")
|
||||
if not mon then return end
|
||||
local name = mon.nickname or Game.data.pokemon[mon.species].name
|
||||
local p = self.player
|
||||
local text = (Game.data.text._SurfingGotOnText or Strings("{PLAYER} got on\n{RAM:wNameBuffer}!"))
|
||||
:gsub("{RAM:wNameBuffer}", name)
|
||||
-- GBPalWhiteOutWithDelay3 runs while the got-on text is still up
|
||||
-- (start_sub_menus.asm .surf), so the blink reads as a text flash
|
||||
-- instead of a flashbang on the empty map (#320). The flash sits
|
||||
-- under the textbox on the stack: only the top state updates, so it
|
||||
-- holds its frames until the text closes. surfing (and the sprite
|
||||
-- swap) only applies when the step happens -- no paddling on land.
|
||||
Game.stack:push(require("src.render.Transition").whiteFlash(Game))
|
||||
-- UseItem prints the got-on text with the party menu still on screen and
|
||||
-- GBPalWhiteOutWithDelay3 + .goBackToMap only run after it
|
||||
-- (start_sub_menus.asm .surf), so the text reads over the menu and the
|
||||
-- blink is the menu closing, not a flashbang on the empty map (#320,
|
||||
-- #385). The mount rides the blink, so nothing paddles on land.
|
||||
Game.stack:push(TextBox.new(Game, text, function()
|
||||
if onClose then onClose() end
|
||||
p.surfing = true
|
||||
require("src.core.Music").setSurfing(Game.data, true)
|
||||
self:stepForwardOrCrossEdge(p.facing)
|
||||
Game.stack:push(require("src.render.Transition").whiteFlash(Game, nil,
|
||||
function() self:stepForwardOrCrossEdge(p.facing) end))
|
||||
end))
|
||||
end
|
||||
|
||||
@@ -2509,41 +2591,56 @@ function OverworldState:nurseHeal(onDone, npc)
|
||||
hello = hello .. "\f"
|
||||
.. (t._ShallWeHealYourPokemonText or Strings("Shall we heal your\nPOKéMON?"))
|
||||
end
|
||||
-- Yellow's companion has its own beat threaded through this sequence
|
||||
local Follower = require("src.world.PikachuFollower")
|
||||
Game.stack:push(TextBox.new(Game, hello, nil, { choice = function(yes)
|
||||
if not yes then
|
||||
Game.stack:push(TextBox.new(Game, bye, onDone))
|
||||
return
|
||||
end
|
||||
local need = t._NeedYourPokemonText or Strings("OK. We'll need\nyour POKéMON.")
|
||||
Game.stack:push(TextBox.new(Game, need, function()
|
||||
-- the nurse turns to the machine, the map music stops, and the
|
||||
-- party heals before the machine runs (predef HealParty)
|
||||
if npc then npc.facing = "left" end
|
||||
require("src.core.Music").stop()
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
for _, mon in ipairs(Game.save.party) do
|
||||
Pokemon.heal(mon)
|
||||
end
|
||||
Game.save.lastHeal = { -- SetLastBlackoutMap
|
||||
map = self.map.id, x = self.player.cellX, y = self.player.cellY,
|
||||
-- the town door of this interior, for LAST_MAP exits after a
|
||||
-- blackout/ESCAPE ROPE warp here
|
||||
outdoor = self.lastOutdoor
|
||||
and { id = self.lastOutdoor.id, x = self.lastOutdoor.x, y = self.lastOutdoor.y }
|
||||
or nil,
|
||||
}
|
||||
self.healAnim = { balls = #Game.save.party, lit = 0, timer = 0,
|
||||
visible = true,
|
||||
-- map anchor: the player's cell when healing
|
||||
-- began (the GB's fixed screen coords assume it
|
||||
-- BG-aligned at (64,64))
|
||||
px = self.player.cellX * 16,
|
||||
py = self.player.cellY * 16 }
|
||||
self.healAnim.onDone = function()
|
||||
if npc then npc:facePlayer(self.player) end
|
||||
self:finishNurseHeal(bye, onDone)
|
||||
end
|
||||
end))
|
||||
-- accepting the heal sends the companion up onto the counter to Nurse
|
||||
-- Joy first: pokecenter.asm runs `callfar PikachuWalksToNurseJoy`
|
||||
-- between SetLastBlackoutMap and NeedYourPokemonText, and the hop has
|
||||
-- to finish before the text box goes up because only the top state
|
||||
-- updates. No follower (or not Yellow) calls straight through (#417).
|
||||
Follower.hopToCounter(self, function()
|
||||
Game.stack:push(TextBox.new(Game, need, function()
|
||||
-- the nurse turns to the machine, the map music stops, and the
|
||||
-- party heals before the machine runs (predef HealParty)
|
||||
if npc then npc.facing = "left" end
|
||||
-- DisablePikachuOverworldSpriteDrawing: Pikachu goes behind the
|
||||
-- counter with the party for the machine animation
|
||||
Follower.setVisible(self, false)
|
||||
require("src.core.Music").stop()
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
for _, mon in ipairs(Game.save.party) do
|
||||
Pokemon.heal(mon)
|
||||
end
|
||||
Game.save.lastHeal = { -- SetLastBlackoutMap
|
||||
map = self.map.id, x = self.player.cellX, y = self.player.cellY,
|
||||
-- the town door of this interior, for LAST_MAP exits after a
|
||||
-- blackout/ESCAPE ROPE warp here
|
||||
outdoor = self.lastOutdoor
|
||||
and { id = self.lastOutdoor.id, x = self.lastOutdoor.x, y = self.lastOutdoor.y }
|
||||
or nil,
|
||||
}
|
||||
self.healAnim = { balls = #Game.save.party, lit = 0, timer = 0,
|
||||
visible = true,
|
||||
-- map anchor: the player's cell when healing
|
||||
-- began (the GB's fixed screen coords assume it
|
||||
-- BG-aligned at (64,64))
|
||||
px = self.player.cellX * 16,
|
||||
py = self.player.cellY * 16 }
|
||||
self.healAnim.onDone = function()
|
||||
-- EnablePikachuOverworldSpriteDrawing, before the fighting-fit
|
||||
-- line: it comes back on the counter facing the player
|
||||
Follower.setVisible(self, true)
|
||||
if npc then npc:facePlayer(self.player) end
|
||||
self:finishNurseHeal(bye, onDone)
|
||||
end
|
||||
end))
|
||||
end)
|
||||
end }))
|
||||
end
|
||||
|
||||
@@ -3024,18 +3121,16 @@ function OverworldState:onStepComplete()
|
||||
entry = nil
|
||||
end
|
||||
-- The arrival disable is POSITIONAL: warpEntryCell above is the whole
|
||||
-- test. justWarped only records that an arrival happened (it still
|
||||
-- backs onWarpArrivalCell's bonk guard for issue #230), so consuming a
|
||||
-- completed step with it swallowed the warp under the player's feet,
|
||||
-- which is why a second ladder one cell from the first did nothing
|
||||
-- (Seafoam B3F has warp tiles on (25,3) and (25,4)) -- issue #265.
|
||||
-- pokered has no such counter: every completed step runs
|
||||
-- CheckWarpsNoCollision (home/overworld.asm), and BIT_STANDING_ON_WARP,
|
||||
-- the flag the bonk path needs, is only set by
|
||||
-- CheckWarpsNoCollisionLoop itself or by IsPlayerStandingOnWarp from
|
||||
-- MapEntryAfterBattle, never on a plain warp arrival -- which is
|
||||
-- exactly what warpEntryCell reproduces.
|
||||
self.justWarped = false
|
||||
-- test. Consuming a completed step with a one-shot "just warped" counter
|
||||
-- instead swallowed the warp under the player's feet, which is why a
|
||||
-- second ladder one cell from the first did nothing (Seafoam B3F has warp
|
||||
-- tiles on (25,3) and (25,4)) -- issue #265. pokered has no such counter:
|
||||
-- every completed step runs CheckWarpsNoCollision (home/overworld.asm).
|
||||
-- That same step is where BIT_STANDING_ON_WARP is maintained: cleared
|
||||
-- before the check (home/overworld.asm:324), set again while standing on a
|
||||
-- warp square, then cleared once more when the square is a warp-activating
|
||||
-- tile that is not a door tile (IsPlayerStandingOnDoorTileOrWarpTile).
|
||||
self:refreshStandingOnWarp()
|
||||
if entry then
|
||||
-- still standing on the warp we arrived through; do not re-trigger it
|
||||
else
|
||||
@@ -3473,6 +3568,10 @@ function OverworldState:afterBattle(result, battle)
|
||||
{ save = Game.save, healTarget = self:healPoint() })
|
||||
self:warpToHealPoint(evolutions)
|
||||
else
|
||||
-- EndTrainerBattle sets BIT_CUR_MAP_LOADED_1 (home/trainers.asm), which
|
||||
-- re-runs the floor's door callback: beating the last Rocket Hideout guard
|
||||
-- opens the lift gate without leaving the map (#372)
|
||||
self:stampClosedDoors()
|
||||
-- throwing the last SAFARI BALL ends the game
|
||||
if Game.save.safari and Game.save.safari.balls <= 0 then
|
||||
self:safariGameOver(Strings("PA: You're out of\nSAFARI BALLs!"))
|
||||
@@ -3606,12 +3705,12 @@ function OverworldState:startWarpTo(mapId, x, y, facing, onDone, opts)
|
||||
self.arriveWarp = nil
|
||||
Game.stack:push(Transition.new(Game, function()
|
||||
self:setMap(mapId, x, y, facing or "down", opts)
|
||||
self.justWarped = true
|
||||
-- The warp we land ON stays inert until we physically step off it, so a
|
||||
-- warp whose destination cell is itself a warp cannot bounce us straight
|
||||
-- back (elevator cars, stacked stair/door mats). This generalizes the
|
||||
-- one-step justWarped guard, which only skipped the very next frame's
|
||||
-- check and so let a mon walked back onto the pad re-trigger it.
|
||||
-- The warp we land ON stays inert for the completed-step check until we
|
||||
-- physically step off it, so a warp whose destination cell is itself a
|
||||
-- warp cannot bounce us straight back (elevator cars, stacked stair/door
|
||||
-- mats). BIT_STANDING_ON_WARP is deliberately NOT touched here:
|
||||
-- ClearVariablesOnEnterMap leaves wMovementFlags alone, so the flag the
|
||||
-- departing tile set rides through the warp (issue #378).
|
||||
self.warpEntryCell = { x = x, y = y }
|
||||
-- Fly/Teleport/Dig/Escape-Rope landings poof the player back in
|
||||
-- (player_animations.asm EnterMapAnim). Blackouts and ordinary
|
||||
@@ -3637,8 +3736,8 @@ function OverworldState:startWarpTo(mapId, x, y, facing, onDone, opts)
|
||||
-- PlayerStepOutFromDoor (engine/overworld/auto_movement.asm): any
|
||||
-- warp that lands on a door tile auto-steps south once, indoor or
|
||||
-- outdoor. Auto-walk leaves the mat, so the arrival disable
|
||||
-- (warpEntryCell / justWarped) is unnecessary -- and would let you
|
||||
-- stand on the door without re-entering if you hold back into it.
|
||||
-- (warpEntryCell) is unnecessary -- and would let you stand on the
|
||||
-- door without re-entering if you hold back into it.
|
||||
-- The walk-out is a simulated d-pad press (wSimulatedJoypadStates),
|
||||
-- not a forced move, so it obeys collision: on a landing with a
|
||||
-- solid cell south of the door (the mansion stair landings back
|
||||
@@ -3647,7 +3746,6 @@ function OverworldState:startWarpTo(mapId, x, y, facing, onDone, opts)
|
||||
if self.map:isDoorTileCell(self.player.cellX, self.player.cellY) then
|
||||
if Collision.canMove(self.map, self.entities, self.player, "down") then
|
||||
self.warpEntryCell = nil
|
||||
self.justWarped = false
|
||||
self:scriptMove(self.player, "down", 1)
|
||||
else
|
||||
self.player.facing = "down"
|
||||
@@ -4090,23 +4188,6 @@ function OverworldState:drawWorld()
|
||||
end
|
||||
end
|
||||
|
||||
-- Rock Tunnel darkness. The original never cuts a window of light around
|
||||
-- the player: it shifts the BG palette for the WHOLE screen (wMapPalOffset
|
||||
-- = 6 -> home/fade.asm LoadGBPal -> FadePal2 `dc 3,3,3,2`) and FLASH shifts
|
||||
-- it back (#322). PaletteFX.DARK_BGP does that for every shade-remapped
|
||||
-- mode, armed at the top of drawWorld. RED++ is the one mode with no
|
||||
-- palette left to shift -- TileRenderer bakes true colour into the tileset
|
||||
-- atlas and sgbWorldZones hands the blit an EMPTY zone list, so no shader
|
||||
-- runs over the world at all -- so there the darkness is composited instead:
|
||||
-- a flat veil over the whole world view (surveying still cannot peek past
|
||||
-- it) at the 85/255 brightness FadePal2 leaves DMG white on.
|
||||
local function fxDark()
|
||||
if not self:darkNeedsOverlay() then return end
|
||||
love.graphics.setColor(0, 0, 0, 1 - 85 / 255)
|
||||
love.graphics.rectangle("fill", 0, 0, vw, vh)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
end
|
||||
|
||||
-- the FLY bird sweeping off with the player
|
||||
local function fxBird()
|
||||
if not self.flyAnim then return end
|
||||
@@ -4184,7 +4265,7 @@ function OverworldState:drawWorld()
|
||||
return PaletteFX.pal(Game.data, self:paletteNameFor(map or self.map))
|
||||
end,
|
||||
fx = { heal = fxHeal, dust = fxDust, cutTree = fxCutTree,
|
||||
emote = fxEmote, dark = fxDark, bird = fxBird, rod = fxRod },
|
||||
emote = fxEmote, bird = fxBird, rod = fxRod },
|
||||
}
|
||||
-- Draw every active field FX into the finished scene. `project(wx, wy)`
|
||||
-- maps a world point to canvas pixels (nil when it is behind the
|
||||
@@ -4235,17 +4316,6 @@ function OverworldState:drawWorld()
|
||||
if self.fishing then
|
||||
at(fxRod, self.player.px + 8, self.player.py + 16)
|
||||
end
|
||||
-- Rock Tunnel darkness is a screen-space veil, not a ground object:
|
||||
-- draw it flat over the finished scene like the tilt path. It fills
|
||||
-- the view in world-pixel units, so it only needs the scale, and it is
|
||||
-- only needed at all in the mode whose palette cannot carry the
|
||||
-- darkening itself (see fxDark).
|
||||
if self:darkNeedsOverlay() then
|
||||
love.graphics.push()
|
||||
love.graphics.scale(scale, scale)
|
||||
fxDark()
|
||||
love.graphics.pop()
|
||||
end
|
||||
end
|
||||
override = Pipelines.drawWorld(pipelineId, ctx)
|
||||
-- world post-processes (a miniature-diorama blur, a colour grade) fold
|
||||
@@ -4308,7 +4378,6 @@ function OverworldState:drawWorld()
|
||||
fxDust()
|
||||
fxCutTree()
|
||||
fxEmote()
|
||||
fxDark()
|
||||
fxBird()
|
||||
fxRod()
|
||||
else
|
||||
@@ -4397,12 +4466,6 @@ function OverworldState:drawWorld()
|
||||
self:billboard(fx, fy, vw, vh, zoneColorsAt(zones, fx, fy), false, fxRod)
|
||||
end
|
||||
|
||||
-- Rock Tunnel darkness is a screen-space veil, not a ground object --
|
||||
-- draw it flat into the upright canvas so it darkens the final
|
||||
-- composited scene uniformly. It no-ops unless this mode needs the
|
||||
-- composited fallback (see fxDark).
|
||||
fxDark()
|
||||
|
||||
Game.renderer:endUprightPass()
|
||||
end
|
||||
|
||||
@@ -4410,6 +4473,32 @@ end
|
||||
|
||||
-- screen-space overlays: drawn to the UI canvas at normal scale
|
||||
function OverworldState:drawUI()
|
||||
-- TalkToPikachu's picture box (engine/pikachu/pikachu_pic_animation.asm
|
||||
-- PlacePikapicTextBoxBorder: TextBoxBorder at (6,5) with b,c = 5,5, so a
|
||||
-- 7x7 box holding the 5x5 pic at (7,6) -- PikaAnimTilemap_1). The
|
||||
-- per-emotion frame gfx (gfx/pikachu/unknown_*) are not extracted, so the
|
||||
-- front pic stands in for every frame of the script; PikachuFollower
|
||||
-- .picLift lifts it on the runs that draw the alternate pose, and the
|
||||
-- script's own duration times the beat (#407, #424).
|
||||
if self.emote and self.emote.pikaPic then
|
||||
require("src.render.Font").drawBox(6, 5, 7, 7)
|
||||
-- one image per path, cached: this draws every frame of the hold, and
|
||||
-- a mod skin can move the path between talks
|
||||
if self.pikaPicPath ~= self.emote.pikaPic then
|
||||
local ok, loaded = pcall(love.graphics.newImage, self.emote.pikaPic)
|
||||
self.pikaPicImg = ok and loaded or nil
|
||||
self.pikaPicPath = self.emote.pikaPic
|
||||
end
|
||||
local img = self.pikaPicImg
|
||||
if img then
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
local w, h = img:getDimensions()
|
||||
local lift = require("src.world.PikachuFollower").picLift(self.emote)
|
||||
love.graphics.draw(img, math.floor(56 + (40 - w) / 2),
|
||||
math.floor(48 + (40 - h) / 2) - lift)
|
||||
end
|
||||
end
|
||||
|
||||
-- poison step flicker (ChangeBGPalColor0_4Frames: dark for two
|
||||
-- 4-frame pulses)
|
||||
if self.poisonFlash and self.poisonFlash > 0 then
|
||||
|
||||
+530
-10
@@ -15,6 +15,7 @@
|
||||
-- UpdatePikachuHappinessAndMood (256-step coin-flip WALKING bump, mood
|
||||
-- converging by 1 per step toward 128).
|
||||
|
||||
local Collision = require("src.world.Collision")
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
|
||||
local PikachuFollower = {}
|
||||
@@ -140,6 +141,14 @@ local function makeFollower(game, ow, x, y, facing)
|
||||
npc.pikachuFollower = true
|
||||
npc.passable = true -- never blocks a step (Collision.occupied)
|
||||
npc.facing = facing or "down"
|
||||
-- the idle animations below pose the walk cycle with no step under it,
|
||||
-- which NPC:walkPhase (moving-only) cannot express. An instance field
|
||||
-- shadows the class method, so NPC:pose keeps working unchanged (#411).
|
||||
npc.walkPhase = function(self)
|
||||
local idle = self.idle
|
||||
if idle and idle.phase then return idle.phase % 2 end
|
||||
return NPC.walkPhase(self)
|
||||
end
|
||||
return npc
|
||||
end
|
||||
|
||||
@@ -172,9 +181,32 @@ local function spawnCell(ow)
|
||||
return p.cellX, p.cellY
|
||||
end
|
||||
|
||||
function PikachuFollower.onMapEntered(game, ow)
|
||||
-- the live follower, for a caller that has to carry it across a setMap
|
||||
-- that rebuilds ow.npcs (OverworldState:crossConnection, #427)
|
||||
function PikachuFollower.current(ow)
|
||||
local npc = findFollower(ow)
|
||||
return npc
|
||||
end
|
||||
|
||||
function PikachuFollower.onMapEntered(game, ow, opts)
|
||||
-- Bill's House owns a short scripted scene that deliberately keeps
|
||||
-- Pikachu off the normal trailing loop. A new map instance ends it.
|
||||
ow.pikachuBillsScene = nil
|
||||
remove(ow)
|
||||
if not shouldSpawn(game, ow) then return end
|
||||
-- opts.keepPikachu is the follower a connection crossing kept alive:
|
||||
-- LoadMapHeader's connection path sets wPikachuSpawnState = 2 and bit 4
|
||||
-- of wPikachuOverworldStateFlags, so SchedulePikachuSpawnForAfterText
|
||||
-- takes .normal_spawn_state -- map coords rebased, sprite data and
|
||||
-- follow command buffer left alone. Re-list the same instance and let
|
||||
-- rebase() shift its cell; a warp arrives without it and respawns
|
||||
-- behind the player, the full spawn path of that same routine.
|
||||
local keep = opts and opts.keepPikachu
|
||||
if keep then
|
||||
table.insert(ow.npcs, keep)
|
||||
table.insert(ow.entities, keep)
|
||||
return
|
||||
end
|
||||
local x, y = spawnCell(ow)
|
||||
local npc = makeFollower(game, ow, x, y, ow.player.facing)
|
||||
table.insert(ow.npcs, npc)
|
||||
@@ -183,9 +215,188 @@ function PikachuFollower.onMapEntered(game, ow)
|
||||
ow.pikachuTrail = { x = ow.player.cellX, y = ow.player.cellY }
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- Idle behavior (pikachu_follow.asm Func_fc803 and the Func_fc842 roll it
|
||||
-- hands off to). Standing still, the follower burns down a frame
|
||||
-- counter; at zero it either looks in a random direction (Random & $c,
|
||||
-- another $20 frames later) or, when the buffered follow command puts it
|
||||
-- two or more cells off the player (ComputePikachuFollowCommand's 5-8
|
||||
-- band), rolls one of four in-place animations: a bounce, the walk cycle
|
||||
-- on the spot, a two frame shuffle, or a clockwise spin. Func_fc82e
|
||||
-- drops whichever is running the moment the player takes a step. Nothing
|
||||
-- here plays a bubble or a cry -- those are TalkToPikachu's alone (#411).
|
||||
-- ---------------------------------------------------------------------
|
||||
|
||||
local IDLE_LOOK = 0x20 -- Func_fc803's pause between random glances
|
||||
local IDLE_REST = 0x10 -- Func_fc835's pause after an animation ends
|
||||
local IDLE_FRAME = 8 -- frames per sprite frame in Func_fc8f8/92b/95d
|
||||
|
||||
local FACINGS = { "down", "up", "left", "right" }
|
||||
-- Func_fc95d .Facings, the order the spin turns through
|
||||
local CLOCKWISE = { down = "left", left = "up", up = "right", right = "down" }
|
||||
|
||||
-- Pointer_fc8d6, transposed to (dx, dy): the asm stores (y, x) and walks
|
||||
-- the table backwards as the $11 counter runs down, so entry N here is
|
||||
-- what counter N draws. A sway four pixels right then four left with the
|
||||
-- body bobbing up twice, netting zero displacement.
|
||||
local BOUNCE = {
|
||||
{ 0, 0 }, { -1, -2 }, { -2, -4 }, { -3, -2 }, { -4, 0 },
|
||||
{ -3, -2 }, { -2, -4 }, { -1, -2 }, { 0, 0 }, { 1, -2 },
|
||||
{ 2, -4 }, { 3, -2 }, { 4, 0 }, { 3, -2 }, { 2, -4 },
|
||||
{ 1, -2 }, { 0, 0 },
|
||||
}
|
||||
|
||||
local function randomInt(a, b)
|
||||
local rand = love and love.math and love.math.random or math.random
|
||||
return rand(a, b)
|
||||
end
|
||||
|
||||
-- back onto the cell's own pixels: while the follower stands, nothing else
|
||||
-- writes px/py, so the bounce offset has to be undone from here
|
||||
local function idleReset(npc)
|
||||
npc.idle = nil
|
||||
npc.px, npc.py = npc.cellX * 16, npc.cellY * 16
|
||||
end
|
||||
|
||||
-- ComputePikachuFollowCommand: the command the idle state reads back is
|
||||
-- 1-4 while the follower sits within a cell of the player and 5-8 once it
|
||||
-- is two or more off, Y deciding whenever the rows differ. Returns the
|
||||
-- facing those 5-8 encode (Func_fc862 turns that way before it bounces),
|
||||
-- or nil for the near band, which only ever glances.
|
||||
local function strandedFacing(ow, npc)
|
||||
local p = ow.player
|
||||
local dy = p.cellY - npc.cellY
|
||||
if dy ~= 0 then
|
||||
if dy > -2 and dy < 2 then return nil end
|
||||
return dy > 0 and "down" or "up"
|
||||
end
|
||||
local dx = p.cellX - npc.cellX
|
||||
if dx > -2 and dx < 2 then return nil end
|
||||
return dx > 0 and "right" or "left"
|
||||
end
|
||||
|
||||
-- Func_fc842: an even roll over the four PointerTable_fc85a entries
|
||||
local function startIdleAnim(npc, facing)
|
||||
local roll = randomInt(0, 3)
|
||||
if roll == 0 then
|
||||
-- Func_fc862 turns toward the player, then asm_fc87f bounces
|
||||
npc.facing = facing or npc.facing
|
||||
npc.idle = { kind = "bounce", frames = 0x11 }
|
||||
elseif roll == 1 then
|
||||
npc.idle = { kind = "walk", frames = 0x30, tick = 0, phase = 0 }
|
||||
elseif roll == 2 then
|
||||
npc.idle = { kind = "shuffle", frames = 0x20, tick = 0, phase = 0 }
|
||||
else
|
||||
npc.idle = { kind = "spin", frames = 0x20, tick = 0 }
|
||||
end
|
||||
end
|
||||
|
||||
local function idleTick(ow, npc)
|
||||
-- Func_fc82e: a step in progress ends the idle state outright
|
||||
if ow.player.moving then idleReset(npc) return end
|
||||
-- Every counter below burns one unit per UpdateSprites call, and a
|
||||
-- standing OverworldLoop spends two DelayFrames on each pass (home/
|
||||
-- overworld.asm: OverworldLoop delays, falls into OverworldLoopLessDelay
|
||||
-- which delays again, then .noDirectionButtonsPressed loops back), so the
|
||||
-- whole Func_fc803 family runs at half this port's 60Hz fixed step: the
|
||||
-- first glance is $20 CALLS, 64 frames, not 32 (#424).
|
||||
npc.idleClock = ((npc.idleClock or 0) + 1) % 2
|
||||
if npc.idleClock ~= 0 then return end
|
||||
local idle = npc.idle
|
||||
if not idle then
|
||||
idle = { kind = "wait", frames = IDLE_LOOK }
|
||||
npc.idle = idle
|
||||
end
|
||||
if idle.kind == "wait" then
|
||||
idle.frames = idle.frames - 1
|
||||
if idle.frames > 0 then return end
|
||||
local facing = strandedFacing(ow, npc)
|
||||
if facing then
|
||||
startIdleAnim(npc, facing)
|
||||
else
|
||||
npc.facing = FACINGS[randomInt(1, 4)]
|
||||
idle.frames = IDLE_LOOK
|
||||
end
|
||||
return
|
||||
end
|
||||
if idle.kind == "bounce" then
|
||||
local o = BOUNCE[idle.frames] or BOUNCE[1]
|
||||
npc.px = npc.cellX * 16 + o[1]
|
||||
npc.py = npc.cellY * 16 + o[2]
|
||||
else
|
||||
idle.tick = idle.tick + 1
|
||||
if idle.tick >= IDLE_FRAME then
|
||||
idle.tick = 0
|
||||
if idle.kind == "walk" then
|
||||
-- Func_fc8f8 runs the anim counter through all four frames; the
|
||||
-- top bit is the mirrored foot, which is our stepFlip
|
||||
idle.phase = (idle.phase + 1) % 4
|
||||
npc.stepFlip = idle.phase >= 2
|
||||
elseif idle.kind == "shuffle" then
|
||||
idle.phase = idle.phase == 0 and 1 or 0 -- Func_fc92b's xor $1
|
||||
else
|
||||
npc.facing = CLOCKWISE[npc.facing] or "down"
|
||||
end
|
||||
end
|
||||
end
|
||||
idle.frames = idle.frames - 1
|
||||
if idle.frames <= 0 then
|
||||
-- Func_fc835: a $10 frame rest, then the idle counter again
|
||||
idleReset(npc)
|
||||
npc.idle = { kind = "wait", frames = IDLE_REST }
|
||||
end
|
||||
end
|
||||
|
||||
-- The cell ahead is a ledge the player just hopped (data/tilesets/
|
||||
-- ledge_tiles.asm, the same row match OverworldState:checkLedgeHop makes).
|
||||
-- The follower only ever retraces cells the player stood on, so a ledge
|
||||
-- tile in the trail means the player jumped it (#409).
|
||||
local function ledgeStep(game, ow, cx, cy, dir)
|
||||
local map = ow.map
|
||||
local d = Collision.DELTA[dir]
|
||||
local fx, fy = cx + d[1], cy + d[2]
|
||||
local lx, ly = cx + d[1] * 2, cy + d[2] * 2
|
||||
if not (map:inBounds(fx, fy) and map:inBounds(lx, ly)) then return false end
|
||||
local tileset = map.def.tileset
|
||||
local standing = map:cellTile(cx, cy)
|
||||
local front = map:cellTile(fx, fy)
|
||||
for _, ledge in ipairs(game.data.field.ledges or {}) do
|
||||
if (ledge.tileset or "OVERWORLD") == tileset
|
||||
and ledge.facing == dir and ledge.input == dir
|
||||
and ledge.standingTile == standing and ledge.ledgeTile == front then
|
||||
return true
|
||||
end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
-- Slide the follower into the connected map's coordinate frame by the
|
||||
-- delta crossConnection applied to the player: the two maps are one
|
||||
-- continuous world, so a seam is a pure translation. MapX/MapY in
|
||||
-- wSpritePikachuStateData2 are all .normal_spawn_state rewrites there,
|
||||
-- never the pixel coords, which is why the original walks through a seam
|
||||
-- instead of popping (#427).
|
||||
function PikachuFollower.rebase(ow, dx, dy)
|
||||
local npc = findFollower(ow)
|
||||
if npc then
|
||||
npc.cellX, npc.cellY = npc.cellX + dx, npc.cellY + dy
|
||||
npc.px, npc.py = npc.px + dx * 16, npc.py + dy * 16
|
||||
if npc.targetX then npc.targetX = npc.targetX + dx end
|
||||
if npc.targetY then npc.targetY = npc.targetY + dy end
|
||||
if npc.goalX then npc.goalX = npc.goalX + dx end
|
||||
if npc.goalY then npc.goalY = npc.goalY + dy end
|
||||
-- Func_fc82e: the player is taking a step, so any idle pose is over
|
||||
if npc.idle then idleReset(npc) end
|
||||
end
|
||||
local trail = ow.pikachuTrail
|
||||
if trail then trail.x, trail.y = trail.x + dx, trail.y + dy end
|
||||
end
|
||||
|
||||
-- one follow step per frame: chase the cell the player last vacated
|
||||
-- (pikachu_follow.asm keeps it one walk step behind)
|
||||
function PikachuFollower.update(game, ow)
|
||||
if ow.pikaHop then return end -- the counter hop owns the follower (#417)
|
||||
if ow.pikachuBillsScene then return end
|
||||
local npc = findFollower(ow)
|
||||
if not npc then
|
||||
if shouldSpawn(game, ow) then PikachuFollower.onMapEntered(game, ow) end
|
||||
@@ -201,15 +412,50 @@ function PikachuFollower.update(game, ow)
|
||||
trail = { x = p.cellX, y = p.cellY }
|
||||
ow.pikachuTrail = trail
|
||||
end
|
||||
-- the player left the trailing cell: it becomes Pikachu's next goal
|
||||
if p.cellX ~= trail.x or p.cellY ~= trail.y then
|
||||
npc.goalX, npc.goalY = trail.x, trail.y
|
||||
trail.x, trail.y = p.cellX, p.cellY
|
||||
-- The follow command is queued the frame the player COMMITS a step, not
|
||||
-- the frame it lands: home/overworld.asm .noCollision sets wWalkCounter
|
||||
-- and calls Func_fcc08 (pikachu_follow.asm Func_fcc42 reads the direction
|
||||
-- of the step just started) before AdvancePlayerSprite, so Pikachu walks
|
||||
-- into the cell the player is vacating during that same step and rests
|
||||
-- exactly one cell behind. Waiting for p.cellX to change put a whole
|
||||
-- extra step between them -- the two-tile gap of issue #410. targetX/Y
|
||||
-- is the committed destination while a step is in flight and nil when
|
||||
-- standing, so a warp or teleport still registers here (and the far > 6
|
||||
-- snap below still catches it).
|
||||
local destX = p.targetX or p.cellX
|
||||
local destY = p.targetY or p.cellY
|
||||
if destX ~= trail.x or destY ~= trail.y then
|
||||
local stepDir = destY > trail.y and "down" or destY < trail.y and "up"
|
||||
or destX > trail.x and "right" or "left"
|
||||
-- A ledge hop commits TWO steps (checkLedgeHop -> scriptMove(p, dir, 2),
|
||||
-- the two simulated presses of HandleLedges) but only ONE follow
|
||||
-- command: Func_fcc08 sees BIT_LEDGE_OR_FISHING and defers to
|
||||
-- Func_fcc64, which appends the $5-$8 hop on the takeoff step and
|
||||
-- appends nothing on the landing step (bit 6 of
|
||||
-- wPikachuOverworldStateFlags toggles between the two). With no command
|
||||
-- behind it the hop cannot leave the buffer -- Func_fcc92 only pops once
|
||||
-- a second command is queued -- so Pikachu walks up to the cell the
|
||||
-- player took off from, waits there two cells behind (the Func_fc842
|
||||
-- idle rolls), and hops one player step later (#424, after #409).
|
||||
if trail.ledgeHop == stepDir then
|
||||
trail.ledgeHop = nil
|
||||
trail.x, trail.y = destX, destY
|
||||
else
|
||||
trail.ledgeHop = ledgeStep(game, ow, trail.x, trail.y, stepDir)
|
||||
and stepDir or nil
|
||||
npc.goalX, npc.goalY = trail.x, trail.y
|
||||
trail.x, trail.y = destX, destY
|
||||
end
|
||||
end
|
||||
if npc.moving or not npc.goalX then return end
|
||||
-- standing still with nothing to chase is the idle state (Func_fc803);
|
||||
-- once a step is under way NPC:update owns px/py, so only the idle
|
||||
-- record is dropped here -- never the interpolated pixels
|
||||
if npc.moving then npc.idle = nil return end
|
||||
if not npc.goalX then idleTick(ow, npc) return end
|
||||
local gx, gy = npc.goalX, npc.goalY
|
||||
if npc.cellX == gx and npc.cellY == gy then
|
||||
npc.goalX, npc.goalY = nil, nil
|
||||
idleTick(ow, npc)
|
||||
return
|
||||
end
|
||||
-- fell more than a screen behind (forced movement, warp math): snap
|
||||
@@ -218,8 +464,10 @@ function PikachuFollower.update(game, ow)
|
||||
npc.cellX, npc.cellY = gx, gy
|
||||
npc.px, npc.py = gx * 16, gy * 16
|
||||
npc.goalX, npc.goalY = nil, nil
|
||||
npc.idle = nil -- the snap already rewrote px/py
|
||||
return
|
||||
end
|
||||
idleReset(npc) -- a real step overrides whatever the idle pose was
|
||||
local dir
|
||||
if npc.cellX < gx then dir = "right"
|
||||
elseif npc.cellX > gx then dir = "left"
|
||||
@@ -228,15 +476,51 @@ function PikachuFollower.update(game, ow)
|
||||
npc.facing = dir
|
||||
npc.targetX = npc.cellX + (dir == "right" and 1 or dir == "left" and -1 or 0)
|
||||
npc.targetY = npc.cellY + (dir == "down" and 1 or dir == "up" and -1 or 0)
|
||||
-- the cell ahead is the ledge the player hopped: clear both cells in one
|
||||
-- step instead of stopping on the ledge (#409). The trail above holds
|
||||
-- this back until the player commits a further step, so it fires from the
|
||||
-- cell on top of the ledge, a step late (#424). pikachu_follow.asm
|
||||
-- Func_fcc08 appends the $5-$8 hop commands while BIT_LEDGE_OR_FISHING
|
||||
-- is set, and Func_fca0a runs them as two AddPikachuStepVector cells over
|
||||
-- one normal step's frames -- no arc and no shadow, the hop command only
|
||||
-- doubles the step vector (NPC:update's hopStep span).
|
||||
if ledgeStep(game, ow, npc.cellX, npc.cellY, dir) then
|
||||
local d = Collision.DELTA[dir]
|
||||
npc.targetX, npc.targetY = npc.cellX + d[1] * 2, npc.cellY + d[2] * 2
|
||||
npc.goalX, npc.goalY = npc.targetX, npc.targetY
|
||||
npc.hopStep = true
|
||||
end
|
||||
-- walk at the player's own step length (the bicycle is moot: shouldSpawn
|
||||
-- hides the follower on a bike, ShouldPikachuSpawn's wWalkBikeSurfState
|
||||
-- check), and halve it while more than one cell behind -- that is
|
||||
-- FastPikachuFollow, which pikachu_follow.asm picks whenever two or more
|
||||
-- steps are queued (AreThereAtLeastTwoStepsInPikachuFollowCommandBuffer:
|
||||
-- walk counter $4 instead of NormalPikachuFollow's $8).
|
||||
local stepLen = p.stepFramesCur or p.stepFrames or 16
|
||||
-- the hop is never a Fast step: Func_fc7aa jumps to Func_fca0a on the $4
|
||||
-- movement status BEFORE it asks AreThereAtLeastTwoSteps..., so its two
|
||||
-- cells ride one normal step's frames even though the goal is two away.
|
||||
if far > 1 and not npc.hopStep then
|
||||
stepLen = math.max(1, math.floor(stepLen / 2))
|
||||
end
|
||||
npc.stepFrames = stepLen
|
||||
npc.moving = true
|
||||
npc.progress = 0
|
||||
-- this frame's npc:update loop already ran (OverworldState:update walks
|
||||
-- self.npcs, then calls here), so burn the step's first frame now.
|
||||
-- Without it the step costs a frame more than the player's and Pikachu
|
||||
-- trails a pixel further every tile.
|
||||
npc:update(ow.map, ow.entities)
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- TalkToPikachu (engine/pikachu/pikachu_emotions.asm + data/pikachu/
|
||||
-- pikachu_emotions.asm): pick a scripted emotion, then play its bubble
|
||||
-- and voiced PCM clip. The face-pic animation half of each emotion
|
||||
-- (pikaemotion_pikapic) has no port; the bubble + clip carry the beat.
|
||||
-- and voiced PCM clip, and raise the framed Pikachu picture the original
|
||||
-- puts over the map (pikaemotion_pikapic -> pikachu_pic_animation.asm
|
||||
-- PlacePikapicTextBoxBorder), drawn by OverworldController:drawUI. The
|
||||
-- per-emotion animation frames (gfx/pikachu/unknown_*) are not extracted,
|
||||
-- so the front pic stands in for all twenty of them (#407).
|
||||
-- ---------------------------------------------------------------------
|
||||
|
||||
-- PikachuEmotionTable, reduced to each entry's bubble + pikaemotion_pcm
|
||||
@@ -296,6 +580,60 @@ local MOOD_MATRIX = {
|
||||
-- .Emotions): scripted one-shots -- 21 is the fishing-rod reaction
|
||||
local MODIFIER_EMOTIONS = { 18, 21, 23, 24, 25 }
|
||||
|
||||
-- ExecutePikaPicAnimScript spends a Delay3 on every pass of its loop
|
||||
-- (pikachu_pic_animation.asm PikaPicAnimTimerAndJoypad), so one script tick
|
||||
-- is three 60Hz frames: the flat 50 frame hold this port used was under a
|
||||
-- third of even the shortest script (#424).
|
||||
local PIKAPIC_TICK = 3
|
||||
local PIKAPIC_LIFT = 4 -- px the stand-in pic rises on an overlay run
|
||||
|
||||
-- pikaemotion_pikapic's script id per emotion: emotion N takes
|
||||
-- PikaPicAnimScript N, except the four listed here (data/pikachu/
|
||||
-- pikachu_emotions.asm).
|
||||
local PIKAPIC_SCRIPT = { [29] = 10, [30] = 20, [31] = 23, [32] = 23 }
|
||||
|
||||
-- Per script: pikapic_setduration's tick count, and for the scripts whose
|
||||
-- overlay is a whole second pose, that frameset's run lengths in ticks
|
||||
-- (data/pikachu/pikachu_pic_objects.asm PikaPicAnimBGFrames_*, which script
|
||||
-- N reaches as frameset N+5, or N+6 from script 10 up). The list alternates
|
||||
-- pikaframedelay (the base pic alone) and pikaframe (the overlay) starting
|
||||
-- with a delay, so a frameset that opens on a pikaframe opens with a zero
|
||||
-- here; the frameset restarts until pikapic_looptofinish runs the duration
|
||||
-- out. Scripts 1, 2, 3, 5, 6, 8 and 9 are left without a list on purpose:
|
||||
-- their overlays (PikaAnimTilemap_14 to _22) only paint a few tiles over a
|
||||
-- pic that otherwise stands still, so with no tiles to paint the port has
|
||||
-- nothing to show for them and must not bob the whole picture instead.
|
||||
local PIKAPIC = {
|
||||
[1] = { dur = 40 },
|
||||
[2] = { dur = 44 },
|
||||
[3] = { dur = 80 },
|
||||
[4] = { dur = 70, seq = { 8, 8, 20, 8 } },
|
||||
[5] = { dur = 32 },
|
||||
[6] = { dur = 50 },
|
||||
[7] = { dur = 58, seq = { 0, 8, 2, 8, 2, 8 } },
|
||||
[8] = { dur = 44 },
|
||||
[9] = { dur = 56 },
|
||||
[10] = { dur = 56, seq = { 8, 11, 5 } },
|
||||
[11] = { dur = 100, seq = { 20, 8, 20, 8 } },
|
||||
[12] = { dur = 50, seq = { 13, 12, 100, 8 } },
|
||||
[13] = { dur = 50, seq = { 5, 5, 5, 5, 100 } },
|
||||
[14] = { dur = 40, seq = { 2, 2, 2, 2 } },
|
||||
[15] = { dur = 50, seq = { 5, 5, 5, 5 } },
|
||||
[16] = { dur = 32, seq = { 0, 8, 100 } },
|
||||
[17] = { dur = 100, seq = { 10, 3, 3, 3, 100 } },
|
||||
[18] = { dur = 32, seq = { 3, 100, 8, 8 } },
|
||||
[19] = { dur = 44, seq = { 0, 6, 6, 6, 6 } },
|
||||
[20] = { dur = 50, seq = { 8, 12, 8, 12 } },
|
||||
[21] = { dur = 40, seq = { 8, 104 } },
|
||||
[22] = { dur = 40, seq = { 8, 100 } },
|
||||
[23] = { dur = 70, seq = { 16, 16, 16, 16 } },
|
||||
[24] = { dur = 60, seq = { 6, 6, 6, 6, 100 } },
|
||||
[25] = { dur = 50, seq = { 6, 106 } },
|
||||
[26] = { dur = 100, seq = { 20, 8, 20, 116 } },
|
||||
[27] = { dur = 30, seq = { 4, 100 } },
|
||||
[28] = { dur = 64, seq = { 12, 12, 12, 100 } },
|
||||
}
|
||||
|
||||
local function moodEmotion(save)
|
||||
local mood = save.pikachuMood or 128
|
||||
local column = 5
|
||||
@@ -340,6 +678,20 @@ local function bubbleIndex(game, name)
|
||||
end
|
||||
|
||||
function PikachuFollower.talk(game, ow, npc, done)
|
||||
-- pikachu_follow.asm steps the follower on the player's own walk clock,
|
||||
-- so it is never mid-tile while the player stands and can always be
|
||||
-- addressed; this port's follow is a frame late, so land the step here
|
||||
-- rather than answer from between two cells (#407). The emote hold
|
||||
-- returns before the npc update loop, so a follower left mid-step would
|
||||
-- freeze between cells for the whole beat.
|
||||
if npc.moving then
|
||||
npc.cellX, npc.cellY = npc.targetX or npc.cellX, npc.targetY or npc.cellY
|
||||
npc.targetX, npc.targetY = nil, nil
|
||||
npc.moving = false
|
||||
npc.progress = 0
|
||||
npc.hopStep = nil
|
||||
end
|
||||
idleReset(npc) -- the bubble anchor reads px/py, and the hold freezes it
|
||||
npc:facePlayer(ow.player)
|
||||
ow.player.facing = OPPOSITE[npc.facing] or ow.player.facing
|
||||
local save = game.save
|
||||
@@ -357,12 +709,180 @@ function PikachuFollower.talk(game, ow, npc, done)
|
||||
-- caches built before the Yellow bubble sheet only carry the three
|
||||
-- shared bubbles; a missing crop degrades to a silent hold
|
||||
local bi = e.bubble and bubbleIndex(game, e.bubble)
|
||||
-- pikaemotion_pikapic: every entry in data/pikachu/pikachu_emotions.asm
|
||||
-- ends with one, and its box is the only thing most of them put on
|
||||
-- screen (emotion 5, the fresh-save cell, has no bubble at all). The
|
||||
-- 40x40 front pic is the size of PikaAnimTilemap_1's 5x5 base frame;
|
||||
-- Sprites.path keeps a mod's replacement skin in play.
|
||||
local Sprites = require("src.pokemon.Sprites")
|
||||
local pic = Sprites.path(game.data, "PIKACHU", "front",
|
||||
{ kind = "overworld" })
|
||||
local anim = PIKAPIC[PIKAPIC_SCRIPT[emotion] or emotion] or PIKAPIC[1]
|
||||
local hold = anim.dur * PIKAPIC_TICK
|
||||
ow.emote = {
|
||||
npc = npc, frames = 50, bubble = bi or false,
|
||||
onDone = done,
|
||||
npc = npc, frames = hold, bubble = bi or false, pikaPic = pic,
|
||||
pikaSeq = anim.seq, pikaTotal = hold, skippable = true, onDone = done,
|
||||
}
|
||||
end
|
||||
|
||||
-- Where the framed pic sits this frame. The overlay a pikaframe run draws
|
||||
-- is a second full-body pose (PikaAnimTilemap_23 and up replace all 5x5
|
||||
-- tiles) out of gfx/pikachu/unknown_*, which the cache does not carry, so
|
||||
-- the port lifts the one pic it has for the length of those runs -- the jump
|
||||
-- the happy emotions make inside the box (#424, still on #407's stand-in).
|
||||
function PikachuFollower.picLift(emote)
|
||||
local seq = emote and emote.pikaSeq
|
||||
if not seq then return 0 end
|
||||
local loop = 0
|
||||
for _, run in ipairs(seq) do loop = loop + run end
|
||||
if loop <= 0 then return 0 end
|
||||
local elapsed = math.max(0, (emote.pikaTotal or 0) - (emote.frames or 0))
|
||||
local tick = math.floor(elapsed / PIKAPIC_TICK) % loop
|
||||
for i, run in ipairs(seq) do
|
||||
if tick < run then return i % 2 == 0 and PIKAPIC_LIFT or 0 end
|
||||
tick = tick - run
|
||||
end
|
||||
return 0
|
||||
end
|
||||
|
||||
-- Bill's House has three map-scripted Yellow companion beats
|
||||
-- (BillsHouseScript0/2/5): Pikachu walks over to investigate Bill, waits at
|
||||
-- the cell separator, then reacts when Bill reappears. Keep it at the
|
||||
-- machine until this map instance is discarded, just like the cartridge's
|
||||
-- disabled following state.
|
||||
local function billsHouseEmotion(game, ow, npc, bubble)
|
||||
local Sprites = require("src.pokemon.Sprites")
|
||||
ow.emote = {
|
||||
npc = npc, frames = 50, bubble = bubbleIndex(game, bubble) or false,
|
||||
pikaPic = Sprites.path(game.data, "PIKACHU", "front",
|
||||
{ kind = "overworld" }),
|
||||
}
|
||||
end
|
||||
|
||||
local function movePikachu(ow, npc, steps, onDone)
|
||||
npc.goalX, npc.goalY = nil, nil
|
||||
idleReset(npc)
|
||||
local function nextStep(i)
|
||||
local step = steps[i]
|
||||
if not step then
|
||||
if onDone then onDone() end
|
||||
return
|
||||
end
|
||||
ow:scriptMove(npc, step[1], step[2], function() nextStep(i + 1) end)
|
||||
end
|
||||
nextStep(1)
|
||||
end
|
||||
|
||||
function PikachuFollower.onBillsHouseEnter(game, ow)
|
||||
if not (GameVersion.isYellow() and ow.map and ow.map.id == "BILLS_HOUSE") then
|
||||
return
|
||||
end
|
||||
if game.save.flags.EVENT_MET_BILL_2 then return end
|
||||
local npc = findFollower(ow)
|
||||
if not npc then return end
|
||||
ow.pikachuBillsScene = true
|
||||
movePikachu(ow, npc, { { "right", 3 }, { "up", 1 } }, function()
|
||||
billsHouseEmotion(game, ow, npc, "QUESTION_BUBBLE")
|
||||
end)
|
||||
end
|
||||
|
||||
function PikachuFollower.onBillEnteredMachine(game, ow)
|
||||
if not (GameVersion.isYellow() and ow.pikachuBillsScene) then return end
|
||||
local npc = findFollower(ow)
|
||||
if not npc then return end
|
||||
local steps = ow.player.facing == "down"
|
||||
and { { "up", 3 } }
|
||||
or { { "up", 1 }, { "left", 1 }, { "up", 2 }, { "right", 1 } }
|
||||
movePikachu(ow, npc, steps, function()
|
||||
billsHouseEmotion(game, ow, npc, "QUESTION_BUBBLE")
|
||||
end)
|
||||
end
|
||||
|
||||
function PikachuFollower.onBillExitedMachine(game, ow)
|
||||
if not (GameVersion.isYellow() and ow.pikachuBillsScene) then return end
|
||||
local npc = findFollower(ow)
|
||||
if not npc then return end
|
||||
idleReset(npc)
|
||||
npc.facing = "left"
|
||||
billsHouseEmotion(game, ow, npc, "EXCLAMATION_BUBBLE")
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- PikachuWalksToNurseJoy (engine/pikachu/pikachu_emotions.asm, run by
|
||||
-- engine/events/pokecenter.asm once the heal is accepted): the companion
|
||||
-- looks up ($36) and hops onto the Poke Center counter. The original
|
||||
-- picks one of three movement scripts by where it stands -- below the
|
||||
-- player (.PikaMovementData1: walk up left, hop up right), left of it
|
||||
-- (.PikaMovementData2: hop up right) or right of it (.PikaMovementData3:
|
||||
-- hop up left) -- and all three land on the counter tile directly in
|
||||
-- front of the player, so the port animates that one hop. Pikachu
|
||||
-- already above the player yields zero movement bytes: no beat (#417).
|
||||
-- ---------------------------------------------------------------------
|
||||
|
||||
local HOP_FRAMES = 32 -- the port's ledge-hop arc (Player:pose hopTotal)
|
||||
|
||||
function PikachuFollower.hopToCounter(ow, done)
|
||||
local npc = GameVersion.isYellow() and findFollower(ow) or nil
|
||||
local p = ow.player
|
||||
local cx, cy = p:facingCell()
|
||||
-- the nurse is talked to across a counter tile (OverworldState:interact);
|
||||
-- anything else is the .pikachu_above_player no-op path
|
||||
if not npc or p.facing ~= "up" or not ow.map:isCounterCell(cx, cy) then
|
||||
if done then done() end
|
||||
return
|
||||
end
|
||||
npc.goalX, npc.goalY = nil, nil
|
||||
npc.targetX, npc.targetY = nil, nil
|
||||
npc.moving, npc.progress, npc.hopStep = false, 0, nil
|
||||
npc.idle = nil
|
||||
npc.facing = "up" -- $36, look up
|
||||
ow.pikaHop = {
|
||||
npc = npc, frames = 0, cellX = cx, cellY = cy, onDone = done,
|
||||
fromX = npc.px, fromY = npc.py, toX = cx * 16, toY = cy * 16,
|
||||
}
|
||||
end
|
||||
|
||||
-- One frame of that hop. OverworldState:update holds the world for it the
|
||||
-- way it holds for the heal machine (only the top state updates, so this
|
||||
-- has to sit between the two text boxes); the arc matches Player:pose's
|
||||
-- ledge hop -- a 10px sine over 32 frames.
|
||||
function PikachuFollower.updateHop(ow)
|
||||
local h = ow.pikaHop
|
||||
if not h then return end
|
||||
h.frames = h.frames + 1
|
||||
local t = math.min(1, h.frames / HOP_FRAMES)
|
||||
h.npc.px = h.fromX + (h.toX - h.fromX) * t
|
||||
h.npc.py = h.fromY + (h.toY - h.fromY) * t
|
||||
- math.floor(10 * math.sin(t * math.pi) + 0.5)
|
||||
if h.frames < HOP_FRAMES then return end
|
||||
h.npc.cellX, h.npc.cellY = h.cellX, h.cellY
|
||||
h.npc.px, h.npc.py = h.toX, h.toY
|
||||
ow.pikaHop = nil
|
||||
-- the player has not moved, so the trail restarts under his feet and the
|
||||
-- follower only steps back off the counter once he walks away
|
||||
ow.pikachuTrail = { x = ow.player.cellX, y = ow.player.cellY }
|
||||
if h.onDone then h.onDone() end
|
||||
end
|
||||
|
||||
-- Disable/EnablePikachuOverworldSpriteDrawing around the healing machine
|
||||
-- (engine/events/pokecenter.asm): Pikachu goes behind the counter with the
|
||||
-- party and comes back standing on it, facing the player -- the respawn is
|
||||
-- wPikachuSpawnState = 5, which is .above_player in pikachu_follow.asm,
|
||||
-- followed by `lb bc, 15, 0` (sprite struct 15 is Pikachu, image index 0
|
||||
-- is facing down). ow.entities is the draw list and ow.npcs the update
|
||||
-- list, so dropping it from entities alone hides it in place (#417).
|
||||
function PikachuFollower.setVisible(ow, visible)
|
||||
local npc = findFollower(ow)
|
||||
if not npc then return end
|
||||
for i, e in ipairs(ow.entities or {}) do
|
||||
if e == npc then table.remove(ow.entities, i) break end
|
||||
end
|
||||
if visible then
|
||||
npc.facing = "down"
|
||||
table.insert(ow.entities, npc)
|
||||
end
|
||||
end
|
||||
|
||||
-- npc the player is facing, when it is the follower (interact hook)
|
||||
function PikachuFollower.at(ow, cx, cy)
|
||||
local npc = findFollower(ow)
|
||||
|
||||
+50
-8
@@ -4,6 +4,7 @@
|
||||
|
||||
local Collision = require("src.world.Collision")
|
||||
local FieldDefaults = require("src.world.FieldDefaults")
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
local SpriteRenderer = require("src.render.SpriteRenderer")
|
||||
|
||||
@@ -41,6 +42,20 @@ function Player.new(data, cx, cy, facing)
|
||||
local ok, img = pcall(love.graphics.newImage, fx.shadow.path)
|
||||
self.shadowImg = ok and img or nil
|
||||
end
|
||||
-- FishingAnim (engine/overworld/player_animations.asm) patches tiles
|
||||
-- $02/$06/$0a -- the bottom tile row of each standing frame -- with
|
||||
-- RedFishingTiles before it parks the rod OAM, so the rod stroke meets a
|
||||
-- pair of hands instead of ending in mid air (#384)
|
||||
if fx then
|
||||
local function posePath(name)
|
||||
local def = fx[name]
|
||||
return def and def.path or nil
|
||||
end
|
||||
local pose = { down = posePath("redFishFront"), up = posePath("redFishBack") }
|
||||
pose.left = posePath("redFishSide")
|
||||
pose.right = pose.left -- the side pose mirrors like the sprite (OAM_XFLIP)
|
||||
if pose.down or pose.up or pose.left then self.fishTiles = pose end
|
||||
end
|
||||
self.cellX, self.cellY = cx, cy
|
||||
self.px, self.py = cx * 16, cy * 16
|
||||
self.facing = facing or "down"
|
||||
@@ -230,24 +245,51 @@ function Player:pose()
|
||||
py = py - math.floor((total - self.spinFrames) * 24 / total)
|
||||
end
|
||||
end
|
||||
local sprite = (self.surfing and self.surfSprite)
|
||||
-- RodResponse (engine/items/item_effects.asm) zeroes wWalkBikeSurfState
|
||||
-- across FishingAnim, so casting from the water shows the on-foot sheet
|
||||
local sprite = (self.fishing and self.sprite)
|
||||
or (self.surfing and self.surfSprite)
|
||||
or (self.onBike and self.bikeSprite) or self.sprite
|
||||
return sprite, self.px, py, facing, phase, flip, hopping
|
||||
end
|
||||
|
||||
function Player:draw(camX, camY)
|
||||
local sprite, px, py, facing, phase, flip, hopping = self:pose()
|
||||
-- the shadow stays on the ground under the jumper: one 8x8 tile
|
||||
-- mirrored into a 2x2 block (normal/XFLIP/YFLIP/both) whose top-left
|
||||
-- is 8px below the sprite's standing top-left (LoadHoppingShadowOAM +
|
||||
-- LedgeHoppingShadowOAMBlock, engine/overworld/ledges.asm)
|
||||
-- the shadow stays on the ground under the jumper, mirrored out of the
|
||||
-- single 8x8 tile the ROM stores -- but the two engines lay it out
|
||||
-- differently, and their shadow.png tiles differ to match.
|
||||
-- RED/BLUE: a 2x2 block (normal/XFLIP/YFLIP/both) whose top-left sits
|
||||
-- 8px below the sprite's standing top-left (LoadHoppingShadowOAM +
|
||||
-- LedgeHoppingShadowOAMBlock at "lb bc, $54, $48",
|
||||
-- engine/overworld/ledges.asm); its tile is blank above the bottom
|
||||
-- four rows, so the four copies make one 16x16 ellipse.
|
||||
-- YELLOW: a single 16x8 row 4px lower. Its LoadHoppingShadowOAM
|
||||
-- copies only two entries (LedgeHoppingShadowOAM: dbsprite 9,11 and
|
||||
-- dbsprite 10,11 OAM_XFLIP, raw OAM y=88 against RED's $54=84) and
|
||||
-- parks sprites 38/39 offscreen at y=$a0, because its tile is a
|
||||
-- full-height half-ellipse that already fills the row. Mirroring
|
||||
-- that tile downward stacked a second blob under the first (#408).
|
||||
if hopping and self.shadowImg then
|
||||
local yellow = GameVersion.isYellow()
|
||||
local sx = math.floor(self.px - camX)
|
||||
local sy = math.floor(self.py - camY) - 4 + 8
|
||||
local sy = math.floor(self.py - camY) - 4 + 8 + (yellow and 4 or 0)
|
||||
love.graphics.draw(self.shadowImg, sx, sy)
|
||||
love.graphics.draw(self.shadowImg, sx + 16, sy, 0, -1, 1)
|
||||
love.graphics.draw(self.shadowImg, sx, sy + 16, 0, 1, -1)
|
||||
love.graphics.draw(self.shadowImg, sx + 16, sy + 16, 0, -1, -1)
|
||||
if not yellow then
|
||||
love.graphics.draw(self.shadowImg, sx, sy + 16, 0, 1, -1)
|
||||
love.graphics.draw(self.shadowImg, sx + 16, sy + 16, 0, -1, -1)
|
||||
end
|
||||
end
|
||||
-- Fishing pose: the standing frame with its bottom tile row swapped for
|
||||
-- RedFishingTiles, which is where the hands and the near half of the rod
|
||||
-- live; the far half is the rod OAM OverworldState draws (FishingRodOAM,
|
||||
-- engine/overworld/player_animations.asm) -- #384
|
||||
local fishTile = self.fishing and self.fishTiles and self.fishTiles[facing]
|
||||
if fishTile then
|
||||
sprite:draw(px, py, camX, camY, facing, 0, false, true)
|
||||
sprite:drawTile(fishTile, math.floor(px - camX),
|
||||
math.floor(py - camY) - 4 + 8, facing == "right")
|
||||
return
|
||||
end
|
||||
sprite:draw(px, py, camX, camY, facing, phase, flip)
|
||||
end
|
||||
|
||||
@@ -0,0 +1,295 @@
|
||||
-- Driver: a POTION used in battle prints, moves the HUD bar and spends the
|
||||
-- turn (#379). pokered engine/items/item_effects.asm:1-3 leaves
|
||||
-- wActionResultOrTookBattleTurn at its success value, and ItemUseMedicine only
|
||||
-- clears it on .healingItemNoEffect (:1241), so a medicine that lands costs
|
||||
-- the turn; the party-menu bar fill is the field case (#252). Data half:
|
||||
-- tests/parity_battle_item_turn.lua. Never under POKEPORT_SPEED: fast-forward
|
||||
-- desynchronizes SFX_HEAL_HP from the bar.
|
||||
-- POKEPORT_DRIVER=tests/drivers/battle_item_turn_bug379_test.lua POKEPORT_IDENTITY=bug379 POKEPORT_TOUCH=0 POKEPORT_VERSION=red love .
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
local Bag = require("src.inventory.Bag")
|
||||
local BattleState = require("src.battle.BattleState")
|
||||
local ItemEffects = require("src.inventory.ItemEffects")
|
||||
local PartyMenu = require("src.ui.PartyMenu")
|
||||
local TextBox = require("src.render.TextBox")
|
||||
|
||||
local pass, fail = 0, 0
|
||||
local function check(label, ok)
|
||||
if ok then pass = pass + 1 else fail = fail + 1 end
|
||||
U.log(ok and "PASS" or "FAIL", label)
|
||||
return ok
|
||||
end
|
||||
local function top() return game.stack:top() end
|
||||
local function isPicker(s)
|
||||
return s ~= nil and (s.screenId == "PartyMenu" or getmetatable(s) == PartyMenu)
|
||||
end
|
||||
local function isBox(s) return getmetatable(s) == TextBox end
|
||||
local function isBag(s) return s ~= nil and s.screenId == "BagMenu" end
|
||||
local function inStack(pred)
|
||||
for _, s in ipairs(game.stack.states or {}) do
|
||||
if pred(s) then return true end
|
||||
end
|
||||
return false
|
||||
end
|
||||
local function boxText(s)
|
||||
local out = {}
|
||||
for _, page in ipairs(s.pages or {}) do
|
||||
for _, line in ipairs(page) do out[#out + 1] = line end
|
||||
end
|
||||
return table.concat(out, " ")
|
||||
end
|
||||
|
||||
-- ---- preconditions -----------------------------------------------------
|
||||
U.log("#379 in-battle medicine: machine checks")
|
||||
check("POTION takes the HP-medicine path", ItemEffects.healsHP("POTION") == true)
|
||||
check("ANTIDOTE does not (status cure, always spent the turn)",
|
||||
ItemEffects.healsHP("ANTIDOTE") ~= true)
|
||||
for _, id in ipairs({ "POTION", "SUPER_POTION", "ANTIDOTE" }) do
|
||||
check(id .. " resolves in the item table", game.data.items[id] ~= nil)
|
||||
end
|
||||
check("SFX_HEAL_HP is in the generated audio",
|
||||
game.data.audio and game.data.audio.sfx
|
||||
and game.data.audio.sfx.Heal_HP ~= nil)
|
||||
check("BattleState:itemUsed exists (queues the foe's turn)",
|
||||
type(BattleState.itemUsed) == "function")
|
||||
|
||||
local vol = game.save.options and game.save.options.sfxVol
|
||||
if vol == 0 then
|
||||
U.log("sfxVol is 0: the heal chime will be SILENT, raise it in OPTION first")
|
||||
else
|
||||
U.log("sfxVol", tostring(vol), "-- expect the heal chime under the bar drain")
|
||||
end
|
||||
|
||||
-- use() must hand back the pre-heal HP; without it neither bar has a start
|
||||
do
|
||||
local scratch = Pokemon.new(game.data, "BULBASAUR", 20)
|
||||
scratch.hp = 4
|
||||
local scratchSave = { party = { scratch }, inventory = {}, flags = {},
|
||||
pokedex = { seen = {}, owned = {} } }
|
||||
local result, msgs, extra = ItemEffects.use(game.data, scratchSave,
|
||||
"POTION", scratch)
|
||||
check("POTION on a hurt mon reports consumed", result == "consumed")
|
||||
check("...and hands back the pre-heal HP",
|
||||
type(extra) == "table" and extra.healedFrom == 4)
|
||||
check("...with the restored-HP message",
|
||||
type(msgs) == "table" and type(msgs[1]) == "string"
|
||||
and msgs[1]:find("restored", 1, true) ~= nil)
|
||||
end
|
||||
|
||||
-- ---- fixture -----------------------------------------------------------
|
||||
local lead = Pokemon.new(game.data, "CHARIZARD", 50)
|
||||
local second = Pokemon.new(game.data, "PIKACHU", 30)
|
||||
lead.hp = 12
|
||||
game.save.party = { lead, second }
|
||||
game.save.player.name = "RED"
|
||||
for _, id in ipairs({ "POTION", "SUPER_POTION", "ANTIDOTE" }) do
|
||||
Bag.add(game.save, id, 9)
|
||||
end
|
||||
|
||||
-- ROUTE_1 is open field with tall grass either side of the path
|
||||
-- (data/generated/maps.lua ROUTE_1); the cell comes off the loaded map so a
|
||||
-- map edit degrades to somewhere else in the grass instead of a wall.
|
||||
U.teleport(game, "ROUTE_1", 5, 5, "down")
|
||||
U.wait(10)
|
||||
local ow = game.overworld
|
||||
local map = ow.map
|
||||
local gx, gy
|
||||
if not map:isWalkableCell(5, 5) then
|
||||
for cy = 0, map.heightCells - 1 do
|
||||
for cx = 0, map.widthCells - 1 do
|
||||
if map:isGrassCell(cx, cy) and map:isWalkableCell(cx, cy) then
|
||||
gx, gy = cx, cy
|
||||
break
|
||||
end
|
||||
end
|
||||
if gx then break end
|
||||
end
|
||||
if gx then
|
||||
U.teleport(game, "ROUTE_1", gx, gy, "down")
|
||||
U.wait(10)
|
||||
ow = game.overworld
|
||||
end
|
||||
end
|
||||
check("player is standing on a walkable ROUTE_1 cell",
|
||||
ow.map:isWalkableCell(ow.player.cellX, ow.player.cellY))
|
||||
|
||||
-- ---- navigation --------------------------------------------------------
|
||||
local function mashUntil(cond, max)
|
||||
for _ = 1, max or 120 do
|
||||
if cond() then return true end
|
||||
U.tap(game, "a")
|
||||
U.wait(4)
|
||||
end
|
||||
return cond()
|
||||
end
|
||||
|
||||
-- FIGHT PKMN / ITEM RUN (BattleState.menuIndex): down from FIGHT is ITEM
|
||||
local function openBagFrom(battle)
|
||||
for _ = 1, 30 do
|
||||
if battle.menuIndex == 3 then break end
|
||||
U.tap(game, battle.menuIndex > 2 and "up" or "down")
|
||||
U.wait(4)
|
||||
end
|
||||
if battle.menuIndex ~= 3 then return nil, "ITEM cell unreachable" end
|
||||
U.tap(game, "a")
|
||||
U.wait(20)
|
||||
local bag = top()
|
||||
if not isBag(bag) then return nil, "bag never opened" end
|
||||
return bag
|
||||
end
|
||||
|
||||
local function cursorTo(menu, want)
|
||||
for _ = 1, 40 do
|
||||
if not menu or menu.index == want then return menu and menu.index == want end
|
||||
U.tap(game, menu.index < want and "down" or "up")
|
||||
U.wait(3)
|
||||
end
|
||||
return menu.index == want
|
||||
end
|
||||
|
||||
local function chooseItem(bag, id)
|
||||
local row
|
||||
for i, r in ipairs(bag.items or {}) do
|
||||
if r.value == id then row = i break end
|
||||
end
|
||||
if not row or not cursorTo(bag, row) then return false end
|
||||
U.tap(game, "a") -- in battle A uses it straight away, no USE / TOSS
|
||||
U.wait(16)
|
||||
return true
|
||||
end
|
||||
|
||||
local function newFight()
|
||||
local battle = BattleState.newWild(game, "PIDGEY", 8)
|
||||
battle.onFinish = function() end
|
||||
game.overworld:pushBattle(battle)
|
||||
mashUntil(function() return battle.phase == "menu" end)
|
||||
return battle
|
||||
end
|
||||
|
||||
-- ======== scripted run: POTION on the hurt lead ==========================
|
||||
U.log("#379 scripted run: POTION on a 12 HP CHARIZARD, mid-battle")
|
||||
local battle = newFight()
|
||||
check("the wild battle reached its FIGHT menu", battle.phase == "menu")
|
||||
|
||||
local turns, ends = 0, 0
|
||||
local realAction, realEnd = battle.executeAction, battle.endOfTurn
|
||||
battle.executeAction = function(self, ...)
|
||||
turns = turns + 1
|
||||
return realAction(self, ...)
|
||||
end
|
||||
battle.endOfTurn = function(self, ...)
|
||||
ends = ends + 1
|
||||
return realEnd(self, ...)
|
||||
end
|
||||
|
||||
local bag, why = openBagFrom(battle)
|
||||
check("ITEM opened the bag" .. (why and (" (" .. why .. ")") or ""), bag ~= nil)
|
||||
if bag then
|
||||
U.shot(game, DIR .. "/bug379_bag_in_battle.png")
|
||||
check("POTION chosen from the bag", chooseItem(bag, "POTION"))
|
||||
local picker = top()
|
||||
check("the party picker opened to pick a target", isPicker(picker))
|
||||
if isPicker(picker) then
|
||||
check("keepOpen is off in battle (the fill is the field case, #252)",
|
||||
picker.keepOpen ~= true)
|
||||
cursorTo(picker, 1)
|
||||
U.tap(game, "a")
|
||||
U.wait(10)
|
||||
|
||||
check("the mon was healed", lead.hp > 12)
|
||||
check("no party-menu bar fill was started in battle", picker.heal == nil)
|
||||
check("the picker popped itself", not inStack(isPicker))
|
||||
check("the bag list closed underneath it", not inStack(isBag))
|
||||
|
||||
-- THE DEFECT: the animate branch swallowed this message, and with it the
|
||||
-- itemUsed tail that spends the turn
|
||||
for _ = 1, 60 do
|
||||
if isBox(top()) then break end
|
||||
U.wait(1)
|
||||
end
|
||||
local box = top()
|
||||
check("the restored-HP message printed (#379)", isBox(box))
|
||||
if isBox(box) then
|
||||
U.wait(50)
|
||||
local said = boxText(box)
|
||||
U.log("box reads:", said)
|
||||
check("...and it is the restored-HP line",
|
||||
said:find("restored", 1, true) ~= nil)
|
||||
U.shot(game, DIR .. "/bug379_message_in_battle.png")
|
||||
end
|
||||
|
||||
-- the turn: drain, the foe's move, end-of-turn, back to the menu
|
||||
for _ = 1, 400 do
|
||||
if battle.phase == "menu" and #battle.queue == 0 then break end
|
||||
U.tap(game, "a")
|
||||
U.wait(4)
|
||||
end
|
||||
check("the foe took its turn after the item (#379)", turns >= 1)
|
||||
check("end-of-turn effects ran (#379)", ends >= 1)
|
||||
check("and the FIGHT menu came back", battle.phase == "menu")
|
||||
U.shot(game, DIR .. "/bug379_back_on_menu.png")
|
||||
end
|
||||
end
|
||||
|
||||
-- ======== control: a refused POTION is free =============================
|
||||
U.log("#379 control: a POTION on a full-HP mon is refused, turn NOT spent")
|
||||
lead.hp = lead.stats.hp
|
||||
local freeTurns = 0
|
||||
battle.executeAction = function(self, ...)
|
||||
freeTurns = freeTurns + 1
|
||||
return realAction(self, ...)
|
||||
end
|
||||
local held = game.save.inventory.POTION or 0
|
||||
local bag2 = openBagFrom(battle)
|
||||
if check("the bag reopened for the refusal", bag2 ~= nil) then
|
||||
chooseItem(bag2, "POTION")
|
||||
local picker = top()
|
||||
if isPicker(picker) then
|
||||
cursorTo(picker, 1)
|
||||
U.tap(game, "a")
|
||||
U.wait(12)
|
||||
end
|
||||
for _ = 1, 60 do
|
||||
if isBox(top()) then break end
|
||||
U.wait(1)
|
||||
end
|
||||
local box = top()
|
||||
if check("the refusal printed", isBox(box)) then
|
||||
U.wait(40)
|
||||
check("...as \"It won't have any effect.\"",
|
||||
boxText(box):find("effect", 1, true) ~= nil)
|
||||
U.shot(game, DIR .. "/bug379_refused.png")
|
||||
end
|
||||
check("a refused item costs no turn (item_effects.asm:1241)", freeTurns == 0)
|
||||
check("and the POTION was not consumed",
|
||||
(game.save.inventory.POTION or 0) == held)
|
||||
end
|
||||
|
||||
U.log(("machine checks: %d passed, %d failed"):format(pass, fail))
|
||||
|
||||
-- ---- re-arm and hand the pad over --------------------------------------
|
||||
for _ = 1, 200 do
|
||||
if battle.phase == "menu" and #battle.queue == 0 then break end
|
||||
U.tap(game, "a")
|
||||
U.wait(4)
|
||||
end
|
||||
lead.hp = 12
|
||||
local handoff = openBagFrom(battle)
|
||||
if handoff then
|
||||
for i, r in ipairs(handoff.items or {}) do
|
||||
if r.value == "POTION" then cursorTo(handoff, i) break end
|
||||
end
|
||||
end
|
||||
|
||||
U.log("The ITEMS list is open mid-battle with the cursor on POTION. Press A,")
|
||||
U.log("pick CHARIZARD: the picker and the list both close, \"CHARIZARD's HP was")
|
||||
U.log("restored!\" prints with the heal chime, the HUD bar climbs, then PIDGEY")
|
||||
U.log("attacks before FIGHT comes back. #379 was no message and a free heal.")
|
||||
|
||||
while true do
|
||||
coroutine.yield()
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,324 @@
|
||||
-- Driver: the applying-attack shake pokered plays after a move's own
|
||||
-- animation (#354). PlayApplyingAttackAnimation dispatches wAnimationType
|
||||
-- 1..6 through AnimationTypePointerTable (engine/battle/animations.asm:475);
|
||||
-- only 1 (vertical shake) and 4 (blink the enemy pic) were wired, so an
|
||||
-- added-effect move like BUBBLEBEAM blinked instead of shaking sideways and a
|
||||
-- status move showed nothing. Wiring half: tests/parity_applying_attack_anim.lua.
|
||||
-- Never under POKEPORT_SPEED: fast-forward desynchronizes the damage sound.
|
||||
-- POKEPORT_DRIVER=tests/drivers/battle_shake_bug354_test.lua POKEPORT_IDENTITY=bug354 POKEPORT_TOUCH=0 SHOT_DIR=/tmp/shots love .
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
local BattleState = require("src.battle.BattleState")
|
||||
|
||||
local pass, fail = 0, 0
|
||||
local function check(label, ok)
|
||||
if ok then pass = pass + 1 else fail = fail + 1 end
|
||||
U.log(ok and "PASS" or "FAIL", label)
|
||||
return ok
|
||||
end
|
||||
|
||||
-- pokered core.asm:3159 / effects.asm:1448: a player damaging move with an
|
||||
-- added effect is type 5, without one type 4, and a primary status effect
|
||||
-- that ends in PlayCurrentMoveAnimation2 is type 6. `frames` counts only
|
||||
-- the frames the screen sits off-centre: b=2 out for 5 frames twice, and
|
||||
-- the slow creep's five off-centre steps of 2 frames, twice over.
|
||||
local MOVES = {
|
||||
{ slot = 1, id = "BUBBLEBEAM", want = 5, peak = 2, frames = 10 },
|
||||
{ slot = 4, id = "TACKLE", want = 4 },
|
||||
{ slot = 3, id = "GROWL", want = 6, peak = 3, frames = 20 },
|
||||
{ slot = 2, id = "HYPNOSIS", want = 6, peak = 3, frames = 20 },
|
||||
}
|
||||
|
||||
U.log("#354 applying-attack shake: machine checks")
|
||||
for _, m in ipairs(MOVES) do
|
||||
check(m.id .. " is in the move table", game.data.moves[m.id] ~= nil)
|
||||
end
|
||||
local anims = game.data.battle_anims and game.data.battle_anims.moveAnims
|
||||
check("battle_anims carries BUBBLEBEAM's own animation",
|
||||
anims ~= nil and anims.BUBBLEBEAM ~= nil)
|
||||
check("...and HYPNOSIS's", anims ~= nil and anims.HYPNOSIS ~= nil)
|
||||
check("BattleState:applyHitFx exists", type(BattleState.applyHitFx) == "function")
|
||||
check("animations are on in OPTIONS (a shake is gated on them)",
|
||||
game.save.options.animations ~= false)
|
||||
|
||||
local vol = game.save.options and game.save.options.sfxVol
|
||||
if vol == 0 then
|
||||
U.log("sfxVol is 0: the damage thud under the shake will be SILENT,")
|
||||
U.log("raise it in OPTION before judging the sound")
|
||||
else
|
||||
U.log("sfxVol", tostring(vol), "-- the damage thud opens types 1, 2, 4 and 5;")
|
||||
U.log("types 3 and 6 (the slow creeps) are silent on purpose")
|
||||
end
|
||||
|
||||
-- ---- fixture -----------------------------------------------------------
|
||||
local squirtle = Pokemon.new(game.data, "SQUIRTLE", 30)
|
||||
squirtle.moves = {}
|
||||
for _, m in ipairs(MOVES) do
|
||||
squirtle.moves[m.slot] = { id = m.id, pp = game.data.moves[m.id].pp }
|
||||
end
|
||||
game.save.party = { squirtle }
|
||||
game.save.player.name = "RED"
|
||||
|
||||
-- data/generated/maps.lua ROUTE_1: (5, 5) is open walkable ground, and the
|
||||
-- battle is pushed straight in rather than encountered
|
||||
U.teleport(game, "ROUTE_1", 5, 5, "down")
|
||||
U.wait(10)
|
||||
local ow = game.overworld
|
||||
if not ow.map:isWalkableCell(5, 5) then
|
||||
-- a map edit moved the path: take the nearest walkable cell instead
|
||||
local sx, sy
|
||||
for r = 1, 6 do
|
||||
for dy = -r, r do
|
||||
for dx = -r, r do
|
||||
if not sx and ow.map:isWalkableCell(5 + dx, 5 + dy) then
|
||||
sx, sy = 5 + dx, 5 + dy
|
||||
end
|
||||
end
|
||||
end
|
||||
if sx then break end
|
||||
end
|
||||
if sx then
|
||||
U.log("(5, 5) is blocked, standing on", sx, sy)
|
||||
U.teleport(game, "ROUTE_1", sx, sy, "down")
|
||||
U.wait(10)
|
||||
ow = game.overworld
|
||||
end
|
||||
end
|
||||
check("the overworld is up on ROUTE_1", ow ~= nil)
|
||||
|
||||
local battle = BattleState.newWild(game, "PIDGEY", 20)
|
||||
battle.onFinish = function() end
|
||||
ow:pushBattle(battle)
|
||||
for _ = 1, 400 do
|
||||
if game.stack:top() == battle and (battle.introSlide or 0) == 0 then break end
|
||||
U.wait(1)
|
||||
end
|
||||
check("the battle reached the screen", game.stack:top() == battle)
|
||||
|
||||
-- a foe that survives four turns: every type needs its own turn to show
|
||||
battle.enemy.mon.stats.hp = 400
|
||||
battle.enemy.mon.hp = 400
|
||||
battle.enemy.shownHP = 400
|
||||
|
||||
-- A PIDGEY that level knows WHIRLWIND, and in a wild battle that blows the
|
||||
-- player out of the fight (result "run") the moment the foe picks it, which
|
||||
-- takes the untested types down with it. Drop it and the foe keeps the
|
||||
-- GUST / SAND-ATTACK / QUICK ATTACK the hand-off notes below describe.
|
||||
-- curMoves aliases mon.moves, so one pass covers both.
|
||||
for i = #battle.enemy.mon.moves, 1, -1 do
|
||||
local id = battle.enemy.mon.moves[i].id
|
||||
if id == "WHIRLWIND" or id == "ROAR" or id == "TELEPORT" then
|
||||
table.remove(battle.enemy.mon.moves, i)
|
||||
end
|
||||
end
|
||||
|
||||
-- ---- watch the fx layer ------------------------------------------------
|
||||
-- one entry per applying-attack row, labelled with the move that queued it
|
||||
local rows, cur, using = {}, nil, nil
|
||||
local realPerform, realFx = battle.performMove, battle.applyHitFx
|
||||
battle.performMove = function(self, user, target, moveInst, ...)
|
||||
using = { id = moveInst and moveInst.id, isPlayer = user.isPlayer }
|
||||
return realPerform(self, user, target, moveInst, ...)
|
||||
end
|
||||
battle.applyHitFx = function(self, hit)
|
||||
cur = { move = using and using.id, isPlayer = using and using.isPlayer,
|
||||
animType = hit.animType, frames = 0, peakX = 0, peakY = 0 }
|
||||
rows[#rows + 1] = cur
|
||||
realFx(self, hit)
|
||||
-- a spent blink table stays on fx with frames = 0, so freshness matters
|
||||
local bl = self.fx and self.fx.blink
|
||||
cur.blinked = bl ~= nil and bl.frames > 0
|
||||
end
|
||||
|
||||
local function sample()
|
||||
local fx = battle.fx
|
||||
if not (cur and fx) then return end
|
||||
local dx, dy = math.abs(fx.shakeX or 0), math.abs(fx.shakeY or 0)
|
||||
if dx > 0 or dy > 0 then
|
||||
cur.frames = cur.frames + 1
|
||||
cur.peakX = math.max(cur.peakX, dx)
|
||||
cur.peakY = math.max(cur.peakY, dy)
|
||||
end
|
||||
end
|
||||
|
||||
-- step n frames, sampling every one, pressing A every `mash` frames -- but
|
||||
-- only while a box is up. An A that lands on the FIGHT menu opens the move
|
||||
-- list and fires the highlighted move, so mashing past the end of a turn
|
||||
-- starts extra turns behind the driver's back and eventually ends the battle.
|
||||
local function pump(n, mash, stop)
|
||||
for i = 1, n do
|
||||
if mash and i % mash == 0 and battle.phase == "messages" then
|
||||
table.insert(game.input.pressQueue, "a")
|
||||
end
|
||||
U.wait(1)
|
||||
game.input.state.a = false
|
||||
sample()
|
||||
if stop and stop() then return end
|
||||
end
|
||||
end
|
||||
|
||||
-- a full turn is the player's announcement, animation and damage drain plus
|
||||
-- the foe's, so budget frames for the slowest of them rather than the typical
|
||||
-- one: a short budget hands the next move a battle still in `messages`
|
||||
local function toMenu()
|
||||
pump(1200, 6, function() return battle.phase == "menu" and #battle.queue == 0 end)
|
||||
return battle.phase == "menu"
|
||||
end
|
||||
|
||||
-- The move list is a flat column in the classic layout, but the wide one
|
||||
-- lays the four slots out 2x2 (WideBattle.navigate): up/down swap rows and
|
||||
-- left/right swap columns, so walking down the list there never reaches
|
||||
-- slots 2 and 4. Return the press that closes the gap in either layout.
|
||||
local function towardSlot(slot)
|
||||
if battle.moveIndex == slot then return nil end
|
||||
if battle:wideLayout() then
|
||||
local row, col = math.floor((battle.moveIndex - 1) / 2), (battle.moveIndex - 1) % 2
|
||||
local wantRow, wantCol = math.floor((slot - 1) / 2), (slot - 1) % 2
|
||||
if row ~= wantRow then return row < wantRow and "down" or "up" end
|
||||
return col < wantCol and "right" or "left"
|
||||
end
|
||||
return battle.moveIndex < slot and "down" or "up"
|
||||
end
|
||||
|
||||
-- FIGHT is menuIndex 1 of the 2x2 grid; A opens moveSelect, where the presses
|
||||
-- above walk the slots. A press that lands on a frame the battle is not
|
||||
-- reading input is simply lost, so every step retries instead of assuming.
|
||||
local function useMove(slot)
|
||||
for _ = 1, 80 do
|
||||
if battle.phase == "moveSelect" then break end
|
||||
if battle.phase == "menu" then
|
||||
if battle.menuIndex ~= 1 then
|
||||
U.tap(game, battle.menuIndex > 2 and "up" or "left")
|
||||
else
|
||||
U.tap(game, "a")
|
||||
end
|
||||
else
|
||||
-- the last turn's boxes are still up: keep turning pages
|
||||
U.tap(game, "a")
|
||||
end
|
||||
U.wait(4)
|
||||
end
|
||||
if battle.phase ~= "moveSelect" then return false end
|
||||
for _ = 1, 30 do
|
||||
local dir = towardSlot(slot)
|
||||
if not dir then break end
|
||||
U.tap(game, dir)
|
||||
U.wait(3)
|
||||
end
|
||||
if battle.moveIndex ~= slot then return false end
|
||||
for _ = 1, 20 do
|
||||
if battle.phase ~= "moveSelect" then return true end
|
||||
U.tap(game, "a")
|
||||
U.wait(3)
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
local function lastRowFor(id)
|
||||
for i = #rows, 1, -1 do
|
||||
if rows[i].move == id and rows[i].isPlayer then return rows[i] end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
local SHOTS = { BUBBLEBEAM = "bug354_bubblebeam_shake.png",
|
||||
HYPNOSIS = "bug354_hypnosis_creep.png",
|
||||
TACKLE = "bug354_tackle_blink.png" }
|
||||
|
||||
check("the battle reached its FIGHT menu", toMenu())
|
||||
|
||||
for _, m in ipairs(MOVES) do
|
||||
-- catch the shake mid-flight for the screenshot: the offset is live for
|
||||
-- only a couple of dozen frames
|
||||
local shotAt
|
||||
-- HYPNOSIS lands 6 times in 10 and TACKLE 19 in 20, and a miss plays no
|
||||
-- applying-attack animation at all, so give each move a few turns to
|
||||
-- connect instead of reading a whiffed one as a regression
|
||||
local sent, r
|
||||
for _ = 1, 8 do
|
||||
-- the foe leans on SAND-ATTACK, and stacked accuracy drops make even
|
||||
-- TACKLE whiff turn after turn, so hand the accuracy back each try
|
||||
battle.player.stages.accuracy = 0
|
||||
sent = useMove(m.slot)
|
||||
if not sent then break end
|
||||
-- A every 8 frames: the text box between the announcement and the
|
||||
-- animation waits on the button like any other
|
||||
pump(1200, 8, function()
|
||||
local live = battle.fx and (m.peak and (battle.fx.shakeX or 0) ~= 0
|
||||
or (not m.peak and battle.fx.blink
|
||||
and battle.fx.blink.frames > 0))
|
||||
if not shotAt and live and SHOTS[m.id] then
|
||||
shotAt = true
|
||||
U.shot(game, DIR .. "/" .. SHOTS[m.id])
|
||||
end
|
||||
local row = lastRowFor(m.id)
|
||||
-- back at the menu with the row in hand: the turn is over either way
|
||||
if row and battle.phase == "menu" and #battle.queue == 0 then return true end
|
||||
return row ~= nil and battle.fx.shakeProg == nil
|
||||
and (row.frames > 0 or row.blinked)
|
||||
end)
|
||||
toMenu()
|
||||
r = lastRowFor(m.id)
|
||||
if r then break end
|
||||
U.log(" " .. m.id .. " did not connect that turn; using it again")
|
||||
end
|
||||
check("chose " .. m.id .. " from the move menu", sent)
|
||||
if sent then
|
||||
check(m.id .. " queued an applying-attack row", r ~= nil)
|
||||
if r then
|
||||
check(("%s is animation type %d (got %s)")
|
||||
:format(m.id, m.want, tostring(r.animType)),
|
||||
r.animType == m.want)
|
||||
U.log((" %s: %d frames off-centre, peak %dpx across / %dpx down, blink %s")
|
||||
:format(m.id, r.frames, r.peakX, r.peakY, tostring(r.blinked)))
|
||||
if m.peak then
|
||||
-- the exact frame count is pinned in the parity suite; sampling from
|
||||
-- a driver misses a frame whenever the logic step outruns the render
|
||||
check(("%s moved the screen %dpx sideways"):format(m.id, m.peak),
|
||||
r.peakX == m.peak)
|
||||
check(("%s held it off-centre (%d of the routine's %d frames)")
|
||||
:format(m.id, r.frames, m.frames), r.frames > 0)
|
||||
check(m.id .. " did not blink the enemy pic instead (#354)",
|
||||
r.blinked == false)
|
||||
else
|
||||
check(m.id .. " still blinks the enemy pic and holds still",
|
||||
r.blinked == true and r.frames == 0)
|
||||
end
|
||||
end
|
||||
if SHOTS[m.id] and not shotAt and m.peak then
|
||||
U.log(" no off-centre frame to capture for " .. m.id)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- the foe's own turns: a plain damaging move is type 1, one with an added
|
||||
-- effect type 2, SAND-ATTACK type 3
|
||||
local foe = {}
|
||||
for _, r in ipairs(rows) do
|
||||
if not r.isPlayer then
|
||||
foe[#foe + 1] = ("%s type %s (%d frames, %dpx across, %dpx down)")
|
||||
:format(tostring(r.move), tostring(r.animType),
|
||||
r.frames, r.peakX, r.peakY)
|
||||
end
|
||||
end
|
||||
U.log("the foe's rows so far: " .. (#foe > 0 and table.concat(foe, "; ")
|
||||
or "none yet"))
|
||||
U.log(("machine checks: %d passed, %d failed"):format(pass, fail))
|
||||
|
||||
-- ---- hand off ----------------------------------------------------------
|
||||
U.log("The pad is yours at the FIGHT menu. Slot 1 BUBBLEBEAM: after the")
|
||||
U.log("bubbles and their white flashes the whole screen -- field, HUD, text")
|
||||
U.log("box -- snaps 2px right and home four times, with the damage thud.")
|
||||
U.log("Slot 2 HYPNOSIS and slot 3 GROWL creep it 1px at a time out to 3px")
|
||||
U.log("and back, twice, in silence. Slot 4 TACKLE is the control: the foe's")
|
||||
U.log("pic blinks and nothing moves. Let the PIDGEY hit back too -- a plain")
|
||||
U.log("move drops the screen 8px vertically, SAND-ATTACK creeps it 6px across.")
|
||||
U.log("Reference: https://youtu.be/4aBT7rjZoIE at 1:19.")
|
||||
U.log("Screenshots: " .. DIR .. "/bug354_*.png")
|
||||
|
||||
while true do
|
||||
coroutine.yield()
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,205 @@
|
||||
-- Eye/ear check on which animation BIDE plays when (#375). BideEffect ends on
|
||||
-- `add XSTATITEM_ANIM / jp PlayBattleAnimation2` (engine/battle/effects.asm:
|
||||
-- 764-789), so the storing turn is the silent X-item spiral; BIDE's own row
|
||||
-- (sound Battle_18) belongs to .UnleashEnergy (engine/battle/core.asm:3501-3529).
|
||||
-- POKEPORT_DRIVER=tests/drivers/bide_anim_bug375_test.lua POKEPORT_IDENTITY=bug375 POKEPORT_TOUCH=0 love .
|
||||
-- No POKEPORT_SPEED: fast-forward scales the logic clock only and desyncs audio.
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
local BattleState = require("src.battle.BattleState")
|
||||
local ChipAudio = require("src.core.ChipAudio")
|
||||
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
|
||||
|
||||
-- data/generated/maps.lua ROUTE_1: the open path south of the first grass
|
||||
-- patch, and pokered data/maps/objects/Route1.asm parks its youngsters at
|
||||
-- (5, 24) and (15, 13), so nobody is standing here or watching.
|
||||
local MAP = "ROUTE_1"
|
||||
local STAND = { x = 5, y = 5, facing = "down" }
|
||||
|
||||
local function check(label, ok)
|
||||
U.log(ok and "PASS" or "FAIL", label)
|
||||
return ok
|
||||
end
|
||||
|
||||
-- ---- what the ear and eye cannot check ---------------------------------
|
||||
local opts = game.save.options or {}
|
||||
local sfxVol = opts.sfxVol or 7
|
||||
if sfxVol == 0 then
|
||||
U.log("FAIL sfx volume is 0: the release hit plays no sound at all, so")
|
||||
U.log(" the half of this that is audible cannot be judged. Set SFX")
|
||||
U.log(" to 7 in OPTION first.")
|
||||
end
|
||||
check(("sfx volume %d"):format(sfxVol), sfxVol > 0)
|
||||
if opts.animations == false then
|
||||
U.log("FAIL OPTION has animations off, which skips every queued anim row")
|
||||
U.log(" (BattleState:animationsOn). Turn ANIMATION on first.")
|
||||
end
|
||||
check("battle animations are on", opts.animations ~= false)
|
||||
|
||||
local moveDef = game.data.moves.BIDE
|
||||
check("BIDE is in the move table", moveDef ~= nil)
|
||||
local anims = (game.data.battle_anims or {}).moveAnims or {}
|
||||
check("BIDE has an animation", anims.BIDE ~= nil)
|
||||
check("XSTATITEM_ANIM has an animation", anims.XSTATITEM_ANIM ~= nil)
|
||||
check("XSTATITEM_DUPLICATE_ANIM has an animation",
|
||||
anims.XSTATITEM_DUPLICATE_ANIM ~= nil)
|
||||
-- the row's sound byte is a move id; playAnimSound resolves it through
|
||||
-- moves.lua, which is where the actual sfx program name lives
|
||||
local sfxName = moveDef and moveDef.anim and moveDef.anim.sound
|
||||
check("BIDE's animation names a sound (" .. tostring(sfxName) .. ")",
|
||||
sfxName ~= nil)
|
||||
if sfxName then
|
||||
local src = ((game.data.audio or {}).sfx or {})[sfxName]
|
||||
and ChipAudio.newSfx(game.data, sfxName) or nil
|
||||
local secs = src and src:getDuration() or 0
|
||||
check(("%s synthesizes (%.2fs)"):format(sfxName, secs), secs > 0)
|
||||
end
|
||||
|
||||
-- ---- a BIDE user, parked on Route 1 ------------------------------------
|
||||
local lead = Pokemon.new(game.data, "CHARMANDER", 20)
|
||||
lead.moves = { { id = "BIDE", pp = 10 } }
|
||||
game.save.party = { lead }
|
||||
|
||||
U.teleport(game, MAP, STAND.x, STAND.y, STAND.facing)
|
||||
U.wait(15)
|
||||
local ow = game.overworld
|
||||
if not ow.map:isWalkableCell(STAND.x, STAND.y) then
|
||||
-- a map edit or a mod blocked the cell: take any free neighbour
|
||||
for _, d in ipairs({ { 0, 1 }, { 0, -1 }, { 1, 0 }, { -1, 0 } }) do
|
||||
local cx, cy = STAND.x + d[1], STAND.y + d[2]
|
||||
if ow.map:isWalkableCell(cx, cy) and not ow:npcAtCell(cx, cy) then
|
||||
U.log(("(%d, %d) is blocked, standing on"):format(STAND.x, STAND.y), cx, cy)
|
||||
U.teleport(game, MAP, cx, cy, STAND.facing)
|
||||
U.wait(15)
|
||||
ow = game.overworld
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
check("standing on " .. MAP, ow.map.id == MAP)
|
||||
|
||||
-- Every anim row this battle queues, in order. Polling the queue catches
|
||||
-- the announcement-time row performMove inserts directly as well as the
|
||||
-- ones animNext adds, which is the whole difference the fix makes.
|
||||
local function watchAnims(battle)
|
||||
local seen, mark = {}, {}
|
||||
return seen, function()
|
||||
for _, row in ipairs(battle.queue) do
|
||||
if row.anim and not mark[row] then
|
||||
mark[row] = true
|
||||
seen[#seen + 1] = row.anim
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
local function listOf(seen)
|
||||
return #seen > 0 and table.concat(seen, ", ") or "(none)"
|
||||
end
|
||||
|
||||
local function has(seen, name)
|
||||
for _, n in ipairs(seen) do
|
||||
if n == name then return true end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
-- Mash A while polling, so a row that comes and goes inside the wait is
|
||||
-- still recorded.
|
||||
local function mash(battle, poll, taps, gap)
|
||||
for _ = 1, taps do
|
||||
U.tap(battle.game, "a")
|
||||
for _ = 1, gap do poll() U.wait(1) end
|
||||
end
|
||||
end
|
||||
|
||||
-- the wipe and the send-out chain hold the battle in "intro" for a while,
|
||||
-- and the send-out text needs the A presses to advance
|
||||
local function waitPhase(battle, phase, tries, poll)
|
||||
for _ = 1, tries do
|
||||
if battle.phase == phase then return true end
|
||||
mash(battle, poll, 1, 6)
|
||||
end
|
||||
return battle.phase == phase
|
||||
end
|
||||
|
||||
-- FIGHT then the only move in the list
|
||||
local function pickBide(battle, poll)
|
||||
U.tap(battle.game, "a")
|
||||
if not waitPhase(battle, "moveSelect", 10, poll) then return false end
|
||||
U.tap(battle.game, "a")
|
||||
return true
|
||||
end
|
||||
|
||||
-- ---- player side -------------------------------------------------------
|
||||
local battle = BattleState.newWild(game, "PIDGEY", 8)
|
||||
battle.onFinish = function() end
|
||||
ow:pushBattle(battle)
|
||||
local seen, poll = watchAnims(battle)
|
||||
check("the battle reached the menu", waitPhase(battle, "menu", 60, poll))
|
||||
check("BIDE is the move on the list", pickBide(battle, poll))
|
||||
for _ = 1, 20 do poll() U.wait(1) end
|
||||
U.shot(game, DIR .. "/bug375_store_spiral.png")
|
||||
for _ = 1, 30 do poll() U.wait(1) end
|
||||
U.shot(game, DIR .. "/bug375_store_text.png")
|
||||
|
||||
check("the storing turn queued XSTATITEM_ANIM", has(seen, "XSTATITEM_ANIM"))
|
||||
check("and not BIDE's hit animation: " .. listOf(seen), not has(seen, "BIDE"))
|
||||
check("BIDE locked in for " .. tostring(battle.player.bideTurns) .. " turns",
|
||||
battle.player.bideTurns ~= nil)
|
||||
|
||||
-- ride out the locked turns: the storing text needs A presses and the menu
|
||||
-- never comes back until BIDE unleashes
|
||||
local released = false
|
||||
for _ = 1, 120 do
|
||||
U.tap(game, "a")
|
||||
for _ = 1, 6 do poll() U.wait(1) end
|
||||
if has(seen, "BIDE") then
|
||||
released = true
|
||||
break
|
||||
end
|
||||
if game.stack:top() ~= battle or battle.player.mon.hp <= 0 then break end
|
||||
end
|
||||
U.shot(game, DIR .. "/bug375_release.png")
|
||||
check("the release queued BIDE's own animation: " .. listOf(seen), released)
|
||||
for _ = 1, 60 do poll() U.wait(1) end
|
||||
|
||||
-- ---- enemy side --------------------------------------------------------
|
||||
-- PlayBattleAnimation2 adds hWhoseTurn, so the foe's storing turn is
|
||||
-- XSTATITEM_DUPLICATE_ANIM (AttackAnimationPointers[174]), never the
|
||||
-- player row.
|
||||
for _ = 1, 8 do
|
||||
if game.stack:top() == ow then break end
|
||||
game.stack:pop()
|
||||
end
|
||||
lead.hp = lead.stats.hp
|
||||
local foeBattle = BattleState.newWild(game, "RATTATA", 8)
|
||||
foeBattle.onFinish = function() end
|
||||
foeBattle.enemy.mon.moves = { { id = "BIDE", pp = 10 } }
|
||||
foeBattle.enemy.curMoves = foeBattle.enemy.mon.moves
|
||||
ow:pushBattle(foeBattle)
|
||||
local foeSeen, foePoll = watchAnims(foeBattle)
|
||||
check("the foe's battle reached the menu",
|
||||
waitPhase(foeBattle, "menu", 60, foePoll))
|
||||
pickBide(foeBattle, foePoll) -- ours does not matter here, the foe's does
|
||||
for _ = 1, 60 do
|
||||
if has(foeSeen, "XSTATITEM_DUPLICATE_ANIM") then break end
|
||||
U.tap(game, "a")
|
||||
for _ = 1, 6 do foePoll() U.wait(1) end
|
||||
end
|
||||
U.shot(game, DIR .. "/bug375_enemy_store.png")
|
||||
check("the foe's storing turn queued XSTATITEM_DUPLICATE_ANIM: "
|
||||
.. listOf(foeSeen), has(foeSeen, "XSTATITEM_DUPLICATE_ANIM"))
|
||||
|
||||
U.log("The pad is yours in a battle where both sides know only BIDE, so")
|
||||
U.log("pick FIGHT then BIDE and watch turn one: the screen palette flashes")
|
||||
U.log("white and balls spiral inward, silently, and then it says storing")
|
||||
U.log("energy. No flash on the RATTATA and no BIDE thud until the turn it")
|
||||
U.log("unleashes, where the thud and its animation come after the text and")
|
||||
U.log("before the enemy HP bar slides down. The foe's spiral looks the same.")
|
||||
|
||||
while true do
|
||||
coroutine.yield()
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,182 @@
|
||||
-- Walking into grass with no useable POKeMON must black the player out, not
|
||||
-- cancel the battle and hand the map back (#425). pokered core.asm:158-162
|
||||
-- runs .checkAnyPartyAlive after the intro and jumps to HandlePlayerBlackOut
|
||||
-- (core.asm:1145-1166, PlayerBlackedOutText2). Machine half also in
|
||||
-- tests/parity_blackout_no_party.lua. Never under POKEPORT_SPEED: the wipe,
|
||||
-- the theme change and the two text boxes come apart from each other.
|
||||
-- POKEPORT_DRIVER=tests/drivers/blackout_no_party_bug425_test.lua POKEPORT_IDENTITY=bug425 POKEPORT_TOUCH=0 POKEPORT_VERSION=red love .
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local Music = require("src.core.Music")
|
||||
local BattleState = require("src.battle.BattleState")
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
local TextBox = require("src.render.TextBox")
|
||||
local SHOT_DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
|
||||
|
||||
local MAP = "ROUTE_1"
|
||||
local pass, fail = 0, 0
|
||||
local function check(label, ok)
|
||||
if ok then pass = pass + 1 else fail = fail + 1 end
|
||||
U.log(ok and "PASS" or "FAIL", label)
|
||||
return ok
|
||||
end
|
||||
|
||||
local function waitFor(fn, limit)
|
||||
for _ = 1, limit do
|
||||
if fn() then return true end
|
||||
U.wait(1)
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
-- every song request in order, so "the battle theme was never restored"
|
||||
-- and "the map theme was never playing" read differently in the log
|
||||
local songs = {}
|
||||
local realPlay = Music.play
|
||||
Music.play = function(data, song, loop, ctx)
|
||||
songs[#songs + 1] = { song = song, frame = U.frame() }
|
||||
U.log(("frame %d play %s"):format(U.frame(), tostring(song)))
|
||||
return realPlay(data, song, loop, ctx)
|
||||
end
|
||||
local function playedAfter(song, frame)
|
||||
for _, s in ipairs(songs) do
|
||||
if s.song == song and s.frame >= frame then return s end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
local opts = game.save.options
|
||||
if (opts and opts.musicVol or 0) == 0 then
|
||||
U.log("MUSIC VOLUME IS 0: the theme swap this bug is about is silent.")
|
||||
U.log("Raise it in OPTION, quit and rerun; the log half still holds.")
|
||||
end
|
||||
if (opts and opts.sfxVol or 0) == 0 then
|
||||
U.log("WARNING sfx volume is 0: the wipe and the text beeps will be")
|
||||
U.log("silent too.")
|
||||
end
|
||||
|
||||
-- MAGIKARP with SPLASH only would still be a battle; 0 HP is the state
|
||||
-- under test, and money is the blackout's other visible half
|
||||
game.save.party = { Pokemon.new(game.data, "MAGIKARP", 10) }
|
||||
local mon = game.save.party[1]
|
||||
local full = mon.stats.hp
|
||||
mon.hp = 0
|
||||
game.save.money = 3000
|
||||
|
||||
-- Route 1's first grass row. data/generated/maps.lua holds the tiles
|
||||
-- (pokered data/maps/objects/Route1.asm has no grass in it), so the cell is
|
||||
-- checked against the loaded map and rescanned if a map edit moved it.
|
||||
local GRASS = { x = 10, y = 6 }
|
||||
U.teleport(game, MAP, GRASS.x, GRASS.y, "down")
|
||||
local ow = game.overworld
|
||||
local function grassAt(cx, cy)
|
||||
return ow.map:isGrassCell(cx, cy) and ow.map:isWalkableCell(cx, cy)
|
||||
end
|
||||
if not grassAt(GRASS.x, GRASS.y) then
|
||||
local found
|
||||
for y = 0, ow.map.heightCells - 1 do
|
||||
for x = 0, ow.map.widthCells - 1 do
|
||||
-- a neighbouring grass cell too, so the hand-off half below has
|
||||
-- somewhere to step without leaving the patch
|
||||
if grassAt(x, y) and grassAt(x + 1, y) then found = { x = x, y = y } break end
|
||||
end
|
||||
if found then break end
|
||||
end
|
||||
if found then
|
||||
U.log(("(%d, %d) is not grass any more, standing on")
|
||||
:format(GRASS.x, GRASS.y), found.x, found.y)
|
||||
GRASS = found
|
||||
U.teleport(game, MAP, found.x, found.y, "down")
|
||||
ow = game.overworld
|
||||
end
|
||||
end
|
||||
check("the player is standing in " .. MAP .. " grass",
|
||||
grassAt(GRASS.x, GRASS.y))
|
||||
|
||||
local heal = ow:healPoint()
|
||||
U.log("heal point:", heal.map, heal.x, heal.y)
|
||||
local MAP_SONG = game.data.audio.mapSongs[MAP]
|
||||
local HEAL_SONG = game.data.audio.mapSongs[heal.map]
|
||||
local WILD_SONG = game.data.audio.battle.wild
|
||||
U.log("songs:", tostring(MAP_SONG), tostring(WILD_SONG), tostring(HEAL_SONG))
|
||||
|
||||
-- the encounter OverworldState:checkEncounter would build for this cell
|
||||
local encDef = game.data.encounters[MAP]
|
||||
local slots = encDef and encDef.grass and encDef.grass.slots
|
||||
check(MAP .. " has a grass encounter table", slots ~= nil and #slots > 0)
|
||||
if not slots then
|
||||
U.log("Nothing can be encountered here; rerun after a ROM re-import.")
|
||||
while true do coroutine.yield() end
|
||||
end
|
||||
|
||||
local start = U.frame()
|
||||
local battle = BattleState.newWild(game, slots[1].species, slots[1].level)
|
||||
check("the encounter starts out flagged dead (no healthy party)",
|
||||
battle.dead == true)
|
||||
local got
|
||||
battle.onFinish = function(result)
|
||||
got = result
|
||||
ow:afterBattle(result, battle)
|
||||
end
|
||||
ow:pushBattle(battle)
|
||||
check("the wipe started the battle theme",
|
||||
waitFor(function() return playedAfter(WILD_SONG, start) ~= nil end, 120))
|
||||
|
||||
local function box()
|
||||
local top = game.stack:top()
|
||||
return getmetatable(top) == TextBox and top or nil
|
||||
end
|
||||
local shown = waitFor(function() return box() ~= nil end, 600)
|
||||
check("the blackout text came up over the map", shown)
|
||||
if shown then
|
||||
local lines = {}
|
||||
for _, page in ipairs(box().pages or {}) do
|
||||
for _, line in ipairs(page) do lines[#lines + 1] = line end
|
||||
end
|
||||
local text = table.concat(lines, " / ")
|
||||
U.log("box reads:", text)
|
||||
check("it is PlayerBlackedOutText2, both paragraphs",
|
||||
text:find("out of", 1, true) ~= nil
|
||||
and text:find("blacked", 1, true) ~= nil)
|
||||
U.wait(60)
|
||||
U.shot(game, SHOT_DIR .. "/bug425_blackout.png")
|
||||
U.log("captured", SHOT_DIR .. "/bug425_blackout.png")
|
||||
end
|
||||
|
||||
-- page both paragraphs, then let the warp run
|
||||
for _ = 1, 12 do
|
||||
if not box() then break end
|
||||
U.tap(game, "a")
|
||||
U.wait(20)
|
||||
end
|
||||
local warped = waitFor(function()
|
||||
return game.overworld and game.overworld.map
|
||||
and game.overworld.map.id == heal.map
|
||||
and not game.overworld.transitioning
|
||||
end, 900)
|
||||
check("the player was warped to the heal point map " .. heal.map, warped)
|
||||
check("onFinish reported a loss, not a cancelled battle", got == "lose")
|
||||
check("the party was revived", mon.hp == full)
|
||||
check("half the money is gone (3000 -> 1500)", game.save.money == 1500)
|
||||
if HEAL_SONG then
|
||||
check("the heal point's own theme replaced the battle theme",
|
||||
playedAfter(HEAL_SONG, start) ~= nil)
|
||||
end
|
||||
local stillBattle = songs[#songs] and songs[#songs].song == WILD_SONG
|
||||
check("the battle theme is not what is still playing", not stillBattle)
|
||||
U.log(("%d passed, %d failed"):format(pass, fail))
|
||||
|
||||
-- re-arm for the hand-off: same 0 HP party, back in the grass
|
||||
mon.hp = 0
|
||||
game.save.money = 3000
|
||||
U.teleport(game, MAP, GRASS.x, GRASS.y, "down")
|
||||
U.log("MAGIKARP is at 0 HP again and you are back in the Route 1 grass.")
|
||||
U.log("Step around until an encounter rolls: the wipe and the battle theme,")
|
||||
U.log("then the two boxes over the map with the route theme back under them,")
|
||||
U.log("then the warp to Pallet Town. Landing back in the grass with the")
|
||||
U.log("battle music looping, over and over, is the bug.")
|
||||
|
||||
while true do
|
||||
coroutine.yield()
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,169 @@
|
||||
-- Manual check on the Celadon Mansion roof house blackboard and pamphlet (#391).
|
||||
-- pokered data/events/hidden_events.asm CELADON_MANSION_ROOF_HOUSE declares both
|
||||
-- with hidden_text_predef (LinkCableHelp, TMNotebook), which the extractor never
|
||||
-- parsed, so both A presses were dead. The data half is in
|
||||
-- tests/parity_celadon_roof_readables.lua.
|
||||
-- POKEPORT_DRIVER=tests/drivers/celadon_roof_readables_bug391_test.lua POKEPORT_IDENTITY=bug391 POKEPORT_TOUCH=0 POKEPORT_VERSION=red love . (no POKEPORT_SPEED: fast-forward desyncs audio)
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local TextBox = require("src.render.TextBox")
|
||||
local Menu = require("src.ui.Menu")
|
||||
-- data/scripts/init.lua, not src/script/MapScripts.lua: OverworldController
|
||||
-- requires it lazily on the first map load, so the registrations it makes
|
||||
-- are not in place yet when the driver starts
|
||||
local MapScripts = require("data.scripts.init")
|
||||
|
||||
local MAP = "CELADON_MANSION_ROOF_HOUSE"
|
||||
local SHOT_DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
|
||||
|
||||
-- hidden_events.asm: hidden_text_predef 3, 4 (pamphlet on the table) and
|
||||
-- 3, 0 / 4, 0 (blackboard in the north wall). Both target cells are solid,
|
||||
-- so the stand cell is the walkable one below, facing up.
|
||||
local PAMPHLET = { x = 3, y = 4 }
|
||||
local BLACKBOARD = { x = 3, y = 0 }
|
||||
local BLACKBOARD_ALT = { x = 4, y = 0 }
|
||||
local BALL = { x = 4, y = 3 }
|
||||
|
||||
local function check(label, ok)
|
||||
U.log(ok and "PASS" or "FAIL", label)
|
||||
return ok
|
||||
end
|
||||
|
||||
local hooks = MapScripts.get(MAP)
|
||||
check(MAP .. " registers an onInteract hook",
|
||||
hooks ~= nil and type(hooks.onInteract) == "function")
|
||||
check("the Eevee ball talk entry is still registered",
|
||||
hooks ~= nil and hooks.talk ~= nil
|
||||
and hooks.talk.TEXT_CELADONMANSION_ROOF_HOUSE_EEVEE_POKEBALL ~= nil)
|
||||
for _, key in ipairs({ "_LinkCableHelpText1", "_LinkCableHelpText2",
|
||||
"_LinkCableInfoText1", "_LinkCableInfoText2", "_LinkCableInfoText3" }) do
|
||||
local s = game.data.text[key]
|
||||
check(key .. " resolves to a string", type(s) == "string" and s ~= "")
|
||||
end
|
||||
|
||||
local opts = game.save.options or {}
|
||||
U.log("audio device present:", love.audio ~= nil,
|
||||
" SFX VOL (0-7):", tostring(opts.sfxVol))
|
||||
if not love.audio or opts.sfxVol == 0 then
|
||||
U.log("WARNING: sfx output is off, so the menu beeps and the wall bonk will",
|
||||
"not be audible; raise SFX VOL in OPTION first")
|
||||
end
|
||||
|
||||
-- reach the pamphlet: warps land at (2,7)/(3,7), so start below the table
|
||||
U.teleport(game, MAP, PAMPHLET.x, PAMPHLET.y + 2, "up")
|
||||
U.wait(10)
|
||||
local ow = game.overworld
|
||||
check("map " .. MAP .. " loaded", ow ~= nil and ow.map ~= nil)
|
||||
check("the pamphlet cell is solid, as the table it sits on",
|
||||
not ow.map:isWalkableCell(PAMPHLET.x, PAMPHLET.y))
|
||||
check("the blackboard cell is solid, as the wall it hangs on",
|
||||
not ow.map:isWalkableCell(BLACKBOARD.x, BLACKBOARD.y))
|
||||
U.hold(game, "up", 20)
|
||||
U.wait(10)
|
||||
|
||||
-- a map edit or a mod could move the table: any walkable neighbour will do,
|
||||
-- {dx, dy, facing} is the offset from the readable plus the look back at it
|
||||
local SIDES = {
|
||||
{ 0, 1, "up" }, { 0, -1, "down" }, { 1, 0, "left" }, { -1, 0, "right" },
|
||||
}
|
||||
-- alt is the blackboard's second cell: hidden_text_predef declares 3,0 and
|
||||
-- 4,0 with the same handler, so either one facing up is the right spot
|
||||
local function faceCell(cell, alt)
|
||||
local p = game.overworld.player
|
||||
local fx, fy = p:facingCell()
|
||||
if fx == cell.x and fy == cell.y then return true end
|
||||
if alt and fx == alt.x and fy == alt.y then return true end
|
||||
for _, s in ipairs(SIDES) do
|
||||
local cx, cy = cell.x + s[1], cell.y + s[2]
|
||||
if game.overworld.map:isWalkableCell(cx, cy)
|
||||
and not game.overworld:npcAtCell(cx, cy) then
|
||||
U.log(("(%d,%d) was not faced from where the walk ended, standing on")
|
||||
:format(cell.x, cell.y), cx, cy, "facing", s[3])
|
||||
U.teleport(game, MAP, cx, cy, s[3])
|
||||
U.wait(10)
|
||||
return true
|
||||
end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
local function boxText(box)
|
||||
local out = {}
|
||||
for _, page in ipairs(box.pages or {}) do
|
||||
for _, line in ipairs(page) do out[#out + 1] = line end
|
||||
end
|
||||
return table.concat(out, " / ")
|
||||
end
|
||||
|
||||
check("standing where the pamphlet is faced", faceCell(PAMPHLET))
|
||||
U.tap(game, "a")
|
||||
U.wait(30)
|
||||
local top = game.stack:top()
|
||||
local isBox = getmetatable(top) == TextBox
|
||||
check("A on the pamphlet opened a text box", isBox)
|
||||
if isBox then
|
||||
U.log("box reads:", boxText(top))
|
||||
check("it is the TM pamphlet, not the generic bookshelf line",
|
||||
boxText(top):find("pamphlet", 1, true) ~= nil)
|
||||
U.shot(game, SHOT_DIR .. "/bug391_pamphlet.png")
|
||||
end
|
||||
-- clear the box: five pages of TMNotebookText, then back to the overworld
|
||||
for _ = 1, 40 do
|
||||
if game.stack:top() == game.overworld then break end
|
||||
U.tap(game, "a")
|
||||
U.wait(8)
|
||||
end
|
||||
check("the pamphlet closed and gave the overworld back",
|
||||
game.stack:top() == game.overworld)
|
||||
|
||||
-- reach the blackboard: the table blocks column 3 between the two, so restart
|
||||
-- from the row below the wall rather than walking the long way around it
|
||||
U.teleport(game, MAP, BLACKBOARD.x, BLACKBOARD.y + 2, "up")
|
||||
U.hold(game, "up", 20) -- one step onto row 1, then a bonk on the north wall
|
||||
U.wait(10)
|
||||
U.log("walked to", game.overworld.player.cellX, game.overworld.player.cellY,
|
||||
"facing", game.overworld.player.facing)
|
||||
check("standing where the blackboard is faced",
|
||||
faceCell(BLACKBOARD, BLACKBOARD_ALT))
|
||||
U.tap(game, "a")
|
||||
U.wait(30)
|
||||
top = game.stack:top()
|
||||
isBox = getmetatable(top) == TextBox
|
||||
check("A on the blackboard opened a text box", isBox)
|
||||
if isBox then
|
||||
U.log("box reads:", boxText(top))
|
||||
check("it is _LinkCableHelpText1",
|
||||
boxText(top):find("TRAINER TIPS", 1, true) ~= nil)
|
||||
end
|
||||
-- through the intro and the prompt into the heading menu
|
||||
local menu
|
||||
for _ = 1, 40 do
|
||||
local t = game.stack:top()
|
||||
if getmetatable(t) == Menu then menu = t break end
|
||||
U.tap(game, "a")
|
||||
U.wait(8)
|
||||
end
|
||||
check("the prompt opened the heading menu", menu ~= nil)
|
||||
if menu then
|
||||
local labels = {}
|
||||
for i, item in ipairs(menu.items or {}) do labels[i] = item.label end
|
||||
U.log("menu rows:", table.concat(labels, " / "))
|
||||
check("four rows in HowToLinkText order",
|
||||
table.concat(labels, "/")
|
||||
== "HOW TO LINK/COLOSSEUM/TRADE CENTER/STOP READING")
|
||||
U.shot(game, SHOT_DIR .. "/bug391_blackboard_menu.png")
|
||||
U.log("captured", SHOT_DIR .. "/bug391_blackboard_menu.png")
|
||||
end
|
||||
|
||||
U.log("The heading menu on screen is the moment to eyeball: a 15x10 box in")
|
||||
U.log("the top-left corner, four rows, nothing clipped. Pick a heading and")
|
||||
U.log("its blurb prints, then the prompt and this menu should come back.")
|
||||
U.log("B or STOP READING drops you back outside and you can walk again.")
|
||||
U.log(("Then stand at (%d,%d) facing down and press A: the ball on the table")
|
||||
:format(BALL.x, BALL.y - 1))
|
||||
U.log("still hands over EEVEE, since the same script file changed.")
|
||||
|
||||
while true do
|
||||
coroutine.yield()
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,139 @@
|
||||
-- Manual check that the elevator floor menu waits for the panel (#395).
|
||||
-- pokered data/maps/objects/CeladonMartElevator.asm has it as `bg_event 3, 0,
|
||||
-- TEXT_CELADONMARTELEVATOR`, and only that text reaches
|
||||
-- DisplayElevatorFloorMenu (engine/events/elevator.asm), which rewrites the
|
||||
-- car's warps and returns without moving the player. No POKEPORT_SPEED here:
|
||||
-- fast-forward desynchronizes the ride's audio.
|
||||
-- SHOT_DIR=/tmp/shots POKEPORT_DRIVER=tests/drivers/elevator_panel_bug395_test.lua POKEPORT_IDENTITY=bug395 POKEPORT_TOUCH=0 POKEPORT_VERSION=red love .
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
|
||||
local ListMenu = require("src.ui.ListMenu")
|
||||
local mapScripts = require("data.scripts.init")
|
||||
|
||||
-- data/generated/maps.lua CELADON_MART_ELEVATOR: sign (3,0) is the panel,
|
||||
-- warp (1,3) is the arrival tile, row 0 is wall, so the panel is read from
|
||||
-- (3,1) facing up. 2F is the floor picked below.
|
||||
local MAP = "CELADON_MART_ELEVATOR"
|
||||
local TEXT = "TEXT_CELADONMARTELEVATOR"
|
||||
local ARRIVE = { x = 1, y = 3 }
|
||||
local PANEL = { x = 3, y = 0 }
|
||||
local STAND = { x = 3, y = 1, facing = "up" }
|
||||
local PICK = "CELADON_MART_2F"
|
||||
|
||||
local function check(label, ok)
|
||||
U.log(ok and "PASS" or "FAIL", label)
|
||||
return ok
|
||||
end
|
||||
|
||||
local def = game.data.maps[MAP]
|
||||
local sign
|
||||
for _, s in ipairs(def.signs or {}) do
|
||||
if s.text == TEXT then sign = s end
|
||||
end
|
||||
check(TEXT .. " is a sign in the map data", sign ~= nil)
|
||||
if sign then
|
||||
check(("the panel sits at (%d,%d)"):format(PANEL.x, PANEL.y),
|
||||
sign.x == PANEL.x and sign.y == PANEL.y)
|
||||
end
|
||||
check("the panel text runs a script", type(mapScripts.talkScript(MAP, TEXT)) == "function")
|
||||
|
||||
local vol = game.save.options and game.save.options.sfxVol
|
||||
if (vol or 0) == 0 then
|
||||
U.log("sfxVol is 0: the ride will be silent, raise it in OPTION first")
|
||||
else
|
||||
U.log("sfxVol", tostring(vol), "-- 100 collision thuds then the PA chime")
|
||||
end
|
||||
|
||||
U.teleport(game, MAP, ARRIVE.x, ARRIVE.y, "up")
|
||||
U.wait(10)
|
||||
local ow = game.overworld
|
||||
check("no menu opened on arrival", getmetatable(game.stack:top()) ~= ListMenu)
|
||||
U.shot(game, DIR .. "/elev395_0_entered.png")
|
||||
|
||||
local function facingThePanel()
|
||||
local fx, fy = ow.player:facingCell()
|
||||
return ow.map:signAtCell(fx, fy) ~= nil
|
||||
end
|
||||
|
||||
-- walk over to the panel, one cell per hold
|
||||
U.hold(game, "right", 24)
|
||||
U.hold(game, "right", 24)
|
||||
U.hold(game, "up", 24)
|
||||
U.hold(game, "up", 24)
|
||||
U.wait(10)
|
||||
U.log("standing at", ow.player.cellX, ow.player.cellY, ow.player.facing)
|
||||
|
||||
if not facingThePanel() and sign then
|
||||
-- a map edit moved the panel: stand on any walkable neighbour of it.
|
||||
-- {dx, dy, facing} is the offset from the panel plus the way back at it.
|
||||
local sides = {
|
||||
{ 0, 1, "up" }, { 0, -1, "down" }, { 1, 0, "left" }, { -1, 0, "right" },
|
||||
}
|
||||
for _, s in ipairs(sides) do
|
||||
local cx, cy = sign.x + s[1], sign.y + s[2]
|
||||
if ow.map:isWalkableCell(cx, cy) then
|
||||
U.log("panel moved; standing on", cx, cy, "facing", s[3])
|
||||
U.teleport(game, MAP, cx, cy, s[3])
|
||||
U.wait(10)
|
||||
ow = game.overworld
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
check("the player is facing the panel", facingThePanel())
|
||||
|
||||
U.tap(game, "a")
|
||||
U.wait(20)
|
||||
local menu = getmetatable(game.stack:top()) == ListMenu
|
||||
check("A on the panel opens the floor list", menu)
|
||||
if menu then
|
||||
local labels = {}
|
||||
for _, item in ipairs(game.stack:top().items or {}) do
|
||||
labels[#labels + 1] = item.label
|
||||
end
|
||||
U.log("floors listed:", table.concat(labels, " "))
|
||||
end
|
||||
U.shot(game, DIR .. "/elev395_1_menu.png")
|
||||
|
||||
U.tap(game, "down") -- 1F -> 2F
|
||||
U.wait(2)
|
||||
U.tap(game, "a")
|
||||
-- 9 zero frames (Celadon farjps into ShakeElevator with no extra Delay3),
|
||||
-- then -1,-1,+1,+1 in 2-frame steps
|
||||
local trace = {}
|
||||
for _ = 1, 24 do
|
||||
trace[#trace + 1] = tostring(ow.bgShakeY or 0)
|
||||
U.wait(1)
|
||||
end
|
||||
U.log("bgShakeY after the pick:", table.concat(trace, ","))
|
||||
U.shot(game, DIR .. "/elev395_2_shake_a.png")
|
||||
U.shot(game, DIR .. "/elev395_3_shake_b.png") -- the other phase of the bounce
|
||||
|
||||
local ElevatorShake = require("src.world.ElevatorShake")
|
||||
for _ = 1, 1200 do
|
||||
U.wait(1)
|
||||
if getmetatable(game.stack:top()) ~= ElevatorShake then break end
|
||||
end
|
||||
U.wait(20)
|
||||
check("the ride left the player in the car", ow.map.id == MAP)
|
||||
check("and standing where they read the panel",
|
||||
ow.player.cellX == STAND.x and ow.player.cellY == STAND.y)
|
||||
local dests = {}
|
||||
for _, w in ipairs(ow.map.def.warps) do dests[#dests + 1] = tostring(w.destMap) end
|
||||
U.log("car exit warps now point at:", table.concat(dests, " "))
|
||||
check("the exits were rewritten to " .. PICK, dests[1] == PICK)
|
||||
U.shot(game, DIR .. "/elev395_4_rode.png")
|
||||
|
||||
U.log("The ride is over and the pad is yours: walk down and left onto the")
|
||||
U.log("door tile at (2,3) and you should come out on CELADON MART 2F. The")
|
||||
U.log("panel is behind you -- A on it should reopen WHICH FLOOR?, and B out")
|
||||
U.log("of that list should leave you in the car with 2F still the exit.")
|
||||
U.log("Worth checking by hand: Silph Co (same panel cell, 11 floors) and the")
|
||||
U.log("Rocket car, whose panel is at (1,1) so you face LEFT from (2,1) and")
|
||||
U.log("with no LIFT KEY get \"It appears to need a key.\" and no list.")
|
||||
|
||||
while true do
|
||||
coroutine.yield()
|
||||
end
|
||||
end
|
||||
@@ -1,47 +0,0 @@
|
||||
-- Driver: elevator ride (ShakeElevator, engine/overworld/elevator.asm).
|
||||
-- Teleports onto the Celadon Mart elevator's exit tile (1,3) -- the real
|
||||
-- arrival cell, where the floor menu opens on entry -- picks 2F, traces
|
||||
-- the bgShakeY scroll offset through the 9-frame lead-in and first shake
|
||||
-- cycles, screenshots both phases of the oscillation, then confirms the
|
||||
-- post-ride walk-out onto the arrival floor (the car's exit warps are
|
||||
-- rewritten and the player walks out, per scripts/CeladonMartElevator.asm,
|
||||
-- instead of a jump-cut warp).
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
|
||||
local ListMenu = require("src.ui.ListMenu")
|
||||
|
||||
U.teleport(game, "CELADON_MART_ELEVATOR", 1, 3, "up")
|
||||
U.wait(5)
|
||||
local ow = game.overworld
|
||||
U.log("map:", ow.map.id,
|
||||
"menu open:", tostring(getmetatable(game.stack:top()) == ListMenu))
|
||||
U.shot(game, DIR .. "/elev_0_menu.png")
|
||||
|
||||
U.tap(game, "down") -- cursor 1F -> 2F
|
||||
U.wait(2)
|
||||
U.tap(game, "a") -- choose 2F: the ElevatorShake state pushes
|
||||
-- expect 9 zero frames (Celadon farjps into ShakeElevator, no extra
|
||||
-- Delay3), then -1,-1,+1,+1,... in 2-frame steps
|
||||
local trace = {}
|
||||
for _ = 1, 24 do
|
||||
trace[#trace + 1] = tostring(ow.bgShakeY or 0)
|
||||
U.wait(1)
|
||||
end
|
||||
U.log("bgShakeY after A:", table.concat(trace, ","))
|
||||
U.shot(game, DIR .. "/elev_1_shake_a.png")
|
||||
U.shot(game, DIR .. "/elev_2_shake_b.png") -- 3 frames later: other phase
|
||||
-- ride out: rest of the 200 shake frames, the PA chime, then the
|
||||
-- scripted walk-out; wait until the walk-out has warped onto the floor
|
||||
for _ = 1, 1200 do
|
||||
U.wait(1)
|
||||
if ow.map.id ~= "CELADON_MART_ELEVATOR" and not ow.transitioning
|
||||
and #ow.scriptMoves == 0 then
|
||||
break
|
||||
end
|
||||
end
|
||||
U.wait(10)
|
||||
U.log("final map:", ow.map.id, "pos:", ow.player.cellX, ow.player.cellY,
|
||||
"bgShakeY:", tostring(ow.bgShakeY or 0))
|
||||
U.shot(game, DIR .. "/elev_3_arrived.png")
|
||||
end
|
||||
@@ -0,0 +1,123 @@
|
||||
-- Manual check that the exit mat you warp in on answers the first press of
|
||||
-- DOWN (#378). The house door outside is a door tile, so it leaves
|
||||
-- BIT_STANDING_ON_WARP set and ClearVariablesOnEnterMap never clears it
|
||||
-- (home/overworld.asm:247, engine/overworld/player_state.asm:190).
|
||||
-- POKEPORT_DRIVER=tests/drivers/exit_mat_bug378_test.lua POKEPORT_IDENTITY=bug378 POKEPORT_TOUCH=0 POKEPORT_VERSION=red love .
|
||||
-- Do not set POKEPORT_SPEED: fast-forward desynchronizes the audio clock.
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local Warp = require("src.world.Warp")
|
||||
|
||||
local HOUSE = "REDS_HOUSE_1F"
|
||||
local TOWN = "PALLET_TOWN"
|
||||
|
||||
local fails = 0
|
||||
local function expect(cond, ...)
|
||||
if not cond then fails = fails + 1 end
|
||||
U.log(cond and "PASS" or "FAIL", ...)
|
||||
end
|
||||
|
||||
local ow
|
||||
local function settle(mapId)
|
||||
for _ = 1, 300 do
|
||||
ow = game.overworld
|
||||
if ow and ow.map.id == mapId and not ow.transitioning
|
||||
and #ow.scriptMoves == 0 and not ow.player.moving then
|
||||
break
|
||||
end
|
||||
U.wait(1)
|
||||
end
|
||||
U.wait(4)
|
||||
ow = game.overworld
|
||||
end
|
||||
|
||||
-- data/generated/maps.lua warps, extracted from pokered
|
||||
-- data/maps/objects/PalletTown.asm and RedsHouse1F.asm: the town warp whose
|
||||
-- destination is the house is the door tile, and the house's LAST_MAP warps
|
||||
-- are the two mat cells on its bottom row.
|
||||
local function warpTo(mapId, destMap)
|
||||
for _, w in ipairs(game.data.maps[mapId].warps or {}) do
|
||||
if w.destMap == destMap then return w end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
local door = warpTo(TOWN, HOUSE)
|
||||
local mat = warpTo(HOUSE, "LAST_MAP")
|
||||
expect(door ~= nil, "Pallet Town has a warp into " .. HOUSE)
|
||||
expect(mat ~= nil, HOUSE .. " has a LAST_MAP exit warp")
|
||||
if not (door and mat) then error("map data has no door/mat pair to test") end
|
||||
U.log(("door at (%d, %d), mat at (%d, %d)"):format(door.x, door.y,
|
||||
mat.x, mat.y))
|
||||
|
||||
-- Approach the door from the south, which is the only side the walk-in works
|
||||
-- from; if a map edit blocked that cell, take any walkable neighbour.
|
||||
U.teleport(game, TOWN, door.x, door.y + 1, "up")
|
||||
settle(TOWN)
|
||||
local stand = { x = door.x, y = door.y + 1, facing = "up" }
|
||||
if not ow.map:isWalkableCell(stand.x, stand.y) then
|
||||
local sides = {
|
||||
{ 0, 1, "up" }, { 0, -1, "down" }, { 1, 0, "left" }, { -1, 0, "right" },
|
||||
}
|
||||
for _, s in ipairs(sides) do
|
||||
local cx, cy = door.x + s[1], door.y + s[2]
|
||||
if ow.map:isWalkableCell(cx, cy) then
|
||||
U.log(("cell (%d, %d) is blocked, approaching from"):format(
|
||||
stand.x, stand.y), cx, cy, "facing", s[3])
|
||||
stand = { x = cx, y = cy, facing = s[3] }
|
||||
U.teleport(game, TOWN, cx, cy, s[3])
|
||||
settle(TOWN)
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Walk in for real: teleporting straight onto the mat would never set the
|
||||
-- flag the door sets, so the bug could not show up. The direction is
|
||||
-- released the instant the warp lands, because a still-held d-pad walks the
|
||||
-- player off the mat and stepping back onto it is ExtraWarpCheck's own
|
||||
-- trigger, which would exit the house before the human sees anything.
|
||||
for _ = 1, 120 do
|
||||
if game.overworld and game.overworld.map.id == HOUSE then break end
|
||||
table.insert(game.input.pressQueue, stand.facing)
|
||||
game.input.state[stand.facing] = true
|
||||
coroutine.yield()
|
||||
end
|
||||
game.input.state[stand.facing] = false
|
||||
settle(HOUSE)
|
||||
expect(ow.map.id == HOUSE, "walked in through the door, map:", ow.map.id)
|
||||
local p = ow.player
|
||||
expect(p.cellX == mat.x and p.cellY == mat.y,
|
||||
("landed on the mat (%d, %d), got:"):format(mat.x, mat.y),
|
||||
p.cellX, p.cellY)
|
||||
expect(ow.map:warpAtCell(p.cellX, p.cellY) ~= nil,
|
||||
"the cell under the player carries a warp entry")
|
||||
expect(not ow.map:isWarpTileCell(p.cellX, p.cellY),
|
||||
("the mat tile ($%02X) is no warp-activating tile, so nothing cleared "
|
||||
.. "the flag"):format(ow.map:cellTile(p.cellX, p.cellY)))
|
||||
expect(ow:canCollisionWarp(),
|
||||
"BIT_STANDING_ON_WARP survived the warp, so DOWN can exit (#378)")
|
||||
expect(Warp.onEdge(ow.map, p.cellX, p.cellY, "down") ~= nil,
|
||||
"and DOWN off the south edge resolves to the exit warp")
|
||||
expect(ow.warpEntryCell ~= nil,
|
||||
"the completed-step arrival record is set, as #265 wants it")
|
||||
|
||||
local vol = game.save.options and game.save.options.sfxVol
|
||||
if vol == 0 then
|
||||
U.log("sfxVol is 0: a wrong bonk would be SILENT, raise it in OPTION first")
|
||||
else
|
||||
U.log("sfxVol", tostring(vol), "-- there should be no collision thud at all")
|
||||
end
|
||||
|
||||
U.log("You are on the mat inside the house, already facing down. Press DOWN.")
|
||||
U.log("Right: it fades out on the first press and you are back in Pallet")
|
||||
U.log("Town on the door, which auto-steps you one cell south. Wrong: a")
|
||||
U.log("collision thud and Red walks in place until you step off the mat.")
|
||||
U.log("The stairs at the top right must still bonk, not bounce floors (#230).")
|
||||
|
||||
if fails > 0 then U.log(fails .. " check(s) failed before the handoff") end
|
||||
|
||||
while true do
|
||||
coroutine.yield()
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,158 @@
|
||||
-- Ear check: a song cued while a jingle sounds must wait for it (#398).
|
||||
-- A fanfare's sfx header claims the music channels and .playMusic rewrites
|
||||
-- only the music-channel state, so a mid-jingle song stays muted (pokered
|
||||
-- audio/engine_1.asm:39-56, :1343-1357).
|
||||
-- POKEPORT_DRIVER=tests/drivers/fanfare_hold_bug398_test.lua POKEPORT_IDENTITY=bug398 POKEPORT_TOUCH=0 love .
|
||||
-- No POKEPORT_SPEED: fast-forward scales the logic clock only and desyncs audio.
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
local BattleState = require("src.battle.BattleState")
|
||||
local ChipAudio = require("src.core.ChipAudio")
|
||||
local Music = require("src.core.Music")
|
||||
local Sound = require("src.core.Sound")
|
||||
|
||||
-- data/generated/maps.lua ROUTE_1: the northern grass patch spans cells
|
||||
-- (10-17, 6-8), and pokered data/maps/objects/Route1.asm keeps its
|
||||
-- youngsters at (5, 24) and (15, 13), well south of it.
|
||||
local MAP = "ROUTE_1"
|
||||
local STAND = { x = 12, y = 7, facing = "down" }
|
||||
local FANFARES = {
|
||||
"Level_Up", "Caught_Mon", "Get_Item1", "Get_Item2",
|
||||
"Get_Key_Item", "Pokedex_Rating", "Dex_Page_Added", "Pokeflute",
|
||||
}
|
||||
|
||||
local function check(label, ok)
|
||||
U.log(ok and "PASS" or "FAIL", label)
|
||||
return ok
|
||||
end
|
||||
|
||||
-- ---- what the ear cannot check -----------------------------------------
|
||||
local opts = game.save.options or {}
|
||||
local sfxVol = opts.sfxVol or 7
|
||||
local musicVol = opts.musicVol or 7
|
||||
if sfxVol == 0 then
|
||||
U.log("FAIL sfx volume is 0: no jingle will sound at all, so nothing")
|
||||
U.log(" below can be judged. Set SFX to 7 in OPTION first.")
|
||||
end
|
||||
if musicVol == 0 then
|
||||
U.log("FAIL music volume is 0: the song that must stay held is muted")
|
||||
U.log(" anyway. Set MUSIC to 7 in OPTION first.")
|
||||
end
|
||||
check(("sfx volume %d, music volume %d"):format(sfxVol, musicVol),
|
||||
sfxVol > 0 and musicVol > 0)
|
||||
|
||||
for _, name in ipairs(FANFARES) do
|
||||
local def = (game.data.audio.sfx or {})[name]
|
||||
local src = def and ChipAudio.newSfx(game.data, name) or nil
|
||||
local secs = src and src:getDuration() or 0
|
||||
check(("%s synthesizes (%.2fs)"):format(name, secs), secs > 0.3)
|
||||
end
|
||||
local fanfares = game.data.audio.fanfares
|
||||
check("Caught_Mon counts as a fanfare",
|
||||
fanfares == nil or fanfares.Caught_Mon == true)
|
||||
-- Music.lua:100 reaches into ChipAudio for the hold: a chip song is started
|
||||
-- by ChipAudio, not by Music, so pausing Music's own source cannot cover it
|
||||
check("ChipAudio.holdMusic exists for Music to call",
|
||||
type(ChipAudio.holdMusic) == "function")
|
||||
local routeSong = (game.data.audio.mapSongs or {})[MAP]
|
||||
check(MAP .. " has a map theme to hold (" .. tostring(routeSong) .. ")",
|
||||
routeSong ~= nil)
|
||||
|
||||
-- ---- park in the grass -------------------------------------------------
|
||||
game.save.party = {
|
||||
Pokemon.new(game.data, "PIDGEY", 4),
|
||||
Pokemon.new(game.data, "CHARIZARD", 50),
|
||||
}
|
||||
game.save.inventory = { POKE_BALL = 20, POTION = 5 }
|
||||
game.save.bagOrder = { "POKE_BALL", "POTION" }
|
||||
|
||||
U.teleport(game, MAP, STAND.x, STAND.y, STAND.facing)
|
||||
U.wait(20)
|
||||
local ow = game.overworld
|
||||
if not ow.map:isWalkableCell(STAND.x, STAND.y) then
|
||||
-- a map edit or a mod blocked the cell: take any free neighbour
|
||||
for _, d in ipairs({ { 0, 1 }, { 0, -1 }, { 1, 0 }, { -1, 0 } }) do
|
||||
local cx, cy = STAND.x + d[1], STAND.y + d[2]
|
||||
if ow.map:isWalkableCell(cx, cy) and not ow:npcAtCell(cx, cy) then
|
||||
U.log(("(%d, %d) is blocked, standing on"):format(STAND.x, STAND.y), cx, cy)
|
||||
U.teleport(game, MAP, cx, cy, STAND.facing)
|
||||
U.wait(20)
|
||||
ow = game.overworld
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
check("standing on " .. MAP, ow.map.id == MAP)
|
||||
|
||||
-- ---- the hold, measured ------------------------------------------------
|
||||
-- Music hands its chip sources to ChipAudio, so the source the restore
|
||||
-- built is only reachable through playMusic's return value.
|
||||
local song
|
||||
local realPlayMusic = ChipAudio.playMusic
|
||||
ChipAudio.playMusic = function(...)
|
||||
local src, err = realPlayMusic(...)
|
||||
song = src or song
|
||||
return src, err
|
||||
end
|
||||
|
||||
local jingle = Sound.play(game.data, "Caught_Mon")
|
||||
song = nil
|
||||
-- BattleState:finish restores the map theme this way, and restoreMap clears
|
||||
-- the current label so even the same theme is rebuilt and restarted
|
||||
Music.restoreMap(game.data)
|
||||
U.wait(45) -- past the threaded first-buffer window, still inside the jingle
|
||||
local stillSounding = jingle ~= nil and jingle:isPlaying()
|
||||
local songUp = song ~= nil and song:isPlaying()
|
||||
if not stillSounding then
|
||||
U.log("FAIL the jingle ended before the restore could be judged; rerun")
|
||||
else
|
||||
check("the map theme stays silent under the jingle", not songUp)
|
||||
end
|
||||
|
||||
for _ = 1, 400 do
|
||||
if not (jingle and jingle:isPlaying()) then break end
|
||||
U.wait(1)
|
||||
end
|
||||
local back = false
|
||||
for _ = 1, 120 do
|
||||
if song and song:isPlaying() then back = true break end
|
||||
U.wait(1)
|
||||
end
|
||||
check("the held theme comes back when the jingle ends", back)
|
||||
ChipAudio.playMusic = realPlayMusic
|
||||
|
||||
-- ---- the real path, so the same beat can be heard in a battle ----------
|
||||
local battle = BattleState.newWild(game, "PIDGEY", 3)
|
||||
battle.rng = function(a, b) return a end -- clean capture, no wobbles
|
||||
ow:pushBattle(battle)
|
||||
for _ = 1, 14 do U.tap(game, "a") U.wait(6) end
|
||||
battle.phase = "messages"
|
||||
battle.afterQueue = "menu"
|
||||
battle:throwBall("POKE_BALL")
|
||||
local ChoiceBox = require("src.ui.ChoiceBox")
|
||||
for _ = 1, 120 do
|
||||
if game.stack:top() == ow then break end
|
||||
if getmetatable(game.stack:top()) == ChoiceBox then
|
||||
U.tap(game, "down") -- decline the nickname, or naming eats the mash
|
||||
U.wait(2)
|
||||
end
|
||||
U.tap(game, "a")
|
||||
U.wait(4)
|
||||
end
|
||||
if not check("the catch ran through to the overworld", game.stack:top() == ow) then
|
||||
-- hand the pad over in the grass whatever the mash got stuck on
|
||||
U.teleport(game, MAP, STAND.x, STAND.y, STAND.facing)
|
||||
end
|
||||
U.wait(60)
|
||||
|
||||
U.log("That was a capture mashed straight out of the battle, which is the")
|
||||
U.log("tightest case: the caught jingle should sound alone start to finish")
|
||||
U.log("and the route theme should only come in after its last note.")
|
||||
U.log("You have 20 balls and a level 4 PIDGEY, so walk the grass and catch")
|
||||
U.log("something, or let it level up, and listen for the same thing.")
|
||||
|
||||
while true do
|
||||
coroutine.yield()
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,187 @@
|
||||
-- Manual check that STRENGTH / SURF / FLASH print their message over the
|
||||
-- party menu and only then blink it away (#385). pokered
|
||||
-- engine/menus/start_sub_menus.asm .flash/.strength/.surf all run PrintText
|
||||
-- -> GBPalWhiteOutWithDelay3 -> jp .goBackToMap, so the menu is the backdrop
|
||||
-- of the text and the blink is the menu closing. No POKEPORT_SPEED here:
|
||||
-- POKEPORT_DRIVER=tests/drivers/field_move_layer_bug385_test.lua POKEPORT_IDENTITY=bug385 POKEPORT_TOUCH=0 POKEPORT_VERSION=red love .
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
local PartyMenu = require("src.ui.PartyMenu")
|
||||
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
|
||||
|
||||
local fails = 0
|
||||
local function check(ok, msg)
|
||||
U.log(ok and "PASS" or "FAIL", msg)
|
||||
if not ok then fails = fails + 1 end
|
||||
return ok
|
||||
end
|
||||
|
||||
U.newGame(game)
|
||||
game.save.options = game.save.options or {}
|
||||
game.save.options.textSpeed = 5 -- SLOW: the shot lands while it types
|
||||
local vol = game.save.options.sfxVol
|
||||
if vol == 0 then
|
||||
U.log("sfxVol is 0: the STRENGTH cry will be silent, turn SFX up in OPTION")
|
||||
else
|
||||
U.log("sfxVol =", tostring(vol), "-- STRENGTH plays the mon's cry over the text")
|
||||
end
|
||||
|
||||
-- every badge the three field moves are gated on at list time
|
||||
game.save.inventory.BOULDERBADGE = true
|
||||
game.save.inventory.SOULBADGE = true
|
||||
game.save.inventory.RAINBOWBADGE = true
|
||||
|
||||
for _, key in ipairs({ "_UsedStrengthText", "_CanMoveBouldersText",
|
||||
"_SurfingGotOnText", "_FlashLightsAreaText" }) do
|
||||
local t = game.data.text[key]
|
||||
check(type(t) == "string" and t ~= "", key .. " extracted from the ROM")
|
||||
end
|
||||
|
||||
local function giveMon(species, move)
|
||||
local mon = Pokemon.new(game.data, species, 30)
|
||||
mon.moves = { { id = move, pp = 15 } }
|
||||
game.save.party = { mon }
|
||||
return mon
|
||||
end
|
||||
|
||||
-- park on `cell` if it is free, else on any walkable neighbour of the
|
||||
-- target that looks back at it (a map edit or a mod moves objects)
|
||||
local function stand(mapId, cell, target)
|
||||
U.teleport(game, mapId, cell.x, cell.y, cell.facing)
|
||||
local ow = game.overworld
|
||||
if ow.map:isWalkableCell(cell.x, cell.y) and not ow:npcAtCell(cell.x, cell.y) then
|
||||
return ow
|
||||
end
|
||||
local sides = {
|
||||
{ 0, 1, "up" }, { 0, -1, "down" }, { 1, 0, "left" }, { -1, 0, "right" },
|
||||
}
|
||||
for _, s in ipairs(sides) do
|
||||
local cx, cy = target.x + s[1], target.y + s[2]
|
||||
if ow.map:isWalkableCell(cx, cy) and not ow:npcAtCell(cx, cy) then
|
||||
U.log(("(%d, %d) is blocked, standing on"):format(cell.x, cell.y),
|
||||
cx, cy, "facing", s[3])
|
||||
U.teleport(game, mapId, cx, cy, s[3])
|
||||
return game.overworld
|
||||
end
|
||||
end
|
||||
return ow
|
||||
end
|
||||
|
||||
-- START -> POKéMON -> the mon -> the field-move row, by looking the rows
|
||||
-- up rather than counting presses (a mod can insert either list)
|
||||
local function openFieldMove(action)
|
||||
U.tap(game, "start")
|
||||
U.wait(12)
|
||||
local menu = game.stack:top()
|
||||
local row
|
||||
for i, it in ipairs(menu and menu.items or {}) do
|
||||
if tostring(it.label):upper():find("MON", 1, true) then row = i break end
|
||||
end
|
||||
if not check(row ~= nil, "START menu lists POKéMON") then return nil end
|
||||
for _ = 2, row do U.tap(game, "down"); U.wait(2) end
|
||||
U.tap(game, "a")
|
||||
U.wait(12)
|
||||
local pm = game.stack:top()
|
||||
if not check(getmetatable(pm) == PartyMenu, "POKéMON opens the party menu") then
|
||||
return nil
|
||||
end
|
||||
U.tap(game, "a") -- A on the mon builds the field-move submenu
|
||||
U.wait(8)
|
||||
local sub
|
||||
for i, it in ipairs(pm.subItems or {}) do
|
||||
if it.action == action then sub = i break end
|
||||
end
|
||||
if not check(sub ~= nil, action:upper() .. " is listed in the submenu") then
|
||||
return nil
|
||||
end
|
||||
for _ = 2, sub do U.tap(game, "down"); U.wait(2) end
|
||||
U.tap(game, "a")
|
||||
return pm
|
||||
end
|
||||
|
||||
-- the state the compositor starts from (StateStack:draw walks up from the
|
||||
-- highest opaque state): what is actually behind the message
|
||||
local function backdrop()
|
||||
return game.stack.states[game.stack:visibleBase()]
|
||||
end
|
||||
local function textIsUp()
|
||||
local top = game.stack:top()
|
||||
return top ~= nil and top.pages ~= nil
|
||||
end
|
||||
local function drainText()
|
||||
for _ = 1, 400 do
|
||||
if not textIsUp() then break end
|
||||
U.tap(game, "a")
|
||||
U.wait(3)
|
||||
end
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------- STRENGTH
|
||||
-- pokered data/maps/objects/SeafoamIslands1F.asm: BOULDER1 at (18, 10),
|
||||
-- so the floor to its west is the approach cell
|
||||
giveMon("MACHOP", "STRENGTH")
|
||||
local ow = stand("SEAFOAM_ISLANDS_1F", { x = 17, y = 10, facing = "right" },
|
||||
{ x = 18, y = 10 })
|
||||
check(ow:npcAtCell(18, 10) ~= nil, "the Seafoam boulder is loaded at (18,10)")
|
||||
local pmStr = openFieldMove("strength")
|
||||
U.wait(6)
|
||||
check(textIsUp(), "STRENGTH puts a message up")
|
||||
check(backdrop() == pmStr, "the party menu is the backdrop of the STRENGTH text")
|
||||
U.shot(game, DIR .. "/bug385_strength.png")
|
||||
drainText()
|
||||
U.wait(60)
|
||||
|
||||
-- ---------------------------------------------------------------- SURF
|
||||
-- pokered data/maps/objects/PalletTown.asm has no object on the south
|
||||
-- shore; (4, 13) is the land cell above the water at (4, 14)
|
||||
giveMon("SQUIRTLE", "SURF")
|
||||
ow = stand("PALLET_TOWN", { x = 4, y = 13, facing = "down" }, { x = 4, y = 13 })
|
||||
ow.player.surfing = false
|
||||
check(ow:useSurfFieldMove() == "ok", "(4,13) faces surfable water")
|
||||
local pmSurf = openFieldMove("surf")
|
||||
U.wait(6)
|
||||
check(textIsUp(), "SURF puts the got-on message up")
|
||||
check(backdrop() == pmSurf, "the party menu is the backdrop of the got-on text")
|
||||
U.shot(game, DIR .. "/bug385_surf.png")
|
||||
drainText()
|
||||
U.wait(60)
|
||||
U.shot(game, DIR .. "/bug385_surf_after.png")
|
||||
ow.player.surfing = false
|
||||
|
||||
-- ---------------------------------------------------------------- FLASH
|
||||
-- pokered data/maps/objects/RockTunnel1F.asm: warp 1 is (15, 3), so
|
||||
-- (15, 4) is a real walkable cell just inside the entrance
|
||||
game.save.flashLit = false
|
||||
giveMon("PIKACHU", "FLASH")
|
||||
ow = stand("ROCK_TUNNEL_1F", { x = 15, y = 4, facing = "down" }, { x = 15, y = 3 })
|
||||
check(ow.dark == true, "ROCK_TUNNEL_1F is dark before FLASH")
|
||||
local pmFlash = openFieldMove("flash")
|
||||
U.wait(6)
|
||||
check(textIsUp(), "FLASH puts a message up")
|
||||
check(backdrop() == pmFlash, "the party menu is the backdrop of the FLASH text")
|
||||
check(ow.dark == true, "the tunnel is still dark while the FLASH text is up")
|
||||
U.shot(game, DIR .. "/bug385_flash_text.png")
|
||||
drainText()
|
||||
U.wait(40)
|
||||
check(ow.dark == false, "the tunnel is lit once the blink hands the map back")
|
||||
U.shot(game, DIR .. "/bug385_flash_after.png")
|
||||
|
||||
U.log(fails == 0 and "all machine checks passed" or (fails .. " machine checks failed"))
|
||||
U.log("shots in", DIR)
|
||||
|
||||
-- hand the pad back on a dark tunnel with FLASH still unused
|
||||
game.save.flashLit = false
|
||||
giveMon("PIKACHU", "FLASH")
|
||||
stand("ROCK_TUNNEL_1F", { x = 15, y = 4, facing = "down" }, { x = 15, y = 3 })
|
||||
|
||||
U.log("You are in the dark tunnel with an unused FLASH: START, POKéMON, A, FLASH.")
|
||||
U.log("The message should read over the six-row party list, not a white screen,")
|
||||
U.log("and the tunnel should only be lit after the blink closes the menu.")
|
||||
U.log("Same for SURF and STRENGTH in the shots: party list behind the text,")
|
||||
U.log("one short blink, then the map.")
|
||||
|
||||
while true do
|
||||
coroutine.yield()
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,219 @@
|
||||
-- Driver: the fishing POSE -- the standing frame's bottom tile row swapped for
|
||||
-- RedFishingTiles so the rod ends in hands -- and its two-stage teardown, rod
|
||||
-- OAM out at once, pose ~10 frames later (#384). FishingAnim / RedFishingTiles
|
||||
-- / res BIT_LEDGE_OR_FISHING: player_animations.asm:378, :485, :449.
|
||||
-- No POKEPORT_SPEED: fast-forward outruns the rendered frames and the audio
|
||||
-- clock, so the ten-frame tail can neither be sampled nor watched.
|
||||
-- POKEPORT_DRIVER=tests/drivers/fishing_pose_bug384_test.lua POKEPORT_IDENTITY=bug384 SHOT_DIR=/tmp/shots love .
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
local Screens = require("src.ui.Screens")
|
||||
local TextBox = require("src.render.TextBox")
|
||||
local SpriteRenderer = require("src.render.SpriteRenderer")
|
||||
local SHOT_DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
|
||||
|
||||
local function check(label, ok)
|
||||
U.log(ok and "PASS" or "FAIL", label)
|
||||
return ok
|
||||
end
|
||||
|
||||
-- Same pond as tests/drivers/fishing_rod_bug321_test.lua: the Viridian City
|
||||
-- water block (x=8..13, y=24..27, data/generated/maps.lua VIRIDIAN_CITY) is
|
||||
-- the one place all four shores sit a few steps apart. shoreFor() re-derives
|
||||
-- each stand cell from the map so a layout edit cannot park us at a wall.
|
||||
local MAP = "VIRIDIAN_CITY"
|
||||
local SPOTS = {
|
||||
{ x = 10, y = 23, facing = "down", note = "north shore" },
|
||||
{ x = 8, y = 28, facing = "up", note = "south shore" },
|
||||
{ x = 14, y = 24, facing = "left", note = "east shore" },
|
||||
{ x = 7, y = 24, facing = "right", note = "west shore" },
|
||||
}
|
||||
local DELTA = { up = { 0, -1 }, down = { 0, 1 }, left = { -1, 0 }, right = { 1, 0 } }
|
||||
local ROD = "OLD_ROD"
|
||||
|
||||
-- ---- the halves of the drawing, before anything is on screen ------------
|
||||
-- A missing sheet, a sheet of the wrong size and a Player that never picks
|
||||
-- up fishTiles all look the same: a rod ending in mid air, which is the
|
||||
-- shipped bug.
|
||||
|
||||
local fx = game.data.field.overworldFx
|
||||
local POSE = {
|
||||
{ key = "redFishFront", facing = "down", asm = "RedFishingTilesFront, tile $02" },
|
||||
{ key = "redFishBack", facing = "up", asm = "RedFishingTilesBack, tile $06" },
|
||||
{ key = "redFishSide", facing = "left", asm = "RedFishingTilesSide, tile $0a" },
|
||||
}
|
||||
for _, p in ipairs(POSE) do
|
||||
local def = fx and fx[p.key]
|
||||
if check(("field.overworldFx.%s resolves (%s)"):format(p.key, p.asm),
|
||||
def ~= nil and type(def.path) == "string") then
|
||||
local ok, img = pcall(love.graphics.newImage, def.path)
|
||||
if check(("%s loads: %s"):format(p.key, def.path), ok and img ~= nil) then
|
||||
local w, h = img:getDimensions()
|
||||
check(("%s is 16x8, one 16-wide tile row"):format(p.key), w == 16 and h == 8)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
check("SpriteRenderer:drawTile exists (the pose row wears the sprite's OBP)",
|
||||
type(SpriteRenderer.drawTile) == "function")
|
||||
|
||||
game.save.party = { Pokemon.new(game.data, "CHARIZARD", 50) }
|
||||
game.save.inventory = { [ROD] = 1 }
|
||||
game.save.bagOrder = { ROD }
|
||||
check("OLD ROD is a real item and the only thing in the bag",
|
||||
game.data.items[ROD] ~= nil and (game.save.inventory[ROD] or 0) > 0)
|
||||
local ItemEffects = require("src.inventory.ItemEffects")
|
||||
check("using it routes to the fishing branch",
|
||||
ItemEffects.use(game.data, game.save, ROD, nil, nil, nil, game.overworld) == "fish")
|
||||
|
||||
local function useRod()
|
||||
Screens.push(game, "BagMenu")
|
||||
U.wait(20)
|
||||
U.tap(game, "a") -- OLD ROD -> USE/TOSS
|
||||
U.wait(20)
|
||||
U.tap(game, "a") -- USE
|
||||
U.wait(30)
|
||||
end
|
||||
|
||||
local function topBox()
|
||||
local top = game.stack:top()
|
||||
if getmetatable(top) ~= TextBox then return nil end
|
||||
local shown = {}
|
||||
for _, page in ipairs(top.pages or {}) do
|
||||
for _, line in ipairs(page) do shown[#shown + 1] = line end
|
||||
end
|
||||
return top, table.concat(shown, " / ")
|
||||
end
|
||||
|
||||
local function shoreFor(map, spot)
|
||||
local d = DELTA[spot.facing]
|
||||
if map:inBounds(spot.x + d[1], spot.y + d[2])
|
||||
and map:isWaterCell(spot.x + d[1], spot.y + d[2])
|
||||
and map:isWalkableCell(spot.x, spot.y) then
|
||||
return spot.x, spot.y
|
||||
end
|
||||
for y = 0, map.def.height * 2 - 1 do
|
||||
for x = 0, map.def.width * 2 - 1 do
|
||||
if map:isWalkableCell(x, y) and not map:isWaterCell(x, y)
|
||||
and map:inBounds(x + d[1], y + d[2])
|
||||
and map:isWaterCell(x + d[1], y + d[2]) then
|
||||
U.log(("(%d,%d) no longer faces water; using (%d,%d) for %s")
|
||||
:format(spot.x, spot.y, x, y, spot.facing))
|
||||
return x, y
|
||||
end
|
||||
end
|
||||
end
|
||||
return spot.x, spot.y
|
||||
end
|
||||
|
||||
-- park on a shore, cast, and stop on the dots box
|
||||
local function castFrom(spot)
|
||||
U.teleport(game, MAP, spot.x, spot.y, spot.facing)
|
||||
U.wait(15)
|
||||
local sx, sy = shoreFor(game.overworld.map, spot)
|
||||
if sx ~= spot.x or sy ~= spot.y then
|
||||
U.teleport(game, MAP, sx, sy, spot.facing)
|
||||
U.wait(15)
|
||||
end
|
||||
local ow = game.overworld
|
||||
local fcx, fcy = ow.player:facingCell()
|
||||
check(("facing %s from (%d,%d), the %s: water in front")
|
||||
:format(spot.facing, sx, sy, spot.note),
|
||||
ow.map:inBounds(fcx, fcy) and ow.map:isWaterCell(fcx, fcy))
|
||||
useRod()
|
||||
return ow
|
||||
end
|
||||
|
||||
-- ---- pose up, one facing at a time -------------------------------------
|
||||
-- Every facing is left on its verdict box; the next teleport pops it, so no
|
||||
-- bite ever reaches a battle here.
|
||||
|
||||
for _, spot in ipairs(SPOTS) do
|
||||
local ow = castFrom(spot)
|
||||
local dots = topBox()
|
||||
check(("%s: USE opened the fishing box"):format(spot.facing), dots ~= nil)
|
||||
check(("%s: the rod OAM is up (overworld.fishing)"):format(spot.facing),
|
||||
ow.fishing ~= nil and ow.fishing.facing == spot.facing)
|
||||
check(("%s: the pose is up (player.fishing)"):format(spot.facing),
|
||||
ow.player.fishing == true)
|
||||
check(("%s: a pose row exists for this facing"):format(spot.facing),
|
||||
ow.player.fishTiles ~= nil and ow.player.fishTiles[spot.facing] ~= nil)
|
||||
if spot.facing == "right" then
|
||||
check("right reuses the left pose row, x-flipped like the sprite",
|
||||
ow.player.fishTiles.right == ow.player.fishTiles.left)
|
||||
end
|
||||
-- RodResponse (engine/items/item_effects.asm:1878) zeroes wWalkBikeSurfState
|
||||
-- across FishingAnim, so the sheet under the pose is always the walking one
|
||||
local sheet = ow.player:pose()
|
||||
check(("%s: the walking sheet is what draws (not surf/bike)"):format(spot.facing),
|
||||
sheet == ow.player.sprite)
|
||||
if not U.shot(game, ("%s/bug384_pose_%s.png"):format(SHOT_DIR, spot.facing)) then
|
||||
check(("%s: screenshot reached disk"):format(spot.facing), false)
|
||||
end
|
||||
end
|
||||
|
||||
-- ---- the retract, sampled frame by frame --------------------------------
|
||||
-- The rod OAM dies with the verdict box (res BIT_LEDGE_OR_FISHING right
|
||||
-- after PrintText, player_animations.asm:449) but the patched player tiles
|
||||
-- live until ReloadMapSpriteTilePatterns (home/reload_sprites.asm) a few
|
||||
-- frames later. Only the no-bite verdict shows that gap: a bite goes
|
||||
-- straight into a battle, which reloads the tiles itself, so drop OLD ROD's
|
||||
-- `always` hook for the rest of this run.
|
||||
local rodFishing = (game.data.field.fishing or {})[ROD]
|
||||
check("field.fishing.OLD_ROD exists", rodFishing ~= nil)
|
||||
if rodFishing then rodFishing.always = nil end
|
||||
|
||||
local ow = castFrom(SPOTS[4])
|
||||
local dots = topBox()
|
||||
if dots then
|
||||
for _ = 1, 12 do
|
||||
U.tap(game, "a")
|
||||
U.wait(12)
|
||||
if game.stack:top() ~= dots then break end
|
||||
end
|
||||
end
|
||||
local verdict, verdictText = topBox()
|
||||
check("the no-bite verdict box opened", verdict ~= nil)
|
||||
if verdictText then U.log("box reads:", verdictText) end
|
||||
|
||||
local rodGone, poseGone
|
||||
for i = 1, 180 do
|
||||
if game.stack:top() == verdict then U.tap(game, "a") else U.wait(1) end
|
||||
if not rodGone and ow.fishing == nil then rodGone = i end
|
||||
if not poseGone and ow.player.fishing == nil then poseGone = i end
|
||||
if poseGone then break end
|
||||
end
|
||||
check("the rod OAM came down", rodGone ~= nil)
|
||||
check("the pose came down too", poseGone ~= nil)
|
||||
if rodGone and poseGone then
|
||||
U.log(("rod out on sample frame %d, pose out on %d"):format(rodGone, poseGone))
|
||||
check("the pose OUTLIVES the rod (two stages, not one)", poseGone > rodGone)
|
||||
check("the tail is around ten frames", poseGone - rodGone >= 5)
|
||||
end
|
||||
|
||||
-- ---- hand off ----------------------------------------------------------
|
||||
local live = castFrom(SPOTS[4])
|
||||
if live.fishing then
|
||||
local box = topBox()
|
||||
if box then
|
||||
for _ = 1, 12 do
|
||||
U.tap(game, "a")
|
||||
U.wait(12)
|
||||
if game.stack:top() ~= box then break end
|
||||
end
|
||||
end
|
||||
end
|
||||
U.log("West shore of the Viridian pond, mid-cast, verdict box open. OLD ROD")
|
||||
U.log("was set to never bite for this cast, so dismissing it stays on the map.")
|
||||
U.log("The rod should read as one unbroken stroke: hands and near end in the")
|
||||
U.log("player's lower body, far end in the loose tile, touching on the same")
|
||||
U.log("scanline, and the lower row the same shade as the torso above it.")
|
||||
U.log("Dismiss the box: the loose tile goes at once, the hands hold about ten")
|
||||
U.log("frames, then the sprite snaps back. Both going together is the bug.")
|
||||
U.log("Shots: " .. SHOT_DIR .. "/bug384_pose_<facing>.png")
|
||||
|
||||
while true do
|
||||
coroutine.yield()
|
||||
end
|
||||
end
|
||||
@@ -181,7 +181,8 @@ return function(game)
|
||||
U.log("Correct: one 8x8 rod stroke touching the player's hands, a mirror")
|
||||
U.log("image left vs right, still up for the whole verdict box (#321).")
|
||||
U.log("Shots: " .. SHOT_DIR .. "/bug321_rod_<facing>_{dots,verdict}.png")
|
||||
U.log("Still unported: the fishing pose, the sprite shake, the ! bubble.")
|
||||
U.log("The pose swaps the sprite's bottom tile row (#384); the sprite shake")
|
||||
U.log("and the ! bubble on a bite are still unported.")
|
||||
|
||||
while true do
|
||||
coroutine.yield()
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
-- Driver: the Rocket Hideout B4F lift gate stays barred until both guards fall,
|
||||
-- then opens on the spot (#372). scripts/RocketHideoutB4F.asm ...DoorCallback-
|
||||
-- Script stamps $2d at lb bc, 5, 12 until CheckBothEventsSet trainer_0/_1, then
|
||||
-- SFX_GO_INSIDE and $e, and re-runs after a battle (home/trainers.asm
|
||||
-- EndTrainerBattle). Data half: tests/parity_hideout_gate.lua. Never under
|
||||
-- POKEPORT_SPEED: fast-forward desynchronizes the door sound from the stamp.
|
||||
-- POKEPORT_DRIVER=tests/drivers/hideout_gate_bug372_test.lua POKEPORT_IDENTITY=bug372 POKEPORT_TOUCH=0 POKEPORT_VERSION=red love .
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
local FieldDefaults = require("src.world.FieldDefaults")
|
||||
|
||||
local MAP = "ROCKET_HIDEOUT_B4F"
|
||||
local MAP_LABEL = "RocketHideoutB4F"
|
||||
local GUARD_0 = "EVENT_BEAT_ROCKET_HIDEOUT_4_TRAINER_0"
|
||||
local GUARD_1 = "EVENT_BEAT_ROCKET_HIDEOUT_4_TRAINER_1"
|
||||
-- data/generated/maps.lua ROCKET_HIDEOUT_B4F: the gate is block (12,5),
|
||||
-- i.e. cells (24-25, 10-11), the only gap in that wall row; the guards
|
||||
-- (data/maps/objects/RocketHideoutB4F.asm) sit at (23,12) and (26,12) and
|
||||
-- Giovanni at (25,3) with the SILPH SCOPE ball at (25,2) behind the gate.
|
||||
local DOOR = { bx = 12, by = 5 }
|
||||
local GATE_CELLS = { { 24, 11 }, { 25, 11 } }
|
||||
local STAND = { x = 24, y = 13, facing = "up" }
|
||||
|
||||
local pass, fail = 0, 0
|
||||
local function check(label, ok)
|
||||
if ok then pass = pass + 1 else fail = fail + 1 end
|
||||
U.log(ok and "PASS" or "FAIL", label)
|
||||
return ok
|
||||
end
|
||||
|
||||
game.save.party = {
|
||||
Pokemon.new(game.data, "CHARIZARD", 40),
|
||||
Pokemon.new(game.data, "NIDOKING", 38),
|
||||
}
|
||||
game.save.player.name = "RED"
|
||||
-- a save that has been here before would already carry the guard events
|
||||
game.save.flags[GUARD_0] = nil
|
||||
game.save.flags[GUARD_1] = nil
|
||||
game.save.defeatedTrainers = {}
|
||||
|
||||
local doors = FieldDefaults.fieldValue(game.data, "cardKeyDoors", "closedDoors")
|
||||
local row = doors and doors[MAP] and doors[MAP][1]
|
||||
check("the B4F gate has a closedDoors row (stale caches had none)", row ~= nil)
|
||||
if row then
|
||||
check("it stamps $2d over block (12,5)",
|
||||
row.block == 0x2d and row.bx == DOOR.bx and row.by == DOOR.by)
|
||||
check("it opens to $e on both guard events",
|
||||
row.open == 0x0e and row.events ~= nil and #row.events == 2)
|
||||
end
|
||||
check("B1F carries its own row ($54 at (12,8))",
|
||||
doors and doors.ROCKET_HIDEOUT_B1F ~= nil
|
||||
and doors.ROCKET_HIDEOUT_B1F[1].block == 0x54)
|
||||
check("the guards set the events the gate watches",
|
||||
game.data:trainerHeader(MAP_LABEL, 2).event == GUARD_0
|
||||
and game.data:trainerHeader(MAP_LABEL, 3).event == GUARD_1)
|
||||
check("Go_Inside is in the generated audio",
|
||||
game.data.audio and game.data.audio.sfx
|
||||
and game.data.audio.sfx.Go_Inside ~= nil)
|
||||
|
||||
local vol = game.save.options and game.save.options.sfxVol
|
||||
if vol == 0 then
|
||||
U.log("sfxVol is 0: the door sound will be SILENT, raise it in OPTION first")
|
||||
else
|
||||
U.log("sfxVol", tostring(vol), "-- expect one door sound as the gate opens")
|
||||
end
|
||||
|
||||
U.teleport(game, MAP, STAND.x, STAND.y, STAND.facing)
|
||||
U.wait(10)
|
||||
local ow = game.overworld
|
||||
|
||||
-- a map edit or a mod moved the doorway: stand on any walkable cell
|
||||
-- touching the gate instead of the cell below it
|
||||
if not ow.map:isWalkableCell(STAND.x, STAND.y) then
|
||||
for _, cell in ipairs(GATE_CELLS) do
|
||||
for _, off in ipairs({ { 0, 1, "up" }, { 0, -1, "down" },
|
||||
{ 1, 0, "left" }, { -1, 0, "right" } }) do
|
||||
local cx, cy = cell[1] + off[1], cell[2] + off[2]
|
||||
if ow.map:isWalkableCell(cx, cy) and not ow:npcAtCell(cx, cy) then
|
||||
U.log(("(%d,%d) is not walkable, standing on"):format(STAND.x, STAND.y),
|
||||
cx, cy, "facing", off[3])
|
||||
U.teleport(game, MAP, cx, cy, off[3])
|
||||
U.wait(10)
|
||||
ow = game.overworld
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
check("the doorway is stamped shut on arrival",
|
||||
ow.map:blockAt(DOOR.bx, DOOR.by) == 0x2d)
|
||||
local blocked = true
|
||||
for _, cell in ipairs(GATE_CELLS) do
|
||||
if ow.map:isWalkableCell(cell[1], cell[2]) then blocked = false end
|
||||
end
|
||||
check("neither doorway cell can be stepped into", blocked)
|
||||
|
||||
local guards = 0
|
||||
for _, n in ipairs(ow.npcs or {}) do
|
||||
local name = n.def and n.def.name
|
||||
if name == "ROCKETHIDEOUTB4F_ROCKET1" or name == "ROCKETHIDEOUTB4F_ROCKET2" then
|
||||
guards = guards + 1
|
||||
end
|
||||
end
|
||||
check("both guards are on the floor", guards == 2)
|
||||
U.log(("machine checks: %d pass, %d fail"):format(pass, fail))
|
||||
|
||||
U.shot(game, (os.getenv("SHOT_DIR") or "/tmp/shots") .. "/bug372_gate_shut.png")
|
||||
|
||||
U.log("You are one cell south of the lift gate: barred, and walking up bumps.")
|
||||
U.log("Beat one grunt and it is still barred; beat the second and the bars turn")
|
||||
U.log("to floor as the fade ends, one door sound, then north to Giovanni.")
|
||||
|
||||
while true do
|
||||
coroutine.yield()
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,171 @@
|
||||
-- Eye check on the Mt Moon B2F Jessie & James ambush (#423): the duo walk up
|
||||
-- to the player and leave behind a fade. pokeyellow scripts/MtMoonB2F.asm
|
||||
-- MtMoonB2FScript_49e15 simulates PAD_UP, Script6/Script9 walk Jessie six and
|
||||
-- James five LEFT steps, Script8/Script11 face them; Script14 hides them between
|
||||
-- GBFadeOutToBlack / GBFadeInFromBlack. No POKEPORT_SPEED (the fade and the
|
||||
-- theme sting ride the audio clock), and no POKEPORT_IDENTITY (it re-imports):
|
||||
-- POKEPORT_VERSION=yellow SHOT_DIR=/tmp/shots POKEPORT_TOUCH=0 POKEPORT_DRIVER=tests/drivers/jessie_james_mtmoon_bug423_test.lua love .
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
local BattleState = require("src.battle.BattleState")
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
|
||||
pcall(function() io.stdout:setvbuf("no") end) -- LOVE block-buffers stdout
|
||||
|
||||
-- pokeyellow data/maps/objects/MtMoonB2F.asm: object 2 JESSIE at (9,3),
|
||||
-- object 6 JAMES at (9,4). MtMoonB2FScript_49e15's trigger is (3,5), so the
|
||||
-- PAD_UP step puts the player on (3,4) and the walks land (3,3) and (4,4).
|
||||
local MAP = "MT_MOON_B2F"
|
||||
local TRIGGER = { x = 3, y = 5 }
|
||||
local JESSIE, JAMES = "MTMOONB2F_JESSIE", "MTMOONB2F_JAMES"
|
||||
local BEAT = "EVENT_BEAT_MT_MOON_3_JESSIE_JAMES"
|
||||
local WANT = {
|
||||
player = { x = TRIGGER.x, y = TRIGGER.y - 1 },
|
||||
jessie = { x = TRIGGER.x, y = TRIGGER.y - 2, facing = "down" },
|
||||
james = { x = TRIGGER.x + 1, y = TRIGGER.y - 1, facing = "left" },
|
||||
}
|
||||
|
||||
local function check(label, ok)
|
||||
U.log(ok and "PASS" or "FAIL", label)
|
||||
return ok
|
||||
end
|
||||
|
||||
local function npcNamed(ow, name)
|
||||
for _, n in ipairs(ow.npcs or {}) do
|
||||
if n.def and n.def.name == name then return n end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
-- ---- what the eye cannot check -----------------------------------------
|
||||
local opts = game.save.options or {}
|
||||
local sfxVol = opts.sfxVol or 7
|
||||
if sfxVol == 0 then
|
||||
U.log("FAIL sfx volume is 0: the theme sting that opens the ambush and the")
|
||||
U.log(" bubble blip over the player are both silent, so the audio half")
|
||||
U.log(" cannot be judged. Set SFX to 7 in OPTION first.")
|
||||
end
|
||||
check(("sfx volume %d"):format(sfxVol), sfxVol > 0)
|
||||
check("running Yellow", GameVersion.isYellow())
|
||||
|
||||
-- one strong mon with one damaging move: a stat move stalls the A-mash
|
||||
local mon = Pokemon.new(game.data, "MEWTWO", 100)
|
||||
mon.moves = { { id = "TACKLE", pp = 35 } }
|
||||
game.save.party = { mon }
|
||||
game.save.flags[BEAT] = nil
|
||||
game.save.flags.EVENT_GOT_HELIX_FOSSIL = true
|
||||
|
||||
-- ---- reach the trigger ---------------------------------------------------
|
||||
U.teleport(game, MAP, TRIGGER.x, TRIGGER.y + 1, "up")
|
||||
local ow = game.overworld
|
||||
U.hold(game, "up", 20)
|
||||
U.wait(10)
|
||||
if not ow.runner:isRunning() then
|
||||
-- a map or mod edit blocked the approach from below: step in from whatever
|
||||
-- free walkable neighbour is left
|
||||
local sides = { { 1, 0, "left" }, { -1, 0, "right" }, { 0, -1, "down" } }
|
||||
for _, s in ipairs(sides) do
|
||||
local cx, cy = TRIGGER.x + s[1], TRIGGER.y + s[2]
|
||||
if ow.map:isWalkableCell(cx, cy) and not ow:npcAtCell(cx, cy) then
|
||||
U.log("approach from below failed, stepping in from", cx, cy)
|
||||
U.teleport(game, MAP, cx, cy, s[3])
|
||||
ow = game.overworld
|
||||
U.hold(game, s[3], 20)
|
||||
U.wait(10)
|
||||
if ow.runner:isRunning() then break end
|
||||
end
|
||||
end
|
||||
end
|
||||
check("the ambush script is running", ow.runner:isRunning())
|
||||
U.shot(game, DIR .. "/jj423_0_trigger.png")
|
||||
|
||||
-- ---- the duo close in ----------------------------------------------------
|
||||
-- Sample every mash: the closed-in positions only hold between the motto and
|
||||
-- the challenge line, and the battle push ends the window.
|
||||
local closed = nil
|
||||
local battle = nil
|
||||
for _ = 1, 1500 do
|
||||
local top = game.stack:top()
|
||||
if getmetatable(top) == BattleState then
|
||||
battle = top
|
||||
break
|
||||
end
|
||||
local jessie, james = npcNamed(ow, JESSIE), npcNamed(ow, JAMES)
|
||||
if not closed and jessie and james and not jessie.moving and not james.moving
|
||||
and jessie.cellX == WANT.jessie.x and james.cellX == WANT.james.x then
|
||||
closed = {
|
||||
px = ow.player.cellX, py = ow.player.cellY,
|
||||
jx = jessie.cellX, jy = jessie.cellY, jf = jessie.facing,
|
||||
mx = james.cellX, my = james.cellY, mf = james.facing,
|
||||
}
|
||||
U.shot(game, DIR .. "/jj423_1_scene.png")
|
||||
end
|
||||
U.tap(game, "a")
|
||||
U.wait(3)
|
||||
end
|
||||
|
||||
check("the duo walked in instead of staying at (9,3) and (9,4)", closed ~= nil)
|
||||
closed = closed or {}
|
||||
U.log("player", tostring(closed.px), tostring(closed.py),
|
||||
"jessie", tostring(closed.jx), tostring(closed.jy), tostring(closed.jf),
|
||||
"james", tostring(closed.mx), tostring(closed.my), tostring(closed.mf))
|
||||
check("the player took the simulated PAD_UP step onto (3,4)",
|
||||
closed.px == WANT.player.x and closed.py == WANT.player.y)
|
||||
check("Jessie is standing directly above the player",
|
||||
closed.jx == WANT.jessie.x and closed.jy == WANT.jessie.y)
|
||||
check("Jessie is facing down at him", closed.jf == WANT.jessie.facing)
|
||||
check("James is standing beside the player",
|
||||
closed.mx == WANT.james.x and closed.my == WANT.james.y)
|
||||
check("James is facing left at him", closed.mf == WANT.james.facing)
|
||||
check("the challenge line opened the battle", battle ~= nil)
|
||||
if battle then
|
||||
for _ = 1, 240 do
|
||||
if battle.showEnemyTrainer and (battle.introSlide or 0) <= 0 then break end
|
||||
U.wait(1)
|
||||
end
|
||||
U.shot(game, DIR .. "/jj423_1b_battle.png")
|
||||
end
|
||||
|
||||
-- ---- and leave behind a fade --------------------------------------------
|
||||
local peakFade, fadeShot = 0, false
|
||||
local settled = false
|
||||
for i = 1, 6000 do
|
||||
local overlay = ow.fadeOverlay
|
||||
local alpha = overlay and overlay.alpha or 0
|
||||
if alpha > peakFade then peakFade = alpha end
|
||||
if game.stack:top() == ow and not ow.runner:isRunning()
|
||||
and #ow.scriptMoves == 0 and game.save.flags[BEAT] then
|
||||
settled = true
|
||||
break
|
||||
end
|
||||
if alpha > 0.35 and not fadeShot then
|
||||
fadeShot = true
|
||||
U.shot(game, DIR .. "/jj423_2_fade.png")
|
||||
end
|
||||
U.tap(game, "a")
|
||||
-- step frame by frame while the overlay lives: a 12-frame ramp sampled
|
||||
-- every third frame reads as no fade at all
|
||||
U.wait((alpha > 0 or i > 1200) and 1 or 3)
|
||||
end
|
||||
U.shot(game, DIR .. "/jj423_3_done.png")
|
||||
|
||||
check("the scene ran to its end", settled)
|
||||
check("the beat flag is set", game.save.flags[BEAT] == true)
|
||||
check("Jessie is gone", npcNamed(ow, JESSIE) == nil)
|
||||
check("James is gone", npcNamed(ow, JAMES) == nil)
|
||||
U.log(("peak fade alpha %.2f"):format(peakFade))
|
||||
check("the exit dipped the screen toward black", peakFade > 0.3)
|
||||
|
||||
U.log("jj423_1_scene.png must show Jessie one cell ABOVE the player looking")
|
||||
U.log("down at him and James on his right looking left, not the pair parked")
|
||||
U.log("across the corridor; the exclamation bubble pops over the player just")
|
||||
U.log("before they start walking, so watch the window live for that beat.")
|
||||
U.log("jj423_2_fade.png is the room dimming with the duo still standing in")
|
||||
U.log("it, and jj423_3_done.png is the same room lit and empty; the second")
|
||||
U.log("Music_MeetJessieJames sting covers the fade, then the cave theme.")
|
||||
|
||||
while true do
|
||||
coroutine.yield()
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,125 @@
|
||||
-- Eye check on the enemy pic in the Mt Moon Jessie & James fight (#439).
|
||||
-- pokeyellow home/trainers2.asm:36-50 IsFightingJessieJames points
|
||||
-- wTrainerPicPointer at JessieJamesPic when ROCKET fights with wTrainerNo
|
||||
-- >= $2a, leaving GetTrainerName alone. Needs a Yellow cache re-imported
|
||||
-- after #439. No POKEPORT_SPEED: the theme sting rides the audio clock.
|
||||
-- POKEPORT_VERSION=yellow POKEPORT_DRIVER=tests/drivers/jj_battle_pic_bug439_test.lua POKEPORT_IDENTITY=bug439 POKEPORT_TOUCH=0 love .
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
local BattleState = require("src.battle.BattleState")
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
local CacheFs = require("src.import.CacheFs")
|
||||
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
|
||||
|
||||
-- pokeyellow data/maps/objects/MtMoonB2F.asm parks JESSIE at (9, 3) and
|
||||
-- JAMES at (9, 4); MtMoonB2FScript's coordinate trigger is the (3, 5) tile
|
||||
-- west of them, reachable from (3, 6) once a fossil is in the bag.
|
||||
local MAP = "MT_MOON_B2F"
|
||||
local TRIGGER = { x = 3, y = 5 }
|
||||
local BEAT = "EVENT_BEAT_MT_MOON_3_JESSIE_JAMES"
|
||||
|
||||
local function check(label, ok)
|
||||
U.log(ok and "PASS" or "FAIL", label)
|
||||
return ok
|
||||
end
|
||||
|
||||
-- ---- what the eye cannot check -----------------------------------------
|
||||
local opts = game.save.options or {}
|
||||
local sfxVol = opts.sfxVol or 7
|
||||
if sfxVol == 0 then
|
||||
U.log("FAIL sfx volume is 0: the theme sting that opens the ambush is")
|
||||
U.log(" silent, so the scene's audio half cannot be judged. Set SFX")
|
||||
U.log(" to 7 in OPTION first.")
|
||||
end
|
||||
check(("sfx volume %d"):format(sfxVol), sfxVol > 0)
|
||||
|
||||
check("running Yellow", GameVersion.isYellow())
|
||||
local trainer = game.data.trainers.OPP_ROCKET
|
||||
check("OPP_ROCKET is in the trainer table", trainer ~= nil)
|
||||
trainer = trainer or {}
|
||||
check("the name is still ROCKET, as GetTrainerName leaves it",
|
||||
trainer.name == "ROCKET")
|
||||
-- a Yellow cache built before #439 has no duo pic at all, and every check
|
||||
-- below it would then be measuring the old grunt-only behaviour
|
||||
if not trainer.picJessieJames then
|
||||
U.log("FAIL the Yellow cache predates #439: no duo pic was extracted.")
|
||||
U.log(" Re-import the Yellow ROM from the launcher, then run again.")
|
||||
end
|
||||
check("the cache carries a duo pic", trainer.picJessieJames ~= nil)
|
||||
if trainer.picJessieJames then
|
||||
check("the duo pic file is on the read path",
|
||||
CacheFs.exists(trainer.picJessieJames)
|
||||
or love.filesystem.getInfo(trainer.picJessieJames) ~= nil)
|
||||
end
|
||||
local picked = BattleState.trainerPicPath(game.data, trainer, "OPP_ROCKET", 42)
|
||||
U.log("pic:", tostring(picked))
|
||||
check("party 42 selects the duo pic", picked == trainer.picJessieJames)
|
||||
check("a lone grunt party still selects the class pic",
|
||||
BattleState.trainerPicPath(game.data, trainer, "OPP_ROCKET", 3) == trainer.pic)
|
||||
|
||||
-- ---- reach the ambush ---------------------------------------------------
|
||||
-- one strong mon so the fight is survivable if the reader plays it out
|
||||
game.save.party = { Pokemon.new(game.data, "MEWTWO", 100) }
|
||||
game.save.flags[BEAT] = nil
|
||||
game.save.flags.EVENT_GOT_HELIX_FOSSIL = true
|
||||
|
||||
U.teleport(game, MAP, TRIGGER.x, TRIGGER.y + 1, "up")
|
||||
local ow = game.overworld
|
||||
U.hold(game, "up", 20)
|
||||
U.wait(10)
|
||||
if not ow.runner:isRunning() then
|
||||
-- a map or mod edit blocked the approach from below: come down onto the
|
||||
-- trigger from whichever free walkable neighbour is left
|
||||
local sides = {
|
||||
{ 0, -1, "down" }, { 1, 0, "left" }, { -1, 0, "right" },
|
||||
}
|
||||
for _, s in ipairs(sides) do
|
||||
local cx, cy = TRIGGER.x + s[1], TRIGGER.y + s[2]
|
||||
if ow.map:isWalkableCell(cx, cy) and not ow:npcAtCell(cx, cy) then
|
||||
U.log("approach from below failed, stepping in from", cx, cy)
|
||||
U.teleport(game, MAP, cx, cy, s[3])
|
||||
ow = game.overworld
|
||||
U.hold(game, s[3], 20)
|
||||
U.wait(10)
|
||||
if ow.runner:isRunning() then break end
|
||||
end
|
||||
end
|
||||
end
|
||||
check("the ambush script is running", ow.runner:isRunning())
|
||||
U.shot(game, DIR .. "/jj439_0_trigger.png")
|
||||
|
||||
-- A-mash the motto and the challenge line until the battle is pushed, then
|
||||
-- stop: the trainer pic stays up until the intro text is dismissed
|
||||
local battle = nil
|
||||
for _ = 1, 1200 do
|
||||
local top = game.stack:top()
|
||||
if getmetatable(top) == BattleState then
|
||||
battle = top
|
||||
break
|
||||
end
|
||||
U.tap(game, "a")
|
||||
U.wait(3)
|
||||
end
|
||||
check("the ambush opened a battle", battle ~= nil)
|
||||
if battle then
|
||||
for _ = 1, 240 do
|
||||
if battle.showEnemyTrainer and (battle.introSlide or 0) <= 0 then break end
|
||||
U.wait(1)
|
||||
end
|
||||
check("the enemy trainer pic is on screen", battle.showEnemyTrainer == true)
|
||||
U.shot(game, DIR .. "/jj439_1_intro.png")
|
||||
U.wait(40)
|
||||
U.shot(game, DIR .. "/jj439_2_intro_late.png")
|
||||
U.log("intro text:", tostring(battle.introText))
|
||||
end
|
||||
|
||||
U.log("Both intro shots must show the two-character duo pic: a woman on the")
|
||||
U.log("left and a man on the right, one 7x7 pic, no Meowth -- not the single")
|
||||
U.log("crouching ROCKET grunt. The name in the intro line stays ROCKET.")
|
||||
U.log("The theme under the trigger shot is Music_MeetJessieJames.")
|
||||
|
||||
while true do
|
||||
coroutine.yield()
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,90 @@
|
||||
-- Manual check of the launcher's Delete labels (#433): a stale hit rect let a
|
||||
-- click on the Mods tab delete a save slot, and Delete never asked first.
|
||||
-- No pokered counterpart: src/import/RomImporter.lua is this port's own desktop
|
||||
-- launcher, so hit-rect coords here come from the panel that draws them.
|
||||
-- POKEPORT_DRIVER=tests/drivers/launcher_delete_bug433_test.lua POKEPORT_IDENTITY=bug433 POKEPORT_TOUCH=0 POKEPORT_VERSION=red love .
|
||||
-- Leave POKEPORT_SPEED unset: this one is clicked by hand, at real time.
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local RomImporter = require("src.import.RomImporter")
|
||||
local SaveData = require("src.core.SaveData")
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
|
||||
local version = GameVersion.get()
|
||||
|
||||
local function check(label, ok)
|
||||
U.log(ok and "PASS" or "FAIL", label)
|
||||
return ok
|
||||
end
|
||||
|
||||
-- A driver run boots straight past the launcher (main.lua: POKEPORT_DRIVER is
|
||||
-- a scripted run), so bring a real interactive launcher up ourselves and take
|
||||
-- over the frame + click handlers the launcher normally owns.
|
||||
local imp = RomImporter.new(function() end, { launcher = true })
|
||||
imp.play = function() U.log("Play is inert here: the game is already booted.") end
|
||||
|
||||
local prevDraw, prevPressed = love.draw, love.mousepressed
|
||||
local prevKey, prevText = love.keypressed, love.textinput
|
||||
love.draw = function()
|
||||
imp:update(love.timer.getDelta())
|
||||
imp:draw()
|
||||
end
|
||||
love.mousepressed = function(x, y, button) imp:mousepressed(x, y, button or 1) end
|
||||
love.keypressed = function(key) imp:keypressed(key) end
|
||||
love.textinput = function(t) imp:textinput(t) end
|
||||
local function restore()
|
||||
love.draw, love.mousepressed = prevDraw, prevPressed
|
||||
love.keypressed, love.textinput = prevKey, prevText
|
||||
end
|
||||
|
||||
local function slotCount()
|
||||
return #(SaveData.listSlots(version) or {})
|
||||
end
|
||||
|
||||
-- Two throwaway rows to click on, in this identity's save dir only.
|
||||
while slotCount() < 2 do imp:_newSlot(version) end
|
||||
imp.tab = version
|
||||
imp.pageScroll = 0
|
||||
U.wait(4)
|
||||
|
||||
-- The Delete rects exist only after the panel that owns them has drawn.
|
||||
local del = (imp.slotDeleteRects or {})[1]
|
||||
if not check("the save panel drew a Delete rect for a slot row", del ~= nil) then
|
||||
U.log("Nothing to click: the", version, "tab drew no slot rows.")
|
||||
restore()
|
||||
while true do coroutine.yield() end
|
||||
end
|
||||
local spot = { x = del.x + del.width / 2, y = del.y + del.height / 2, id = del.id }
|
||||
U.log(("Delete for %s sits at (%d, %d)."):format(spot.id, spot.x, spot.y))
|
||||
|
||||
imp.tab = "mods"
|
||||
U.wait(4)
|
||||
check("a Mods frame leaves no save Delete rect live",
|
||||
imp.slotDeleteRects == nil)
|
||||
local before = slotCount()
|
||||
imp:mousepressed(spot.x, spot.y, 1)
|
||||
U.wait(4)
|
||||
check("the reporter's click on that spot from Mods deletes nothing",
|
||||
slotCount() == before and imp._confirmDelete == nil)
|
||||
|
||||
imp.tab = version
|
||||
U.wait(4)
|
||||
imp:mousepressed(spot.x, spot.y, 1)
|
||||
U.wait(2)
|
||||
check("one click on Delete arms instead of deleting",
|
||||
slotCount() == before and imp._confirmDelete ~= nil
|
||||
and imp._confirmDelete.id == spot.id)
|
||||
imp:mousepressed(4, 4, 1) -- a press anywhere else
|
||||
U.wait(2)
|
||||
check("a press elsewhere takes the arm back off", imp._confirmDelete == nil)
|
||||
check("the slot survived the whole sequence", slotCount() == before)
|
||||
|
||||
U.log("The launcher on screen is live; the", version, "tab is up, disarmed.")
|
||||
U.log("Clicking Delete once should turn that label red and read \"Sure?\"")
|
||||
U.log("without the row moving, and go back to \"Delete\" after ~4 seconds or")
|
||||
U.log("on any other click. A second click on \"Sure?\" removes the slot.")
|
||||
|
||||
while true do
|
||||
coroutine.yield()
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,184 @@
|
||||
-- Driver: a multi-hit move must step the enemy HP bar down once per strike
|
||||
-- (#394), not empty it on hit 1 and freeze for the rest. pokered
|
||||
-- ApplyDamageToEnemyPokemon (engine/battle/core.asm:4684-4727) subtracts
|
||||
-- wDamage then runs UpdateHPBar2 inside the wNumAttacksLeft loop. Machine
|
||||
-- half: tests/engine/multihit_hp_drain.lua. Never under POKEPORT_SPEED:
|
||||
-- fast-forward desynchronizes the per-strike damage sound from the bar.
|
||||
-- POKEPORT_DRIVER=tests/drivers/multihit_bug394_test.lua POKEPORT_IDENTITY=bug394 POKEPORT_TOUCH=0 POKEPORT_VERSION=red love .
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
local BattleState = require("src.battle.BattleState")
|
||||
|
||||
local MOVE = "DOUBLESLAP"
|
||||
local pass, fail = 0, 0
|
||||
local function check(label, ok)
|
||||
if ok then pass = pass + 1 else fail = fail + 1 end
|
||||
U.log(ok and "PASS" or "FAIL", label)
|
||||
return ok
|
||||
end
|
||||
|
||||
local moveDef = game.data.moves[MOVE]
|
||||
check(MOVE .. " resolves in the move table", moveDef ~= nil)
|
||||
check("...as a 2-to-5 strike move",
|
||||
moveDef ~= nil and moveDef.effect == "TWO_TO_FIVE_ATTACKS_EFFECT")
|
||||
check("SNORLAX resolves (a big HP pool, so each strike is visible)",
|
||||
game.data.pokemon.SNORLAX ~= nil)
|
||||
check("the damage sound is in the generated audio",
|
||||
game.data.audio and game.data.audio.sfx
|
||||
and game.data.audio.sfx.Damage ~= nil)
|
||||
|
||||
local vol = game.save.options and game.save.options.sfxVol
|
||||
if vol == 0 then
|
||||
U.log("sfxVol is 0: the per-strike damage hits will be SILENT,",
|
||||
"raise it in OPTION first")
|
||||
else
|
||||
U.log("sfxVol", tostring(vol), "-- expect one damage hit per strike")
|
||||
end
|
||||
|
||||
-- :L50 so a SNORLAX counterattack cannot end the run before the handoff
|
||||
local lead = Pokemon.new(game.data, "BULBASAUR", 50)
|
||||
lead.moves = { { id = MOVE, pp = 10, maxPP = 10 } } -- one slot, no mis-pick
|
||||
game.save.party = { lead }
|
||||
|
||||
-- offscreen scratch battle: the queue a multi-hit turn builds, with no
|
||||
-- animation timing in the way. Each drain row has to name its target and
|
||||
-- carry the HP left after its own strike; unpinned rows were the bug.
|
||||
do
|
||||
local seqIndex = 0
|
||||
local scratch = BattleState.newWild(game, "SNORLAX", 30)
|
||||
scratch.onFinish = function() end
|
||||
scratch.rng = function(_, hi) -- 5 hits, hit, no crit, max damage roll
|
||||
seqIndex = seqIndex + 1
|
||||
local scripted = ({ 7, 0, 255, 255 })[seqIndex]
|
||||
return scripted ~= nil and scripted or hi
|
||||
end
|
||||
local startHP = scratch.enemy.mon.hp
|
||||
scratch:performMove(scratch.player, scratch.enemy, { id = MOVE, pp = 10 })
|
||||
local stops, anims = {}, 0
|
||||
for _, row in ipairs(scratch.queue) do
|
||||
if row.drain then stops[#stops + 1] = row
|
||||
elseif row.anim == MOVE then anims = anims + 1 end
|
||||
end
|
||||
check(("%d strikes queued %d animations"):format(#stops, anims),
|
||||
#stops > 1 and anims == #stops)
|
||||
local stepped, named = true, true
|
||||
local prev = startHP
|
||||
local shownStops = {}
|
||||
for _, row in ipairs(stops) do
|
||||
named = named and row.battler == scratch.enemy
|
||||
stepped = stepped and type(row.stopAt) == "number" and row.stopAt < prev
|
||||
prev = row.stopAt or prev
|
||||
shownStops[#shownStops + 1] = tostring(row.stopAt)
|
||||
end
|
||||
check("every drain row names the enemy", named)
|
||||
check("and stops on its own strike's HP, one step at a time", stepped)
|
||||
check("the last stop is the post-turn HP", prev == scratch.enemy.mon.hp)
|
||||
U.log(("enemy HP %d -> stops: %s"):format(startHP,
|
||||
table.concat(shownStops, ", ")))
|
||||
end
|
||||
|
||||
-- ROUTE_1 is open field (data/generated/maps.lua ROUTE_1); the cell is read
|
||||
-- off the loaded map so a map edit degrades to another walkable cell
|
||||
-- instead of a wall.
|
||||
U.teleport(game, "ROUTE_1", 5, 5, "down")
|
||||
U.wait(10)
|
||||
local map = game.overworld.map
|
||||
if not map:isWalkableCell(5, 5) then
|
||||
local fx, fy
|
||||
for cy = 0, map.heightCells - 1 do
|
||||
for cx = 0, map.widthCells - 1 do
|
||||
if map:isWalkableCell(cx, cy) then fx, fy = cx, cy break end
|
||||
end
|
||||
if fx then break end
|
||||
end
|
||||
if fx then
|
||||
U.teleport(game, "ROUTE_1", fx, fy, "down")
|
||||
U.wait(10)
|
||||
end
|
||||
end
|
||||
local ow = game.overworld
|
||||
check("player stands on a walkable ROUTE_1 cell",
|
||||
ow.map:isWalkableCell(ow.player.cellX, ow.player.cellY))
|
||||
|
||||
local function mashUntil(cond, max)
|
||||
for _ = 1, max or 120 do
|
||||
if cond() then return true end
|
||||
U.tap(game, "a")
|
||||
U.wait(4)
|
||||
end
|
||||
return cond()
|
||||
end
|
||||
|
||||
local function newFight()
|
||||
local battle = BattleState.newWild(game, "SNORLAX", 30)
|
||||
battle.onFinish = function() end
|
||||
game.overworld:pushBattle(battle)
|
||||
U.wait(220) -- the send-out intro plays before the menu is reachable
|
||||
mashUntil(function() return battle.phase == "menu" end)
|
||||
return battle
|
||||
end
|
||||
|
||||
-- ---- scripted run: watch the bar across the strikes ---------------------
|
||||
local battle = newFight()
|
||||
check("the wild battle reached its FIGHT menu", battle.phase == "menu")
|
||||
U.shot(game, DIR .. "/bug394_menu.png")
|
||||
|
||||
U.tap(game, "a") -- FIGHT
|
||||
U.wait(16)
|
||||
U.tap(game, "a") -- the only move slot
|
||||
U.wait(8)
|
||||
|
||||
-- sample on the falling edge of battle.draining, a frame at a time: a
|
||||
-- coarser poll can miss a strike's drain entirely and read the next one's
|
||||
-- rest twice. Rows that leave the enemy bar where it was (the foe's own
|
||||
-- turn drains the player bar) and the full-bar reset a finished battle
|
||||
-- does are skipped. A is tapped only every eighth frame, to walk the
|
||||
-- queue's text rows without eating a whole drain.
|
||||
local seen, wasDraining = {}, false
|
||||
local lastShown = battle.enemy.shownHP or battle.enemy.mon.hp
|
||||
for frame = 1, 2400 do
|
||||
local draining = battle.draining ~= nil
|
||||
if wasDraining and not draining then
|
||||
local shown = battle.enemy.shownHP or battle.enemy.mon.hp
|
||||
if shown < lastShown - 0.5 then
|
||||
lastShown = shown
|
||||
seen[#seen + 1] = shown
|
||||
U.shot(game, DIR .. ("/bug394_hit%d.png"):format(#seen))
|
||||
U.log(("after strike %d the enemy bar rests on %.0f HP, model HP %d")
|
||||
:format(#seen, shown, battle.enemy.mon.hp))
|
||||
end
|
||||
end
|
||||
wasDraining = draining
|
||||
if not draining and battle.phase == "menu" and #battle.queue == 0 then break end
|
||||
if not draining and frame % 8 == 0 then U.tap(game, "a") end
|
||||
U.wait(1)
|
||||
end
|
||||
local rests = {}
|
||||
for i, hp in ipairs(seen) do rests[i] = ("%.0f"):format(hp) end
|
||||
U.log(("enemy bar rests: %s"):format(table.concat(rests, ", ")))
|
||||
check(("more than one strike moved the enemy bar (%d)"):format(#seen), #seen > 1)
|
||||
check("the first strike did not drain the whole turn's damage (#394)",
|
||||
#seen > 1 and seen[1] > seen[#seen])
|
||||
|
||||
U.log(("machine checks: %d passed, %d failed"):format(pass, fail))
|
||||
|
||||
-- ---- re-arm and hand the pad over --------------------------------------
|
||||
lead.hp = lead.stats.hp
|
||||
lead.status = nil
|
||||
lead.moves[1].pp = lead.moves[1].maxPP
|
||||
local handoff = newFight()
|
||||
U.log("A fresh SNORLAX is waiting on FIGHT with DOUBLESLAP in the only slot.")
|
||||
U.log("Press A twice and watch the enemy bar: it should tick down a bit on")
|
||||
U.log("every strike, in step with the replayed slap and its damage hit, with")
|
||||
U.log("the HP number dropping each time. Before #394 the whole bar emptied on")
|
||||
U.log("slap one and then sat frozen for the rest.")
|
||||
if handoff.phase ~= "menu" then
|
||||
U.log("(the menu did not come back on its own: mash A to reach FIGHT)")
|
||||
end
|
||||
|
||||
while true do
|
||||
coroutine.yield()
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,161 @@
|
||||
-- Manual check that Oak's NIDORINO keeps its mirrored front sprite across the
|
||||
-- _OakSpeechText2A -> _OakSpeechText2B page break (#397). OakSpeechText2 is one
|
||||
-- PrintText over one flipped pic (pokered engine/movie/oak_speech/oak_speech.asm
|
||||
-- :80-85, :172-177); advance() used to clear picFlip on the pic-less next step.
|
||||
-- SHOT_DIR=/tmp/bug397 POKEPORT_DRIVER=tests/drivers/oak_nidorino_flip_bug397_test.lua POKEPORT_IDENTITY=bug397 POKEPORT_TOUCH=0 POKEPORT_VERSION=red love .
|
||||
-- Do not set POKEPORT_SPEED: fast-forward desynchronizes the cry from the wipe.
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local TextBox = require("src.render.TextBox")
|
||||
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
|
||||
|
||||
local function check(label, ok)
|
||||
U.log(ok and "PASS" or "FAIL", label)
|
||||
return ok
|
||||
end
|
||||
|
||||
local function top() return game.stack:top() end
|
||||
local function isBox(s) return getmetatable(s) == TextBox end
|
||||
|
||||
local function speechState()
|
||||
for _, s in ipairs(game.stack.states or {}) do
|
||||
if s.demoPic ~= nil or (s.steps and s.answers) then return s end
|
||||
end
|
||||
end
|
||||
|
||||
local function boxText(s)
|
||||
if not isBox(s) then return "" end
|
||||
local out = {}
|
||||
for _, page in ipairs(s.pages or {}) do
|
||||
for _, line in ipairs(page) do out[#out + 1] = line end
|
||||
end
|
||||
return table.concat(out, " / ")
|
||||
end
|
||||
|
||||
local function stepId(speech)
|
||||
local cur = speech.steps and speech.steps[speech.step]
|
||||
return cur and (cur.id or cur.kind) or "?"
|
||||
end
|
||||
|
||||
-- boot flow: attract movie -> title -> menu. With a save on disk NEW GAME is
|
||||
-- the second row, so land on it the same way silly_oak_intro_test does.
|
||||
U.wait(5)
|
||||
U.tap(game, "start")
|
||||
U.wait(15)
|
||||
U.tap(game, "a")
|
||||
U.wait(8)
|
||||
local ok, saved = pcall(function()
|
||||
return require("src.core.SaveData").load() ~= nil
|
||||
end)
|
||||
if ok and saved then
|
||||
U.tap(game, "down")
|
||||
U.wait(5)
|
||||
end
|
||||
U.tap(game, "a")
|
||||
U.wait(20)
|
||||
|
||||
local speech
|
||||
for _ = 1, 90 do
|
||||
speech = speechState()
|
||||
if speech then break end
|
||||
U.wait(1)
|
||||
end
|
||||
if not check("NEW GAME entered Oak's speech", speech ~= nil) then
|
||||
U.log("top is", tostring(top()), "-- CONTINUE was picked instead")
|
||||
while true do coroutine.yield() end
|
||||
end
|
||||
|
||||
local demoIdx
|
||||
for i, s in ipairs(speech.steps or {}) do
|
||||
if (s.kind or "say") == "demo" then demoIdx = i break end
|
||||
end
|
||||
check("the step list still has a demo beat", demoIdx ~= nil)
|
||||
check("the show-off front sprite loaded (" ..
|
||||
tostring(speech.demoSpecies) .. ")", speech.demoPic ~= nil)
|
||||
|
||||
local vol = game.save and game.save.options and game.save.options.sfxVol
|
||||
if vol == 0 then
|
||||
U.log("sfxVol is 0: the NIDORINO cry will be silent, raise it in OPTION")
|
||||
else
|
||||
U.log("sfxVol", tostring(vol), "-- the cry plays as the sprite wipes in")
|
||||
end
|
||||
|
||||
if not demoIdx then
|
||||
while true do coroutine.yield() end
|
||||
end
|
||||
|
||||
-- mash only up to the demo beat, then let the wipe finish on its own
|
||||
for _ = 1, 300 do
|
||||
if speech.step >= demoIdx then break end
|
||||
U.tap(game, "a")
|
||||
U.wait(3)
|
||||
end
|
||||
for _ = 1, 180 do
|
||||
if speech.step == demoIdx and not speech.picReveal and isBox(top()) then
|
||||
break
|
||||
end
|
||||
U.wait(1)
|
||||
end
|
||||
|
||||
check("_OakSpeechText2A is up on the demo beat",
|
||||
speech.step == demoIdx and isBox(top()))
|
||||
local textA = boxText(top())
|
||||
U.log("page A reads:", textA)
|
||||
check("the mirrored pic is on screen (picFlip set, demo pic)",
|
||||
speech.picFlip == true and speech.pic == speech.demoPic)
|
||||
U.shot(game, DIR .. "/oak_nido_2a.png")
|
||||
|
||||
-- one A per page of 2A; picFlip must survive every one of them. The page
|
||||
-- count is not fixed (an A during the typewriter only finishes the line), so
|
||||
-- keep turning until the step moves on rather than budgeting presses.
|
||||
local held = true
|
||||
for _ = 1, 60 do
|
||||
U.tap(game, "a")
|
||||
U.wait(12)
|
||||
if speech.picFlip ~= true or speech.pic ~= speech.demoPic then
|
||||
held = false
|
||||
break
|
||||
end
|
||||
if speech.step > demoIdx then break end
|
||||
end
|
||||
check("picFlip and pic unchanged after the page break", held)
|
||||
check("the speech moved on to the pic-less step (" .. stepId(speech) .. ")",
|
||||
speech.step > demoIdx)
|
||||
local textB = boxText(top())
|
||||
U.log("page B reads:", textB)
|
||||
check("a second page is up and it is not page A",
|
||||
isBox(top()) and textB ~= "" and textB ~= textA)
|
||||
U.shot(game, DIR .. "/oak_nido_2b.png")
|
||||
U.log("shots in", DIR)
|
||||
|
||||
-- put the beat back so the page break can be watched live: pop the box and
|
||||
-- rewind the step counter. The wipe and the cry are both skipped on the way
|
||||
-- back in -- the sprite is already standing there and it already called once,
|
||||
-- so replaying them reads as NIDORINO entering twice -- leaving just page A.
|
||||
for _ = 1, 8 do
|
||||
if top() == speech then break end
|
||||
game.stack:pop()
|
||||
end
|
||||
if top() == speech then
|
||||
local Sound = require("src.core.Sound")
|
||||
local realReveal, realCry = speech.revealPic, Sound.playCry
|
||||
speech.revealPic = function(self, _, next)
|
||||
self.picReveal = nil
|
||||
if next then next() end
|
||||
end
|
||||
Sound.playCry = function() end
|
||||
speech.step = demoIdx
|
||||
speech:runStep(speech.steps[demoIdx])
|
||||
speech.revealPic, Sound.playCry = realReveal, realCry
|
||||
U.wait(30)
|
||||
end
|
||||
|
||||
U.log("NIDORINO is standing where the wipe left it, facing LEFT with its horn")
|
||||
U.log("toward screen-left, and page A is back up. Press A to turn the page:")
|
||||
U.log("the sprite must not move or mirror, it stays facing left until the")
|
||||
U.log("screen fades to white for the naming beat.")
|
||||
|
||||
while true do
|
||||
coroutine.yield()
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,102 @@
|
||||
-- Ear check on the Viridian Mart parcel jingle (#374): the key-item fanfare
|
||||
-- used to fire when the box opened, two pages early. pokered carries it as a
|
||||
-- text command inside the gift text (scripts/ViridianMart.asm
|
||||
-- sound_get_key_item, home/text.asm TextCommand_SOUND), so it lands after the
|
||||
-- last character. Do not set POKEPORT_SPEED: fast-forward scales the logic
|
||||
-- clock only, so audio and text desynchronize and the check means nothing.
|
||||
-- POKEPORT_DRIVER=tests/drivers/parcel_jingle_bug374_test.lua POKEPORT_IDENTITY=bug374 POKEPORT_TOUCH=0 POKEPORT_VERSION=red love .
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local Flags = require("src.script.Flags")
|
||||
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
|
||||
|
||||
local MAP = "VIRIDIAN_MART"
|
||||
local QUEST = "_ViridianMartClerkParcelQuestText"
|
||||
-- pokered data/maps/objects/ViridianMart.asm: warp_event 3, 7 is the door;
|
||||
-- ViridianMartDefaultScript's .PlayerMovement walks left 1 / up 2 from it to
|
||||
-- the counter, so the driver only has to stand on the mat.
|
||||
local STAND = { x = 3, y = 7, facing = "up" }
|
||||
|
||||
local function check(label, ok)
|
||||
U.log(ok and "PASS" or "FAIL", label)
|
||||
return ok
|
||||
end
|
||||
|
||||
Flags.set(game.save, "EVENT_GOT_STARTER")
|
||||
game.save.flags.EVENT_GOT_OAKS_PARCEL = nil
|
||||
game.save.flags.EVENT_OAK_GOT_PARCEL = nil
|
||||
check("starter flag set, both parcel flags clear",
|
||||
Flags.get(game.save, "EVENT_GOT_STARTER")
|
||||
and not Flags.get(game.save, "EVENT_GOT_OAKS_PARCEL")
|
||||
and not Flags.get(game.save, "EVENT_OAK_GOT_PARCEL"))
|
||||
|
||||
-- the whole point of the bug: the gift text is the three-page quest, so the
|
||||
-- jingle has two pages of typing to be early over
|
||||
local text = game.data.text[QUEST]
|
||||
check(QUEST .. " resolves", type(text) == "string" and text ~= "")
|
||||
local pages = 1
|
||||
for _ in tostring(text):gmatch("\f") do pages = pages + 1 end
|
||||
check("the quest text is three pages", pages == 3)
|
||||
local last = tostring(text):match("([^\f]*)$") or ""
|
||||
check("its last page is the got-parcel line",
|
||||
last:find("PARCEL", 1, true) ~= nil)
|
||||
|
||||
local sfx = game.data.audio and game.data.audio.sfx
|
||||
check("Get_Key_Item is in the sfx table", sfx ~= nil and sfx.Get_Key_Item ~= nil)
|
||||
local parcel = game.data.items.OAKS_PARCEL
|
||||
check("OAKS_PARCEL is a key item (picks Get_Key_Item over Get_Item1)",
|
||||
parcel ~= nil and parcel.keyItem == true)
|
||||
|
||||
local story = require("data.scripts.story")
|
||||
local gift
|
||||
for _, row in ipairs(story.VIRIDIAN_MART.talk.TEXT_VIRIDIANMART_CLERK) do
|
||||
if row[1] == "give_item" and row[2] == "OAKS_PARCEL" then gift = row end
|
||||
end
|
||||
check("the clerk's give_item row passes the quest text",
|
||||
gift ~= nil and gift[4] == QUEST)
|
||||
|
||||
local vol = game.save.options and game.save.options.sfxVol
|
||||
if vol == 0 then
|
||||
U.log("SFX VOLUME IS 0. Nothing will be audible. Raise it in OPTION and rerun.")
|
||||
else
|
||||
check("sfx volume is audible (" .. tostring(vol) .. ")", vol ~= 0)
|
||||
end
|
||||
|
||||
U.teleport(game, MAP, STAND.x, STAND.y, STAND.facing)
|
||||
U.wait(10)
|
||||
local ow = game.overworld
|
||||
if ow and not ow.map:isWalkableCell(STAND.x, STAND.y) then
|
||||
-- a map edit or a mod moved the mat: any free walkable neighbour of the
|
||||
-- door still lands inside the shop, and the clerk's script drives the walk
|
||||
local sides = { { 1, 0 }, { -1, 0 }, { 0, 1 }, { 0, -1 } }
|
||||
for _, s in ipairs(sides) do
|
||||
local cx, cy = STAND.x + s[1], STAND.y + s[2]
|
||||
if ow.map:isWalkableCell(cx, cy) and not ow:npcAtCell(cx, cy) then
|
||||
U.log(("door cell (%d, %d) is blocked, standing on"):format(STAND.x, STAND.y),
|
||||
cx, cy)
|
||||
U.teleport(game, MAP, cx, cy, STAND.facing)
|
||||
ow = game.overworld
|
||||
U.wait(10)
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
check("standing in " .. MAP, ow ~= nil and ow.map.id == MAP)
|
||||
U.wait(50)
|
||||
|
||||
U.tap(game, "a") U.wait(40) -- the clerk's "you came from PALLET" page
|
||||
U.tap(game, "a") U.wait(90) -- simulated walk to the counter, quest page 1
|
||||
U.tap(game, "a") U.wait(60) -- page 2
|
||||
U.tap(game, "a") U.wait(180) -- last page types out, the jingle belongs here
|
||||
U.shot(game, DIR .. "/374_parcel.png")
|
||||
U.log("captured", DIR .. "/374_parcel.png")
|
||||
|
||||
U.log("The fanfare should start only after \"got / OAK's PARCEL!\" has")
|
||||
U.log("finished typing, the music ducks under it, and A is ignored until it")
|
||||
U.log("ends; then the arrow blinks and A closes the box. The pad is yours:")
|
||||
U.log("clear both parcel flags and walk back in the door to hear it again.")
|
||||
|
||||
while true do
|
||||
coroutine.yield()
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,316 @@
|
||||
-- Yellow follower cadence (#424): the ledge hop deferred a player step, the
|
||||
-- standing idle counters at OverworldLoop's two DelayFrames per pass, and the
|
||||
-- pikapic beat timed by its own pikapic_setduration (pokeyellow engine/pikachu/
|
||||
-- pikachu_follow.asm Func_fc803/Func_fcc64/Func_fcc92, home/overworld.asm:43,
|
||||
-- pikachu_pic_animation.asm ExecutePikaPicAnimScript). Never POKEPORT_SPEED:
|
||||
-- it scales the logic clock alone and every number judged here is a frame count.
|
||||
-- POKEPORT_DRIVER=tests/drivers/pikachu_cadence_bug424_test.lua POKEPORT_IDENTITY=bug424 POKEPORT_TOUCH=0 POKEPORT_VERSION=yellow love .
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
local PikachuFollower = require("src.world.PikachuFollower")
|
||||
|
||||
local SHOT_DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
|
||||
|
||||
local results = {}
|
||||
local function check(label, ok)
|
||||
results[#results + 1] = { label = label, ok = ok and true or false }
|
||||
return ok
|
||||
end
|
||||
local function report()
|
||||
for _, r in ipairs(results) do U.log(r.ok and "PASS" or "FAIL", r.label) end
|
||||
end
|
||||
local function idleForever()
|
||||
while true do coroutine.yield() end
|
||||
end
|
||||
|
||||
if not GameVersion.isYellow() then
|
||||
check("running the Yellow cache (POKEPORT_VERSION=yellow)", false)
|
||||
report()
|
||||
U.log("Red and Blue have no follower, no idle rolls and no pikapic box.")
|
||||
idleForever()
|
||||
end
|
||||
|
||||
-- ShouldPikachuSpawn's preconditions (pikachu_follow.asm): the lab gift
|
||||
-- happened and a healthy Pikachu leads the party. Without both there is no
|
||||
-- follower and every count below would read as the bug.
|
||||
game.save.flags = game.save.flags or {}
|
||||
game.save.flags.EVENT_GOT_STARTER = true
|
||||
game.save.party = { Pokemon.new(game.data, "PIKACHU", 20) }
|
||||
game.save.onBike = false
|
||||
|
||||
local frames = 0
|
||||
local function step(n)
|
||||
for _ = 1, n do
|
||||
coroutine.yield()
|
||||
frames = frames + 1
|
||||
end
|
||||
end
|
||||
|
||||
-- ROUTE_1 (pokeyellow data/maps/objects/Route1.asm has no object near the
|
||||
-- ledge run) carries the first plain south-facing ledge; (5, 4) is the cell
|
||||
-- to hop from, re-derived below out of the cached blocks against
|
||||
-- data.field.ledges so a map edit moves the test instead of breaking it.
|
||||
local MAP = "ROUTE_1"
|
||||
local WANT = { x = 5, y = 4 }
|
||||
|
||||
local function southLedge(map, cx, cy)
|
||||
if not (map:inBounds(cx, cy) and map:inBounds(cx, cy + 3)) then return false end
|
||||
-- the landing plus one more open cell below it: the queued hop only drains
|
||||
-- on the player's next step, so the ledge needs room for that step
|
||||
if not (map:isWalkableCell(cx, cy) and map:isWalkableCell(cx, cy - 1)
|
||||
and map:isWalkableCell(cx, cy + 2)
|
||||
and map:isWalkableCell(cx, cy + 3)) then
|
||||
return false
|
||||
end
|
||||
local standing, front = map:cellTile(cx, cy), map:cellTile(cx, cy + 1)
|
||||
for _, l in ipairs(game.data.field.ledges or {}) do
|
||||
if (l.tileset or "OVERWORLD") == map.def.tileset
|
||||
and l.facing == "down" and l.input == "down"
|
||||
and l.standingTile == standing and l.ledgeTile == front then
|
||||
return true
|
||||
end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
U.teleport(game, MAP, WANT.x, WANT.y - 1, "down")
|
||||
U.wait(20)
|
||||
|
||||
local ow = game.overworld
|
||||
check("overworld is up on " .. MAP, ow ~= nil and ow.map.id == MAP)
|
||||
if not ow then
|
||||
report()
|
||||
idleForever()
|
||||
end
|
||||
|
||||
local hx, hy = WANT.x, WANT.y
|
||||
if not southLedge(ow.map, hx, hy) then
|
||||
for cy = 1, ow.map.heightCells - 4 do
|
||||
for cx = 0, ow.map.widthCells - 1 do
|
||||
if southLedge(ow.map, cx, cy) then hx, hy = cx, cy break end
|
||||
end
|
||||
if hx ~= WANT.x or hy ~= WANT.y then break end
|
||||
end
|
||||
U.log(("(%d, %d) is no longer a south ledge; using"):format(WANT.x, WANT.y),
|
||||
hx, hy)
|
||||
U.teleport(game, MAP, hx, hy - 1, "down")
|
||||
U.wait(20)
|
||||
ow = game.overworld
|
||||
end
|
||||
check(("a south ledge to hop at (%d, %d)"):format(hx, hy),
|
||||
southLedge(ow.map, hx, hy))
|
||||
|
||||
local function follower()
|
||||
for _, n in ipairs(ow.npcs or {}) do
|
||||
if n.pikachuFollower then return n end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
local npc = follower()
|
||||
if not check("the follower spawned", npc ~= nil) then
|
||||
report()
|
||||
idleForever()
|
||||
end
|
||||
|
||||
-- ---- the standing idle clock ----
|
||||
-- Func_fc803 reloads $20 and decrements it once per UpdateSprites call, and
|
||||
-- a standing OverworldLoop burns two DelayFrames per pass, so one counter
|
||||
-- unit is two of this port's fixed steps: the glance lands on frame 64.
|
||||
local function glanceLeft()
|
||||
local idle = npc.idle
|
||||
if idle and idle.kind == "wait" then return idle.frames end
|
||||
return nil
|
||||
end
|
||||
|
||||
for _ = 1, 90 do
|
||||
if glanceLeft() then break end
|
||||
step(1)
|
||||
end
|
||||
if not check("the follower is in the standing glance state", glanceLeft() ~= nil) then
|
||||
report()
|
||||
idleForever()
|
||||
end
|
||||
|
||||
local last, lastAt = glanceLeft(), frames
|
||||
-- the first change lands a partial unit after the sampling started, so it
|
||||
-- only sets the baseline: every gap measured after it is a whole unit
|
||||
local minGap, maxGap, seen, resetAt, period = 99, 0, 0, nil, nil
|
||||
for _ = 1, 300 do
|
||||
step(1)
|
||||
local now = glanceLeft()
|
||||
if now == nil then break end
|
||||
if now ~= last then
|
||||
seen = seen + 1
|
||||
local gap = frames - lastAt
|
||||
if seen > 1 then
|
||||
if gap < minGap then minGap = gap end
|
||||
if gap > maxGap then maxGap = gap end
|
||||
end
|
||||
if now > last then
|
||||
if resetAt then period = period or frames - resetAt end
|
||||
resetAt = frames
|
||||
end
|
||||
last, lastAt = now, frames
|
||||
end
|
||||
end
|
||||
check("the idle counter loses one unit every two frames, not every frame",
|
||||
minGap == 2 and maxGap == 2)
|
||||
check("a full glance takes 64 frames ($20 units), not 32", period == 64)
|
||||
U.log("idle counter gap", minGap, "to", maxGap, "frames; glance period",
|
||||
tostring(period))
|
||||
|
||||
-- ---- the ledge hop, one player step late ----
|
||||
-- Func_fcc08 hands a ledge step to Func_fcc64, which appends the $5-$8 hop
|
||||
-- on the takeoff step and nothing on the landing step; Func_fcc92 cannot pop
|
||||
-- a command with nothing queued behind it, so the hop waits for the player's
|
||||
-- next step and Pikachu sits two cells back until then.
|
||||
local sawHop, hopLen = false, nil
|
||||
local function sample()
|
||||
local f = follower()
|
||||
if f and f.hopStep then
|
||||
sawHop = true
|
||||
hopLen = hopLen or f.stepFrames
|
||||
end
|
||||
end
|
||||
|
||||
for _ = 1, 200 do
|
||||
table.insert(game.input.pressQueue, "down")
|
||||
game.input.state["down"] = true
|
||||
step(1)
|
||||
sample()
|
||||
if (ow.player.hopFrames or 0) > 0 then break end
|
||||
end
|
||||
game.input.state["down"] = false
|
||||
check("the player's ledge hop fired", (ow.player.hopFrames or 0) > 0)
|
||||
|
||||
for _ = 1, 150 do
|
||||
step(1)
|
||||
sample()
|
||||
end
|
||||
npc = follower()
|
||||
check("the follower waits on the cell the player took off from",
|
||||
npc ~= nil and npc.cellX == hx and npc.cellY == hy)
|
||||
check("it does not hop while the player stands still", not sawHop)
|
||||
if npc then
|
||||
U.log("player at", ow.player.cellX, ow.player.cellY,
|
||||
"| follower waiting at", npc.cellX, npc.cellY)
|
||||
end
|
||||
|
||||
-- one more player step drains the buffered hop
|
||||
for _ = 1, 120 do
|
||||
table.insert(game.input.pressQueue, "down")
|
||||
game.input.state["down"] = true
|
||||
step(1)
|
||||
sample()
|
||||
if ow.player.cellY > hy + 2 then break end
|
||||
end
|
||||
game.input.state["down"] = false
|
||||
for _ = 1, 120 do
|
||||
step(1)
|
||||
sample()
|
||||
end
|
||||
|
||||
check("the deferred hop then ran", sawHop)
|
||||
-- Func_fc7aa jumps to Func_fca0a on the $4 movement status before it asks
|
||||
-- AreThereAtLeastTwoStepsInPikachuFollowCommandBuffer, so a hop is never a
|
||||
-- Fast step even though its goal is two cells off
|
||||
local walkLen = ow.player.stepFramesCur or ow.player.stepFrames or 16
|
||||
check("the hop takes a full step's frames, not a halved Fast step",
|
||||
hopLen == walkLen)
|
||||
U.log("hop step frames", tostring(hopLen), "against a walk's", walkLen)
|
||||
npc = follower()
|
||||
check("the follower cleared the ledge row",
|
||||
npc ~= nil and npc.cellY > hy + 1
|
||||
and ow.map:isWalkableCell(npc.cellX, npc.cellY))
|
||||
|
||||
-- ---- the pikapic beat ----
|
||||
-- happiness 255 with mood 200 is the matrix cell for emotion 20 (a heart
|
||||
-- bubble and PikaPicAnimScript20: 50 ticks, so 150 frames, with the second
|
||||
-- full-body pose up for 24 of every 40 ticks).
|
||||
game.save.pikachuHappiness = 255
|
||||
game.save.pikachuMood = 200
|
||||
|
||||
local DIRS = { { "down", 0, 1 }, { "up", 0, -1 },
|
||||
{ "left", -1, 0 }, { "right", 1, 0 } }
|
||||
local function facingFollower()
|
||||
local fx, fy = ow.player:facingCell()
|
||||
return follower() ~= nil and ow:npcAtCell(fx, fy) == follower()
|
||||
end
|
||||
if not facingFollower() then
|
||||
for _, d in ipairs(DIRS) do
|
||||
if npc and npc.cellX == ow.player.cellX + d[2]
|
||||
and npc.cellY == ow.player.cellY + d[3] then
|
||||
U.tap(game, d[1])
|
||||
U.wait(6)
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
check("player is facing the follower", facingFollower())
|
||||
|
||||
-- a muted run sounds like a beat that never started, so say so up front
|
||||
local sfxVol = game.save.options and game.save.options.sfxVol
|
||||
U.log("save.options.sfxVol:", tostring(sfxVol))
|
||||
if (sfxVol or 0) == 0 then
|
||||
U.log("WARNING sfx volume is 0: the squeak cannot be heard whether or not")
|
||||
U.log("WARNING one plays. Raise it in OPTIONS before judging the sound.")
|
||||
end
|
||||
|
||||
U.tap(game, "a")
|
||||
U.wait(1)
|
||||
local emote = ow.emote
|
||||
if not check("the A press raised the framed pikapic", emote ~= nil) then
|
||||
report()
|
||||
idleForever()
|
||||
end
|
||||
check("the beat runs the script's 50 ticks at three frames each",
|
||||
emote.pikaTotal == 150)
|
||||
check("its length is not the old flat 50 frame hold", emote.pikaTotal ~= 50)
|
||||
check("the pikapic marks itself skippable", emote.skippable == true)
|
||||
check("emotion 20 carries an overlay frameset", type(emote.pikaSeq) == "table")
|
||||
|
||||
local lifted, grounded, liftShot = 0, 0, nil
|
||||
for _ = 1, 60 do
|
||||
if not ow.emote then break end
|
||||
local lift = PikachuFollower.picLift(ow.emote)
|
||||
if lift > 0 then
|
||||
lifted = lifted + 1
|
||||
if not liftShot then
|
||||
liftShot = SHOT_DIR .. "/bug424_pikapic_lift.png"
|
||||
if U.shot(game, liftShot) then U.log("captured", liftShot)
|
||||
else liftShot = nil end
|
||||
end
|
||||
else
|
||||
grounded = grounded + 1
|
||||
end
|
||||
step(1)
|
||||
end
|
||||
check("the pic sits on the box floor for part of the beat", grounded > 0)
|
||||
check("and rises off it for another part", lifted > 0)
|
||||
U.log("of the first 60 frames the pic was up for", lifted, "and down for",
|
||||
grounded)
|
||||
|
||||
local leftWhenCut = ow.emote and ow.emote.frames or 0
|
||||
U.tap(game, "a")
|
||||
U.wait(2)
|
||||
check("A cuts the beat short (PikaPicAnimTimerAndJoypad)",
|
||||
leftWhenCut > 0 and ow.emote == nil)
|
||||
U.log("cut with", leftWhenCut, "frames still on the clock")
|
||||
|
||||
report()
|
||||
|
||||
-- hand the pad back one cell above the same ledge so Down replays the hop
|
||||
U.teleport(game, MAP, hx, hy - 1, "down")
|
||||
U.wait(20)
|
||||
|
||||
U.log("Hold Down: Pikachu should walk up to the ledge lip and WAIT there,")
|
||||
U.log("then clear both cells in one motion on your next step, at walking")
|
||||
U.log("speed. Let go and count the first glance: about a second, not half.")
|
||||
U.log("Face it, press A: the framed pic hops inside the box for the whole")
|
||||
U.log("beat, and A or B cuts the beat off early.")
|
||||
|
||||
idleForever()
|
||||
end
|
||||
@@ -0,0 +1,197 @@
|
||||
-- Yellow's follower has to trail exactly one cell behind over a long walk
|
||||
-- (#410). ROUTE_1 column x=0 is 36 cells of plain path (tile $2c: no
|
||||
-- grass, no ledge row, no object on it, per the generated ROUTE_1 blocks
|
||||
-- and pokeyellow data/maps/objects/Route1.asm), so 32 steps north measure
|
||||
-- the gap with nothing else moving. Never add POKEPORT_SPEED here: it
|
||||
-- scales the logic clock only, and the gap is a timing measurement. No
|
||||
-- POKEPORT_IDENTITY either: the Yellow cache lives in the default save dir.
|
||||
-- POKEPORT_DRIVER=tests/drivers/pikachu_follow_distance_bug410_test.lua POKEPORT_TOUCH=0 POKEPORT_VERSION=yellow love .
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
local PikachuFollower = require("src.world.PikachuFollower")
|
||||
|
||||
local MAP = "ROUTE_1"
|
||||
local START = { x = 0, y = 34 }
|
||||
local STEPS = 32
|
||||
|
||||
local function check(label, ok)
|
||||
U.log(ok and "PASS" or "FAIL", label)
|
||||
return ok
|
||||
end
|
||||
|
||||
-- ShouldPikachuSpawn wants the lab gift and a healthy party Pikachu; the
|
||||
-- level 100 lead plus a long REPEL is belt and braces, since the column
|
||||
-- below carries no grass tile and cannot roll an encounter anyway
|
||||
game.save.party = { Pokemon.new(game.data, "PIKACHU", 100) }
|
||||
game.save.flags = game.save.flags or {}
|
||||
game.save.flags.EVENT_GOT_STARTER = true
|
||||
game.save.onBike = false
|
||||
game.save.repelSteps = 9999
|
||||
game.save.player.name = "bryan"
|
||||
|
||||
U.teleport(game, MAP, START.x, START.y, "up")
|
||||
U.wait(10)
|
||||
|
||||
local ow = game.overworld
|
||||
local map = ow.map
|
||||
|
||||
-- the run has to be walkable, grass-free and warp-free the whole way; a
|
||||
-- later map edit degrades to the longest column that still is, rather
|
||||
-- than walking the player into a fence for 500 frames
|
||||
local function runLength(cx, fromY)
|
||||
local n = 0
|
||||
local y = fromY
|
||||
while y >= 0 and map:isWalkableCell(cx, y) and not map:isGrassCell(cx, y)
|
||||
and not map:warpAtCell(cx, y) do
|
||||
n = n + 1
|
||||
y = y - 1
|
||||
end
|
||||
return n
|
||||
end
|
||||
|
||||
if runLength(START.x, START.y) < STEPS + 1 then
|
||||
local best, bestX, bestY = 0, START.x, START.y
|
||||
for cx = 0, map.widthCells - 1 do
|
||||
for cy = map.heightCells - 1, 0, -1 do
|
||||
local n = runLength(cx, cy)
|
||||
if n > best then best, bestX, bestY = n, cx, cy end
|
||||
end
|
||||
end
|
||||
U.log(("column %d is short (%d cells); walking column %d from y=%d (%d cells)")
|
||||
:format(START.x, runLength(START.x, START.y), bestX, bestY, best))
|
||||
START.x, START.y = bestX, bestY
|
||||
STEPS = math.min(STEPS, best - 1)
|
||||
U.teleport(game, MAP, START.x, START.y, "up")
|
||||
U.wait(10)
|
||||
ow = game.overworld
|
||||
map = ow.map
|
||||
end
|
||||
check(("a straight %d step run exists at column %d"):format(STEPS, START.x),
|
||||
STEPS >= 30 and runLength(START.x, START.y) >= STEPS + 1)
|
||||
|
||||
local function follower()
|
||||
for _, n in ipairs(ow.npcs or {}) do
|
||||
if n.pikachuFollower then return n end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
check("the follower spawned on " .. MAP, follower() ~= nil)
|
||||
|
||||
-- The far > 6 snap teleports the follower onto its goal and hides any
|
||||
-- drift the walk built up, so a broken run would read as a clean one.
|
||||
-- PikachuFollower.update is looked up on the module table at every call
|
||||
-- site, so wrapping the field here counts snaps without touching the
|
||||
-- engine: a cell that changes across the call while the follower is not
|
||||
-- mid-step is the snap and nothing else (a normal step lands its cell
|
||||
-- inside NPC:update, which OverworldState runs before this).
|
||||
local snaps, fastCommits = 0, 0
|
||||
local realUpdate = PikachuFollower.update
|
||||
PikachuFollower.update = function(g, o)
|
||||
local npc = follower()
|
||||
local bx, by, bmoving
|
||||
if npc then bx, by, bmoving = npc.cellX, npc.cellY, npc.moving end
|
||||
realUpdate(g, o)
|
||||
if npc and not bmoving and not npc.moving
|
||||
and (npc.cellX ~= bx or npc.cellY ~= by) then
|
||||
snaps = snaps + 1
|
||||
end
|
||||
if npc and not bmoving and npc.moving
|
||||
and (npc.stepFrames or 16) < (o.player.stepFramesCur or 16) then
|
||||
fastCommits = fastCommits + 1
|
||||
end
|
||||
end
|
||||
|
||||
-- The distance sampled is the settled one: while a step is in flight the
|
||||
-- follower's committed cell is targetX/Y, which is where it will stand
|
||||
-- when the player's own landing frame is over. Raw cell distance is
|
||||
-- kept alongside it so a report of 1 cannot come from reading the wrong
|
||||
-- field; it reads 2 all the way through a held walk, because both
|
||||
-- sprites are then mid-step, and settles to 1 the moment input stops.
|
||||
local function gap()
|
||||
local p = ow.player
|
||||
local npc = follower()
|
||||
if not npc then return -1, -1 end
|
||||
local pxc = p.targetX or p.cellX
|
||||
local pyc = p.targetY or p.cellY
|
||||
local nx = npc.targetX or npc.cellX
|
||||
local ny = npc.targetY or npc.cellY
|
||||
return math.abs(pxc - nx) + math.abs(pyc - ny),
|
||||
math.abs(p.cellX - npc.cellX) + math.abs(p.cellY - npc.cellY)
|
||||
end
|
||||
|
||||
local series, raws = {}, {}
|
||||
local prevX, prevY = ow.player.cellX, ow.player.cellY
|
||||
local frames = 0
|
||||
while #series < STEPS and frames < STEPS * 40 do
|
||||
table.insert(game.input.pressQueue, "up")
|
||||
game.input.state.up = true
|
||||
frames = frames + 1
|
||||
coroutine.yield()
|
||||
local p = ow.player
|
||||
if p.cellX ~= prevX or p.cellY ~= prevY then
|
||||
prevX, prevY = p.cellX, p.cellY
|
||||
local g, r = gap()
|
||||
series[#series + 1] = g
|
||||
raws[#raws + 1] = r
|
||||
end
|
||||
end
|
||||
game.input.state.up = false
|
||||
U.wait(20) -- let the last follow step land before the final reading
|
||||
|
||||
check(("all %d steps completed (%d recorded)"):format(STEPS, #series),
|
||||
#series == STEPS)
|
||||
|
||||
local maxGap, badSteps = 0, 0
|
||||
for _, g in ipairs(series) do
|
||||
if g > maxGap then maxGap = g end
|
||||
if g ~= 1 then badSteps = badSteps + 1 end
|
||||
end
|
||||
local function avg(from, to)
|
||||
local sum, n = 0, 0
|
||||
for i = from, to do
|
||||
if series[i] then sum = sum + series[i] n = n + 1 end
|
||||
end
|
||||
return n > 0 and sum / n or 0
|
||||
end
|
||||
local head, tail = avg(1, 8), avg(#series - 7, #series)
|
||||
local finalGap, finalRaw = gap()
|
||||
|
||||
local function compact(list)
|
||||
local out, row = {}, {}
|
||||
for i, g in ipairs(list) do
|
||||
row[#row + 1] = (g >= 0 and g < 10) and tostring(g) or ("[" .. g .. "]")
|
||||
if i % 40 == 0 then out[#out + 1] = table.concat(row) row = {} end
|
||||
end
|
||||
if #row > 0 then out[#out + 1] = table.concat(row) end
|
||||
return out
|
||||
end
|
||||
for _, row in ipairs(compact(series)) do U.log("gap per step:", row) end
|
||||
for _, row in ipairs(compact(raws)) do U.log("raw cell gap: ", row) end
|
||||
|
||||
check("the gap is 1 on every step", badSteps == 0 and #series > 0)
|
||||
check(("max gap is 1 (saw %d)"):format(maxGap), maxGap == 1)
|
||||
check(("final gap is 1 (saw %d)"):format(finalGap), finalGap == 1)
|
||||
check(("Pikachu came to rest one cell behind (saw %d)"):format(finalRaw),
|
||||
finalRaw == 1)
|
||||
check(("no upward trend (first 8 avg %.2f, last 8 avg %.2f)")
|
||||
:format(head, tail), tail <= head)
|
||||
check(("the far > 6 snap never fired (%d)"):format(snaps), snaps == 0)
|
||||
U.log("fast (half length) follow steps committed:", fastCommits)
|
||||
|
||||
PikachuFollower.update = realUpdate
|
||||
|
||||
local SHOT_DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
|
||||
if U.shot(game, SHOT_DIR .. "/bug410_follow_distance.png") then
|
||||
U.log("captured", SHOT_DIR .. "/bug410_follow_distance.png")
|
||||
end
|
||||
|
||||
U.log("Pikachu has just walked 32 cells up ROUTE_1 and should be standing")
|
||||
U.log("one cell below you, close enough to touch. Walk on and it stays")
|
||||
U.log("there; the bug left a visible cell of daylight between you.")
|
||||
|
||||
while true do
|
||||
coroutine.yield()
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,313 @@
|
||||
-- Two Yellow companion beats you have to watch rather than assert: the idle
|
||||
-- animations Pikachu plays while you stand still (#411, pokeyellow
|
||||
-- engine/pikachu/pikachu_follow.asm Func_fc803) and its hop onto the Poke
|
||||
-- Center counter when the heal is accepted (#417, engine/pikachu/
|
||||
-- pikachu_emotions.asm PikachuWalksToNurseJoy). Do not add POKEPORT_SPEED:
|
||||
-- it scales the logic clock only while audio keeps its own real-time
|
||||
-- accumulator, which desynchronizes exactly the timing being judged.
|
||||
-- POKEPORT_VERSION=yellow POKEPORT_TOUCH=0 \
|
||||
-- POKEPORT_DRIVER=tests/drivers/pikachu_idle_center_bug411_bug417_test.lua love .
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
|
||||
local SHOT_DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
|
||||
local shots = {}
|
||||
local ok = true
|
||||
|
||||
local function check(label, pass)
|
||||
if not pass then ok = false end
|
||||
U.log(pass and "PASS" or "FAIL", label)
|
||||
return pass
|
||||
end
|
||||
|
||||
local function shot(name)
|
||||
local path = SHOT_DIR .. "/" .. name .. ".png"
|
||||
if U.shot(game, path) then
|
||||
shots[#shots + 1] = path .. " @f" .. U.frame()
|
||||
return true
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
-- Yellow only: the follower, the counter hop and the pikapic box are all
|
||||
-- behind GameVersion.isYellow(), so a Red boot would silently show none of
|
||||
-- this and every look-at-it line below would be a lie.
|
||||
if not check("running the Yellow cache", GameVersion.isYellow()) then
|
||||
U.log("re-run with POKEPORT_VERSION=yellow; nothing else here applies.")
|
||||
while true do coroutine.yield() end
|
||||
end
|
||||
check("SPRITE_PIKACHU is in the extracted sprite set",
|
||||
game.data.sprites and game.data.sprites.SPRITE_PIKACHU ~= nil)
|
||||
|
||||
-- ShouldPikachuSpawn wants the lab gift flag and a healthy party Pikachu;
|
||||
-- the second mon is here so the heal has something to visibly restore.
|
||||
game.save.player.name = "bryan"
|
||||
game.save.flags = game.save.flags or {}
|
||||
game.save.flags.EVENT_GOT_STARTER = true
|
||||
game.save.usedPokecenter = false -- BIT_USED_POKECENTER: get the full prompt
|
||||
game.save.party = {
|
||||
Pokemon.new(game.data, "PIKACHU", 16),
|
||||
Pokemon.new(game.data, "PIDGEY", 12),
|
||||
}
|
||||
-- hurt but not poisoned: a poisoned 1 HP Pikachu faints on the walk over
|
||||
-- and ShouldPikachuSpawn would despawn the follower mid-driver
|
||||
for _, mon in ipairs(game.save.party) do
|
||||
mon.hp = math.max(1, math.floor(mon.stats.hp / 3))
|
||||
end
|
||||
|
||||
local function follower()
|
||||
local ow = game.overworld
|
||||
for _, n in ipairs(ow and ow.npcs or {}) do
|
||||
if n.pikachuFollower then return n end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
-- one step in dir, reporting whether the player's cell actually changed;
|
||||
-- lets the walks below route around a map edit instead of shoving at a wall
|
||||
local function walk(dir)
|
||||
local ow = game.overworld
|
||||
local x, y = ow.player.cellX, ow.player.cellY
|
||||
U.hold(game, dir, 20)
|
||||
U.wait(6)
|
||||
return ow.player.cellX ~= x or ow.player.cellY ~= y
|
||||
end
|
||||
|
||||
-- ------------------------------------------------------------ #411 idle
|
||||
U.teleport(game, "PALLET_TOWN", 10, 8, "down")
|
||||
U.wait(20)
|
||||
check("follower spawned on the map", follower() ~= nil)
|
||||
|
||||
-- a couple of steps first: the follower has to be trailing a cell behind
|
||||
-- before standing still means anything
|
||||
local walked = 0
|
||||
for _, dir in ipairs({ "left", "left", "down", "right" }) do
|
||||
if walk(dir) then walked = walked + 1 end
|
||||
if walked >= 2 then break end
|
||||
end
|
||||
check("player took at least two steps before standing still", walked >= 2)
|
||||
U.wait(30)
|
||||
|
||||
local npc = follower()
|
||||
local IDLE_FRAMES = 540
|
||||
local poses, order = {}, {}
|
||||
local kinds, kindOrder = {}, {}
|
||||
local facings, facingOrder = {}, {}
|
||||
local moved, sawIdleRecord = false, false
|
||||
local idleStart = U.frame()
|
||||
local shotFrames = {}
|
||||
|
||||
if npc then
|
||||
for i = 1, IDLE_FRAMES do
|
||||
if npc.moving then moved = true end
|
||||
local idle = npc.idle
|
||||
local kind = idle and idle.kind or "none"
|
||||
if idle then sawIdleRecord = true end
|
||||
if not kinds[kind] then
|
||||
kinds[kind] = 0
|
||||
kindOrder[#kindOrder + 1] = kind
|
||||
end
|
||||
kinds[kind] = kinds[kind] + 1
|
||||
if not facings[npc.facing] then
|
||||
facings[npc.facing] = true
|
||||
facingOrder[#facingOrder + 1] = npc.facing
|
||||
end
|
||||
-- the pose a human can see: the countdown inside idle is deliberately
|
||||
-- left out, since a frozen follower would still tick it down and make
|
||||
-- "something changed" true for free
|
||||
local pose = string.format("%s|%s|%d,%d|%s", kind, tostring(npc.facing),
|
||||
math.floor(npc.px - npc.cellX * 16),
|
||||
math.floor(npc.py - npc.cellY * 16),
|
||||
tostring(npc.stepFlip))
|
||||
if not poses[pose] then
|
||||
poses[pose] = true
|
||||
order[#order + 1] = pose
|
||||
end
|
||||
-- five stills spread across the sample, far enough apart to catch a
|
||||
-- glance or a bounce between them
|
||||
if i % 108 == 0 then
|
||||
shotFrames[#shotFrames + 1] = U.frame()
|
||||
shot(string.format("bug411_idle_%d", i / 108))
|
||||
else
|
||||
U.wait(1)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
check("follower stayed put for the whole idle sample", npc ~= nil and not moved)
|
||||
check("the follower's idle state exists at all", sawIdleRecord)
|
||||
check("its visible pose changed while standing still", #order > 1)
|
||||
U.log(("idle sample: %d frames from f%d, %d distinct poses")
|
||||
:format(IDLE_FRAMES, idleStart, #order))
|
||||
local kindLine = {}
|
||||
for _, k in ipairs(kindOrder) do
|
||||
kindLine[#kindLine + 1] = k .. "x" .. kinds[k]
|
||||
end
|
||||
U.log("idle kinds seen:", table.concat(kindLine, " "))
|
||||
U.log("facings seen:", table.concat(facingOrder, " "))
|
||||
for i = 1, math.min(#order, 8) do U.log(" pose", order[i]) end
|
||||
U.log("idle shots at frames:", table.concat(shotFrames, " "))
|
||||
|
||||
-- -------------------------------------------------- #417 Poke Center hop
|
||||
-- pokeyellow data/maps/objects/ViridianPokecenter.asm: the nurse is
|
||||
-- object 1 at (3, 1), the counter tile is (3, 2) and the player talks to
|
||||
-- her from (3, 3) facing up. Positions below are derived from the loaded
|
||||
-- object rather than typed in, so a re-extract or a mod that shifts her
|
||||
-- still lands the player at the counter.
|
||||
local MAP = "VIRIDIAN_POKECENTER"
|
||||
U.teleport(game, MAP, 3, 6, "up")
|
||||
U.wait(20)
|
||||
local ow = game.overworld
|
||||
|
||||
local nurse
|
||||
for _, n in ipairs(ow.npcs or {}) do
|
||||
local d = n.def
|
||||
if d and (d.sprite == "SPRITE_NURSE" or (d.name or ""):find("NURSE")) then
|
||||
nurse = n
|
||||
break
|
||||
end
|
||||
end
|
||||
check("nurse object loaded on " .. MAP, nurse ~= nil)
|
||||
if nurse then
|
||||
local entry = game.data:textEntry(ow.map.def.label, nurse.def.text)
|
||||
check("her text entry is the TX_SCRIPT nurse marker",
|
||||
entry ~= nil and not not entry.nurse)
|
||||
end
|
||||
|
||||
-- stand two cells off the nurse with a counter tile between us: that is
|
||||
-- the geometry OverworldState:interact reaches across, and the same
|
||||
-- p.facing == "up" + isCounterCell test PikachuFollower.hopToCounter makes
|
||||
local stand
|
||||
if nurse then
|
||||
local sides = {
|
||||
{ 0, 1, "up" }, { 0, -1, "down" }, { 1, 0, "left" }, { -1, 0, "right" },
|
||||
}
|
||||
for _, s in ipairs(sides) do
|
||||
local counterX, counterY = nurse.cellX + s[1], nurse.cellY + s[2]
|
||||
local sx, sy = nurse.cellX + s[1] * 2, nurse.cellY + s[2] * 2
|
||||
if ow.map:inBounds(sx, sy) and ow.map:isWalkableCell(sx, sy)
|
||||
and ow.map:isCounterCell(counterX, counterY)
|
||||
and not ow:npcAtCell(sx, sy) then
|
||||
stand = { x = sx, y = sy, facing = s[3] }
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
check("found a counter cell to talk across", stand ~= nil)
|
||||
|
||||
if stand then
|
||||
-- walk there rather than teleport, so the follower trails in behind and
|
||||
-- is standing where the hop actually starts from
|
||||
local guard = 0
|
||||
while (ow.player.cellY > stand.y) and guard < 8 do
|
||||
if not walk("up") then break end
|
||||
guard = guard + 1
|
||||
end
|
||||
while (ow.player.cellX < stand.x) and guard < 12 do
|
||||
if not walk("right") then break end
|
||||
guard = guard + 1
|
||||
end
|
||||
while (ow.player.cellX > stand.x) and guard < 12 do
|
||||
if not walk("left") then break end
|
||||
guard = guard + 1
|
||||
end
|
||||
while (ow.player.cellY > stand.y) and guard < 16 do
|
||||
if not walk("up") then break end
|
||||
guard = guard + 1
|
||||
end
|
||||
if ow.player.cellX ~= stand.x or ow.player.cellY ~= stand.y then
|
||||
U.log("walk did not reach the counter, teleporting to",
|
||||
stand.x, stand.y)
|
||||
U.teleport(game, MAP, stand.x, stand.y, stand.facing)
|
||||
U.wait(20)
|
||||
ow = game.overworld
|
||||
end
|
||||
ow.player.facing = stand.facing
|
||||
U.wait(10)
|
||||
end
|
||||
|
||||
local fx, fy = ow.player:facingCell()
|
||||
check("player is at the counter facing the nurse",
|
||||
ow.map:isCounterCell(fx, fy) and ow.player.facing == "up")
|
||||
check("follower survived the walk over", follower() ~= nil)
|
||||
shot("bug417_at_counter")
|
||||
|
||||
-- talk, say YES, and mash through the whole sequence; stop the moment the
|
||||
-- overworld is back on top so the last A cannot re-open the nurse
|
||||
local hurt = 0
|
||||
for _, mon in ipairs(game.save.party) do
|
||||
if mon.hp < mon.stats.hp then hurt = hurt + 1 end
|
||||
end
|
||||
check("the party is hurt going in, so the heal check means something",
|
||||
hurt == #game.save.party)
|
||||
|
||||
U.tap(game, "a")
|
||||
U.wait(10)
|
||||
local sawHop, sawMachine, hopFrames = false, false, 0
|
||||
local shotHop, shotMachine = false, false
|
||||
for _ = 1, 600 do
|
||||
local cur = game.overworld
|
||||
if cur.pikaHop then
|
||||
sawHop = true
|
||||
hopFrames = hopFrames + 1
|
||||
if not shotHop and hopFrames > 16 then
|
||||
shotHop = shot("bug417_hop")
|
||||
end
|
||||
end
|
||||
if cur.healAnim then
|
||||
sawMachine = true
|
||||
if not shotMachine then shotMachine = shot("bug417_machine") end
|
||||
end
|
||||
-- the hop deliberately runs with the overworld back on top (both text
|
||||
-- boxes have popped themselves by then), so "overworld is top" alone is
|
||||
-- not the end of the sequence -- wait for the party to be healed and
|
||||
-- both held animations to be over
|
||||
local partyUp = #game.save.party > 0
|
||||
for _, mon in ipairs(game.save.party) do
|
||||
if mon.hp ~= mon.stats.hp then partyUp = false end
|
||||
end
|
||||
if partyUp and not cur.pikaHop and not cur.healAnim
|
||||
and game.stack:top() == cur then
|
||||
break
|
||||
end
|
||||
U.tap(game, "a")
|
||||
U.wait(4)
|
||||
end
|
||||
U.wait(30)
|
||||
|
||||
check("the heal script ran back out to the overworld",
|
||||
game.stack:top() == game.overworld)
|
||||
check("Pikachu hopped to the counter (#417)", sawHop)
|
||||
check("the healing machine ran", sawMachine)
|
||||
local healed = #game.save.party > 0
|
||||
for _, mon in ipairs(game.save.party) do
|
||||
if mon.hp ~= mon.stats.hp or mon.status ~= nil then healed = false end
|
||||
end
|
||||
check("the party is actually healed", healed)
|
||||
local pika = follower()
|
||||
check("follower is back on screen after the heal", pika ~= nil)
|
||||
if pika then
|
||||
U.log(("follower rests at cell (%d, %d) facing %s")
|
||||
:format(pika.cellX, pika.cellY, tostring(pika.facing)))
|
||||
end
|
||||
shot("bug417_after_heal")
|
||||
|
||||
U.log(ok and "ALL PASS" or "SOME CHECKS FAILED")
|
||||
for _, s in ipairs(shots) do U.log("shot", s) end
|
||||
|
||||
U.log("Standing still with Pikachu right behind you, all it should do is")
|
||||
U.log("glance around: pikachu_follow.asm only rolls the bounce/spin/shuffle")
|
||||
U.log("animations when the follower is 2+ cells adrift, so at a normal one")
|
||||
U.log("cell gap the facing changes ARE the whole idle behaviour. A sprite")
|
||||
U.log("frozen one way for the whole sample is the bug. To see the livelier")
|
||||
U.log("animations, strand it first -- hop a ledge or cut through a door so")
|
||||
U.log("it falls behind, then stand still before it catches up.")
|
||||
U.log("You are parked at the Viridian counter facing the nurse: press A and")
|
||||
U.log("say YES to watch Pikachu jump up onto the counter again.")
|
||||
|
||||
while true do
|
||||
coroutine.yield()
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,277 @@
|
||||
-- Ledge hop with the Yellow follower: one shadow under the jumper (#408)
|
||||
-- and Pikachu clearing the ledge in one motion instead of stopping on it
|
||||
-- (#409). The hop cell is searched out of the loaded map's own blocks
|
||||
-- against data.field.ledges (pokeyellow data/tilesets/ledge_tiles.asm), so
|
||||
-- a map edit moves the test instead of breaking it. Never add
|
||||
-- POKEPORT_SPEED here: it scales only the logic clock while audio keeps its
|
||||
-- own real-time accumulator, which desyncs the exact 32 frames being judged.
|
||||
-- POKEPORT_DRIVER=tests/drivers/pikachu_ledge_bug408_bug409_test.lua POKEPORT_IDENTITY=bug408 POKEPORT_TOUCH=0 POKEPORT_VERSION=yellow love .
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
|
||||
local results = {}
|
||||
local function check(label, ok)
|
||||
results[#results + 1] = { label = label, ok = ok and true or false }
|
||||
return ok
|
||||
end
|
||||
local function report()
|
||||
for _, r in ipairs(results) do U.log(r.ok and "PASS" or "FAIL", r.label) end
|
||||
end
|
||||
|
||||
if not GameVersion.isYellow() then
|
||||
check("running under POKEPORT_VERSION=yellow", false)
|
||||
report()
|
||||
U.log("Nothing else in this driver applies to RED/BLUE: there is no")
|
||||
U.log("follower to hop, and the four-quadrant shadow is correct there.")
|
||||
while true do coroutine.yield() end
|
||||
end
|
||||
|
||||
-- ShouldPikachuSpawn's two preconditions (engine/pikachu/
|
||||
-- pikachu_follow.asm): the lab gift happened and a healthy Pikachu is in
|
||||
-- the party. Without both, ow.npcs never gets a follower and every
|
||||
-- follower check below would read as the bug.
|
||||
game.save.flags = game.save.flags or {}
|
||||
game.save.flags.EVENT_GOT_STARTER = true
|
||||
game.save.party = { Pokemon.new(game.data, "PIKACHU", 20) }
|
||||
game.save.onBike = false
|
||||
|
||||
-- ROUTE_1 (10x18 blocks) is the first outdoor map with a plain
|
||||
-- south-facing ledge run; (5, 4) is the standing cell of one, read out of
|
||||
-- the cached blocks rather than typed from a map screenshot. scanHop
|
||||
-- re-derives it below and this only picks the starting point.
|
||||
local MAP = "ROUTE_1"
|
||||
local WANT = { x = 5, y = 4 }
|
||||
|
||||
-- data.field.ledges rows for the loaded tileset, in the shape
|
||||
-- OverworldState:checkLedgeHop matches (facing == input == the pressed
|
||||
-- direction, standing tile under the player, ledge tile in front).
|
||||
local function ledgeRows(map, dir)
|
||||
local rows = {}
|
||||
for _, l in ipairs(game.data.field.ledges or {}) do
|
||||
if (l.tileset or "OVERWORLD") == map.def.tileset
|
||||
and l.facing == dir and l.input == dir then
|
||||
rows[#rows + 1] = l
|
||||
end
|
||||
end
|
||||
return rows
|
||||
end
|
||||
|
||||
local function ledgeTileSet(map)
|
||||
local set = {}
|
||||
for _, l in ipairs(game.data.field.ledges or {}) do
|
||||
if (l.tileset or "OVERWORLD") == map.def.tileset then
|
||||
set[l.ledgeTile] = true
|
||||
end
|
||||
end
|
||||
return set
|
||||
end
|
||||
|
||||
-- a cell the player can hop south from, with two walkable cells above it
|
||||
-- to walk in from and a walkable landing two cells below
|
||||
local function hopCellOk(map, cx, cy)
|
||||
if not (map:inBounds(cx, cy) and map:inBounds(cx, cy + 3)) then
|
||||
return false
|
||||
end
|
||||
if not map:isWalkableCell(cx, cy) then return false end
|
||||
if not map:isWalkableCell(cx, cy + 2) then return false end
|
||||
-- one more open cell below the landing: the queued hop only drains on
|
||||
-- the player's NEXT step, so the ledge needs room to take it (#424)
|
||||
if not map:isWalkableCell(cx, cy + 3) then return false end
|
||||
if not (map:isWalkableCell(cx, cy - 1)
|
||||
and map:isWalkableCell(cx, cy - 2)) then
|
||||
return false
|
||||
end
|
||||
local standing = map:cellTile(cx, cy)
|
||||
local front = map:cellTile(cx, cy + 1)
|
||||
for _, l in ipairs(ledgeRows(map, "down")) do
|
||||
if l.standingTile == standing and l.ledgeTile == front then return true end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
local function scanHop(map)
|
||||
for cy = 2, map.heightCells - 3 do
|
||||
for cx = 0, map.widthCells - 1 do
|
||||
if hopCellOk(map, cx, cy) then return cx, cy end
|
||||
end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
U.teleport(game, MAP, WANT.x, WANT.y - 2, "down")
|
||||
U.wait(20)
|
||||
|
||||
local ow = game.overworld
|
||||
check("overworld is up on " .. MAP, ow ~= nil and ow.map.id == MAP)
|
||||
if not ow then
|
||||
report()
|
||||
while true do coroutine.yield() end
|
||||
end
|
||||
|
||||
local hx, hy = WANT.x, WANT.y
|
||||
if not hopCellOk(ow.map, hx, hy) then
|
||||
local sx, sy = scanHop(ow.map)
|
||||
if sx then
|
||||
U.log(("(%d, %d) is no longer a south ledge; using"):format(WANT.x, WANT.y),
|
||||
sx, sy)
|
||||
hx, hy = sx, sy
|
||||
U.teleport(game, MAP, hx, hy - 2, "down")
|
||||
U.wait(20)
|
||||
ow = game.overworld
|
||||
end
|
||||
end
|
||||
check(("a south ledge to hop at (%d, %d)"):format(hx, hy),
|
||||
hopCellOk(ow.map, hx, hy))
|
||||
|
||||
local LEDGES = ledgeTileSet(ow.map)
|
||||
|
||||
local function follower()
|
||||
for _, n in ipairs(ow.npcs or {}) do
|
||||
if n.pikachuFollower then return n end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
check("the follower spawned", follower() ~= nil)
|
||||
|
||||
-- sampled every frame from here on: the two things that only exist
|
||||
-- mid-motion. A follower at rest on a ledge tile is #409 exactly -- it
|
||||
-- walked onto the ledge and stopped there instead of clearing it.
|
||||
local maxHop, sawHopStep, restedOnLedge = 0, false, false
|
||||
local function sample()
|
||||
local p = ow.player
|
||||
if p.hopFrames and p.hopFrames > maxHop then maxHop = p.hopFrames end
|
||||
local npc = follower()
|
||||
if not npc then return end
|
||||
if npc.hopStep then sawHopStep = true end
|
||||
if not npc.moving and LEDGES[ow.map:cellTile(npc.cellX, npc.cellY)] then
|
||||
restedOnLedge = true
|
||||
end
|
||||
end
|
||||
|
||||
local function step(n)
|
||||
for _ = 1, n do
|
||||
coroutine.yield()
|
||||
sample()
|
||||
end
|
||||
end
|
||||
|
||||
-- walk south into the ledge, one frame of held Down at a time, and let go
|
||||
-- the moment the hop commits so the landing cannot roll straight into a
|
||||
-- second hop
|
||||
local walked = 0
|
||||
for _ = 1, 200 do
|
||||
table.insert(game.input.pressQueue, "down")
|
||||
game.input.state["down"] = true
|
||||
coroutine.yield()
|
||||
sample()
|
||||
walked = walked + 1
|
||||
if ow.player.hopFrames and ow.player.hopFrames > 0 then break end
|
||||
end
|
||||
game.input.state["down"] = false
|
||||
|
||||
check("the player's ledge hop actually fired", maxHop > 0)
|
||||
|
||||
-- mid-arc: count the shadow quads one Player:draw puts down. Player:draw
|
||||
-- issues its shadow copies back to back before the sprite, so the longest
|
||||
-- run of consecutive love.graphics.draw calls carrying shadowImg is that
|
||||
-- per-frame count. Yellow's LedgeHoppingShadowOAM is two entries (dbsprite
|
||||
-- 9,11 and 10,11 OAM_XFLIP, engine/overworld/ledges.asm) mirrored into one
|
||||
-- 16x8 ellipse; RED's four-quadrant block is what #408 was leaking in.
|
||||
local shadowDraws
|
||||
local img = ow.player.shadowImg
|
||||
check("the hop shadow tile loaded", img ~= nil)
|
||||
if img then
|
||||
local real = love.graphics.draw
|
||||
local run, best = 0, 0
|
||||
love.graphics.draw = function(a, ...)
|
||||
if a == img then
|
||||
run = run + 1
|
||||
if run > best then best = run end
|
||||
else
|
||||
run = 0
|
||||
end
|
||||
return real(a, ...)
|
||||
end
|
||||
step(4)
|
||||
love.graphics.draw = real
|
||||
shadowDraws = best
|
||||
check("exactly one mirrored pair of shadow quads per frame (2 draws)",
|
||||
shadowDraws == 2)
|
||||
end
|
||||
|
||||
local SHOT_DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
|
||||
local midShot = SHOT_DIR .. "/bug408_hop_midair.png"
|
||||
if ow.player.hopFrames and ow.player.hopFrames > 0 then
|
||||
check("mid-arc screenshot reached disk", U.shot(game, midShot) and true)
|
||||
U.log("captured", midShot, "at hopFrames", ow.player.hopFrames)
|
||||
else
|
||||
check("mid-arc screenshot reached disk", false)
|
||||
U.log("the arc was already over before the capture; no mid-air frame")
|
||||
end
|
||||
|
||||
-- The hop command is buffered a step behind the player: Func_fcc64 appends
|
||||
-- nothing for the landing half of the jump, so Func_fcc92 cannot pop the
|
||||
-- hop until another command queues behind it. Pikachu therefore walks to
|
||||
-- the cell the player took off from and waits there (#424).
|
||||
step(120)
|
||||
local waiting = follower()
|
||||
check("the follower waits on the cell the player took off from",
|
||||
waiting ~= nil and waiting.cellX == hx and waiting.cellY == hy)
|
||||
check("it has not hopped while the player stood still", not sawHopStep)
|
||||
|
||||
-- one more player step drains the queued hop
|
||||
for _ = 1, 90 do
|
||||
table.insert(game.input.pressQueue, "down")
|
||||
game.input.state["down"] = true
|
||||
coroutine.yield()
|
||||
sample()
|
||||
if ow.player.cellY > hy + 2 then break end
|
||||
end
|
||||
game.input.state["down"] = false
|
||||
|
||||
-- let the follower finish crossing (its hop is one normal step's frames
|
||||
-- with a doubled step vector, pikachu_follow.asm Func_fca0a)
|
||||
step(120)
|
||||
|
||||
local npc = follower()
|
||||
check("the follower is still on the map", npc ~= nil)
|
||||
if npc then
|
||||
local tile = ow.map:cellTile(npc.cellX, npc.cellY)
|
||||
check("the follower came to rest on a walkable cell",
|
||||
ow.map:isWalkableCell(npc.cellX, npc.cellY))
|
||||
check("that cell is not a ledge tile", not LEDGES[tile])
|
||||
check("the follower ended below the ledge row", npc.cellY > hy + 1)
|
||||
check("it took the doubled hop step, not two walks", sawHopStep)
|
||||
U.log(("follower rests at (%d, %d), tile %s; player at (%d, %d)")
|
||||
:format(npc.cellX, npc.cellY, tostring(tile),
|
||||
ow.player.cellX, ow.player.cellY))
|
||||
end
|
||||
check("the follower never stood still on a ledge tile", not restedOnLedge)
|
||||
|
||||
local afterShot = SHOT_DIR .. "/bug409_after_follow.png"
|
||||
check("landing screenshot reached disk", U.shot(game, afterShot) and true)
|
||||
U.log("captured", afterShot)
|
||||
U.log("walked", walked, "frames into the ledge at", hx, hy)
|
||||
|
||||
report()
|
||||
|
||||
-- hand the pad back one cell short of the same ledge, so Down alone
|
||||
-- re-runs the hop as many times as the reader wants
|
||||
U.teleport(game, MAP, hx, hy - 1, "down")
|
||||
U.wait(20)
|
||||
|
||||
U.log("Hold Down to hop the ledge again from here.")
|
||||
U.log("During the arc there should be one flat ellipse on the ground under")
|
||||
U.log("the player, not a taller blob with a seam across its middle.")
|
||||
U.log("Pikachu should walk up to the cell you jumped from and STAY there")
|
||||
U.log("until you take another step, then clear both cells in one motion.")
|
||||
U.log("The near misses to watch for are Pikachu hopping alongside you and")
|
||||
U.log("Pikachu pausing a beat on the ledge tile itself.")
|
||||
|
||||
while true do
|
||||
coroutine.yield()
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,189 @@
|
||||
-- A connection crossing has to carry Yellow's follower, not respawn it (#427).
|
||||
-- pokeyellow home/overworld.asm:648-655 sets bit 4 of
|
||||
-- wPikachuOverworldStateFlags before LoadMapHeader, so pikachu_follow.asm's
|
||||
-- SchedulePikachuSpawnForAfterText takes .normal_spawn_state: coords rebased,
|
||||
-- sprite data left alone. Never POKEPORT_SPEED here, the offsets are pixels.
|
||||
-- POKEPORT_DRIVER=tests/drivers/pikachu_seam_bug427_test.lua POKEPORT_TOUCH=0 POKEPORT_VERSION=yellow love .
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
local PikachuFollower = require("src.world.PikachuFollower")
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
local Map = require("src.world.Map")
|
||||
|
||||
local SHOT_DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
|
||||
|
||||
local function check(label, ok)
|
||||
U.log(ok and "PASS" or "FAIL", label)
|
||||
return ok
|
||||
end
|
||||
|
||||
if not GameVersion.isYellow() then
|
||||
check("only Yellow has a follower; re-run with POKEPORT_VERSION=yellow", false)
|
||||
while true do coroutine.yield() end
|
||||
end
|
||||
|
||||
-- pokeyellow data/maps/objects/PalletTown.asm puts no object on the town's
|
||||
-- north exit and PALLET_TOWN's north connection to ROUTE_1 carries offset 0,
|
||||
-- so the landing column is the column walked off the edge.
|
||||
local MAP = "PALLET_TOWN"
|
||||
local NORTH = "ROUTE_1"
|
||||
local COL = 10
|
||||
local START_Y = 3
|
||||
|
||||
game.save.party = { Pokemon.new(game.data, "PIKACHU", 100) }
|
||||
game.save.flags = game.save.flags or {}
|
||||
game.save.flags.EVENT_GOT_STARTER = true
|
||||
game.save.onBike = false
|
||||
game.save.repelSteps = 9999
|
||||
game.save.player.name = "bryan"
|
||||
|
||||
U.teleport(game, MAP, COL, START_Y, "up")
|
||||
U.wait(10)
|
||||
local ow = game.overworld
|
||||
local map = ow.map
|
||||
|
||||
-- a usable exit column: four clear cells up to the edge, nothing standing
|
||||
-- in them, and a landing the engine's own edge read accepts (Map.defPassable
|
||||
-- is what crossConnection consults before it commits to the crossing)
|
||||
local function exitOK(cx)
|
||||
for cy = 0, START_Y do
|
||||
if not map:isWalkableCell(cx, cy) then return false end
|
||||
-- ow.npcs carries the follower too, and it is standing in this column
|
||||
local at = ow:npcAtCell(cx, cy)
|
||||
if at and not at.pikachuFollower then return false end
|
||||
end
|
||||
local p = ow.player
|
||||
local keepX = p.cellX
|
||||
p.cellX = cx
|
||||
local dest, ts, lx, ly = ow:connectionLanding("up")
|
||||
p.cellX = keepX
|
||||
return dest ~= nil and dest.id == NORTH
|
||||
and Map.defPassable(dest, ts, lx, ly, false)
|
||||
end
|
||||
|
||||
if not exitOK(COL) then
|
||||
local found
|
||||
for cx = 0, map.widthCells - 1 do
|
||||
if exitOK(cx) then found = cx break end
|
||||
end
|
||||
U.log(("column %d no longer walks off the north edge; using column %s")
|
||||
:format(COL, tostring(found)))
|
||||
COL = found or COL
|
||||
U.teleport(game, MAP, COL, START_Y, "up")
|
||||
U.wait(10)
|
||||
ow = game.overworld
|
||||
map = ow.map
|
||||
end
|
||||
check(("column %d walks off %s's north edge onto %s"):format(COL, MAP, NORTH),
|
||||
exitOK(COL))
|
||||
|
||||
local pika = PikachuFollower.current(ow)
|
||||
check("the follower spawned on " .. MAP, pika ~= nil)
|
||||
|
||||
-- exactly one step north, then stop: the follower has to be trailing and
|
||||
-- settled, since a respawn on the cell it already occupies is invisible
|
||||
local p = ow.player
|
||||
local stopY = p.cellY - 1
|
||||
for _ = 1, 60 do
|
||||
if p.cellY <= stopY and not p.moving then break end
|
||||
table.insert(game.input.pressQueue, "up")
|
||||
game.input.state.up = true
|
||||
coroutine.yield()
|
||||
end
|
||||
game.input.state.up = false
|
||||
U.wait(20)
|
||||
U.log(("before the seam: player (%d, %d), follower (%s, %s)")
|
||||
:format(p.cellX, p.cellY, tostring(pika and pika.cellX),
|
||||
tostring(pika and pika.cellY)))
|
||||
check("the follower trails one cell behind before the seam",
|
||||
pika ~= nil and pika.cellX == p.cellX and pika.cellY == p.cellY + 1)
|
||||
if U.shot(game, SHOT_DIR .. "/bug427_before_seam.png") then
|
||||
U.log("captured", SHOT_DIR .. "/bug427_before_seam.png")
|
||||
end
|
||||
|
||||
-- Hold up through the crossing, sampling once per logic step (main.lua
|
||||
-- resumes the driver exactly once per Game:update). The pixel offset
|
||||
-- between the two sprites is the measurement: a respawn loses the in-flight
|
||||
-- step, so the offset snaps even when the new instance lands on the right
|
||||
-- cell. The mid-seam capture is armed inline instead of through U.shot so
|
||||
-- the walk keeps running while it lands.
|
||||
local samples, missing, maxJump = 0, 0, 0
|
||||
local prevX, prevY
|
||||
local crossedAt, pikaAfter
|
||||
local frames = 0
|
||||
while frames < 240 do
|
||||
table.insert(game.input.pressQueue, "up")
|
||||
game.input.state.up = true
|
||||
frames = frames + 1
|
||||
coroutine.yield()
|
||||
local npc = PikachuFollower.current(ow)
|
||||
if not npc then
|
||||
missing = missing + 1
|
||||
else
|
||||
local pl = ow.player
|
||||
local ox, oy = npc.px - pl.px, npc.py - pl.py
|
||||
if prevX then
|
||||
local jump = math.max(math.abs(ox - prevX), math.abs(oy - prevY))
|
||||
if jump > maxJump then maxJump = jump end
|
||||
end
|
||||
prevX, prevY = ox, oy
|
||||
samples = samples + 1
|
||||
end
|
||||
if not crossedAt and ow.map.def.id == NORTH then
|
||||
crossedAt = frames
|
||||
pikaAfter = npc
|
||||
game.capturePath = SHOT_DIR .. "/bug427_mid_seam.png"
|
||||
end
|
||||
if crossedAt and frames >= crossedAt + 24 then break end
|
||||
end
|
||||
game.input.state.up = false
|
||||
U.wait(30)
|
||||
|
||||
check("the player crossed the seam onto " .. NORTH,
|
||||
crossedAt ~= nil and ow.map.def.id == NORTH)
|
||||
check("the follower across the seam is the same instance",
|
||||
pikaAfter ~= nil and pikaAfter == pika)
|
||||
check("it is still the same instance once the walk settles",
|
||||
PikachuFollower.current(ow) == pika)
|
||||
check(("it never dropped out of ow.npcs (%d of %d frames)")
|
||||
:format(missing, samples + missing), missing == 0 and samples > 0)
|
||||
check(("its pixel offset to the player never jumped (max %dpx)")
|
||||
:format(maxJump), maxJump <= 4)
|
||||
|
||||
local settled = PikachuFollower.current(ow)
|
||||
p = ow.player
|
||||
if settled then
|
||||
U.log(("player (%d, %d) facing %s, follower (%d, %d)")
|
||||
:format(p.cellX, p.cellY, tostring(p.facing),
|
||||
settled.cellX, settled.cellY))
|
||||
end
|
||||
check("the follower came to rest one cell behind",
|
||||
settled ~= nil and settled.cellX == p.cellX
|
||||
and settled.cellY == p.cellY + 1)
|
||||
local trail = ow.pikachuTrail
|
||||
check("the chased trail cell was rebased into the new map",
|
||||
trail ~= nil and ow.map:inBounds(trail.x, trail.y))
|
||||
|
||||
local mid = io.open(SHOT_DIR .. "/bug427_mid_seam.png", "rb")
|
||||
if mid then
|
||||
mid:close()
|
||||
U.log("captured", SHOT_DIR .. "/bug427_mid_seam.png")
|
||||
else
|
||||
U.log("FAIL screenshot did not reach disk:", SHOT_DIR .. "/bug427_mid_seam.png")
|
||||
end
|
||||
if U.shot(game, SHOT_DIR .. "/bug427_after_seam.png") then
|
||||
U.log("captured", SHOT_DIR .. "/bug427_after_seam.png")
|
||||
end
|
||||
|
||||
U.log("You just walked north out of Pallet onto Route 1 with Pikachu behind")
|
||||
U.log("you. In bug427_mid_seam.png it has to be below you and mid-stride, on")
|
||||
U.log("the pixels the walk left it on; the bug put it squarely on a cell in")
|
||||
U.log("front of you instead, once per seam, which is the pop in the report.")
|
||||
U.log("Warps still respawn it behind you (walk through any door), and a")
|
||||
U.log("fainted or deposited Pikachu still leaves no follower at a seam.")
|
||||
|
||||
while true do
|
||||
coroutine.yield()
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,251 @@
|
||||
-- Manual check that the Yellow follower answers an A press (#407).
|
||||
-- TalkToPikachu (pokeyellow engine/pikachu/pikachu_emotions.asm) picks an
|
||||
-- emotion, plays its bubble + voiced clip and raises the framed pikapic;
|
||||
-- the port's follower is a frame behind the player, so the old not-moving
|
||||
-- gate in OverworldState:interact ate the press right after a step landed.
|
||||
-- No POKEPORT_SPEED: it scales the logic clock only, and the cry runs on
|
||||
-- the real-time audio accumulator, so a fast run desyncs what is judged.
|
||||
-- POKEPORT_DRIVER=tests/drivers/pikachu_talk_bug407_test.lua POKEPORT_IDENTITY=bug407 POKEPORT_TOUCH=0 POKEPORT_VERSION=yellow love .
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
local Sound = require("src.core.Sound")
|
||||
|
||||
local SHOT_DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
|
||||
local OPPOSITE = { up = "down", down = "up", left = "right", right = "left" }
|
||||
|
||||
local function check(label, ok)
|
||||
U.log(ok and "PASS" or "FAIL", label)
|
||||
return ok
|
||||
end
|
||||
|
||||
local function idle()
|
||||
while true do coroutine.yield() end
|
||||
end
|
||||
|
||||
-- the Yellow cache is a separate mount; on Red/Blue there is no follower
|
||||
-- at all and every line below would fail for the wrong reason
|
||||
if not check("running the Yellow cache (POKEPORT_VERSION=yellow)",
|
||||
GameVersion.isYellow()) then
|
||||
U.log("Red and Blue have no follower. Re-run with POKEPORT_VERSION=yellow.")
|
||||
idle()
|
||||
end
|
||||
|
||||
-- ShouldPikachuSpawn's three inputs (pikachu_follow.asm): the lab gift
|
||||
-- happened, a healthy starter Pikachu is in the party, and the sprite
|
||||
-- exists in the cache. Happiness/mood are left at their boot values
|
||||
-- (90 / 128, init_player_data.asm) so the mood matrix lands on the same
|
||||
-- cell a fresh save would pick.
|
||||
game.save.party = { Pokemon.new(game.data, "PIKACHU", 12) }
|
||||
game.save.player.name = "bryan"
|
||||
game.save.flags = game.save.flags or {}
|
||||
game.save.flags.EVENT_GOT_STARTER = true
|
||||
|
||||
-- pokeyellow data/maps/objects/PalletTown.asm: the town's objects sit at
|
||||
-- (10,4), (3,8) and (11,14) and its warps at (5,5), (13,5), (12,11), so
|
||||
-- the road cells around (10,8) are clear of all of them.
|
||||
local MAP = "PALLET_TOWN"
|
||||
local START = { x = 10, y = 8, facing = "down" }
|
||||
|
||||
U.teleport(game, MAP, START.x, START.y, START.facing)
|
||||
U.wait(10)
|
||||
|
||||
local ow = game.overworld
|
||||
local function follower()
|
||||
for _, n in ipairs(ow.npcs or {}) do
|
||||
if n.pikachuFollower then return n end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
local npc = follower()
|
||||
if not check("follower is in ow.npcs with pikachuFollower set", npc ~= nil) then
|
||||
U.log("EVENT_GOT_STARTER:", tostring(game.save.flags.EVENT_GOT_STARTER),
|
||||
"party PIKACHU hp:",
|
||||
tostring(game.save.party[1] and game.save.party[1].hp),
|
||||
"SPRITE_PIKACHU:",
|
||||
tostring(game.data.sprites and game.data.sprites.SPRITE_PIKACHU ~= nil))
|
||||
idle()
|
||||
end
|
||||
|
||||
-- one real step, so the follower trails onto the cell just vacated and
|
||||
-- the press lands in exactly the window #407 used to swallow. A later
|
||||
-- map edit that walls (10,9) in degrades to any free neighbour instead
|
||||
-- of walking into a fence.
|
||||
local p = ow.player
|
||||
local DIRS = { { "down", 0, 1 }, { "up", 0, -1 },
|
||||
{ "left", -1, 0 }, { "right", 1, 0 } }
|
||||
local stepDir
|
||||
for _, d in ipairs(DIRS) do
|
||||
local cx, cy = p.cellX + d[2], p.cellY + d[3]
|
||||
if ow.map:inBounds(cx, cy) and ow.map:isWalkableCell(cx, cy)
|
||||
and not ow:npcAtCell(cx, cy) then
|
||||
stepDir = d[1]
|
||||
break
|
||||
end
|
||||
end
|
||||
if not check("a walkable neighbour to step into exists", stepDir ~= nil) then
|
||||
idle()
|
||||
end
|
||||
U.hold(game, stepDir, 24) -- 16 frame step plus the turn frame and slack
|
||||
U.wait(6)
|
||||
|
||||
-- turn back the way we came: tryMove on a new facing only turns, so the
|
||||
-- tap cannot walk back onto the follower's cell
|
||||
U.tap(game, OPPOSITE[stepDir])
|
||||
U.wait(6)
|
||||
|
||||
local function facingFollower()
|
||||
local fx, fy = ow.player:facingCell()
|
||||
return ow:npcAtCell(fx, fy) == npc
|
||||
end
|
||||
|
||||
if not facingFollower() then
|
||||
-- the follower is somewhere else (a step it could not take, a mod):
|
||||
-- turn toward whichever neighbouring cell it actually occupies
|
||||
for _, d in ipairs(DIRS) do
|
||||
if npc.cellX == ow.player.cellX + d[2]
|
||||
and npc.cellY == ow.player.cellY + d[3] then
|
||||
U.log("follower is", d[1], "of the player, turning that way instead")
|
||||
U.tap(game, d[1])
|
||||
U.wait(6)
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
check("player is facing the follower's cell", facingFollower())
|
||||
U.log("player at", ow.player.cellX, ow.player.cellY,
|
||||
"facing", ow.player.facing,
|
||||
"| follower at", npc.cellX, npc.cellY)
|
||||
|
||||
-- a muted run sounds exactly like the bug, so say so before anyone
|
||||
-- listens for the clip (Sound.setVolumeLevel reads save.options.sfxVol)
|
||||
local sfxVol = game.save.options and game.save.options.sfxVol
|
||||
U.log("save.options.sfxVol:", tostring(sfxVol))
|
||||
if (sfxVol or 0) == 0 then
|
||||
U.log("WARNING sfx volume is 0: no cry can be heard whether or not one")
|
||||
U.log("WARNING plays. Raise it in OPTIONS before judging the sound half.")
|
||||
end
|
||||
|
||||
-- record what the talk actually asked the mixer for: PCM clip, chip
|
||||
-- fallback, or nothing at all. playCry calls Sound.playPikaCry through
|
||||
-- the table, so the wrapper sees the fallback too.
|
||||
local cries = {}
|
||||
local realPika, realChip = Sound.playPikaCry, Sound.playCry
|
||||
Sound.playPikaCry = function(data, n)
|
||||
local src = realPika(data, n)
|
||||
cries[#cries + 1] = { kind = "pcm clip", id = n, src = src }
|
||||
return src
|
||||
end
|
||||
Sound.playCry = function(data, species)
|
||||
local src = realChip(data, species)
|
||||
cries[#cries + 1] = { kind = "chip cry", id = species, src = src }
|
||||
return src
|
||||
end
|
||||
local function restoreSound()
|
||||
Sound.playPikaCry, Sound.playCry = realPika, realChip
|
||||
end
|
||||
|
||||
-- data.field.emotionBubbles is the sheet TalkToPikachu's bubble index
|
||||
-- resolves against; an unbuilt Yellow cache carries only the three
|
||||
-- shared bubbles and the talk degrades to a silent hold
|
||||
local sheet = game.data.field and game.data.field.emotionBubbles
|
||||
local function bubbleReport(emote)
|
||||
if emote.bubble == false or emote.bubble == nil then
|
||||
U.log("this emotion has NO bubble: a cry and the framed pic are all it")
|
||||
U.log("puts on screen, which is correct behavior, not the bug")
|
||||
return true
|
||||
end
|
||||
local rect = sheet and sheet.bubbles and sheet.bubbles[emote.bubble]
|
||||
local ok = rect ~= nil and (sheet.path ~= nil)
|
||||
check("bubble index " .. tostring(emote.bubble) ..
|
||||
" resolves against the cache sheet", ok)
|
||||
if ok then
|
||||
U.log("bubble", rect.name or "?", "crop",
|
||||
rect.x, rect.y, rect.w, rect.h, "of", sheet.path)
|
||||
end
|
||||
return ok
|
||||
end
|
||||
|
||||
local function reportCries(from)
|
||||
for i = #cries, 1, -1 do
|
||||
if i > from then
|
||||
U.log("cry:", cries[i].kind, tostring(cries[i].id),
|
||||
cries[i].src and "source created" or "NO SOURCE")
|
||||
end
|
||||
end
|
||||
return #cries > from
|
||||
end
|
||||
|
||||
-- ---- press one: whatever the mood matrix picks on a boot-value save ----
|
||||
local before = #cries
|
||||
U.tap(game, "a")
|
||||
U.wait(6)
|
||||
|
||||
local emote = ow.emote
|
||||
check("the A press reached the follower (ow.emote is set)",
|
||||
emote ~= nil and emote.npc == npc)
|
||||
if not emote then
|
||||
restoreSound()
|
||||
U.log("Nothing answered the press. That is #407 exactly: no bubble, no")
|
||||
U.log("cry, no framed picture, and the map keeps running underneath.")
|
||||
idle()
|
||||
end
|
||||
check("the framed pikapic path resolved",
|
||||
type(emote.pikaPic) == "string"
|
||||
and love.filesystem.getInfo(emote.pikaPic) ~= nil)
|
||||
check("a cry source was created", reportCries(before))
|
||||
-- the beat now runs the pikapic script's own pikapic_setduration, three
|
||||
-- 60Hz frames per tick, so even the shortest script (32 ticks) outlasts
|
||||
-- the 50 frame hold this port used to serve every emotion (#424)
|
||||
check("the hold runs the script's own duration, not a flat 50 frames",
|
||||
(emote.frames or 0) >= 80)
|
||||
bubbleReport(emote)
|
||||
U.log("happiness", tostring(game.save.pikachuHappiness or 90),
|
||||
"mood", tostring(game.save.pikachuMood or 128))
|
||||
U.log("on boot values (90 / 128) the matrix cell is emotion 5:")
|
||||
U.log("PCM clip 31 and no bubble at all, so sound with no bubble is right")
|
||||
if U.shot(game, SHOT_DIR .. "/bug407_talk_mood.png") then
|
||||
U.log("captured", SHOT_DIR .. "/bug407_talk_mood.png")
|
||||
end
|
||||
|
||||
-- ---- press two: a scripted emotion that must show a bubble ----
|
||||
-- wPikachuEmotionModifier 5 is MapSpecificPikachuExpression's fifth
|
||||
-- entry, emotion 25: BOLT_BUBBLE plus PCM clip 35. Forcing it takes the
|
||||
-- mood roll out of the picture, so a missing bubble here is a real fault.
|
||||
-- wait the pikapic beat out instead of counting frames: its length is the
|
||||
-- script's now, and an A press during it would only cut it short
|
||||
-- (PikaPicAnimTimerAndJoypad, #424)
|
||||
for _ = 1, 400 do
|
||||
if not ow.emote then break end
|
||||
U.wait(1)
|
||||
end
|
||||
U.wait(6)
|
||||
game.save.pikachuEmotionModifier = 5
|
||||
check("still facing the follower for the second press", facingFollower())
|
||||
before = #cries
|
||||
U.tap(game, "a")
|
||||
U.wait(6)
|
||||
|
||||
emote = ow.emote
|
||||
check("emotion 25 (forced) answered the press", emote ~= nil)
|
||||
if emote then
|
||||
check("emotion 25 carries a bubble index", type(emote.bubble) == "number")
|
||||
bubbleReport(emote)
|
||||
check("emotion 25 played a cry", reportCries(before))
|
||||
if U.shot(game, SHOT_DIR .. "/bug407_talk_bolt.png") then
|
||||
U.log("captured", SHOT_DIR .. "/bug407_talk_bolt.png")
|
||||
end
|
||||
end
|
||||
restoreSound()
|
||||
|
||||
U.log("Both presses have already happened; the screen shows the second.")
|
||||
U.log("A framed Pikachu picture sits over the map with a lightning bubble")
|
||||
U.log("above the follower and a voiced squeak plays. Face it and press A")
|
||||
U.log("again for the mood-picked one: on a fresh save that emotion has a")
|
||||
U.log("cry but no bubble, which is right. Nothing at all -- no box, no")
|
||||
U.log("picture, no sound -- is #407 still biting.")
|
||||
|
||||
idle()
|
||||
end
|
||||
@@ -0,0 +1,91 @@
|
||||
-- Manual check that EXIT GAME actually ends the process (#339): the two
|
||||
-- background workers are now told to quit from love.quit and Android exits
|
||||
-- outright, so the app reopens without swiping it out of Recents.
|
||||
-- Port lifecycle only, no pokered analogue (the GB has no process).
|
||||
-- POKEPORT_DRIVER=tests/drivers/quit_process_bug339_test.lua POKEPORT_IDENTITY=bug339 POKEPORT_TOUCH=0 love .
|
||||
-- Do not set POKEPORT_SPEED: fast-forward desynchronizes the title music.
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
|
||||
local function check(label, ok)
|
||||
U.log(ok and "PASS" or "FAIL", label)
|
||||
return ok
|
||||
end
|
||||
|
||||
-- No map setup: the quit path is the title's EXIT GAME row
|
||||
-- (src/ui/TitleState.lua:368), reachable straight from boot. The START
|
||||
-- menu QUIT is not a process quit, it power-cycles to the title
|
||||
-- (src/core/Game.lua:165), so it cannot verify this.
|
||||
U.wait(5)
|
||||
U.tap(game, "start") -- past the copyright splash / attract movie
|
||||
U.wait(30)
|
||||
|
||||
local title = game.stack:top()
|
||||
check("the title screen is on top",
|
||||
title ~= nil and title.screenId == "TitleState")
|
||||
|
||||
-- love.quit reaches both workers through package.loaded, so what matters is
|
||||
-- that the module this session loaded is the one carrying shutdown
|
||||
local chip = package.loaded["src.core.ChipAudio"]
|
||||
check("ChipAudio is loaded in this session", chip ~= nil)
|
||||
check("ChipAudio.shutdown exists",
|
||||
chip ~= nil and type(chip.shutdown) == "function")
|
||||
check("love.thread is available, so the chip worker is the live one",
|
||||
love.thread ~= nil and love.thread.newThread ~= nil)
|
||||
|
||||
-- src/update/Check's worker is started by the launcher
|
||||
-- (src/import/RomImporter.lua:594), not by a driver run
|
||||
local upd = package.loaded["src.update.Check"]
|
||||
if upd then
|
||||
check("Check.shutdown exists", type(upd.shutdown) == "function")
|
||||
else
|
||||
local ok, mod = pcall(require, "src.update.Check")
|
||||
check("Check.shutdown exists (module not started this run)",
|
||||
ok and type(mod.shutdown) == "function")
|
||||
end
|
||||
|
||||
check("love.event.quit is reachable",
|
||||
love.event ~= nil and love.event.quit ~= nil)
|
||||
|
||||
local opts = game.save.options
|
||||
if (opts and opts.sfxVol or 0) == 0 then
|
||||
U.log("WARNING sfx volume is 0: the menu presses will make no sound.")
|
||||
end
|
||||
if (opts and opts.musicVol or 0) == 0 then
|
||||
U.log("WARNING music volume is 0: with the title theme silent the chip")
|
||||
U.log("worker may never have started, which is the thread this fix joins.")
|
||||
end
|
||||
|
||||
-- park the cursor on EXIT GAME and stop there; pressing it is the human's
|
||||
-- job, since the press ends the process
|
||||
U.tap(game, "a")
|
||||
U.wait(20)
|
||||
local menu = game.stack:top()
|
||||
local wanted, labels = nil, {}
|
||||
for i, item in ipairs(menu and menu.items or {}) do
|
||||
labels[#labels + 1] = tostring(item.label)
|
||||
if tostring(item.label):find("EXIT", 1, true) then wanted = i end
|
||||
end
|
||||
check("the title menu is open", menu ~= nil and menu.items ~= nil)
|
||||
check("it has an EXIT GAME row", wanted ~= nil)
|
||||
U.log("menu rows:", table.concat(labels, ", "))
|
||||
|
||||
if wanted then
|
||||
for _ = 1, #menu.items do
|
||||
if menu.index == wanted then break end
|
||||
U.tap(game, "down")
|
||||
U.wait(6)
|
||||
end
|
||||
check("the cursor is parked on EXIT GAME", menu.index == wanted)
|
||||
end
|
||||
|
||||
U.log("The cursor sits on EXIT GAME; press A and the window should close.")
|
||||
U.log("A few seconds later `pgrep -fl love` must print nothing at all: before")
|
||||
U.log("this fix a headless love process stayed resident burning a core.")
|
||||
U.log("On Android, reopening from the launcher without swiping the app out of")
|
||||
U.log("Recents should cold boot normally instead of flashing black.")
|
||||
|
||||
while true do
|
||||
coroutine.yield()
|
||||
end
|
||||
end
|
||||
@@ -45,8 +45,9 @@ return function(game)
|
||||
ow = game.overworld
|
||||
end
|
||||
|
||||
-- 1) Arrive on the 2F stairs via a REAL warp so warpEntryCell/justWarped
|
||||
-- are set. Teleporting straight onto (7,1) bypasses takeWarp and would
|
||||
-- 1) Arrive on the 2F stairs via a REAL warp so the arrival state is real
|
||||
-- (warpEntryCell set, BIT_STANDING_ON_WARP cleared by the stair tile).
|
||||
-- Teleporting straight onto (7,1) bypasses takeWarp and would
|
||||
-- never set the arrival-inert state, so the bug could not reproduce --
|
||||
-- we must walk up onto the 1F stairs and let the warp carry us.
|
||||
U.teleport(game, "REDS_HOUSE_1F", 7, 3, "up")
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user