mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-17 11:11:10 +02:00
Compare commits
85 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 898bf0c71e | |||
| 5a7add8eaa | |||
| 66334dfacd | |||
| a33f3b1ceb | |||
| 6e724cedf7 | |||
| e949c78639 | |||
| 9bcfdb1f0d | |||
| fccb122c59 | |||
| e2b2a5fb9f | |||
| f2a3e5f05b | |||
| 5f855568c8 | |||
| 24696e3be2 | |||
| b17dce232e | |||
| e1b20723fd | |||
| fdab15a6dc | |||
| 9c5f33e187 | |||
| 4916fc8f4b | |||
| fc19e58678 | |||
| 9610bcfb72 | |||
| 1bc252741a | |||
| 97e3fb296c | |||
| 74eeb411ba | |||
| 040ca3f332 | |||
| 5ec440792e | |||
| 9d3ee6a597 | |||
| 6c4c9d14e2 | |||
| d165189032 | |||
| 84bf25fcd5 | |||
| 3945b9d078 | |||
| 6796804593 | |||
| 858c5bc9b8 | |||
| 71bdbb7e48 | |||
| c346c78697 | |||
| ba2bac5bed | |||
| bcfc94d448 | |||
| bebfc87cd4 | |||
| c8e035d332 | |||
| e58929428c | |||
| a389416b7b | |||
| 0a8bca1a6d | |||
| 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 | |||
| cd33858020 | |||
| fb58fa788b | |||
| d09cf80da5 | |||
| aab980b05a |
@@ -40,6 +40,7 @@ body:
|
|||||||
- Windows
|
- Windows
|
||||||
- Linux
|
- Linux
|
||||||
- Android
|
- Android
|
||||||
|
- iOS
|
||||||
- Multiple platforms
|
- Multiple platforms
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
name: Release
|
name: Release
|
||||||
|
|
||||||
# Builds the macOS, Windows, and Linux desktop apps, an Android APK, and the
|
# Builds the macOS, Windows, and Linux desktop apps, an Android APK, an iOS
|
||||||
# Anbernic RG34XXSP (Stock OS 64-bit MOD / PortMaster) port on the self-hosted
|
# IPA, and the Anbernic RG34XXSP (Stock OS 64-bit MOD / PortMaster) port on
|
||||||
# Mac runner, and publishes them as a GitHub Release.
|
# the self-hosted Mac runner, and publishes them as a GitHub Release.
|
||||||
#
|
#
|
||||||
# Versioning:
|
# Versioning:
|
||||||
# - First ever release is 0.1.0.
|
# - First ever release is 0.1.0.
|
||||||
@@ -170,6 +170,15 @@ jobs:
|
|||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
scripts/build_android.sh --version "${{ steps.ver.outputs.version }}"
|
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
|
- name: Build Anbernic RG34XXSP port
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
@@ -226,6 +235,10 @@ jobs:
|
|||||||
[ -n "$apk" ] || { echo "::error::no Android APK found under dist/android/debug"; exit 1; }
|
[ -n "$apk" ] || { echo "::error::no Android APK found under dist/android/debug"; exit 1; }
|
||||||
cp "$apk" "$outdir/gen1recomp-${v}-android.apk"
|
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
|
# Anbernic handheld port (suffix names the CFW it targets, so a
|
||||||
# future RG35XX/other-CFW pack can ship alongside it).
|
# future RG35XX/other-CFW pack can ship alongside it).
|
||||||
rg34="dist/rg34xxsp/gen1recomp-rg34xxsp-stockos64-mod.zip"
|
rg34="dist/rg34xxsp/gen1recomp-rg34xxsp-stockos64-mod.zip"
|
||||||
@@ -344,6 +357,7 @@ jobs:
|
|||||||
"dist/release/gen1recomp-${v}-windows.zip" \
|
"dist/release/gen1recomp-${v}-windows.zip" \
|
||||||
"dist/release/gen1recomp-${v}-linux.zip" \
|
"dist/release/gen1recomp-${v}-linux.zip" \
|
||||||
"dist/release/gen1recomp-${v}-android.apk" \
|
"dist/release/gen1recomp-${v}-android.apk" \
|
||||||
|
"dist/release/gen1recomp-${v}-ios.ipa" \
|
||||||
"dist/release/gen1recomp-${v}-rg34xxsp-stockos64-mod.zip" \
|
"dist/release/gen1recomp-${v}-rg34xxsp-stockos64-mod.zip" \
|
||||||
"dist/release/gen1recomp-${v}.love" \
|
"dist/release/gen1recomp-${v}.love" \
|
||||||
"dist/release/sha256sums.txt"
|
"dist/release/sha256sums.txt"
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ __pycache__/
|
|||||||
.*
|
.*
|
||||||
!.github/
|
!.github/
|
||||||
!.gitignore
|
!.gitignore
|
||||||
|
!.luacheckrc
|
||||||
|
|
||||||
# Android build outputs / local SDK path / packaged payload (keep love-android sources)
|
# Android build outputs / local SDK path / packaged payload (keep love-android sources)
|
||||||
mobile/android/app/build/
|
mobile/android/app/build/
|
||||||
@@ -39,3 +40,6 @@ mobile/dist/
|
|||||||
# Tiled map-editing workspace (tools/tiled_export.py). Derived from the imported
|
# Tiled map-editing workspace (tools/tiled_export.py). Derived from the imported
|
||||||
# ROM cache exactly like data/generated/, so it is never committable.
|
# ROM cache exactly like data/generated/, so it is never committable.
|
||||||
/build/tiled/
|
/build/tiled/
|
||||||
|
|
||||||
|
# per-machine iOS bundle-id pin (see scripts/build_ios.sh)
|
||||||
|
mobile/ios/bundle_id.local
|
||||||
|
|||||||
+43
@@ -0,0 +1,43 @@
|
|||||||
|
-- Static-analysis config for `luacheck` (https://luacheck.readthedocs.io).
|
||||||
|
--
|
||||||
|
-- Run it over the engine with: luacheck src (or scripts/lint.sh)
|
||||||
|
--
|
||||||
|
-- The point is a high-signal baseline: the categories left on are the ones
|
||||||
|
-- that catch real defects -- undefined globals/locals (the class that hid a
|
||||||
|
-- `music.volume` crash: applyVolume read a `state` that was still the nil
|
||||||
|
-- global), unused values, unreachable code, redefinitions. The cosmetic
|
||||||
|
-- categories the codebase deliberately lives with (a `self`/`dt` an
|
||||||
|
-- interface requires but a given method ignores, documented empty
|
||||||
|
-- fall-through branches, the odd long line) are muted so they don't drown
|
||||||
|
-- the signal.
|
||||||
|
|
||||||
|
std = "luajit"
|
||||||
|
|
||||||
|
-- LÖVE exposes `love` as a mutable table: games assign their callbacks onto
|
||||||
|
-- it (love.wheelmoved, love.run, ...), so it is a regular global, not
|
||||||
|
-- read-only -- otherwise every callback registration reads as a violation.
|
||||||
|
globals = { "love" }
|
||||||
|
|
||||||
|
read_globals = {
|
||||||
|
"jit",
|
||||||
|
-- LuaJIT 2.1 ships table.unpack even though the bare 5.1 `table` std lacks
|
||||||
|
-- it; without this, every `table.unpack` reads as an undefined field.
|
||||||
|
table = { fields = { "unpack" } },
|
||||||
|
}
|
||||||
|
|
||||||
|
-- Vendored/native trees and the test suites have their own conventions.
|
||||||
|
exclude_files = {
|
||||||
|
"mobile/",
|
||||||
|
"tests/",
|
||||||
|
"tools/save-editor/",
|
||||||
|
}
|
||||||
|
|
||||||
|
ignore = {
|
||||||
|
"212", -- unused argument -- self/dt kept for a shared method signature
|
||||||
|
"213", -- unused loop variable -- `for _, v in` where only v is wanted
|
||||||
|
"421", -- shadowing a local -- deliberate re-use in a few tight scopes
|
||||||
|
"431", -- shadowing an upvalue
|
||||||
|
"432", -- shadowing an argument
|
||||||
|
"542", -- empty if branch -- documented fall-throughs, not gaps
|
||||||
|
"631", -- line is too long
|
||||||
|
}
|
||||||
@@ -8,36 +8,68 @@ supplied by the player.
|
|||||||
|
|
||||||
**SUPPORT / ANNOUNCEMENTS / MODS:** [Discord](https://bois.icu)
|
**SUPPORT / ANNOUNCEMENTS / MODS:** [Discord](https://bois.icu)
|
||||||
|
|
||||||
<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> </p>
|
<p align="center">
|
||||||
<p align="center"> <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> </p>
|
|
||||||
|
<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
|
### Watch the latest update video
|
||||||
|
|
||||||
[](https://www.youtube.com/watch?v=TbHdJIrKJJU)
|
[](https://www.youtube.com/watch?v=8IOgqbe4YvA)
|
||||||
|
|
||||||
|
|
||||||
This project does not include a ROM, emulate the Game Boy, transpile assembly,
|
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
|
or download a disassembly. A canonical US Poke Red, Blue, or Yellow ROM is the
|
||||||
game content input.
|
only game content input.
|
||||||
|
|
||||||
The ROM is verified, used during import, and then released from memory. It is
|
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
|
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
|
do not ask for the ROM again. Red, Blue, and Yellow can all be imported and
|
||||||
side by side.
|
played side by side.
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
Open the desktop app. On first boot, choose your legally obtained `.gb` file
|
Open the desktop app. On first boot, choose your legally obtained `.gb` /
|
||||||
or drop it onto the window. Import takes a few seconds and the game starts
|
`.gbc` file or drop it onto the window. Import takes a few seconds and the
|
||||||
automatically.
|
game starts automatically.
|
||||||
|
|
||||||
Only the canonical 1 MiB US Red and Blue ROMs are accepted. The importer
|
Only the canonical 1 MiB US Red, Blue, and Yellow ROMs are accepted. The
|
||||||
verifies SHA-1 before creating any game data:
|
importer verifies SHA-1 before creating any game data:
|
||||||
|
|
||||||
- Red: `ea9bcae617fdf159b045185467ae58b2e4a48b9a`
|
- Red: `ea9bcae617fdf159b045185467ae58b2e4a48b9a`
|
||||||
- Blue: `d7037c83e1ae5b39bde3c30787637ba1d4c48ce2`
|
- Blue: `d7037c83e1ae5b39bde3c30787637ba1d4c48ce2`
|
||||||
|
- Yellow: `cc7d03262ebfaf2f06772c1a480c7d9d5f4a38e1`
|
||||||
|
|
||||||
The packaged app contains neither a ROM nor pre-extracted game data. Music,
|
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
|
sound effects, and cries are synthesized while the game runs from compact
|
||||||
@@ -76,6 +108,17 @@ supported out of the box.
|
|||||||
COLORS, TILT, ZOOM, GBC FX, and VOID FILL are also in the Options menu
|
COLORS, TILT, ZOOM, GBC FX, and VOID FILL are also in the Options menu
|
||||||
and persist in `options.lua`.
|
and persist in `options.lua`.
|
||||||
|
|
||||||
|
### Low-end devices
|
||||||
|
|
||||||
|
**OPTIONS → PERFORMANCE** scales the port's optional extras for weaker
|
||||||
|
hardware: **HIGH** (everything on), **BALANCED** (no 3D tilt or GBC FX),
|
||||||
|
**LOW** (also no survey zoom, FPS capped), or **AUTO** — the default, which
|
||||||
|
picks a tier from your device (ARM handhelds → LOW, phones → BALANCED,
|
||||||
|
normal desktops → HIGH, unchanged). It only scales presentation; the
|
||||||
|
fixed-step game logic is identical on every tier, and a lower tier hides
|
||||||
|
your tilt/zoom/GBC-FX preferences without forgetting them. Details in
|
||||||
|
[docs/new-features.md](docs/new-features.md#performance-tier-low-end-devices).
|
||||||
|
|
||||||
### Rulesets
|
### Rulesets
|
||||||
|
|
||||||
**OPTIONS → RULESET** picks which set of Gen 1 battle behaviors to run.
|
**OPTIONS → RULESET** picks which set of Gen 1 battle behaviors to run.
|
||||||
@@ -110,11 +153,11 @@ famous bugs included:
|
|||||||
|
|
||||||
## Running From Source
|
## 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:
|
double-click `Play-Mac.command` or `Play-Windows.bat`, or run:
|
||||||
|
|
||||||
```sh
|
```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
|
scripts/run.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -145,6 +188,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
|
already written to either location is touched automatically, so copy files
|
||||||
over yourself if you want to carry existing progress across the switch.
|
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
|
## Handhelds
|
||||||
|
|
||||||
A PortMaster-style port for the **Anbernic RG34XXSP** on Stock OS 64-bit MOD
|
A PortMaster-style port for the **Anbernic RG34XXSP** on Stock OS 64-bit MOD
|
||||||
|
|||||||
@@ -1,4 +1,9 @@
|
|||||||
function love.conf(t)
|
function love.conf(t)
|
||||||
|
-- PhysFS ignores symlinks unless told otherwise, so a mod dev-linked into
|
||||||
|
-- mods/ (ln -s, matching the mklink /J workflow on Windows) is invisible
|
||||||
|
-- to love.filesystem.getDirectoryItems without this.
|
||||||
|
love.filesystem.setSymlinksEnabled(true)
|
||||||
|
|
||||||
local editor = os.getenv("POKEPORT_EDITOR") == "1"
|
local editor = os.getenv("POKEPORT_EDITOR") == "1"
|
||||||
local developer = os.getenv("POKEPORT_DEV") == "1"
|
local developer = os.getenv("POKEPORT_DEV") == "1"
|
||||||
if arg then
|
if arg then
|
||||||
@@ -62,7 +67,10 @@ function love.conf(t)
|
|||||||
-- the game follow the device. The renderer letterboxes the 160x144
|
-- the game follow the device. The renderer letterboxes the 160x144
|
||||||
-- viewport into whatever size results, and the on-screen touch controls
|
-- viewport into whatever size results, and the on-screen touch controls
|
||||||
-- re-lay themselves out from the new window size, so both orientations
|
-- re-lay themselves out from the new window size, so both orientations
|
||||||
-- just work. iOS follows the Info.plist orientations
|
-- just work. FULL_SENSOR ignores the device's rotation lock, so
|
||||||
|
-- GameActivity.setOrientationBis remaps it to FULL_USER after SDL has
|
||||||
|
-- run: same orientations allowed, but auto-rotate being off now wins.
|
||||||
|
-- iOS follows the Info.plist orientations
|
||||||
-- (see mobile/ios/overlays/love-ios.plist, now portrait + landscape).
|
-- (see mobile/ios/overlays/love-ios.plist, now portrait + landscape).
|
||||||
t.window.resizable = true
|
t.window.resizable = true
|
||||||
-- Starting size is a tall portrait hint; the OS resizes to the real
|
-- Starting size is a tall portrait hint; the OS resizes to the real
|
||||||
@@ -78,6 +86,14 @@ function love.conf(t)
|
|||||||
-- player to copy their ROM there instead of needing a native file
|
-- 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).
|
-- picker (LOVE 11.5 on Android has none -- see src/import/RomImporter.lua).
|
||||||
t.externalstorage = osName == "Android"
|
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
|
else
|
||||||
t.window.resizable = true
|
t.window.resizable = true
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -0,0 +1,686 @@
|
|||||||
|
-- Generated by tools/build_data.py. DO NOT EDIT.
|
||||||
|
-- Pokemon Yellow palettes (COLORS=OG YELLOW): SuperPalettes +
|
||||||
|
-- CGBBasePalettes (authentic GBC look) as 4 8-bit RGB colors
|
||||||
|
-- per palette (color 0 first), plus MonsterPalettes.
|
||||||
|
return {
|
||||||
|
cgbBase = {
|
||||||
|
["0F"] = {
|
||||||
|
{ 255, 255, 255 },
|
||||||
|
{ 107, 8, 255 },
|
||||||
|
{ 0, 74, 255 },
|
||||||
|
{ 8, 8, 8 },
|
||||||
|
},
|
||||||
|
BADGE = {
|
||||||
|
{ 255, 255, 255 },
|
||||||
|
{ 189, 66, 0 },
|
||||||
|
{ 140, 115, 90 },
|
||||||
|
{ 25, 25, 25 },
|
||||||
|
},
|
||||||
|
BLACK = {
|
||||||
|
{ 255, 255, 255 },
|
||||||
|
{ 25, 25, 25 },
|
||||||
|
{ 25, 25, 25 },
|
||||||
|
{ 25, 25, 25 },
|
||||||
|
},
|
||||||
|
BLUEMON = {
|
||||||
|
{ 255, 255, 255 },
|
||||||
|
{ 132, 148, 255 },
|
||||||
|
{ 0, 8, 206 },
|
||||||
|
{ 25, 25, 25 },
|
||||||
|
},
|
||||||
|
BROWNMON = {
|
||||||
|
{ 255, 255, 255 },
|
||||||
|
{ 239, 148, 82 },
|
||||||
|
{ 140, 74, 41 },
|
||||||
|
{ 25, 25, 25 },
|
||||||
|
},
|
||||||
|
CAVE = {
|
||||||
|
{ 255, 255, 255 },
|
||||||
|
{ 189, 66, 0 },
|
||||||
|
{ 140, 115, 90 },
|
||||||
|
{ 25, 25, 25 },
|
||||||
|
},
|
||||||
|
CELADON = {
|
||||||
|
{ 255, 255, 255 },
|
||||||
|
{ 41, 255, 41 },
|
||||||
|
{ 90, 189, 255 },
|
||||||
|
{ 25, 25, 25 },
|
||||||
|
},
|
||||||
|
CERULEAN = {
|
||||||
|
{ 255, 255, 255 },
|
||||||
|
{ 41, 66, 255 },
|
||||||
|
{ 90, 189, 255 },
|
||||||
|
{ 25, 25, 25 },
|
||||||
|
},
|
||||||
|
CINNABAR = {
|
||||||
|
{ 255, 255, 255 },
|
||||||
|
{ 255, 66, 66 },
|
||||||
|
{ 90, 189, 255 },
|
||||||
|
{ 25, 25, 25 },
|
||||||
|
},
|
||||||
|
CYANMON = {
|
||||||
|
{ 255, 255, 255 },
|
||||||
|
{ 132, 214, 255 },
|
||||||
|
{ 0, 140, 255 },
|
||||||
|
{ 25, 25, 25 },
|
||||||
|
},
|
||||||
|
FUCHSIA = {
|
||||||
|
{ 255, 255, 255 },
|
||||||
|
{ 255, 123, 123 },
|
||||||
|
{ 90, 189, 255 },
|
||||||
|
{ 25, 25, 25 },
|
||||||
|
},
|
||||||
|
GAMEFREAK = {
|
||||||
|
{ 255, 255, 255 },
|
||||||
|
{ 255, 156, 0 },
|
||||||
|
{ 156, 156, 0 },
|
||||||
|
{ 25, 25, 25 },
|
||||||
|
},
|
||||||
|
GRAYMON = {
|
||||||
|
{ 255, 255, 255 },
|
||||||
|
{ 165, 189, 82 },
|
||||||
|
{ 90, 90, 41 },
|
||||||
|
{ 25, 25, 25 },
|
||||||
|
},
|
||||||
|
GREENBAR = {
|
||||||
|
{ 255, 255, 255 },
|
||||||
|
{ 255, 255, 0 },
|
||||||
|
{ 0, 255, 0 },
|
||||||
|
{ 25, 25, 25 },
|
||||||
|
},
|
||||||
|
GREENMON = {
|
||||||
|
{ 255, 255, 255 },
|
||||||
|
{ 140, 255, 90 },
|
||||||
|
{ 8, 181, 49 },
|
||||||
|
{ 25, 25, 25 },
|
||||||
|
},
|
||||||
|
INDIGO = {
|
||||||
|
{ 255, 255, 255 },
|
||||||
|
{ 90, 66, 255 },
|
||||||
|
{ 90, 189, 255 },
|
||||||
|
{ 25, 25, 25 },
|
||||||
|
},
|
||||||
|
LAVENDER = {
|
||||||
|
{ 255, 255, 255 },
|
||||||
|
{ 206, 33, 255 },
|
||||||
|
{ 90, 189, 255 },
|
||||||
|
{ 25, 25, 25 },
|
||||||
|
},
|
||||||
|
LOGO1 = {
|
||||||
|
{ 255, 255, 255 },
|
||||||
|
{ 255, 255, 0 },
|
||||||
|
{ 255, 0, 0 },
|
||||||
|
{ 255, 0, 0 },
|
||||||
|
},
|
||||||
|
LOGO2 = {
|
||||||
|
{ 255, 255, 255 },
|
||||||
|
{ 255, 255, 0 },
|
||||||
|
{ 58, 58, 206 },
|
||||||
|
{ 0, 0, 140 },
|
||||||
|
},
|
||||||
|
MEWMON = {
|
||||||
|
{ 255, 255, 255 },
|
||||||
|
{ 255, 255, 0 },
|
||||||
|
{ 255, 8, 8 },
|
||||||
|
{ 25, 25, 25 },
|
||||||
|
},
|
||||||
|
PALLET = {
|
||||||
|
{ 255, 255, 255 },
|
||||||
|
{ 189, 140, 255 },
|
||||||
|
{ 90, 189, 255 },
|
||||||
|
{ 25, 25, 25 },
|
||||||
|
},
|
||||||
|
PEWTER = {
|
||||||
|
{ 255, 255, 255 },
|
||||||
|
{ 148, 148, 123 },
|
||||||
|
{ 90, 189, 255 },
|
||||||
|
{ 25, 25, 25 },
|
||||||
|
},
|
||||||
|
PIKACHUS_BEACH = {
|
||||||
|
{ 255, 255, 255 },
|
||||||
|
{ 255, 255, 0 },
|
||||||
|
{ 90, 189, 255 },
|
||||||
|
{ 25, 25, 25 },
|
||||||
|
},
|
||||||
|
PIKACHUS_BEACH_TITLE = {
|
||||||
|
{ 255, 255, 255 },
|
||||||
|
{ 74, 74, 74 },
|
||||||
|
{ 255, 173, 0 },
|
||||||
|
{ 25, 25, 25 },
|
||||||
|
},
|
||||||
|
PIKACHU_PORTRAIT = {
|
||||||
|
{ 255, 255, 255 },
|
||||||
|
{ 255, 148, 0 },
|
||||||
|
{ 156, 58, 8 },
|
||||||
|
{ 25, 25, 25 },
|
||||||
|
},
|
||||||
|
PINKMON = {
|
||||||
|
{ 255, 255, 255 },
|
||||||
|
{ 255, 123, 148 },
|
||||||
|
{ 255, 0, 49 },
|
||||||
|
{ 25, 25, 25 },
|
||||||
|
},
|
||||||
|
PURPLEMON = {
|
||||||
|
{ 255, 255, 255 },
|
||||||
|
{ 206, 123, 255 },
|
||||||
|
{ 156, 0, 181 },
|
||||||
|
{ 25, 25, 25 },
|
||||||
|
},
|
||||||
|
REDBAR = {
|
||||||
|
{ 255, 255, 255 },
|
||||||
|
{ 255, 255, 0 },
|
||||||
|
{ 255, 0, 0 },
|
||||||
|
{ 25, 25, 25 },
|
||||||
|
},
|
||||||
|
REDMON = {
|
||||||
|
{ 255, 255, 255 },
|
||||||
|
{ 255, 140, 0 },
|
||||||
|
{ 255, 0, 0 },
|
||||||
|
{ 25, 25, 25 },
|
||||||
|
},
|
||||||
|
ROUTE = {
|
||||||
|
{ 255, 255, 255 },
|
||||||
|
{ 132, 255, 33 },
|
||||||
|
{ 90, 189, 255 },
|
||||||
|
{ 25, 25, 25 },
|
||||||
|
},
|
||||||
|
SAFFRON = {
|
||||||
|
{ 255, 255, 255 },
|
||||||
|
{ 255, 255, 0 },
|
||||||
|
{ 90, 189, 255 },
|
||||||
|
{ 25, 25, 25 },
|
||||||
|
},
|
||||||
|
SLOTS1 = {
|
||||||
|
{ 255, 255, 255 },
|
||||||
|
{ 206, 8, 255 },
|
||||||
|
{ 255, 0, 0 },
|
||||||
|
{ 25, 25, 25 },
|
||||||
|
},
|
||||||
|
SLOTS2 = {
|
||||||
|
{ 255, 255, 255 },
|
||||||
|
{ 255, 33, 156 },
|
||||||
|
{ 255, 255, 0 },
|
||||||
|
{ 25, 25, 25 },
|
||||||
|
},
|
||||||
|
SLOTS3 = {
|
||||||
|
{ 255, 255, 255 },
|
||||||
|
{ 66, 255, 0 },
|
||||||
|
{ 255, 255, 0 },
|
||||||
|
{ 25, 25, 25 },
|
||||||
|
},
|
||||||
|
SLOTS4 = {
|
||||||
|
{ 255, 255, 255 },
|
||||||
|
{ 0, 255, 255 },
|
||||||
|
{ 255, 255, 0 },
|
||||||
|
{ 25, 25, 25 },
|
||||||
|
},
|
||||||
|
TOWNMAP = {
|
||||||
|
{ 255, 255, 255 },
|
||||||
|
{ 0, 173, 255 },
|
||||||
|
{ 82, 230, 0 },
|
||||||
|
{ 8, 8, 8 },
|
||||||
|
},
|
||||||
|
VERMILION = {
|
||||||
|
{ 255, 255, 255 },
|
||||||
|
{ 255, 156, 0 },
|
||||||
|
{ 90, 189, 255 },
|
||||||
|
{ 25, 25, 25 },
|
||||||
|
},
|
||||||
|
VIRIDIAN = {
|
||||||
|
{ 255, 255, 255 },
|
||||||
|
{ 156, 255, 0 },
|
||||||
|
{ 90, 189, 255 },
|
||||||
|
{ 25, 25, 25 },
|
||||||
|
},
|
||||||
|
YELLOWBAR = {
|
||||||
|
{ 255, 255, 255 },
|
||||||
|
{ 255, 255, 0 },
|
||||||
|
{ 255, 148, 0 },
|
||||||
|
{ 25, 25, 25 },
|
||||||
|
},
|
||||||
|
YELLOWMON = {
|
||||||
|
{ 255, 255, 255 },
|
||||||
|
{ 255, 255, 0 },
|
||||||
|
{ 230, 115, 0 },
|
||||||
|
{ 25, 25, 25 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
order = {
|
||||||
|
"ROUTE",
|
||||||
|
"PALLET",
|
||||||
|
"VIRIDIAN",
|
||||||
|
"PEWTER",
|
||||||
|
"CERULEAN",
|
||||||
|
"LAVENDER",
|
||||||
|
"VERMILION",
|
||||||
|
"CELADON",
|
||||||
|
"FUCHSIA",
|
||||||
|
"CINNABAR",
|
||||||
|
"INDIGO",
|
||||||
|
"SAFFRON",
|
||||||
|
"TOWNMAP",
|
||||||
|
"LOGO1",
|
||||||
|
"LOGO2",
|
||||||
|
"0F",
|
||||||
|
"MEWMON",
|
||||||
|
"BLUEMON",
|
||||||
|
"REDMON",
|
||||||
|
"CYANMON",
|
||||||
|
"PURPLEMON",
|
||||||
|
"BROWNMON",
|
||||||
|
"GREENMON",
|
||||||
|
"PINKMON",
|
||||||
|
"YELLOWMON",
|
||||||
|
"GRAYMON",
|
||||||
|
"SLOTS1",
|
||||||
|
"SLOTS2",
|
||||||
|
"SLOTS3",
|
||||||
|
"SLOTS4",
|
||||||
|
"BLACK",
|
||||||
|
"GREENBAR",
|
||||||
|
"YELLOWBAR",
|
||||||
|
"REDBAR",
|
||||||
|
"BADGE",
|
||||||
|
"CAVE",
|
||||||
|
"GAMEFREAK",
|
||||||
|
"PIKACHUS_BEACH",
|
||||||
|
"PIKACHU_PORTRAIT",
|
||||||
|
"PIKACHUS_BEACH_TITLE",
|
||||||
|
},
|
||||||
|
palettes = {
|
||||||
|
["0F"] = {
|
||||||
|
{ 255, 255, 247 },
|
||||||
|
{ 197, 165, 247 },
|
||||||
|
{ 90, 165, 247 },
|
||||||
|
{ 25, 16, 16 },
|
||||||
|
},
|
||||||
|
BADGE = {
|
||||||
|
{ 255, 255, 247 },
|
||||||
|
{ 165, 123, 90 },
|
||||||
|
{ 181, 173, 165 },
|
||||||
|
{ 49, 49, 49 },
|
||||||
|
},
|
||||||
|
BLACK = {
|
||||||
|
{ 255, 255, 247 },
|
||||||
|
{ 49, 49, 49 },
|
||||||
|
{ 49, 49, 49 },
|
||||||
|
{ 49, 49, 49 },
|
||||||
|
},
|
||||||
|
BLUEMON = {
|
||||||
|
{ 255, 255, 247 },
|
||||||
|
{ 173, 181, 255 },
|
||||||
|
{ 74, 82, 165 },
|
||||||
|
{ 49, 49, 49 },
|
||||||
|
},
|
||||||
|
BROWNMON = {
|
||||||
|
{ 255, 255, 247 },
|
||||||
|
{ 214, 189, 148 },
|
||||||
|
{ 148, 115, 82 },
|
||||||
|
{ 49, 49, 49 },
|
||||||
|
},
|
||||||
|
CAVE = {
|
||||||
|
{ 255, 255, 247 },
|
||||||
|
{ 165, 123, 90 },
|
||||||
|
{ 181, 173, 165 },
|
||||||
|
{ 49, 49, 49 },
|
||||||
|
},
|
||||||
|
CELADON = {
|
||||||
|
{ 255, 255, 247 },
|
||||||
|
{ 181, 255, 181 },
|
||||||
|
{ 189, 222, 255 },
|
||||||
|
{ 49, 49, 49 },
|
||||||
|
},
|
||||||
|
CERULEAN = {
|
||||||
|
{ 255, 255, 247 },
|
||||||
|
{ 181, 189, 255 },
|
||||||
|
{ 189, 222, 255 },
|
||||||
|
{ 49, 49, 49 },
|
||||||
|
},
|
||||||
|
CINNABAR = {
|
||||||
|
{ 255, 255, 247 },
|
||||||
|
{ 255, 123, 115 },
|
||||||
|
{ 189, 222, 255 },
|
||||||
|
{ 49, 49, 49 },
|
||||||
|
},
|
||||||
|
CYANMON = {
|
||||||
|
{ 255, 255, 247 },
|
||||||
|
{ 214, 230, 255 },
|
||||||
|
{ 58, 197, 230 },
|
||||||
|
{ 49, 49, 49 },
|
||||||
|
},
|
||||||
|
FUCHSIA = {
|
||||||
|
{ 255, 255, 247 },
|
||||||
|
{ 255, 214, 214 },
|
||||||
|
{ 189, 222, 255 },
|
||||||
|
{ 49, 49, 49 },
|
||||||
|
},
|
||||||
|
GAMEFREAK = {
|
||||||
|
{ 255, 255, 247 },
|
||||||
|
{ 230, 197, 115 },
|
||||||
|
{ 165, 165, 90 },
|
||||||
|
{ 49, 49, 49 },
|
||||||
|
},
|
||||||
|
GRAYMON = {
|
||||||
|
{ 255, 255, 247 },
|
||||||
|
{ 206, 206, 148 },
|
||||||
|
{ 132, 132, 115 },
|
||||||
|
{ 49, 49, 49 },
|
||||||
|
},
|
||||||
|
GREENBAR = {
|
||||||
|
{ 255, 255, 247 },
|
||||||
|
{ 255, 255, 156 },
|
||||||
|
{ 0, 173, 0 },
|
||||||
|
{ 49, 49, 49 },
|
||||||
|
},
|
||||||
|
GREENMON = {
|
||||||
|
{ 255, 255, 247 },
|
||||||
|
{ 197, 230, 148 },
|
||||||
|
{ 107, 173, 123 },
|
||||||
|
{ 49, 49, 49 },
|
||||||
|
},
|
||||||
|
INDIGO = {
|
||||||
|
{ 255, 255, 247 },
|
||||||
|
{ 140, 140, 206 },
|
||||||
|
{ 189, 222, 255 },
|
||||||
|
{ 49, 49, 49 },
|
||||||
|
},
|
||||||
|
LAVENDER = {
|
||||||
|
{ 255, 255, 247 },
|
||||||
|
{ 222, 189, 239 },
|
||||||
|
{ 189, 222, 255 },
|
||||||
|
{ 49, 49, 49 },
|
||||||
|
},
|
||||||
|
LOGO1 = {
|
||||||
|
{ 255, 255, 247 },
|
||||||
|
{ 247, 247, 140 },
|
||||||
|
{ 173, 0, 33 },
|
||||||
|
{ 173, 0, 33 },
|
||||||
|
},
|
||||||
|
LOGO2 = {
|
||||||
|
{ 255, 255, 247 },
|
||||||
|
{ 247, 247, 140 },
|
||||||
|
{ 148, 148, 197 },
|
||||||
|
{ 58, 58, 132 },
|
||||||
|
},
|
||||||
|
MEWMON = {
|
||||||
|
{ 255, 255, 247 },
|
||||||
|
{ 255, 247, 181 },
|
||||||
|
{ 222, 132, 132 },
|
||||||
|
{ 49, 49, 49 },
|
||||||
|
},
|
||||||
|
PALLET = {
|
||||||
|
{ 255, 255, 247 },
|
||||||
|
{ 230, 222, 255 },
|
||||||
|
{ 189, 222, 255 },
|
||||||
|
{ 49, 49, 49 },
|
||||||
|
},
|
||||||
|
PEWTER = {
|
||||||
|
{ 255, 255, 247 },
|
||||||
|
{ 189, 189, 181 },
|
||||||
|
{ 189, 222, 255 },
|
||||||
|
{ 49, 49, 49 },
|
||||||
|
},
|
||||||
|
PIKACHUS_BEACH = {
|
||||||
|
{ 255, 255, 247 },
|
||||||
|
{ 255, 247, 181 },
|
||||||
|
{ 189, 222, 255 },
|
||||||
|
{ 49, 49, 49 },
|
||||||
|
},
|
||||||
|
PIKACHUS_BEACH_TITLE = {
|
||||||
|
{ 255, 255, 247 },
|
||||||
|
{ 132, 132, 132 },
|
||||||
|
{ 255, 206, 74 },
|
||||||
|
{ 49, 49, 49 },
|
||||||
|
},
|
||||||
|
PIKACHU_PORTRAIT = {
|
||||||
|
{ 255, 255, 247 },
|
||||||
|
{ 230, 189, 74 },
|
||||||
|
{ 148, 115, 82 },
|
||||||
|
{ 49, 49, 49 },
|
||||||
|
},
|
||||||
|
PINKMON = {
|
||||||
|
{ 255, 255, 247 },
|
||||||
|
{ 255, 197, 214 },
|
||||||
|
{ 255, 148, 173 },
|
||||||
|
{ 49, 49, 49 },
|
||||||
|
},
|
||||||
|
PURPLEMON = {
|
||||||
|
{ 255, 255, 247 },
|
||||||
|
{ 222, 181, 247 },
|
||||||
|
{ 181, 123, 189 },
|
||||||
|
{ 49, 49, 49 },
|
||||||
|
},
|
||||||
|
REDBAR = {
|
||||||
|
{ 255, 255, 247 },
|
||||||
|
{ 255, 255, 156 },
|
||||||
|
{ 214, 74, 49 },
|
||||||
|
{ 49, 49, 49 },
|
||||||
|
},
|
||||||
|
REDMON = {
|
||||||
|
{ 255, 255, 247 },
|
||||||
|
{ 255, 197, 90 },
|
||||||
|
{ 214, 74, 49 },
|
||||||
|
{ 49, 49, 49 },
|
||||||
|
},
|
||||||
|
ROUTE = {
|
||||||
|
{ 255, 255, 247 },
|
||||||
|
{ 189, 214, 156 },
|
||||||
|
{ 189, 222, 255 },
|
||||||
|
{ 49, 49, 49 },
|
||||||
|
},
|
||||||
|
SAFFRON = {
|
||||||
|
{ 255, 255, 247 },
|
||||||
|
{ 255, 255, 156 },
|
||||||
|
{ 189, 222, 255 },
|
||||||
|
{ 49, 49, 49 },
|
||||||
|
},
|
||||||
|
SLOTS1 = {
|
||||||
|
{ 255, 255, 247 },
|
||||||
|
{ 222, 181, 247 },
|
||||||
|
{ 214, 74, 49 },
|
||||||
|
{ 49, 49, 49 },
|
||||||
|
},
|
||||||
|
SLOTS2 = {
|
||||||
|
{ 255, 255, 247 },
|
||||||
|
{ 255, 189, 214 },
|
||||||
|
{ 239, 239, 66 },
|
||||||
|
{ 49, 49, 49 },
|
||||||
|
},
|
||||||
|
SLOTS3 = {
|
||||||
|
{ 255, 255, 247 },
|
||||||
|
{ 189, 255, 165 },
|
||||||
|
{ 239, 239, 66 },
|
||||||
|
{ 49, 49, 49 },
|
||||||
|
},
|
||||||
|
SLOTS4 = {
|
||||||
|
{ 255, 255, 247 },
|
||||||
|
{ 189, 239, 255 },
|
||||||
|
{ 239, 239, 66 },
|
||||||
|
{ 49, 49, 49 },
|
||||||
|
},
|
||||||
|
TOWNMAP = {
|
||||||
|
{ 255, 255, 247 },
|
||||||
|
{ 165, 214, 255 },
|
||||||
|
{ 140, 189, 82 },
|
||||||
|
{ 25, 16, 16 },
|
||||||
|
},
|
||||||
|
VERMILION = {
|
||||||
|
{ 255, 255, 247 },
|
||||||
|
{ 255, 206, 132 },
|
||||||
|
{ 189, 222, 255 },
|
||||||
|
{ 49, 49, 49 },
|
||||||
|
},
|
||||||
|
VIRIDIAN = {
|
||||||
|
{ 255, 255, 247 },
|
||||||
|
{ 214, 255, 173 },
|
||||||
|
{ 189, 222, 255 },
|
||||||
|
{ 49, 49, 49 },
|
||||||
|
},
|
||||||
|
YELLOWBAR = {
|
||||||
|
{ 255, 255, 247 },
|
||||||
|
{ 255, 255, 156 },
|
||||||
|
{ 230, 189, 74 },
|
||||||
|
{ 49, 49, 49 },
|
||||||
|
},
|
||||||
|
YELLOWMON = {
|
||||||
|
{ 255, 255, 247 },
|
||||||
|
{ 255, 255, 156 },
|
||||||
|
{ 230, 189, 74 },
|
||||||
|
{ 49, 49, 49 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
pokemon = {
|
||||||
|
ABRA = "YELLOWMON",
|
||||||
|
AERODACTYL = "GRAYMON",
|
||||||
|
ALAKAZAM = "YELLOWMON",
|
||||||
|
ARBOK = "PURPLEMON",
|
||||||
|
ARCANINE = "REDMON",
|
||||||
|
ARTICUNO = "BLUEMON",
|
||||||
|
BEEDRILL = "YELLOWMON",
|
||||||
|
BELLSPROUT = "GREENMON",
|
||||||
|
BLASTOISE = "CYANMON",
|
||||||
|
BULBASAUR = "GREENMON",
|
||||||
|
BUTTERFREE = "CYANMON",
|
||||||
|
CATERPIE = "GREENMON",
|
||||||
|
CHANSEY = "PINKMON",
|
||||||
|
CHARIZARD = "REDMON",
|
||||||
|
CHARMANDER = "REDMON",
|
||||||
|
CHARMELEON = "REDMON",
|
||||||
|
CLEFABLE = "PINKMON",
|
||||||
|
CLEFAIRY = "PINKMON",
|
||||||
|
CLOYSTER = "GRAYMON",
|
||||||
|
CUBONE = "GRAYMON",
|
||||||
|
DEWGONG = "BLUEMON",
|
||||||
|
DIGLETT = "BROWNMON",
|
||||||
|
DITTO = "GRAYMON",
|
||||||
|
DODRIO = "BROWNMON",
|
||||||
|
DODUO = "BROWNMON",
|
||||||
|
DRAGONAIR = "BLUEMON",
|
||||||
|
DRAGONITE = "BROWNMON",
|
||||||
|
DRATINI = "GRAYMON",
|
||||||
|
DROWZEE = "YELLOWMON",
|
||||||
|
DUGTRIO = "BROWNMON",
|
||||||
|
EEVEE = "GRAYMON",
|
||||||
|
EKANS = "PURPLEMON",
|
||||||
|
ELECTABUZZ = "YELLOWMON",
|
||||||
|
ELECTRODE = "YELLOWMON",
|
||||||
|
EXEGGCUTE = "PINKMON",
|
||||||
|
EXEGGUTOR = "GREENMON",
|
||||||
|
FARFETCHD = "BROWNMON",
|
||||||
|
FEAROW = "BROWNMON",
|
||||||
|
FLAREON = "REDMON",
|
||||||
|
GASTLY = "PURPLEMON",
|
||||||
|
GENGAR = "PURPLEMON",
|
||||||
|
GEODUDE = "GRAYMON",
|
||||||
|
GLOOM = "REDMON",
|
||||||
|
GOLBAT = "BLUEMON",
|
||||||
|
GOLDEEN = "REDMON",
|
||||||
|
GOLDUCK = "CYANMON",
|
||||||
|
GOLEM = "GRAYMON",
|
||||||
|
GRAVELER = "GRAYMON",
|
||||||
|
GRIMER = "PURPLEMON",
|
||||||
|
GROWLITHE = "BROWNMON",
|
||||||
|
GYARADOS = "BLUEMON",
|
||||||
|
HAUNTER = "PURPLEMON",
|
||||||
|
HITMONCHAN = "BROWNMON",
|
||||||
|
HITMONLEE = "BROWNMON",
|
||||||
|
HORSEA = "CYANMON",
|
||||||
|
HYPNO = "YELLOWMON",
|
||||||
|
IVYSAUR = "GREENMON",
|
||||||
|
JIGGLYPUFF = "PINKMON",
|
||||||
|
JOLTEON = "YELLOWMON",
|
||||||
|
JYNX = "MEWMON",
|
||||||
|
KABUTO = "BROWNMON",
|
||||||
|
KABUTOPS = "BROWNMON",
|
||||||
|
KADABRA = "YELLOWMON",
|
||||||
|
KAKUNA = "YELLOWMON",
|
||||||
|
KANGASKHAN = "BROWNMON",
|
||||||
|
KINGLER = "REDMON",
|
||||||
|
KOFFING = "PURPLEMON",
|
||||||
|
KRABBY = "REDMON",
|
||||||
|
LAPRAS = "CYANMON",
|
||||||
|
LICKITUNG = "PINKMON",
|
||||||
|
MACHAMP = "GRAYMON",
|
||||||
|
MACHOKE = "GRAYMON",
|
||||||
|
MACHOP = "GRAYMON",
|
||||||
|
MAGIKARP = "REDMON",
|
||||||
|
MAGMAR = "REDMON",
|
||||||
|
MAGNEMITE = "GRAYMON",
|
||||||
|
MAGNETON = "GRAYMON",
|
||||||
|
MANKEY = "BROWNMON",
|
||||||
|
MAROWAK = "GRAYMON",
|
||||||
|
MEOWTH = "YELLOWMON",
|
||||||
|
METAPOD = "GREENMON",
|
||||||
|
MEW = "MEWMON",
|
||||||
|
MEWTWO = "MEWMON",
|
||||||
|
MOLTRES = "REDMON",
|
||||||
|
MR_MIME = "PINKMON",
|
||||||
|
MUK = "PURPLEMON",
|
||||||
|
NIDOKING = "PURPLEMON",
|
||||||
|
NIDOQUEEN = "BLUEMON",
|
||||||
|
NIDORAN_F = "BLUEMON",
|
||||||
|
NIDORAN_M = "PURPLEMON",
|
||||||
|
NIDORINA = "BLUEMON",
|
||||||
|
NIDORINO = "PURPLEMON",
|
||||||
|
NINETALES = "YELLOWMON",
|
||||||
|
ODDISH = "GREENMON",
|
||||||
|
OMANYTE = "BLUEMON",
|
||||||
|
OMASTAR = "BLUEMON",
|
||||||
|
ONIX = "GRAYMON",
|
||||||
|
PARAS = "REDMON",
|
||||||
|
PARASECT = "REDMON",
|
||||||
|
PERSIAN = "YELLOWMON",
|
||||||
|
PIDGEOT = "BROWNMON",
|
||||||
|
PIDGEOTTO = "BROWNMON",
|
||||||
|
PIDGEY = "BROWNMON",
|
||||||
|
PIKACHU = "YELLOWMON",
|
||||||
|
PINSIR = "BROWNMON",
|
||||||
|
POLIWAG = "BLUEMON",
|
||||||
|
POLIWHIRL = "BLUEMON",
|
||||||
|
POLIWRATH = "BLUEMON",
|
||||||
|
PONYTA = "REDMON",
|
||||||
|
PORYGON = "GRAYMON",
|
||||||
|
PRIMEAPE = "BROWNMON",
|
||||||
|
PSYDUCK = "YELLOWMON",
|
||||||
|
RAICHU = "YELLOWMON",
|
||||||
|
RAPIDASH = "REDMON",
|
||||||
|
RATICATE = "GRAYMON",
|
||||||
|
RATTATA = "GRAYMON",
|
||||||
|
RHYDON = "GRAYMON",
|
||||||
|
RHYHORN = "GRAYMON",
|
||||||
|
SANDSHREW = "BROWNMON",
|
||||||
|
SANDSLASH = "BROWNMON",
|
||||||
|
SCYTHER = "GREENMON",
|
||||||
|
SEADRA = "CYANMON",
|
||||||
|
SEAKING = "REDMON",
|
||||||
|
SEEL = "BLUEMON",
|
||||||
|
SHELLDER = "GRAYMON",
|
||||||
|
SLOWBRO = "PINKMON",
|
||||||
|
SLOWPOKE = "PINKMON",
|
||||||
|
SNORLAX = "PINKMON",
|
||||||
|
SPEAROW = "BROWNMON",
|
||||||
|
SQUIRTLE = "CYANMON",
|
||||||
|
STARMIE = "GRAYMON",
|
||||||
|
STARYU = "REDMON",
|
||||||
|
TANGELA = "BLUEMON",
|
||||||
|
TAUROS = "GRAYMON",
|
||||||
|
TENTACOOL = "CYANMON",
|
||||||
|
TENTACRUEL = "CYANMON",
|
||||||
|
VAPOREON = "CYANMON",
|
||||||
|
VENOMOTH = "PURPLEMON",
|
||||||
|
VENONAT = "PURPLEMON",
|
||||||
|
VENUSAUR = "GREENMON",
|
||||||
|
VICTREEBEL = "GREENMON",
|
||||||
|
VILEPLUME = "REDMON",
|
||||||
|
VOLTORB = "YELLOWMON",
|
||||||
|
VULPIX = "REDMON",
|
||||||
|
WARTORTLE = "CYANMON",
|
||||||
|
WEEDLE = "YELLOWMON",
|
||||||
|
WEEPINBELL = "GREENMON",
|
||||||
|
WEEZING = "PURPLEMON",
|
||||||
|
WIGGLYTUFF = "PINKMON",
|
||||||
|
ZAPDOS = "YELLOWMON",
|
||||||
|
ZUBAT = "BLUEMON",
|
||||||
|
},
|
||||||
|
source = "pokeyellow data/sgb/sgb_palettes.asm + data/pokemon/palettes.asm",
|
||||||
|
}
|
||||||
@@ -11,8 +11,70 @@
|
|||||||
-- equivalent, kept for save compatibility: rows 1-4 also self-heal
|
-- equivalent, kept for save compatibility: rows 1-4 also self-heal
|
||||||
-- older saves (flag set before the port hid the ball) by hiding the
|
-- older saves (flag set before the port hid the ball) by hiding the
|
||||||
-- leftover ball on the next interaction.
|
-- 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 {
|
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 = {
|
talk = {
|
||||||
TEXT_CELADONMANSION_ROOF_HOUSE_EEVEE_POKEBALL = {
|
TEXT_CELADONMANSION_ROOF_HOUSE_EEVEE_POKEBALL = {
|
||||||
{ "check_flag", "EVENT_GOT_EEVEE" }, -- 1
|
{ "check_flag", "EVENT_GOT_EEVEE" }, -- 1
|
||||||
@@ -22,11 +84,16 @@ return {
|
|||||||
{ "jump", 13 }, -- 4
|
{ "jump", 13 }, -- 4
|
||||||
{ "give_pokemon", "EEVEE", 25 }, -- 5
|
{ "give_pokemon", "EEVEE", 25 }, -- 5
|
||||||
{ "jump_if_false", 12 }, -- 6 (party+box full)
|
{ "jump_if_false", 12 }, -- 6 (party+box full)
|
||||||
{ "play_sound", "Get_Item1" }, -- 7 (GotMonText jingle)
|
-- flag + HideObject before the jingle and GotMonText: the nickname
|
||||||
{ "show_text", "_GotMonText", { RAM = "EEVEE" } }, -- 8
|
-- prompt inside give_pokemon and the text row both yield, and a
|
||||||
{ "set_flag", "EVENT_GOT_EEVEE" }, -- 9
|
-- 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",
|
{ "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
|
{ "jump", 13 }, -- 11
|
||||||
{ "show_text", "_BoxIsFullText" }, -- 12
|
{ "show_text", "_BoxIsFullText" }, -- 12
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ local function middleAgedMan(game, ow, npc, done)
|
|||||||
end
|
end
|
||||||
local menu = ListMenu.new(game, "", items, {
|
local menu = ListMenu.new(game, "", items, {
|
||||||
onChoose = function(item)
|
onChoose = function(item)
|
||||||
|
game.stack:pop()
|
||||||
push(game, t[BADGE_TEXT[item.value]], loop)
|
push(game, t[BADGE_TEXT[item.value]], loop)
|
||||||
end,
|
end,
|
||||||
onCancel = function()
|
onCancel = function()
|
||||||
|
|||||||
@@ -3,6 +3,10 @@
|
|||||||
-- Both NPCs use text_asm with an hRandomAdd roll to pick one of several
|
-- Both NPCs use text_asm with an hRandomAdd roll to pick one of several
|
||||||
-- flavor lines (no flags, no branching outcome) -- ported as a weighted
|
-- flavor lines (no flags, no branching outcome) -- ported as a weighted
|
||||||
-- math.random pick each time the NPC is talked to.
|
-- math.random pick each time the NPC is talked to.
|
||||||
|
--
|
||||||
|
-- Yellow renames Slowbro -> Electrode in the text labels (see
|
||||||
|
-- tools/yellow_symbol_aliases.py); look up both so Red/Blue and Yellow
|
||||||
|
-- share this script.
|
||||||
|
|
||||||
local M = {}
|
local M = {}
|
||||||
|
|
||||||
@@ -12,6 +16,29 @@ local function push(game, ow, npc, done, text)
|
|||||||
game.stack:push(TextBox.new(game, text, done))
|
game.stack:push(TextBox.new(game, text, done))
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- CeruleanCitySlowbroText / CeruleanCityElectrodeText
|
||||||
|
-- cp 180 -> 76/256 chance of 1st; cp 120 -> 60/256 chance of 2nd;
|
||||||
|
-- cp 60 -> 60/256 chance of 3rd; else 60/256 chance of 4th.
|
||||||
|
local function talkMon(game, ow, npc, done)
|
||||||
|
local t = game.data.text
|
||||||
|
local roll = math.random(0, 255)
|
||||||
|
local text
|
||||||
|
if roll >= 180 then
|
||||||
|
text = t._CeruleanCitySlowbroTookASnoozeText
|
||||||
|
or t._CeruleanCityElectrodeTookASnoozeText
|
||||||
|
elseif roll >= 120 then
|
||||||
|
text = t._CeruleanCitySlowbroIsLoafingAroundText
|
||||||
|
or t._CeruleanCityElectrodeIsLoafingAroundText
|
||||||
|
elseif roll >= 60 then
|
||||||
|
text = t._CeruleanCitySlowbroTurnedAwayText
|
||||||
|
or t._CeruleanCityElectrodeTurnedAwayText
|
||||||
|
else
|
||||||
|
text = t._CeruleanCitySlowbroIgnoredOrdersText
|
||||||
|
or t._CeruleanCityElectrodeIgnoredOrdersText
|
||||||
|
end
|
||||||
|
push(game, ow, npc, done, text)
|
||||||
|
end
|
||||||
|
|
||||||
M.CERULEAN_CITY = {
|
M.CERULEAN_CITY = {
|
||||||
talk = {
|
talk = {
|
||||||
-- CeruleanCityCooltrainerF1Text (scripts/CeruleanCity.asm:362-393)
|
-- CeruleanCityCooltrainerF1Text (scripts/CeruleanCity.asm:362-393)
|
||||||
@@ -23,32 +50,20 @@ M.CERULEAN_CITY = {
|
|||||||
local text
|
local text
|
||||||
if roll >= 180 then
|
if roll >= 180 then
|
||||||
text = t._CeruleanCityCooltrainerF1SlowbroUseSonicboomText
|
text = t._CeruleanCityCooltrainerF1SlowbroUseSonicboomText
|
||||||
|
or t._CeruleanCityCooltrainerF1ElectrodeUseSonicboomText
|
||||||
elseif roll >= 100 then
|
elseif roll >= 100 then
|
||||||
text = t._CeruleanCityCooltrainerF1SlowbroPunchText
|
text = t._CeruleanCityCooltrainerF1SlowbroPunchText
|
||||||
|
or t._CeruleanCityCooltrainerF1ElectrodePunchText
|
||||||
else
|
else
|
||||||
text = t._CeruleanCityCooltrainerF1SlowbroWithdrawText
|
text = t._CeruleanCityCooltrainerF1SlowbroWithdrawText
|
||||||
|
or t._CeruleanCityCooltrainerF1ElectrodeWithdrawText
|
||||||
end
|
end
|
||||||
push(game, ow, npc, done, text)
|
push(game, ow, npc, done, text)
|
||||||
end,
|
end,
|
||||||
|
|
||||||
-- CeruleanCitySlowbroText (scripts/CeruleanCity.asm:395-436)
|
TEXT_CERULEANCITY_SLOWBRO = talkMon,
|
||||||
-- cp 180 -> 76/256 chance of 1st; cp 120 -> 60/256 chance of 2nd;
|
-- Yellow map object uses TEXT_CERULEANCITY_ELECTRODE
|
||||||
-- cp 60 -> 60/256 chance of 3rd; else 60/256 chance of 4th.
|
TEXT_CERULEANCITY_ELECTRODE = talkMon,
|
||||||
TEXT_CERULEANCITY_SLOWBRO = function(game, ow, npc, done)
|
|
||||||
local t = game.data.text
|
|
||||||
local roll = math.random(0, 255)
|
|
||||||
local text
|
|
||||||
if roll >= 180 then
|
|
||||||
text = t._CeruleanCitySlowbroTookASnoozeText
|
|
||||||
elseif roll >= 120 then
|
|
||||||
text = t._CeruleanCitySlowbroIsLoafingAroundText
|
|
||||||
elseif roll >= 60 then
|
|
||||||
text = t._CeruleanCitySlowbroTurnedAwayText
|
|
||||||
else
|
|
||||||
text = t._CeruleanCitySlowbroIgnoredOrdersText
|
|
||||||
end
|
|
||||||
push(game, ow, npc, done, text)
|
|
||||||
end,
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
-- Viridian School House's two readables (data/events/hidden_events.asm,
|
||||||
|
-- hidden_events_for VIRIDIAN_SCHOOL_HOUSE):
|
||||||
|
-- hidden_text_predef 3, 0 PrintBlackboardLinkCableText, ViridianSchoolBlackboard
|
||||||
|
-- hidden_text_predef 3, 4 PrintNotebookText, ViridianSchoolNotebook
|
||||||
|
-- tools/extract/field.py only parses `hidden_event` rows, so neither
|
||||||
|
-- hidden_text_predef row reaches data/generated/field.lua and both tiles
|
||||||
|
-- were dead A presses (#503). Same hook shape, and the same sibling asm
|
||||||
|
-- file, as the Celadon roof house in data/scripts/celadon_eevee.lua (#391);
|
||||||
|
-- hidden_text_predef spends the facing byte on the tx_pre id, so neither
|
||||||
|
-- tile gates on facing.
|
||||||
|
|
||||||
|
local Menu = require("src.ui.Menu")
|
||||||
|
local TextBox = require("src.render.TextBox")
|
||||||
|
|
||||||
|
-- ViridianSchoolBlackboard (engine/events/hidden_events/school_blackboard.asm):
|
||||||
|
-- StatusAilmentText1/2 are the two columns of the 12x8 box at the top left
|
||||||
|
-- (hlcoord 0, 0 + `lb bc, 6, 10`); picking a status prints its
|
||||||
|
-- ViridianBlackboardStatusPointers entry and jumps back to .blackboardLoop,
|
||||||
|
-- QUIT or B falls through to .exitBlackboard.
|
||||||
|
local STATUS_LABELS = {
|
||||||
|
{ " SLP", "_ViridianBlackboardSleepText" },
|
||||||
|
{ " PSN", "_ViridianBlackboardPoisonText" },
|
||||||
|
{ " PAR", "_ViridianBlackboardPrlzText" },
|
||||||
|
{ " BRN", "_ViridianBlackboardBurnText" },
|
||||||
|
{ " FRZ", "_ViridianBlackboardFrozenText" },
|
||||||
|
}
|
||||||
|
|
||||||
|
local function blackboard(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 = 12, th = 8, rowStep = 1 }))
|
||||||
|
end
|
||||||
|
-- ViridianSchoolBlackboardText2 is reprinted on every .blackboardLoop
|
||||||
|
-- pass, immediately before HandleMenuInput
|
||||||
|
function askHeading()
|
||||||
|
game.stack:push(TextBox.new(game,
|
||||||
|
text._ViridianSchoolBlackboardText2 or "Which heading do\nyou want to read?",
|
||||||
|
showMenu))
|
||||||
|
end
|
||||||
|
items = {}
|
||||||
|
for i, row in ipairs(STATUS_LABELS) do
|
||||||
|
local label, key = row[1], row[2]
|
||||||
|
items[i] = { label = label, onSelect = function()
|
||||||
|
game.stack:push(TextBox.new(game, text[key] or label, askHeading))
|
||||||
|
end }
|
||||||
|
end
|
||||||
|
-- no onSelect: Menu's own pop closes the box, matching .exitBlackboard
|
||||||
|
items[#items + 1] = { label = " QUIT" }
|
||||||
|
game.stack:push(TextBox.new(game,
|
||||||
|
text._ViridianSchoolBlackboardText1
|
||||||
|
or "The blackboard\ndescribes POKéMON\vSTATUS changes\vduring battles.",
|
||||||
|
askHeading))
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ViridianSchoolNotebook (engine/events/hidden_events/school_notebooks.asm):
|
||||||
|
-- pages 1-3 each end in TurnPageSchoolNotebook (TurnPageText + YesNoChoice)
|
||||||
|
-- and NO stops the read; page 4 turns without asking and runs straight into
|
||||||
|
-- page 5, the girl catching you at it.
|
||||||
|
local function notebook(game)
|
||||||
|
local text = game.data.text or {}
|
||||||
|
local function page(n, after)
|
||||||
|
return TextBox.new(game, text["_ViridianSchoolNotebookText" .. n] or "", after)
|
||||||
|
end
|
||||||
|
local function turnPage(nextPage)
|
||||||
|
return function()
|
||||||
|
game.stack:push(TextBox.new(game, text._TurnPageText or "Turn the page?",
|
||||||
|
nil, { choice = function(yes)
|
||||||
|
if yes then game.stack:push(nextPage()) end
|
||||||
|
end }))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
local function page5() return page(5) end
|
||||||
|
local function page4() return page(4, function() game.stack:push(page5()) end) end
|
||||||
|
local function page3() return page(3, turnPage(page4)) end
|
||||||
|
local function page2() return page(2, turnPage(page3)) end
|
||||||
|
game.stack:push(page(1, turnPage(page2)))
|
||||||
|
end
|
||||||
|
|
||||||
|
return {
|
||||||
|
VIRIDIAN_SCHOOL_HOUSE = {
|
||||||
|
onInteract = function(game, ow, fx, fy)
|
||||||
|
if fx == 3 and fy == 0 then
|
||||||
|
blackboard(game)
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
if fx == 3 and fy == 4 then
|
||||||
|
notebook(game)
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
return false
|
||||||
|
end,
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -60,6 +60,7 @@ local files = {
|
|||||||
"data.scripts.flavor.victory_road_2f",
|
"data.scripts.flavor.victory_road_2f",
|
||||||
"data.scripts.flavor.viridian_city",
|
"data.scripts.flavor.viridian_city",
|
||||||
"data.scripts.flavor.viridian_nickname_house",
|
"data.scripts.flavor.viridian_nickname_house",
|
||||||
|
"data.scripts.flavor.viridian_school_house", -- #503
|
||||||
"data.scripts.flavor.wardens_house",
|
"data.scripts.flavor.wardens_house",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+92
-19
@@ -183,6 +183,7 @@ M.BILLS_HOUSE = {
|
|||||||
overworld = ow },
|
overworld = ow },
|
||||||
"BILLS_HOUSE", "BILLSHOUSE_BILL_POKEMON")
|
"BILLS_HOUSE", "BILLSHOUSE_BILL_POKEMON")
|
||||||
game.save.flags.EVENT_BILL_SAID_USE_CELL_SEPARATOR = true
|
game.save.flags.EVENT_BILL_SAID_USE_CELL_SEPARATOR = true
|
||||||
|
require("src.world.PikachuFollower").onBillEnteredMachine(game, ow)
|
||||||
done()
|
done()
|
||||||
end
|
end
|
||||||
if ow.player.facing == "down" then
|
if ow.player.facing == "down" then
|
||||||
@@ -273,6 +274,9 @@ M.BILLS_HOUSE = {
|
|||||||
Commands.hide_object(ctx, "BILLS_HOUSE", "BILLSHOUSE_BILL1")
|
Commands.hide_object(ctx, "BILLS_HOUSE", "BILLSHOUSE_BILL1")
|
||||||
Commands.show_object(ctx, "BILLS_HOUSE", "BILLSHOUSE_BILL2")
|
Commands.show_object(ctx, "BILLS_HOUSE", "BILLSHOUSE_BILL2")
|
||||||
end
|
end
|
||||||
|
if not flags.EVENT_MET_BILL_2 then
|
||||||
|
require("src.world.PikachuFollower").onBillsHouseEnter(game, ow)
|
||||||
|
end
|
||||||
end,
|
end,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -643,7 +647,12 @@ M.WARDENS_HOUSE = {
|
|||||||
TEXT_WARDENSHOUSE_WARDEN = {
|
TEXT_WARDENSHOUSE_WARDEN = {
|
||||||
{ "face_player" }, -- 1
|
{ "face_player" }, -- 1
|
||||||
{ "check_flag", "EVENT_GOT_HM04" }, -- 2
|
{ "check_flag", "EVENT_GOT_HM04" }, -- 2
|
||||||
{ "jump_if_true", 13 }, -- 3
|
-- #535: previously jumped to the same silent-end target as the
|
||||||
|
-- give-then-thank fallthrough (row 13), so the warden said nothing
|
||||||
|
-- on every visit after the trade. pokered's .got_item branch
|
||||||
|
-- (scripts/WardensHouse.asm) instead prints HM04ExplanationText,
|
||||||
|
-- so route here to the new row 17 that does the same.
|
||||||
|
{ "jump_if_true", 17 }, -- 3
|
||||||
{ "check_item", "GOLD_TEETH" }, -- 4
|
{ "check_item", "GOLD_TEETH" }, -- 4
|
||||||
{ "jump_if_false", 15 }, -- 5
|
{ "jump_if_false", 15 }, -- 5
|
||||||
{ "show_text", "_WardensHouseWardenGaveTheGoldTeethText" }, -- 6
|
{ "show_text", "_WardensHouseWardenGaveTheGoldTeethText" }, -- 6
|
||||||
@@ -654,9 +663,15 @@ M.WARDENS_HOUSE = {
|
|||||||
{ "give_item", "HM_STRENGTH", 1, false }, -- 10
|
{ "give_item", "HM_STRENGTH", 1, false }, -- 10
|
||||||
{ "show_text", "_WardensHouseWardenReceivedHM04Text" }, -- 11
|
{ "show_text", "_WardensHouseWardenReceivedHM04Text" }, -- 11
|
||||||
{ "set_flag", "EVENT_GOT_HM04" }, -- 12
|
{ "set_flag", "EVENT_GOT_HM04" }, -- 12
|
||||||
{ "jump", 16 }, -- 13 (already got it)
|
{ "jump", 18 }, -- 13 (already got it this convo; jp .done)
|
||||||
{ "jump", 16 }, -- 14 (unused)
|
{ "jump", 18 }, -- 14 (unused)
|
||||||
{ "show_text", "_WardensHouseWardenGibberish1Text" }, -- 15
|
{ "show_text", "_WardensHouseWardenGibberish1Text" }, -- 15
|
||||||
|
{ "jump", 18 }, -- 16 (#535: skip the new explanation row below)
|
||||||
|
-- #535: pokered .got_item branch (scripts/WardensHouse.asm) --
|
||||||
|
-- printed on every subsequent talk once EVENT_GOT_HM04 is set.
|
||||||
|
-- Text is _WardensHouseWardenHM04ExplanationText (text/WardensHouse.asm):
|
||||||
|
-- HM04 teaches Strength, and hints at the Safari Zone secret house.
|
||||||
|
{ "show_text", "_WardensHouseWardenHM04ExplanationText" }, -- 17
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -666,6 +681,48 @@ M.WARDENS_HOUSE = {
|
|||||||
-- generic OPP_GIOVANNI#2 battle)
|
-- 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 = {
|
M.SILPH_CO_11F = {
|
||||||
-- Giovanni's battle is a COORDINATE TRIGGER, not a talk.
|
-- Giovanni's battle is a COORDINATE TRIGGER, not a talk.
|
||||||
-- SilphCo11FDefaultScript (scripts/SilphCo11F.asm) checks
|
-- SilphCo11FDefaultScript (scripts/SilphCo11F.asm) checks
|
||||||
@@ -692,13 +749,11 @@ M.SILPH_CO_11F = {
|
|||||||
ow:scriptMove(gio, "down", 3, function()
|
ow:scriptMove(gio, "down", 3, function()
|
||||||
gio:facePlayer(ow.player)
|
gio:facePlayer(ow.player)
|
||||||
ow:engageTrainer(gio, function()
|
ow:engageTrainer(gio, function()
|
||||||
-- SilphCo11FTeamRocketLeavesScript: Giovanni leaves the floor
|
-- SilphCo11FTeamRocketLeavesScript: every Silph rocket leaves
|
||||||
-- after the loss (the street rockets are handled by
|
-- after the loss (the street rockets are handled by
|
||||||
-- M.SAFFRON_CITY.onEnter in story4.lua).
|
-- M.SAFFRON_CITY.onEnter in story4.lua).
|
||||||
if game.save.flags.EVENT_BEAT_SILPH_CO_GIOVANNI then
|
if game.save.flags.EVENT_BEAT_SILPH_CO_GIOVANNI then
|
||||||
local Commands = require("src.script.Commands")
|
silphRocketsLeave(game, ow)
|
||||||
local ctx = { game = game, save = game.save, overworld = ow }
|
|
||||||
Commands.hide_object(ctx, "SILPH_CO_11F", "SILPHCO11F_GIOVANNI")
|
|
||||||
end
|
end
|
||||||
end)
|
end)
|
||||||
end)
|
end)
|
||||||
@@ -706,28 +761,46 @@ M.SILPH_CO_11F = {
|
|||||||
end,
|
end,
|
||||||
onEnter = function(game, ow)
|
onEnter = function(game, ow)
|
||||||
if game.save.flags.EVENT_BEAT_SILPH_CO_GIOVANNI then
|
if game.save.flags.EVENT_BEAT_SILPH_CO_GIOVANNI then
|
||||||
local Commands = require("src.script.Commands")
|
silphRocketsLeave(game, ow)
|
||||||
local ctx = { game = game, save = game.save, overworld = ow }
|
|
||||||
Commands.hide_object(ctx, "SILPH_CO_11F", "SILPHCO11F_GIOVANNI")
|
|
||||||
end
|
end
|
||||||
end,
|
end,
|
||||||
talk = {
|
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 = {
|
TEXT_SILPHCO11F_SILPH_PRESIDENT = {
|
||||||
{ "face_player" }, -- 1
|
{ "face_player" }, -- 1
|
||||||
{ "check_flag", "EVENT_BEAT_SILPH_CO_GIOVANNI" }, -- 2
|
{ "check_flag", "EVENT_GOT_MASTER_BALL" }, -- 2
|
||||||
{ "jump_if_false", 10 }, -- 3
|
{ "jump_if_true", 9 }, -- 3
|
||||||
{ "check_flag", "EVENT_GOT_MASTER_BALL" }, -- 4
|
{ "show_text", "_SilphCo11FSilphPresidentText" }, -- 4
|
||||||
{ "jump_if_true", 10 }, -- 5
|
|
||||||
{ "show_text", "_SilphCo11FSilphPresidentText" }, -- 6
|
|
||||||
-- give-then-print like scripts/SilphCo11F.asm
|
-- give-then-print like scripts/SilphCo11F.asm
|
||||||
{ "give_item", "MASTER_BALL", 1, false }, -- 7
|
{ "give_item", "MASTER_BALL", 1, false }, -- 5
|
||||||
{ "show_text", "_SilphCo11FSilphPresidentReceivedMasterBallText" }, -- 8
|
{ "show_text", "_SilphCo11FSilphPresidentReceivedMasterBallText" }, -- 6
|
||||||
{ "set_flag", "EVENT_GOT_MASTER_BALL" }, -- 9
|
{ "set_flag", "EVENT_GOT_MASTER_BALL" }, -- 7
|
||||||
{ "jump", 11 }, -- 10
|
{ "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):
|
-- Victory Road boulder switches (scripts/VictoryRoad1F/2F/3F.asm):
|
||||||
-- a boulder resting on a switch removes a barrier block; the 3F hole
|
-- 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()
|
t._PokemonTower6FBeGoneText or "Be gone...\nIntruders...", function()
|
||||||
local BattleState = require("src.battle.BattleState")
|
local BattleState = require("src.battle.BattleState")
|
||||||
local battle = BattleState.newWild(game, "MAROWAK", 30)
|
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
|
if not game.save.inventory.SILPH_SCOPE then
|
||||||
battle:makeGhost()
|
battle:makeGhost()
|
||||||
end
|
end
|
||||||
@@ -182,17 +187,16 @@ M.POKEMON_TOWER_6F = {
|
|||||||
-- preFrames: the shake's lead-in delays -- ShakeElevator's own Delay3s
|
-- preFrames: the shake's lead-in delays -- ShakeElevator's own Delay3s
|
||||||
-- come to 9 frames; Silph/Rocket's ...ShakeScript prefixes another
|
-- come to 9 frames; Silph/Rocket's ...ShakeScript prefixes another
|
||||||
-- Delay3 (12) while CeladonMartElevatorShakeScript farjps straight in
|
-- 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
|
-- After the ride the original does NOT jump-cut to the floor: choosing a
|
||||||
-- floor in engine/events/elevator.asm DisplayElevatorFloorMenu rewrites
|
-- floor rewrites the elevator car's own warp entries (wWarpEntries, via
|
||||||
-- the elevator car's own warp entries (wWarpEntries, via .UpdateWarp) to
|
-- .UpdateWarp) to the chosen floor's exit warp and returns control, and
|
||||||
-- the chosen floor's exit warp, then the player walks out of the car onto
|
-- the player walks out of the car onto that warp themselves.
|
||||||
-- 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" }
|
|
||||||
|
|
||||||
-- .UpdateWarp: point EVERY car exit warp at the same floor's elevator
|
-- .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
|
-- door (warp id, map id). Shared generated map data, but the car's
|
||||||
@@ -205,46 +209,6 @@ local function elevatorSetExit(ow, floor)
|
|||||||
end
|
end
|
||||||
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 function elevatorFloors(elevatorMapId, game)
|
||||||
local floors = {}
|
local floors = {}
|
||||||
for mapId, def in pairs(game.data.maps) do
|
for mapId, def in pairs(game.data.maps) do
|
||||||
@@ -272,9 +236,9 @@ local function elevatorFloors(elevatorMapId, game)
|
|||||||
end
|
end
|
||||||
|
|
||||||
local function elevatorSeedExit(ow, floors, fromMapId)
|
local function elevatorSeedExit(ow, floors, fromMapId)
|
||||||
-- Seed a walk-out destination before the menu (or key-gate text):
|
-- Seed a walk-out destination on entry (before the panel is ever
|
||||||
-- entry floor when known, else the first listed floor (1F). Choosing
|
-- read): entry floor when known, else the first listed floor (1F).
|
||||||
-- a floor still rewrites via elevatorWalkOut; B-cancel / no-key leave
|
-- 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
|
-- 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
|
-- placeholder (#123) or the car's static default floor (#90: Rocket
|
||||||
-- Hideout defaults to B1F even when entered from B2F/B4F).
|
-- Hideout defaults to B1F even when entered from B2F/B4F).
|
||||||
@@ -288,68 +252,68 @@ local function elevatorSeedExit(ow, floors, fromMapId)
|
|||||||
return exitFloor
|
return exitFloor
|
||||||
end
|
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 {
|
return {
|
||||||
-- fromMapId: the floor the player just left (setMap passes it), so a
|
-- 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
|
-- 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
|
-- default to UNUSED_MAP_ED, which is not in Data.maps -- Warp.resolve
|
||||||
-- asserted and hard-crashed (#123).
|
-- asserted and hard-crashed (#123).
|
||||||
onEnter = function(game, ow, fromMapId)
|
onEnter = function(game, ow, fromMapId)
|
||||||
local floors = elevatorFloors(elevatorMapId, game)
|
elevatorSeedExit(ow, elevatorFloors(elevatorMapId, game), fromMapId)
|
||||||
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,
|
|
||||||
}))
|
|
||||||
end,
|
end,
|
||||||
|
talk = { [panelText] = panel },
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
M.SILPH_CO_ELEVATOR = elevator("SILPH_CO_ELEVATOR")
|
M.SILPH_CO_ELEVATOR = elevator("SILPH_CO_ELEVATOR",
|
||||||
M.CELADON_MART_ELEVATOR = elevator("CELADON_MART_ELEVATOR", nil, 9)
|
"TEXT_SILPHCOELEVATOR_ELEVATOR")
|
||||||
|
M.CELADON_MART_ELEVATOR = elevator("CELADON_MART_ELEVATOR",
|
||||||
|
"TEXT_CELADONMARTELEVATOR", nil, 9)
|
||||||
M.ROCKET_HIDEOUT_ELEVATOR = elevator("ROCKET_HIDEOUT_ELEVATOR",
|
M.ROCKET_HIDEOUT_ELEVATOR = elevator("ROCKET_HIDEOUT_ELEVATOR",
|
||||||
|
"TEXT_ROCKETHIDEOUTELEVATOR",
|
||||||
{ item = "LIFT_KEY", text = "_RocketHideoutElevatorAppearsToNeedKeyText" })
|
{ 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 },
|
{ 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 = {
|
M.VERMILION_DOCK = {
|
||||||
onEnter = function(game, ow)
|
onEnter = function(game, ow)
|
||||||
local Flags = require("src.script.Flags")
|
local Flags = require("src.script.Flags")
|
||||||
@@ -729,17 +703,39 @@ M.VERMILION_DOCK = {
|
|||||||
ow:startDustAnim(cx, 1, function() puff(n - 1, cx + 2) end)
|
ow:startDustAnim(cx, 1, function() puff(n - 1, cx + 2) end)
|
||||||
end
|
end
|
||||||
puff(3, 15)
|
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 = {}
|
local rows = {}
|
||||||
rows[#rows + 1] = { "wait", 100 }
|
local function setBlock(bx, by, block)
|
||||||
rows[#rows + 1] = { "play_sound", "SS_Anne_Horn" }
|
if bx < 1 or bx > 8 then return end
|
||||||
for _, b in ipairs(DOCK_SHIP_BLOCKS) do
|
if bx == pbx and by == pby then return end
|
||||||
rows[#rows + 1] = { "replace_block", b.bx, b.by, b.water }
|
rows[#rows + 1] = { "replace_block", bx, by, block }
|
||||||
end
|
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 }
|
rows[#rows + 1] = { "move_player", "up", 2 }
|
||||||
-- keep Music_Surfing across the city warp (pokered stays on it
|
-- no keepMusic on this warp: Music_Surfing belongs to the dock's
|
||||||
-- through the exit-ship walk)
|
-- cutscene, and VERMILION_CITY's own theme has to take over as the
|
||||||
rows[#rows + 1] = { "play_music", "Music_Surfing", { keep = true } }
|
-- player crosses in (EnterMap's PlayDefaultMusic)
|
||||||
rows[#rows + 1] = { "warp", "VERMILION_CITY", 18, 31, "up" }
|
rows[#rows + 1] = { "warp", "VERMILION_CITY", 18, 31, "up" }
|
||||||
rows[#rows + 1] = { "move_player", "up", 2 }
|
rows[#rows + 1] = { "move_player", "up", 2 }
|
||||||
ow:queueScript(rows)
|
ow:queueScript(rows)
|
||||||
|
|||||||
@@ -171,6 +171,28 @@ local function dojoBall(species, ownBall, otherBall, askKey)
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- FightingDojoDefaultScript does not use the Master's facing direction for
|
||||||
|
-- this battle. It checks exactly the cell left of him, turns both sprites
|
||||||
|
-- toward one another, and then displays his trainer text. Keeping this out
|
||||||
|
-- of trainer sight also lets him keep his original downward-facing pose.
|
||||||
|
local function dojoMasterGate(game, ow, x, y)
|
||||||
|
if x ~= 4 or y ~= 3 or game.save.flags.EVENT_BEAT_KARATE_MASTER then
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
local master
|
||||||
|
for _, npc in ipairs(ow.npcs) do
|
||||||
|
if npc.def and npc.def.name == "FIGHTINGDOJO_KARATE_MASTER" then
|
||||||
|
master = npc
|
||||||
|
break
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if not master or ow:trainerDefeated(master) then return false end
|
||||||
|
ow.player.facing = "right"
|
||||||
|
master:facePlayer(ow.player)
|
||||||
|
ow:engageTrainer(master)
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
M.FIGHTING_DOJO = {
|
M.FIGHTING_DOJO = {
|
||||||
talk = {
|
talk = {
|
||||||
TEXT_FIGHTINGDOJO_HITMONLEE_POKE_BALL =
|
TEXT_FIGHTINGDOJO_HITMONLEE_POKE_BALL =
|
||||||
@@ -197,6 +219,7 @@ M.FIGHTING_DOJO = {
|
|||||||
end
|
end
|
||||||
return false
|
return false
|
||||||
end,
|
end,
|
||||||
|
onStep = dojoMasterGate,
|
||||||
}
|
}
|
||||||
|
|
||||||
-- -------------------------------------------------------------------
|
-- -------------------------------------------------------------------
|
||||||
|
|||||||
+32
-4
@@ -135,6 +135,9 @@ M.SILPH_CO_2F = {
|
|||||||
talk = {
|
talk = {
|
||||||
TEXT_SILPHCO2F_SILPH_WORKER_F = gift({
|
TEXT_SILPHCO2F_SILPH_WORKER_F = gift({
|
||||||
flag = "EVENT_GOT_TM36", item = "TM_SELFDESTRUCT",
|
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",
|
received = "_SilphCo2FSilphWorkerFReceivedTM36Text",
|
||||||
explain = "_SilphCo2FSilphWorkerFTM36ExplanationText",
|
explain = "_SilphCo2FSilphWorkerFTM36ExplanationText",
|
||||||
noRoom = "_SilphCo2FSilphWorkerFTM36NoRoomText",
|
noRoom = "_SilphCo2FSilphWorkerFTM36NoRoomText",
|
||||||
@@ -787,8 +790,19 @@ local function bikeGateGuard(coords, stopText, explainText)
|
|||||||
local t = text(game)
|
local t = text(game)
|
||||||
push(game, t[stopText] or "Hey! Wait up!", function()
|
push(game, t[stopText] or "Hey! Wait up!", function()
|
||||||
push(game, t[explainText] or "You need a\nBICYCLE for\nCYCLING ROAD!", function()
|
push(game, t[explainText] or "You need a\nBICYCLE for\nCYCLING ROAD!", function()
|
||||||
|
-- pokered's Route16Gate1FGuardScript / Route18Gate1FGuardScript
|
||||||
|
-- (scripts/Route16Gate1F.asm, Route18Gate1F.asm) simulate one
|
||||||
|
-- PAD_RIGHT step after the refusal text, and only clear
|
||||||
|
-- wJoyIgnore / hand control back once that step finishes
|
||||||
|
-- (PlayerMovingRightScript). Without it the player was left
|
||||||
|
-- parked beside the guard's counter with no way past. #518
|
||||||
|
local function shoveRight()
|
||||||
|
ow:scriptMove(ow.player, "right", 1)
|
||||||
|
end
|
||||||
if dist > 0 then
|
if dist > 0 then
|
||||||
ow:scriptMove(ow.player, "up", dist)
|
ow:scriptMove(ow.player, "up", dist, shoveRight)
|
||||||
|
else
|
||||||
|
shoveRight()
|
||||||
end
|
end
|
||||||
end)
|
end)
|
||||||
end)
|
end)
|
||||||
@@ -836,6 +850,19 @@ M.SILPH_CO_7F = {
|
|||||||
end,
|
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)
|
-- S.S. Anne 2F rival ambush (scripts/SSAnne2F.asm; coords 36/37,8)
|
||||||
M.SS_ANNE_2F = {
|
M.SS_ANNE_2F = {
|
||||||
onStep = function(game, ow, x, y)
|
onStep = function(game, ow, x, y)
|
||||||
@@ -848,11 +875,12 @@ M.SS_ANNE_2F = {
|
|||||||
{ "face_object", 2, onLeft and "down" or "right" }, -- 3
|
{ "face_object", 2, onLeft and "down" or "right" }, -- 3
|
||||||
{ "show_text", "_SSAnne2FRivalText" }, -- 4
|
{ "show_text", "_SSAnne2FRivalText" }, -- 4
|
||||||
{ "rival_battle", "OPP_RIVAL2", 1 }, -- 5
|
{ "rival_battle", "OPP_RIVAL2", 1 }, -- 5
|
||||||
{ "jump_if_false", 10 }, -- 6
|
{ "jump_if_false", 11 }, -- 6
|
||||||
{ "set_flag", "EVENT_BEAT_SS_ANNE_RIVAL" }, -- 7
|
{ "set_flag", "EVENT_BEAT_SS_ANNE_RIVAL" }, -- 7
|
||||||
{ "show_text", "_SSAnne2FRivalDefeatedText" }, -- 8
|
{ "show_text", "_SSAnne2FRivalDefeatedText" }, -- 8
|
||||||
{ "move_npc_to", 2, 36, 4 }, -- 9
|
{ "show_text", "_SSAnne2FRivalCutMasterText" }, -- 9
|
||||||
{ "hide_object", "SS_ANNE_2F", "SSANNE2F_RIVAL" }, -- 10
|
{ "walk_npc", 2, ssAnne2FRivalExitDirs(onLeft) }, -- 10
|
||||||
|
{ "hide_object", "SS_ANNE_2F", "SSANNE2F_RIVAL" }, -- 11
|
||||||
}, onLeft and "up" or "left")
|
}, onLeft and "up" or "left")
|
||||||
end,
|
end,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,10 +23,15 @@ M.CERULEAN_MELANIES_HOUSE = {
|
|||||||
rows[#rows + 1] = { "jump_if_false", "declined" }
|
rows[#rows + 1] = { "jump_if_false", "declined" }
|
||||||
rows[#rows + 1] = { "give_pokemon", "BULBASAUR", 10 }
|
rows[#rows + 1] = { "give_pokemon", "BULBASAUR", 10 }
|
||||||
rows[#rows + 1] = { "jump_if_false", "end" } -- party + box full
|
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",
|
rows[#rows + 1] = { "hide_object", "CERULEAN_MELANIES_HOUSE",
|
||||||
"CERULEANMELANIESHOUSE_BULBASAUR" }
|
"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] = { "jump", "end" }
|
||||||
rows[#rows + 1] = { "label", "declined" }
|
rows[#rows + 1] = { "label", "declined" }
|
||||||
rows[#rows + 1] = { "show_text", "MelanieText5" }
|
rows[#rows + 1] = { "show_text", "MelanieText5" }
|
||||||
@@ -60,8 +65,13 @@ M.ROUTE_24 = {
|
|||||||
{ "jump_if_false", "declined" },
|
{ "jump_if_false", "declined" },
|
||||||
{ "give_pokemon", "CHARMANDER", 10 },
|
{ "give_pokemon", "CHARMANDER", 10 },
|
||||||
{ "jump_if_false", "end" },
|
{ "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" },
|
{ "set_flag", "EVENT_54F" },
|
||||||
|
{ "show_text", "_Route24DamianText2" },
|
||||||
{ "jump", "end" },
|
{ "jump", "end" },
|
||||||
{ "label", "declined" },
|
{ "label", "declined" },
|
||||||
{ "show_text", "_Route24DamianText3" },
|
{ "show_text", "_Route24DamianText3" },
|
||||||
@@ -87,9 +97,10 @@ M.VERMILION_CITY = {
|
|||||||
rows[#rows + 1] = { "jump_if_false", "declined" }
|
rows[#rows + 1] = { "jump_if_false", "declined" }
|
||||||
rows[#rows + 1] = { "give_pokemon", "SQUIRTLE", 10 }
|
rows[#rows + 1] = { "give_pokemon", "SQUIRTLE", 10 }
|
||||||
rows[#rows + 1] = { "jump_if_false", "end" }
|
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",
|
rows[#rows + 1] = { "set_flag",
|
||||||
"EVENT_GOT_SQUIRTLE_FROM_OFFICER_JENNY" }
|
"EVENT_GOT_SQUIRTLE_FROM_OFFICER_JENNY" }
|
||||||
|
rows[#rows + 1] = { "show_text", "_OfficerJennyText3" }
|
||||||
rows[#rows + 1] = { "jump", "end" }
|
rows[#rows + 1] = { "jump", "end" }
|
||||||
rows[#rows + 1] = { "label", "declined" }
|
rows[#rows + 1] = { "label", "declined" }
|
||||||
rows[#rows + 1] = { "show_text", "_OfficerJennyText4" }
|
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_JESSIE" },
|
||||||
{ "show_object", "MT_MOON_B2F", "MTMOONB2F_JAMES" },
|
{ "show_object", "MT_MOON_B2F", "MTMOONB2F_JAMES" },
|
||||||
{ "show_text", "_MtMoonJessieJamesText1" },
|
{ "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" },
|
{ "show_text", "_MtMoonJessieJamesText2" },
|
||||||
{ "start_battle", "trainer", "OPP_ROCKET", 42 },
|
{ "start_battle", "trainer", "OPP_ROCKET", 42 },
|
||||||
{ "check_battle_result", "win" },
|
{ "check_battle_result", "win" },
|
||||||
@@ -57,8 +69,10 @@ M.MT_MOON_B2F = {
|
|||||||
{ "show_text", "_MtMoonJessieJamesText4" },
|
{ "show_text", "_MtMoonJessieJamesText4" },
|
||||||
{ "stop_music" },
|
{ "stop_music" },
|
||||||
{ "play_music", "Music_MeetJessieJames" },
|
{ "play_music", "Music_MeetJessieJames" },
|
||||||
|
{ "fade", "out" },
|
||||||
{ "hide_object", "MT_MOON_B2F", "MTMOONB2F_JESSIE" },
|
{ "hide_object", "MT_MOON_B2F", "MTMOONB2F_JESSIE" },
|
||||||
{ "hide_object", "MT_MOON_B2F", "MTMOONB2F_JAMES" },
|
{ "hide_object", "MT_MOON_B2F", "MTMOONB2F_JAMES" },
|
||||||
|
{ "fade", "in" },
|
||||||
{ "play_default_music" },
|
{ "play_default_music" },
|
||||||
{ "set_flag", "EVENT_BEAT_MT_MOON_3_JESSIE_JAMES" },
|
{ "set_flag", "EVENT_BEAT_MT_MOON_3_JESSIE_JAMES" },
|
||||||
}, {})
|
}, {})
|
||||||
|
|||||||
@@ -65,6 +65,13 @@ display option — COLORS, TILT, ZOOM, VOID FILL, MAX FPS — works normally. If
|
|||||||
your device turns out to handle the pass, launch with `POKEPORT_GBCFX=1` to
|
your device turns out to handle the pass, launch with `POKEPORT_GBCFX=1` to
|
||||||
put the row back.
|
put the row back.
|
||||||
|
|
||||||
|
**PERFORMANCE defaults to LOW here.** The OPTIONS → PERFORMANCE tier defaults
|
||||||
|
to AUTO, which reads this device as an ARM Linux handheld and resolves to
|
||||||
|
**LOW**: the 3D tilt and survey zoom stay off and the frame rate is capped,
|
||||||
|
so the overworld runs smoothly on the H700 out of the box. Bump it to
|
||||||
|
BALANCED or HIGH from OPTIONS if you want the extras and your device keeps
|
||||||
|
up; see [Performance tier](new-features.md#performance-tier-low-end-devices).
|
||||||
|
|
||||||
The pack bundles the LÖVE 11.5 aarch64 runtime from
|
The pack bundles the LÖVE 11.5 aarch64 runtime from
|
||||||
[PortMaster](https://portmaster.games/), so the device does not need a
|
[PortMaster](https://portmaster.games/), so the device does not need a
|
||||||
separate `love_11.5` runtime download on first launch. The launcher resolves
|
separate `love_11.5` runtime download on first launch. The launcher resolves
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ this port.
|
|||||||
|
|
||||||
| # | Mechanic | Value | Why the Game Boy had this limit | Where it lives here |
|
| # | Mechanic | Value | Why the Game Boy had this limit | Where it lives here |
|
||||||
|---|---|---|---|---|
|
|---|---|---|---|---|
|
||||||
| 1 | Bag capacity | 20 item slots | `wNumBagItems` save block was a fixed 20-entry id/quantity array in SRAM | `src/inventory/Bag.lua:8` (`Bag.CAPACITY = 20`) |
|
| 1 | Bag capacity | 20 item slots by default | `wNumBagItems` save block was a fixed 20-entry id/quantity array in SRAM | `Data.constants.bagSize`, read by `src/inventory/Bag.lua` (`Bag.capacity`) |
|
||||||
| 2 | Party size | 6 Pokémon | `wPartyMon1..6` were 6 fixed save-RAM slots | `src/pokemon/Party.lua:5` (`Party.MAX = 6`) |
|
| 2 | Party size | 6 Pokémon | `wPartyMon1..6` were 6 fixed save-RAM slots | `src/pokemon/Party.lua:5` (`Party.MAX = 6`) |
|
||||||
| 3 | PC storage | 12 boxes × 20 Pokémon | `wBoxDataStart` / Bill's PC allocated a fixed 12×20 SRAM block | `src/pokemon/Boxes.lua:7-8` |
|
| 3 | PC storage | 12 boxes × 20 Pokémon | `wBoxDataStart` / Bill's PC allocated a fixed 12×20 SRAM block | `src/pokemon/Boxes.lua:7-8` |
|
||||||
| 4 | Moves per Pokémon | 4 | Fixed 4-move-slot field in the party/box Pokémon struct | `src/pokemon/Pokemon.lua:20`, enforced again in `src/battle/BattleState.lua:1941` |
|
| 4 | Moves per Pokémon | 4 | Fixed 4-move-slot field in the party/box Pokémon struct | `src/pokemon/Pokemon.lua:20`, enforced again in `src/battle/BattleState.lua:1941` |
|
||||||
@@ -38,6 +38,10 @@ this port.
|
|||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
|
- Mods may patch `constants.bagSize` through the public content registry. The
|
||||||
|
native `save.lua` format keeps every existing item when the configured
|
||||||
|
limit changes; exporting to a cartridge `.sav` still writes only the first
|
||||||
|
20 bag slots because the original SRAM layout has no room for more.
|
||||||
- PC Box **overflow handling** was deliberately changed even though the
|
- PC Box **overflow handling** was deliberately changed even though the
|
||||||
20×12 box *shape* was kept faithful: instead of Gen 1's "full box discards
|
20×12 box *shape* was kept faithful: instead of Gen 1's "full box discards
|
||||||
or blocks the deposit," this port spills into the next box with room.
|
or blocks the deposit," this port spills into the next box with room.
|
||||||
|
|||||||
@@ -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).
|
||||||
@@ -167,5 +167,25 @@ The console understands these verbs (anything else is evaluated as Lua):
|
|||||||
- `trace PAT | trace off` — trace events/hooks matching a glob pattern.
|
- `trace PAT | trace off` — trace events/hooks matching a glob pattern.
|
||||||
- `help` — list the verbs.
|
- `help` — list the verbs.
|
||||||
|
|
||||||
|
## Tool input and title-menu hooks
|
||||||
|
|
||||||
|
Tool mods that need to act once per game logic tick can wrap `input.step`.
|
||||||
|
It runs immediately before queued button edges are promoted, so input added by
|
||||||
|
the wrapper is visible during that same fixed step. The callback receives
|
||||||
|
`(next, game, dt)` and must call `next(game, dt)`.
|
||||||
|
|
||||||
|
`ui.title_menu.items` receives `(next, game, items)` and follows the same
|
||||||
|
decorate-after-`next` convention as `ui.start_menu.items`. It is the safe place
|
||||||
|
for a tool to offer a fresh-session action before gameplay begins.
|
||||||
|
|
||||||
|
Ephemeral tools can wrap `save.write(next, game)` and return `false` to veto a
|
||||||
|
progress write before world state is captured or any bytes reach disk.
|
||||||
|
|
||||||
|
`render.hud` receives `(next, game, viewport)` after the finished game frame is
|
||||||
|
composited and before touch controls draw. The window-space viewport contains
|
||||||
|
`width`, `height`, `gameX`, `gameY`, `gameWidth`, `gameHeight`, `scale`, `dpiX`,
|
||||||
|
and `dpiY`, so a tool can use the letterbox margins without drawing over the
|
||||||
|
playfield or pushing an updating game state.
|
||||||
|
|
||||||
Developer mode also arms the mod loader's dev tripwire, which flags mods
|
Developer mode also arms the mod loader's dev tripwire, which flags mods
|
||||||
that reach outside their permission set.
|
that reach outside their permission set.
|
||||||
|
|||||||
+78
-7
@@ -86,14 +86,20 @@ Game Boy equivalent:
|
|||||||
## Colors mode
|
## Colors mode
|
||||||
|
|
||||||
The `2` key (and the Options menu COLORS row) cycles the display mode
|
The `2` key (and the Options menu COLORS row) cycles the display mode
|
||||||
through **OG RED → SGB → ADVANCED → OG → OG INV → SGB INV → CLASSIC → OG RED**.
|
through **OG RED → SGB → ADVANCED → OG → OG INV → SGB INV → CLASSIC → OG RED**
|
||||||
|
(on Blue the first slot labels **OG BLUE**; on Yellow, **OG YELLOW**).
|
||||||
The first three are the real colorizations; the rest are DMG-shade novelties:
|
The first three are the real colorizations; the rest are DMG-shade novelties:
|
||||||
|
|
||||||
- **OG RED**: the Game Boy Color boot-ROM look for Pokemon Red -- one global
|
- **OG RED** / **OG BLUE**: the Game Boy Color boot-ROM look for that cart --
|
||||||
red BG palette + one green OBJ palette, every map, no per-map variation
|
one global BG palette + one OBJ palette, every map, no per-map variation
|
||||||
(Pokemon Red has no CGB code, so on a GBC the boot ROM colors it globally).
|
(Red/Blue ship no CGB code, so on a GBC the boot ROM colors them globally).
|
||||||
The player/NPCs stay green over the red terrain via the OBP bake +
|
The player/NPCs keep the boot-ROM OBJ color over the terrain via the OBP
|
||||||
post-zone redraw (`PaletteFX.GBC_BG` / `GBC_OBJ`).
|
bake + post-zone redraw (`PaletteFX.GBC_BG` / `GBC_OBJ`, or Blue's blue/pink
|
||||||
|
pair).
|
||||||
|
- **OG YELLOW** (Yellow playthrough, same `ogred` save id): Pokemon Yellow's
|
||||||
|
authentic GBC look from `CGBBasePalettes` (`data/palettes_yellow.lua`,
|
||||||
|
sourced from pret/pokeyellow). Per-map / per-species colors, not a single
|
||||||
|
boot-ROM ramp -- Yellow was CGB-enhanced.
|
||||||
- **SGB** (default): the per-map Super Game Boy region palettes
|
- **SGB** (default): the per-map Super Game Boy region palettes
|
||||||
(`data/sgb/sgb_palettes.asm`). Sprites tint with the region palette, as on
|
(`data/sgb/sgb_palettes.asm`). Sprites tint with the region palette, as on
|
||||||
real SGB. (This is the mode formerly mislabeled "GBC".)
|
real SGB. (This is the mode formerly mislabeled "GBC".)
|
||||||
@@ -137,6 +143,41 @@ effect, `=1` forces it available. The Anbernic handheld pack exports `0` from
|
|||||||
its launcher because the device reports `"Linux"` while its GPU is in the
|
its launcher because the device reports `"Linux"` while its GPU is in the
|
||||||
phone class (see [Anbernic RG34XXSP](anbernic-rg34xxsp.md)).
|
phone class (see [Anbernic RG34XXSP](anbernic-rg34xxsp.md)).
|
||||||
|
|
||||||
|
## Performance tier (low-end devices)
|
||||||
|
|
||||||
|
The Options **PERFORMANCE** row scales the port's optional presentation
|
||||||
|
extras down for weaker hardware. The extras it governs are the three
|
||||||
|
heaviest things the port adds on top of the original -- the whole-screen 3D
|
||||||
|
**TILT** (transforms the entire map as a ground plane), the **GBC FX**
|
||||||
|
post-process shader (a fullscreen pass), and survey **ZOOM** (zooming out
|
||||||
|
renders the connected neighbor maps, a lot of extra overdraw) -- plus a hard
|
||||||
|
FPS ceiling. None of this touches game logic, which is fixed-step off `dt`
|
||||||
|
(`src/core/FixedStep.lua`), so every tier plays identically; they differ
|
||||||
|
only in how much eye-candy the renderer is allowed to do.
|
||||||
|
|
||||||
|
| Tier | TILT | GBC FX | Survey ZOOM | Extra FPS ceiling |
|
||||||
|
| ------------ | ---- | ------ | ----------- | ----------------- |
|
||||||
|
| **HIGH** | on | on | on | none |
|
||||||
|
| **BALANCED** | off | off | on | none |
|
||||||
|
| **LOW** | off | off | off | 60 |
|
||||||
|
| **AUTO** | picks a default from the device (below) |||
|
||||||
|
|
||||||
|
- **AUTO** (the default) reads the device once at boot: ARM Linux handhelds
|
||||||
|
(e.g. the RG34XXSP) resolve to **LOW**, phones/tablets and very-low-core
|
||||||
|
desktops to **BALANCED**, and everything else -- a normal desktop, and
|
||||||
|
every existing `options.lua` that predates this option -- to **HIGH**,
|
||||||
|
so the common case is unchanged. See `src/core/Performance.detect`.
|
||||||
|
- AUTO only chooses the *default*; all four tiers are selectable, so a
|
||||||
|
wrong guess is one row away from being overridden.
|
||||||
|
- The clamps are applied **live** against your stored options and never
|
||||||
|
rewrite them (`Game:applyOptions`), so a lower tier hides your TILT / GBC
|
||||||
|
FX / ZOOM without forgetting them -- raising the tier restores exactly
|
||||||
|
what you had. (This is why the TILT / GBC FX / ZOOM rows still show your
|
||||||
|
saved choice on a clamped tier: it's your preference, waiting for a tier
|
||||||
|
that can afford it.)
|
||||||
|
- Persisted as `save.options.performance` (`auto` | `high` | `balanced` |
|
||||||
|
`low`); unit-tested in `tests/engine/performance_tiers.lua`.
|
||||||
|
|
||||||
## Peer-to-peer link play (lua-enet)
|
## Peer-to-peer link play (lua-enet)
|
||||||
|
|
||||||
Trades and link battles connect two copies of the game directly over
|
Trades and link battles connect two copies of the game directly over
|
||||||
@@ -151,6 +192,13 @@ tradeoff vs. the relay). Headless tests drive the protocol over an
|
|||||||
in-memory loopback (`Net.loopbackPair`); under LÖVE the same test file
|
in-memory loopback (`Net.loopbackPair`); under LÖVE the same test file
|
||||||
also exercises real UDP pairing.
|
also exercises real UDP pairing.
|
||||||
|
|
||||||
|
Red, Blue, and Yellow copies link with each other, as the real cable
|
||||||
|
does. The compatibility fingerprint hashes only data a link mode can
|
||||||
|
actually read, so Yellow's Dragonair/Dragonite catch-rate retunes (the
|
||||||
|
only R/B/Y link-surface difference) no longer read as different games
|
||||||
|
(issue #511). Moving the fingerprint is a link parity change: builds
|
||||||
|
from before this fix will refuse to pair with builds after it.
|
||||||
|
|
||||||
## Fair play in link and online matches
|
## Fair play in link and online matches
|
||||||
|
|
||||||
A link session is decided by the battle and nothing else, so for its
|
A link session is decided by the battle and nothing else, so for its
|
||||||
@@ -193,6 +241,11 @@ flushes the live options. Old saves that still embed an `options` table are
|
|||||||
migrated once into `options.lua` on load.
|
migrated once into `options.lua` on load.
|
||||||
|
|
||||||
- Music / SFX volume
|
- 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
|
- Music Filter
|
||||||
- OG GLITCHES on / off (Gen 1 quirks vs. modern-clean battle rules)
|
- OG GLITCHES on / off (Gen 1 quirks vs. modern-clean battle rules)
|
||||||
- BATTLE LAYOUT (OG / WIDE); see "Widescreen battle layout" below
|
- BATTLE LAYOUT (OG / WIDE); see "Widescreen battle layout" below
|
||||||
@@ -260,6 +313,14 @@ the last controller restores it immediately. Layout re-derives from the
|
|||||||
window size on rotation. Desktop testing: `POKEPORT_TOUCH=1 love .` forces
|
window size on rotation. Desktop testing: `POKEPORT_TOUCH=1 love .` forces
|
||||||
the overlay on and lets the mouse act as a finger (`=0` forces it off).
|
the overlay on and lets the mouse act as a finger (`=0` forces it off).
|
||||||
|
|
||||||
|
The launcher's **Touch Controls** button opens a drag editor: move each
|
||||||
|
button freely, **Disable** to hide the overlay permanently (for
|
||||||
|
controllers / emulation handhelds — distinct from the temporary
|
||||||
|
gamepad auto-hide), **Reset** for defaults, **Done** to save into
|
||||||
|
`options.lua` as normalized window fractions so rotation keeps the
|
||||||
|
relative placement. In-game, Options → **TOUCH PAD** toggles the same
|
||||||
|
on/off flag without leaving a play session.
|
||||||
|
|
||||||
## Translation support
|
## Translation support
|
||||||
|
|
||||||
Every string the player can read is now reachable from a mod, so a
|
Every string the player can read is now reachable from a mod, so a
|
||||||
@@ -319,7 +380,8 @@ semantics - so the two windows read as one app. Six tabs:
|
|||||||
party dock, so deposit and withdraw live in one place. Empty slots are
|
party dock, so deposit and withdraw live in one place. Empty slots are
|
||||||
clickable and create a mon there.
|
clickable and create a mon there.
|
||||||
- **Items**: money, a searchable item picker (replacing the arrows that
|
- **Items**: money, a searchable item picker (replacing the arrows that
|
||||||
cycled one id at a time through ~250 items), the 20-slot bag, PC storage
|
cycled one id at a time through ~250 items), the configurable bag (20 slots
|
||||||
|
by default), PC storage
|
||||||
with no slot cap, and the eight badges as toggle chips.
|
with no slot cap, and the eight badges as toggle chips.
|
||||||
- **Events**: flags, defeated trainers, taken items and per-map object
|
- **Events**: flags, defeated trainers, taken items and per-map object
|
||||||
toggles, with a real filter field and a two-column paged grid.
|
toggles, with a real filter field and a two-column paged grid.
|
||||||
@@ -362,3 +424,12 @@ kind, number, height/weight, dex text) to a PNG at 4x scale under
|
|||||||
`prints/` in the save directory, then reports the filename in a dialog.
|
`prints/` in the save directory, then reports the filename in a dialog.
|
||||||
No printer hardware or link cable emulation involved; the file is the
|
No printer hardware or link cable emulation involved; the file is the
|
||||||
printout.
|
printout.
|
||||||
|
|
||||||
|
## Find Mods (community mod indexes)
|
||||||
|
|
||||||
|
A FIND MODS tab sits beside MODS in the launcher and browses a published
|
||||||
|
mod index: a metadata-only feed listing mods that live in their authors'
|
||||||
|
own repositories. No index ships with the launcher and none is ever added
|
||||||
|
automatically, so the tab opens on an "Add an index" prompt until you name
|
||||||
|
one; paste an index URL or its `owner/repo` and it is remembered in
|
||||||
|
`options.lua`. More than one index can be added, and the listings merge.
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
|
|
||||||
local editorMode = os.getenv("POKEPORT_EDITOR") == "1" or POKEPORT_EDITOR_MODE == true
|
local editorMode = os.getenv("POKEPORT_EDITOR") == "1" or POKEPORT_EDITOR_MODE == true
|
||||||
|
|
||||||
local Game, EditorApp, Importer
|
local Game, EditorApp, Importer, TouchEditor
|
||||||
|
|
||||||
local autopilot -- optional scripted-input dev tool (tests/autopilot.lua)
|
local autopilot -- optional scripted-input dev tool (tests/autopilot.lua)
|
||||||
local driverCo -- optional frame-driver (POKEPORT_DRIVER=file.lua): a
|
local driverCo -- optional frame-driver (POKEPORT_DRIVER=file.lua): a
|
||||||
@@ -134,6 +134,26 @@ function closeEditor()
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- ------------------------------------------------------------ touch controls editor
|
||||||
|
-- Suspends the launcher while the player drags on-screen buttons / toggles
|
||||||
|
-- the overlay off (#327). No ROM cache needed -- options.lua only.
|
||||||
|
local touchEditorHost
|
||||||
|
local closeTouchControlsEditor -- forward declaration
|
||||||
|
|
||||||
|
local function openTouchControlsEditor()
|
||||||
|
touchEditorHost = Importer
|
||||||
|
Importer = nil
|
||||||
|
TouchEditor = require("src.ui.TouchControlsEditor")
|
||||||
|
TouchEditor.load({ onClose = function() closeTouchControlsEditor() end })
|
||||||
|
end
|
||||||
|
|
||||||
|
function closeTouchControlsEditor()
|
||||||
|
if TouchEditor and TouchEditor.unload then TouchEditor.unload() end
|
||||||
|
TouchEditor = nil
|
||||||
|
Importer = touchEditorHost
|
||||||
|
touchEditorHost = nil
|
||||||
|
end
|
||||||
|
|
||||||
local function bootGame(version)
|
local function bootGame(version)
|
||||||
-- The launcher hands us the chosen game (Red / Blue / Yellow); scripted and
|
-- The launcher hands us the chosen game (Red / Blue / Yellow); scripted and
|
||||||
-- headless runs fall back to POKEPORT_VERSION, then Red. Set the active
|
-- headless runs fall back to POKEPORT_VERSION, then Red. Set the active
|
||||||
@@ -239,11 +259,17 @@ function love.load(args)
|
|||||||
Importer = RomImporter.new(function(version)
|
Importer = RomImporter.new(function(version)
|
||||||
Importer = nil
|
Importer = nil
|
||||||
bootGame(version)
|
bootGame(version)
|
||||||
end, { launcher = true, forceImport = forceImport, onEditSave = openEditor })
|
end, {
|
||||||
|
launcher = true,
|
||||||
|
forceImport = forceImport,
|
||||||
|
onEditSave = openEditor,
|
||||||
|
onEditTouchControls = openTouchControlsEditor,
|
||||||
|
})
|
||||||
end
|
end
|
||||||
|
|
||||||
function love.update(dt)
|
function love.update(dt)
|
||||||
if editorMode then return EditorApp.update(dt) end
|
if editorMode then return EditorApp.update(dt) end
|
||||||
|
if TouchEditor then return TouchEditor.update(dt) end
|
||||||
if Importer then return Importer:update(dt) end
|
if Importer then return Importer:update(dt) end
|
||||||
|
|
||||||
-- Scripted runs (autopilot / POKEPORT_DRIVER) observe and act exactly
|
-- Scripted runs (autopilot / POKEPORT_DRIVER) observe and act exactly
|
||||||
@@ -283,6 +309,7 @@ end
|
|||||||
|
|
||||||
function love.draw()
|
function love.draw()
|
||||||
if editorMode then return EditorApp.draw() end
|
if editorMode then return EditorApp.draw() end
|
||||||
|
if TouchEditor then return TouchEditor.draw() end
|
||||||
if Importer then return Importer:draw() end
|
if Importer then return Importer:draw() end
|
||||||
|
|
||||||
Game:draw()
|
Game:draw()
|
||||||
@@ -303,36 +330,61 @@ end
|
|||||||
|
|
||||||
function love.keypressed(key, scancode, isrepeat)
|
function love.keypressed(key, scancode, isrepeat)
|
||||||
if editorMode then return EditorApp.keypressed(key) end
|
if editorMode then return EditorApp.keypressed(key) end
|
||||||
|
if TouchEditor then return TouchEditor.keypressed(key) end
|
||||||
if Importer then return Importer:keypressed(key) end
|
if Importer then return Importer:keypressed(key) end
|
||||||
Game:keypressed(key)
|
Game:keypressed(key)
|
||||||
end
|
end
|
||||||
|
|
||||||
function love.keyreleased(key)
|
function love.keyreleased(key)
|
||||||
if editorMode then return end
|
if editorMode or TouchEditor then return end
|
||||||
if Importer then return end
|
if Importer then return end
|
||||||
Game:keyreleased(key)
|
Game:keyreleased(key)
|
||||||
end
|
end
|
||||||
|
|
||||||
function love.gamepadpressed(joystick, button)
|
function love.gamepadpressed(joystick, button)
|
||||||
if editorMode then return end
|
if editorMode or TouchEditor then return end
|
||||||
if Importer then return Importer:gamepadpressed(joystick, button) end
|
if Importer then return Importer:gamepadpressed(joystick, button) end
|
||||||
Game:gamepadpressed(joystick, button)
|
Game:gamepadpressed(joystick, button)
|
||||||
end
|
end
|
||||||
|
|
||||||
function love.gamepadreleased(joystick, button)
|
function love.gamepadreleased(joystick, button)
|
||||||
if editorMode then return end
|
if editorMode or TouchEditor then return end
|
||||||
if Importer then return Importer:gamepadreleased(joystick, button) end
|
if Importer then return Importer:gamepadreleased(joystick, button) end
|
||||||
Game:gamepadreleased(joystick, button)
|
Game:gamepadreleased(joystick, button)
|
||||||
end
|
end
|
||||||
|
|
||||||
function love.gamepadaxis(joystick, axis, value)
|
function love.gamepadaxis(joystick, axis, value)
|
||||||
if editorMode then return end
|
if editorMode or TouchEditor then return end
|
||||||
if Importer then return Importer:gamepadaxis(joystick, axis, value) end
|
if Importer then return Importer:gamepadaxis(joystick, axis, value) end
|
||||||
Game:gamepadaxis(joystick, axis, value)
|
Game:gamepadaxis(joystick, axis, value)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
function love.joystickpressed(joystick, button)
|
||||||
|
if editorMode or TouchEditor then return end
|
||||||
|
if Importer then return Importer:joystickpressed(joystick, button) end
|
||||||
|
Game:joystickpressed(joystick, button)
|
||||||
|
end
|
||||||
|
|
||||||
|
function love.joystickreleased(joystick, button)
|
||||||
|
if editorMode or TouchEditor 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 or TouchEditor 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 or TouchEditor then return end
|
||||||
|
if Importer then return Importer:joystickhat(joystick, hat, direction) end
|
||||||
|
Game:joystickhat(joystick, hat, direction)
|
||||||
|
end
|
||||||
|
|
||||||
function love.joystickremoved(joystick)
|
function love.joystickremoved(joystick)
|
||||||
if editorMode then return end
|
if editorMode or TouchEditor then return end
|
||||||
if Importer then return end
|
if Importer then return end
|
||||||
Game:joystickremoved(joystick)
|
Game:joystickremoved(joystick)
|
||||||
end
|
end
|
||||||
@@ -341,7 +393,7 @@ end
|
|||||||
-- direction's key-up can be delivered to the OS instead of the game while
|
-- direction's key-up can be delivered to the OS instead of the game while
|
||||||
-- unfocused, so reset input on either transition rather than trust it.
|
-- unfocused, so reset input on either transition rather than trust it.
|
||||||
function love.focus(f)
|
function love.focus(f)
|
||||||
if editorMode then return end
|
if editorMode or TouchEditor then return end
|
||||||
if Importer then
|
if Importer then
|
||||||
if Importer.focus then Importer:focus(f) end
|
if Importer.focus then Importer:focus(f) end
|
||||||
return
|
return
|
||||||
@@ -351,25 +403,50 @@ end
|
|||||||
|
|
||||||
-- v is true when the window becomes visible again, false on minimize.
|
-- v is true when the window becomes visible again, false on minimize.
|
||||||
function love.visible(v)
|
function love.visible(v)
|
||||||
if editorMode then return end
|
if editorMode or TouchEditor then return end
|
||||||
if Importer then return end
|
if Importer then return end
|
||||||
Game:visible(v)
|
Game:visible(v)
|
||||||
end
|
end
|
||||||
|
|
||||||
function love.touchpressed(id, x, y, dx, dy, pressure)
|
function love.touchpressed(id, x, y, dx, dy, pressure)
|
||||||
if editorMode then return end
|
if editorMode then return end
|
||||||
if Importer then return Importer:mousepressed(x, y, 1) end
|
if TouchEditor then
|
||||||
|
-- iOS synthesizes mousepressed for the primary touch (same as the
|
||||||
|
-- launcher); Android drives the editor through love.touch directly.
|
||||||
|
if love.system.getOS() == "iOS" then return end
|
||||||
|
return TouchEditor.touchpressed(id, x, y)
|
||||||
|
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)
|
Game:touchpressed(id, x, y)
|
||||||
end
|
end
|
||||||
|
|
||||||
function love.touchmoved(id, x, y, dx, dy, pressure)
|
function love.touchmoved(id, x, y, dx, dy, pressure)
|
||||||
if editorMode then return end
|
if editorMode then return end
|
||||||
|
if TouchEditor then
|
||||||
|
if love.system.getOS() == "iOS" then return end
|
||||||
|
return TouchEditor.touchmoved(id, x, y)
|
||||||
|
end
|
||||||
if Importer then return end
|
if Importer then return end
|
||||||
Game:touchmoved(id, x, y)
|
Game:touchmoved(id, x, y)
|
||||||
end
|
end
|
||||||
|
|
||||||
function love.touchreleased(id, x, y, dx, dy, pressure)
|
function love.touchreleased(id, x, y, dx, dy, pressure)
|
||||||
if editorMode then return end
|
if editorMode then return end
|
||||||
|
if TouchEditor then
|
||||||
|
if love.system.getOS() == "iOS" then return end
|
||||||
|
return TouchEditor.touchreleased(id, x, y)
|
||||||
|
end
|
||||||
if Importer then return end
|
if Importer then return end
|
||||||
Game:touchreleased(id, x, y)
|
Game:touchreleased(id, x, y)
|
||||||
end
|
end
|
||||||
@@ -379,12 +456,36 @@ function love.wheelmoved(x, y)
|
|||||||
if EditorApp.wheelmoved then return EditorApp.wheelmoved(x, y) end
|
if EditorApp.wheelmoved then return EditorApp.wheelmoved(x, y) end
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
if TouchEditor then return end
|
||||||
if Importer then return end
|
if Importer then return end
|
||||||
Game:wheelmoved(x, y)
|
Game:wheelmoved(x, y)
|
||||||
end
|
end
|
||||||
|
|
||||||
function love.mousepressed(x, y, button)
|
function love.mousepressed(x, y, button, istouch)
|
||||||
if Importer then return Importer:mousepressed(x, y, button) end
|
if TouchEditor then
|
||||||
|
-- Android primary touch already arrived via love.touchpressed; a second
|
||||||
|
-- mouse path would double-fire Done / begin a second drag.
|
||||||
|
if love.system.getOS() == "Android" then return end
|
||||||
|
return TouchEditor.mousepressed(x, y, button)
|
||||||
|
end
|
||||||
|
if Importer then
|
||||||
|
-- The same double-fire TouchEditor guards against, which the launcher was
|
||||||
|
-- missing: love.touchpressed above forwards the primary touch to the
|
||||||
|
-- Importer on Android, and LÖVE ALSO synthesizes a mouse press for that
|
||||||
|
-- same touch, so one tap ran every launcher button twice. On Import that
|
||||||
|
-- meant two choose() calls and two stacked SAF picker activities: the
|
||||||
|
-- player picked their ROM, the top picker closed, and the second was still
|
||||||
|
-- underneath asking for it again, which is the "import the file twice"
|
||||||
|
-- in #553. Filtering on istouch keeps a real mouse (DeX, a Chromebook, a
|
||||||
|
-- USB mouse) working, which an Android-wide return would have broken.
|
||||||
|
--
|
||||||
|
-- ANDROID ONLY, and the OS test is load bearing: love.touchpressed above
|
||||||
|
-- returns early on iOS and never forwards, so there the synthesized mouse
|
||||||
|
-- press is the ONLY event the launcher gets. Filtering istouch on both
|
||||||
|
-- killed every tap on iOS outright.
|
||||||
|
if istouch and love.system.getOS() == "Android" then return end
|
||||||
|
return Importer:mousepressed(x, y, button)
|
||||||
|
end
|
||||||
if editorMode and EditorApp.mousepressed then
|
if editorMode and EditorApp.mousepressed then
|
||||||
return EditorApp.mousepressed(x, y, button)
|
return EditorApp.mousepressed(x, y, button)
|
||||||
end
|
end
|
||||||
@@ -394,6 +495,10 @@ function love.mousepressed(x, y, button)
|
|||||||
end
|
end
|
||||||
|
|
||||||
function love.mousereleased(x, y, button)
|
function love.mousereleased(x, y, button)
|
||||||
|
if TouchEditor then
|
||||||
|
if love.system.getOS() == "Android" then return end
|
||||||
|
return TouchEditor.mousereleased(x, y, button)
|
||||||
|
end
|
||||||
if Importer then return end
|
if Importer then return end
|
||||||
if editorMode and EditorApp.mousereleased then
|
if editorMode and EditorApp.mousereleased then
|
||||||
return EditorApp.mousereleased(x, y, button)
|
return EditorApp.mousereleased(x, y, button)
|
||||||
@@ -404,6 +509,10 @@ function love.mousereleased(x, y, button)
|
|||||||
end
|
end
|
||||||
|
|
||||||
function love.mousemoved(x, y)
|
function love.mousemoved(x, y)
|
||||||
|
if TouchEditor then
|
||||||
|
if love.system.getOS() == "Android" then return end
|
||||||
|
return TouchEditor.mousemoved(x, y)
|
||||||
|
end
|
||||||
if editorMode or Importer then return end
|
if editorMode or Importer then return end
|
||||||
if mouseTouch and Game and love.mouse.isDown(1) then
|
if mouseTouch and Game and love.mouse.isDown(1) then
|
||||||
Game:touchmoved("mouse", x, y)
|
Game:touchmoved("mouse", x, y)
|
||||||
@@ -411,6 +520,7 @@ function love.mousemoved(x, y)
|
|||||||
end
|
end
|
||||||
|
|
||||||
function love.textinput(text)
|
function love.textinput(text)
|
||||||
|
if TouchEditor then return end
|
||||||
if Importer then return Importer:textinput(text) end
|
if Importer then return Importer:textinput(text) end
|
||||||
if editorMode and EditorApp.textinput then
|
if editorMode and EditorApp.textinput then
|
||||||
return EditorApp.textinput(text)
|
return EditorApp.textinput(text)
|
||||||
@@ -424,6 +534,16 @@ function love.quit()
|
|||||||
pcall(function()
|
pcall(function()
|
||||||
require("src.core.DiscordPresence").shutdown()
|
require("src.core.DiscordPresence").shutdown()
|
||||||
end)
|
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
|
end
|
||||||
|
|
||||||
function love.filedropped(file)
|
function love.filedropped(file)
|
||||||
@@ -461,6 +581,13 @@ function love.run()
|
|||||||
for name, a, b, c, d, e, f in love.event.poll() do
|
for name, a, b, c, d, e, f in love.event.poll() do
|
||||||
if name == "quit" then
|
if name == "quit" then
|
||||||
if not love.quit or not love.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
|
return a or 0
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
+1
-1
@@ -82,7 +82,7 @@ scripts, tests, and mobile build sources are excluded.
|
|||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `app.application_id` | `com.theboisclub.pokemonred` |
|
| `app.application_id` | `com.theboisclub.pokemonred` |
|
||||||
| `app.name` | Pokemon Red |
|
| `app.name` | Pokemon Red |
|
||||||
| `app.orientation` | `portrait` |
|
| `app.orientation` | `fullUser`. This is only the manifest default: SDL requests FULL_SENSOR at window creation (resizable window, no `SDL_HINT_ORIENTATIONS`), and `GameActivity.setOrientationBis` remaps that to FULL_USER so the device's rotation lock is honoured. |
|
||||||
| `app.version_name` / `app.version_code` | set from `--version X.Y.Z` (code = major*10000 + minor*100 + patch); left as-is if `--version` is omitted |
|
| `app.version_name` / `app.version_code` | set from `--version X.Y.Z` (code = major*10000 + minor*100 + patch); left as-is if `--version` is omitted |
|
||||||
| Permissions | INTERNET / RECORD_AUDIO / WRITE_EXTERNAL_STORAGE stripped; VIBRATE + BLUETOOTH kept |
|
| Permissions | INTERNET / RECORD_AUDIO / WRITE_EXTERNAL_STORAGE stripped; VIBRATE + BLUETOOTH kept |
|
||||||
|
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ import android.app.AlertDialog;
|
|||||||
import android.content.Context;
|
import android.content.Context;
|
||||||
import android.content.DialogInterface;
|
import android.content.DialogInterface;
|
||||||
import android.content.Intent;
|
import android.content.Intent;
|
||||||
|
import android.content.pm.ActivityInfo;
|
||||||
import android.content.pm.ApplicationInfo;
|
import android.content.pm.ApplicationInfo;
|
||||||
import android.content.res.AssetManager;
|
import android.content.res.AssetManager;
|
||||||
import android.media.AudioManager;
|
import android.media.AudioManager;
|
||||||
@@ -78,8 +79,20 @@ public class GameActivity extends SDLActivity {
|
|||||||
private static final String PICKED_SAVE_FILENAME = "picked_save.sav";
|
private static final String PICKED_SAVE_FILENAME = "picked_save.sav";
|
||||||
private static final String PENDING_EXPORT_FILENAME = "pending_export.sav";
|
private static final String PENDING_EXPORT_FILENAME = "pending_export.sav";
|
||||||
private static final String EXPORT_DONE_FILENAME = "export_done.flag";
|
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).
|
// Destination basename for the in-flight SAF pick (set by showFilePicker).
|
||||||
|
// Saved/restored across instance state: the picker is a separate activity
|
||||||
|
// and Android may destroy this one while it is up (memory pressure, or
|
||||||
|
// "Don't keep activities"). A recreated instance still receives
|
||||||
|
// onActivityResult, so without this a mod or save pick came back with the
|
||||||
|
// field reset and was filed as picked_rom.gb, which Lua then rejected as a
|
||||||
|
// bad ROM instead of installing it (#553).
|
||||||
private String pendingPickFilename = PICKED_ROM_FILENAME;
|
private String pendingPickFilename = PICKED_ROM_FILENAME;
|
||||||
|
private static final String STATE_PENDING_PICK = "pendingPickFilename";
|
||||||
|
private static final String STATE_PENDING_CREATE = "pendingCreateSuggestedName";
|
||||||
// Suggested download name for the in-flight SAF create (set by showCreateDocument).
|
// Suggested download name for the in-flight SAF create (set by showCreateDocument).
|
||||||
private String pendingCreateSuggestedName = "export.sav";
|
private String pendingCreateSuggestedName = "export.sav";
|
||||||
private static boolean immersiveActive = false;
|
private static boolean immersiveActive = false;
|
||||||
@@ -144,6 +157,14 @@ public class GameActivity extends SDLActivity {
|
|||||||
}
|
}
|
||||||
|
|
||||||
super.onCreate(savedInstanceState);
|
super.onCreate(savedInstanceState);
|
||||||
|
if (savedInstanceState != null) {
|
||||||
|
// Restore the in-flight SAF destinations, so a pick that returns to
|
||||||
|
// a recreated activity still lands under the basename it asked for.
|
||||||
|
String pick = savedInstanceState.getString(STATE_PENDING_PICK);
|
||||||
|
if (pick != null) pendingPickFilename = pick;
|
||||||
|
String create = savedInstanceState.getString(STATE_PENDING_CREATE);
|
||||||
|
if (create != null) pendingCreateSuggestedName = create;
|
||||||
|
}
|
||||||
metrics = getResources().getDisplayMetrics();
|
metrics = getResources().getDisplayMetrics();
|
||||||
|
|
||||||
// Set low-latency audio values
|
// Set low-latency audio values
|
||||||
@@ -299,6 +320,54 @@ public class GameActivity extends SDLActivity {
|
|||||||
super.onResume();
|
super.onResume();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SDL decides the activity's requested orientation at window creation
|
||||||
|
* (SDLActivity.setOrientationBis). With a resizable window and no
|
||||||
|
* SDL_HINT_ORIENTATIONS -- exactly what conf.lua produces on Android --
|
||||||
|
* it asks for SCREEN_ORIENTATION_FULL_SENSOR, and that request overrides
|
||||||
|
* the android:screenOrientation="fullUser" set in the manifest. The
|
||||||
|
* *_SENSOR constants follow the accelerometer even when the player has
|
||||||
|
* turned auto-rotate off, so the game kept rotating on a device whose
|
||||||
|
* rotation was locked.
|
||||||
|
*
|
||||||
|
* Remap SDL's choice onto the matching *_USER constant, which allows the
|
||||||
|
* same orientations but defers to the system rotation setting. Applied
|
||||||
|
* after super so SDL keeps deciding *which* orientations the window may
|
||||||
|
* take; this only changes who breaks the tie, the sensor or the player.
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public void setOrientationBis(int w, int h, boolean resizable, String hint) {
|
||||||
|
super.setOrientationBis(w, h, resizable, hint);
|
||||||
|
|
||||||
|
// The *_USER constants only exist from API 18; below that the sensor
|
||||||
|
// ones are all there is, so leave SDL's request alone.
|
||||||
|
if (android.os.Build.VERSION.SDK_INT < 18) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
int requested = getRequestedOrientation();
|
||||||
|
int userRequested;
|
||||||
|
switch (requested) {
|
||||||
|
case ActivityInfo.SCREEN_ORIENTATION_FULL_SENSOR:
|
||||||
|
userRequested = ActivityInfo.SCREEN_ORIENTATION_FULL_USER;
|
||||||
|
break;
|
||||||
|
case ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE:
|
||||||
|
userRequested = ActivityInfo.SCREEN_ORIENTATION_USER_LANDSCAPE;
|
||||||
|
break;
|
||||||
|
case ActivityInfo.SCREEN_ORIENTATION_SENSOR_PORTRAIT:
|
||||||
|
userRequested = ActivityInfo.SCREEN_ORIENTATION_USER_PORTRAIT;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
// SENSOR / plain LANDSCAPE / PORTRAIT etc: either already
|
||||||
|
// explicit or never produced by setOrientationBis.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Log.d("GameActivity", "requestedOrientation " + requested + " -> " + userRequested
|
||||||
|
+ " (honour the device rotation lock)");
|
||||||
|
setRequestedOrientation(userRequested);
|
||||||
|
}
|
||||||
|
|
||||||
@Keep
|
@Keep
|
||||||
public void setImmersiveMode(boolean immersive_mode) {
|
public void setImmersiveMode(boolean immersive_mode) {
|
||||||
if (android.os.Build.VERSION.SDK_INT >= 28) {
|
if (android.os.Build.VERSION.SDK_INT >= 28) {
|
||||||
@@ -452,6 +521,17 @@ public class GameActivity extends SDLActivity {
|
|||||||
return new File(new File(getExternalFilesDir(null), "save"), ROM_SAVE_IDENTITY);
|
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) {
|
private boolean copyFileToUri(File source, Uri destUri) {
|
||||||
InputStream in = null;
|
InputStream in = null;
|
||||||
OutputStream out = null;
|
OutputStream out = null;
|
||||||
@@ -475,6 +555,13 @@ public class GameActivity extends SDLActivity {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onSaveInstanceState(Bundle outState) {
|
||||||
|
super.onSaveInstanceState(outState);
|
||||||
|
outState.putString(STATE_PENDING_PICK, pendingPickFilename);
|
||||||
|
outState.putString(STATE_PENDING_CREATE, pendingCreateSuggestedName);
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
protected void onActivityResult(int requestCode, int resultCode, Intent data) {
|
protected void onActivityResult(int requestCode, int resultCode, Intent data) {
|
||||||
super.onActivityResult(requestCode, resultCode, data);
|
super.onActivityResult(requestCode, resultCode, data);
|
||||||
@@ -491,14 +578,7 @@ public class GameActivity extends SDLActivity {
|
|||||||
Uri uri = data.getData();
|
Uri uri = data.getData();
|
||||||
if (copyFileToUri(source, uri)) {
|
if (copyFileToUri(source, uri)) {
|
||||||
// Signal Lua on next focus that the SAF export finished.
|
// Signal Lua on next focus that the SAF export finished.
|
||||||
File flag = new File(saveIdentityDir(), EXPORT_DONE_FILENAME);
|
writeSaveDirFlag(EXPORT_DONE_FILENAME, "ok");
|
||||||
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());
|
|
||||||
}
|
|
||||||
// Keep pending_export.sav so a retry still works; Lua may remove it.
|
// Keep pending_export.sav so a retry still works; Lua may remove it.
|
||||||
} else {
|
} else {
|
||||||
Log.d("GameActivity", "could not write export to " + uri);
|
Log.d("GameActivity", "could not write export to " + uri);
|
||||||
@@ -521,19 +601,36 @@ public class GameActivity extends SDLActivity {
|
|||||||
? pendingPickFilename : PICKED_ROM_FILENAME;
|
? pendingPickFilename : PICKED_ROM_FILENAME;
|
||||||
File destFile = new File(destDir, destName);
|
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 {
|
try {
|
||||||
source = getContentResolver().openInputStream(uri);
|
source = getContentResolver().openInputStream(uri);
|
||||||
} catch (FileNotFoundException e) {
|
} catch (Exception e) {
|
||||||
Log.d("GameActivity", "could not open picked file: " + e.getMessage());
|
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) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
if (!copyAssetFile(source, destFile.getPath())) {
|
if (!copyAssetFile(source, destFile.getPath())) {
|
||||||
Log.d("GameActivity", "could not copy picked file to " + destFile);
|
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)
|
# 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
|
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)),
|
(`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"`.
|
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>
|
<key>CFBundleSignature</key>
|
||||||
<string>????</string>
|
<string>????</string>
|
||||||
<key>CFBundleVersion</key>
|
<key>CFBundleVersion</key>
|
||||||
<string>1.0</string>
|
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||||
<key>LSRequiresIPhoneOS</key>
|
<key>LSRequiresIPhoneOS</key>
|
||||||
<true/>
|
<true/>
|
||||||
<key>LSSupportsOpeningDocumentsInPlace</key>
|
<key>LSSupportsOpeningDocumentsInPlace</key>
|
||||||
@@ -100,5 +100,16 @@
|
|||||||
</dict>
|
</dict>
|
||||||
</dict>
|
</dict>
|
||||||
</array>
|
</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>
|
</dict>
|
||||||
</plist>
|
</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()
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
# Nuzlocke
|
||||||
|
|
||||||
|
An enforced Gen 1 Nuzlocke.
|
||||||
|
|
||||||
|
Oak configures Slow Start, whether duplicate evolutionary families are skipped
|
||||||
|
or consume an area's encounter, and whether Safari maps are separate areas.
|
||||||
|
|
||||||
|
After Slow Start, the mod enforces mandatory nicknames for starters, gifts,
|
||||||
|
and catches; one capture per area; no duplicate evolutionary families; and
|
||||||
|
permanent death. A party Pokémon says that it died and is removed immediately.
|
||||||
|
If the final party member dies, the game runs the credits to THE END and then
|
||||||
|
deletes the active save.
|
||||||
|
|
||||||
@@ -0,0 +1,224 @@
|
|||||||
|
-- Nuzlocke rules. Uses the current engine's internal seams while the
|
||||||
|
-- equivalent public API hooks are being added.
|
||||||
|
return function(mod)
|
||||||
|
mod.hooks:wrap("intro.oak_speech.build", function(next, steps, speech)
|
||||||
|
steps = next(steps, speech)
|
||||||
|
mod.ui.insertStepAfter(steps, "oak_welcome", {
|
||||||
|
id = "nuzlocke_intro", kind = "say", pic = "oak",
|
||||||
|
text = "A Nuzlocke is a\npromise.\fEvery loss is\npermanent.",
|
||||||
|
})
|
||||||
|
mod.ui.insertStepAfter(steps, "nuzlocke_intro", {
|
||||||
|
id = "nuzlocke_slow_start", kind = "yesno", pic = "oak",
|
||||||
|
saveKey = "slow_start", defaultNo = true,
|
||||||
|
text = "Use SLOW START?\nRules start with\nPOKé BALLS.",
|
||||||
|
})
|
||||||
|
mod.ui.insertStepAfter(steps, "nuzlocke_slow_start", {
|
||||||
|
id = "nuzlocke_dupes", kind = "choice", pic = "oak",
|
||||||
|
saveKey = "dupes_mode", text = "When you meet a\nknown family?",
|
||||||
|
choices = { "SKIP", "LOSE" }, values = { "skip", "strict" },
|
||||||
|
})
|
||||||
|
mod.ui.insertStepAfter(steps, "nuzlocke_dupes", {
|
||||||
|
id = "nuzlocke_safari", kind = "yesno", pic = "oak",
|
||||||
|
saveKey = "safari_sectors", text = "Separate SAFARI\nsectors?",
|
||||||
|
})
|
||||||
|
mod.ui.insertStepAfter(steps, "nuzlocke_safari", {
|
||||||
|
id = "nuzlocke_close", kind = "say", pic = "oak",
|
||||||
|
text = "Give every friend\na name. Keep them\nsafe. Good luck!",
|
||||||
|
})
|
||||||
|
return steps
|
||||||
|
end)
|
||||||
|
|
||||||
|
mod.events:on("intro.oak_speech.answered", function(ev)
|
||||||
|
if ev.saveKey then mod.save:set(ev.saveKey, ev.value) end
|
||||||
|
end)
|
||||||
|
|
||||||
|
local function active(game, battle)
|
||||||
|
if not (game and game.save) or (battle and (battle.demo or battle.ghost)) then return false end
|
||||||
|
if not mod.save:get("slow_start", false) then return true end
|
||||||
|
if mod.save:get("balls_unlocked", false) then return true end
|
||||||
|
for id, count in pairs(game.save.inventory or {}) do
|
||||||
|
if count > 0 and game.data.items[id] and game.data.items[id].ball then
|
||||||
|
mod.save:set("balls_unlocked", true)
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
|
local function areaKey(game, battle)
|
||||||
|
if battle and battle.safari and not mod.save:get("safari_sectors", false) then
|
||||||
|
return "SAFARI_ZONE"
|
||||||
|
end
|
||||||
|
return (game.overworld and game.overworld.map and game.overworld.map.id)
|
||||||
|
or (game.save.player and game.save.player.map) or "UNKNOWN"
|
||||||
|
end
|
||||||
|
|
||||||
|
local function family(data, species)
|
||||||
|
local found, pending = {}, { species }
|
||||||
|
while #pending > 0 do
|
||||||
|
local id = table.remove(pending)
|
||||||
|
if not found[id] then
|
||||||
|
found[id] = true
|
||||||
|
for _, evo in ipairs((data.pokemon[id] or {}).evolutions or {}) do pending[#pending + 1] = evo.species end
|
||||||
|
for parent, def in pairs(data.pokemon or {}) do
|
||||||
|
for _, evo in ipairs(def.evolutions or {}) do
|
||||||
|
if evo.species == id then pending[#pending + 1] = parent end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return found
|
||||||
|
end
|
||||||
|
|
||||||
|
local function ownsFamily(game, species)
|
||||||
|
local members = family(game.data, species)
|
||||||
|
local function owns(mon) return mon and members[mon.species] end
|
||||||
|
for _, mon in ipairs(game.save.party or {}) do if owns(mon) then return true end end
|
||||||
|
for _, box in ipairs(game.save.boxes or {}) do
|
||||||
|
for _, mon in ipairs(box) do if owns(mon) then return true end end
|
||||||
|
end
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
|
local function caughtAreas()
|
||||||
|
local areas = mod.save:get("caught_areas")
|
||||||
|
if type(areas) ~= "table" then areas = {}; mod.save:set("caught_areas", areas) end
|
||||||
|
return areas
|
||||||
|
end
|
||||||
|
|
||||||
|
local function denied(game, battle, species)
|
||||||
|
if not active(game, battle) then return nil end
|
||||||
|
if caughtAreas()[areaKey(game, battle)] then return "area" end
|
||||||
|
if ownsFamily(game, species) then return "dupes" end
|
||||||
|
end
|
||||||
|
|
||||||
|
mod.events:on("pokemon.caught", function(ev)
|
||||||
|
-- A successful capture proves that Slow Start has ended even when it was
|
||||||
|
-- the last ball in the bag.
|
||||||
|
if mod.save:get("slow_start", false) then mod.save:set("balls_unlocked", true) end
|
||||||
|
if active(ev.game, ev.battle) then
|
||||||
|
caughtAreas()[areaKey(ev.game, ev.battle)] = ev.species
|
||||||
|
mod.save:set("caught_areas", caughtAreas())
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
|
||||||
|
mod.events:on("game.ready", function()
|
||||||
|
local BattleState = require("src.battle.BattleState")
|
||||||
|
local Commands = require("src.script.Commands")
|
||||||
|
local Party = require("src.pokemon.Party")
|
||||||
|
local Boxes = require("src.pokemon.Boxes")
|
||||||
|
local Pokemon = require("src.pokemon.Pokemon")
|
||||||
|
local Runtime = require("src.mods.Runtime")
|
||||||
|
local Screens = require("src.ui.Screens")
|
||||||
|
local Strings = require("src.core.Strings")
|
||||||
|
local SaveData = require("src.core.SaveData")
|
||||||
|
local GameVersion = require("src.core.GameVersion")
|
||||||
|
local Bag = require("src.inventory.Bag")
|
||||||
|
|
||||||
|
BattleState.askNicknameUI = function(self, mon)
|
||||||
|
self.lockedBall, self.blankForAskName = nil, false
|
||||||
|
return self:buildScreen("NamingScreen", {
|
||||||
|
title = Strings("NICKNAME?"), maxLen = 10,
|
||||||
|
onDone = function(name) mon.nickname = name or "A" end,
|
||||||
|
})
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Gifts and starters use the same mandatory naming screen.
|
||||||
|
Commands.give_pokemon = function(ctx, species, level)
|
||||||
|
local gift = { ctx = ctx, species = species, level = level }
|
||||||
|
if ctx.game.mods then ctx.game.mods.events:emit("pokemon.before_give", gift) end
|
||||||
|
local mon = Pokemon.new(ctx.game.data, gift.species, gift.level)
|
||||||
|
ctx.game.stringBuffer, ctx.pendingPokemonName = ctx.game.data.pokemon[gift.species].name or gift.species, gift.species
|
||||||
|
BattleState.stampOT(ctx.save, mon)
|
||||||
|
local inParty = Party.add(ctx.save.party, mon)
|
||||||
|
local boxNum = inParty and nil or Boxes.deposit(ctx.save, mon)
|
||||||
|
if not inParty and not boxNum then ctx.lastCheck = false; return end
|
||||||
|
if ctx.save.pokedex then ctx.save.pokedex.seen[gift.species], ctx.save.pokedex.owned[gift.species] = true, true end
|
||||||
|
ctx.lastCheck, ctx.addedToParty, ctx.boxNum = true, inParty, boxNum
|
||||||
|
if ctx.runner then
|
||||||
|
Screens.push(ctx.game, "NamingScreen", {
|
||||||
|
title = Strings("NICKNAME?"), maxLen = 10,
|
||||||
|
onDone = function(name) mon.nickname = name or "A"; ctx.runner:resume() end,
|
||||||
|
})
|
||||||
|
ctx.runner:yield()
|
||||||
|
else mon.nickname = "A" end
|
||||||
|
if boxNum then
|
||||||
|
ctx.game.boxMonNicks, ctx.game.stringBuffer = mon.nickname, tostring(boxNum)
|
||||||
|
if ctx.runner then Commands.show_text(ctx, "_SentToBoxText") end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local vanillaThrowBall = BattleState.throwBall
|
||||||
|
BattleState.throwBall = function(self, ball)
|
||||||
|
local reason = denied(self.game, self, self.enemy and self.enemy.mon.species)
|
||||||
|
if reason then
|
||||||
|
if reason == "dupes" and mod.save:get("dupes_mode", "skip") == "strict" then
|
||||||
|
caughtAreas()[areaKey(self.game, self)] = "DUPES_LOST"
|
||||||
|
mod.save:set("caught_areas", caughtAreas())
|
||||||
|
end
|
||||||
|
Bag.add(self.game.save, ball, 1, self.game.data)
|
||||||
|
self:say(reason == "area" and "This area already\nhas a captured POKéMON!"
|
||||||
|
or "You already have\nthis POKéMON family!")
|
||||||
|
return
|
||||||
|
end
|
||||||
|
return vanillaThrowBall(self, ball)
|
||||||
|
end
|
||||||
|
|
||||||
|
local vanillaOnFaint = BattleState.onFaint
|
||||||
|
BattleState.onFaint = function(self, battler)
|
||||||
|
if not (battler.isPlayer and active(self.game, self)) then return vanillaOnFaint(self, battler) end
|
||||||
|
if battler.faintQueued then return end
|
||||||
|
battler.faintQueued = true
|
||||||
|
if self.participants then self.participants[battler.mon] = nil end
|
||||||
|
Runtime.emit("battle.fainted", { battle = self, battler = battler })
|
||||||
|
for i, mon in ipairs(self.game.save.party) do
|
||||||
|
if mon == battler.mon then table.remove(self.game.save.party, i); break end
|
||||||
|
end
|
||||||
|
self:actNext(function()
|
||||||
|
battler.fainted = true
|
||||||
|
require("src.core.Sound").playCry(self.data, battler.mon.species)
|
||||||
|
require("src.core.Sound").play(self.data, "Faint_Fall")
|
||||||
|
self.fx = self.fx or {}; self.fx.faint = { battler = battler, frames = 30 }
|
||||||
|
end)
|
||||||
|
self.nextInsert = (self.nextInsert or 0) + 1
|
||||||
|
table.insert(self.queue, self.nextInsert, { wait = 30 })
|
||||||
|
self:sayNext(Strings("%s\ndied!", battler.name))
|
||||||
|
self:act(function() self:playerMonFainted() end)
|
||||||
|
end
|
||||||
|
|
||||||
|
local vanillaPlayerFainted = BattleState.playerMonFainted
|
||||||
|
BattleState.playerMonFainted = function(self)
|
||||||
|
if active(self.game, self) and not Party.firstHealthy(self.game.save.party) then
|
||||||
|
self.nuzlockeGameOver, self.result, self.afterQueue = true, "nuzlocke_game_over", "finish"
|
||||||
|
self:sayNext(Strings("All of your\nPOKéMON are dead..."))
|
||||||
|
return
|
||||||
|
end
|
||||||
|
return vanillaPlayerFainted(self)
|
||||||
|
end
|
||||||
|
|
||||||
|
local vanillaFinish = BattleState.finish
|
||||||
|
BattleState.finish = function(self)
|
||||||
|
if not self.nuzlockeGameOver then return vanillaFinish(self) end
|
||||||
|
self.nuzlockeGameOver = nil
|
||||||
|
self.game.stack:pop()
|
||||||
|
Runtime.emit("battle.ended", { battle = self, result = "nuzlocke_game_over" })
|
||||||
|
-- Game over has no victory lap: delete the slot, then use Credits only
|
||||||
|
-- as its existing THE END renderer / A-or-B wait screen.
|
||||||
|
local version = GameVersion.get()
|
||||||
|
local slot = SaveData.activeSlot(version)
|
||||||
|
if slot then SaveData.deleteSlot(version, slot)
|
||||||
|
elseif love and love.filesystem then
|
||||||
|
local main = SaveData.saveFilename(version)
|
||||||
|
love.filesystem.remove(main); love.filesystem.remove(main .. ".bak"); love.filesystem.remove(main .. ".tmp")
|
||||||
|
end
|
||||||
|
local ending = Screens.push(self.game, "Credits", function()
|
||||||
|
require("src.core.Music").stop()
|
||||||
|
while self.game.stack:top() do self.game.stack:pop() end
|
||||||
|
Screens.push(self.game, "IntroMovie", function()
|
||||||
|
if self.game.makeTitleState then self.game.stack:push(self.game:makeTitleState()) end
|
||||||
|
end)
|
||||||
|
end)
|
||||||
|
ending.phase, ending.timer = "end_wait", 0
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
end
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"id": "nuzlocke",
|
||||||
|
"name": "Nuzlocke",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"api": 2,
|
||||||
|
"entry": "main.lua",
|
||||||
|
"profile": "content",
|
||||||
|
"category": "GAMEPLAY",
|
||||||
|
"game_version": ">=0.0.0-dev <1.0.0",
|
||||||
|
"priority": 100,
|
||||||
|
"permissions": [
|
||||||
|
"engine_internals"
|
||||||
|
],
|
||||||
|
"dependencies": [],
|
||||||
|
"optional_dependencies": [],
|
||||||
|
"conflicts": [],
|
||||||
|
"description": "A configurable Gen 1 Nuzlocke with permanent death and area catches.",
|
||||||
|
"github": "bryanthaboi/nuzlocke"
|
||||||
|
}
|
||||||
@@ -308,6 +308,9 @@ build_ios() {
|
|||||||
if [ "$IOS_RELEASE" = true ]; then
|
if [ "$IOS_RELEASE" = true ]; then
|
||||||
args+=(--release)
|
args+=(--release)
|
||||||
fi
|
fi
|
||||||
|
if [ "$VERSION_EXPLICIT" = true ]; then
|
||||||
|
args+=(--version "$VERSION")
|
||||||
|
fi
|
||||||
"$ROOT/scripts/build_ios.sh" ${args[@]+"${args[@]}"}
|
"$ROOT/scripts/build_ios.sh" ${args[@]+"${args[@]}"}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+283
-18
@@ -2,11 +2,16 @@
|
|||||||
# Packages the LÖVE2D Pokémon Red port into an iOS app via LÖVE 11.5's
|
# 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).
|
# 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)
|
# (default) Simulator Debug (ad-hoc signed)
|
||||||
# --device iphoneos SDK (needs signing / DEVELOPMENT_TEAM)
|
# --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
|
# --release Release configuration
|
||||||
|
# --version X.Y.Z stamp MARKETING_VERSION / CURRENT_PROJECT_VERSION
|
||||||
# --fetch Download love-11.5-ios-source.zip into mobile/ios/love-src/
|
# --fetch Download love-11.5-ios-source.zip into mobile/ios/love-src/
|
||||||
# --package-only Zip game.love + apply plist overlay; skip xcodebuild
|
# --package-only Zip game.love + apply plist overlay; skip xcodebuild
|
||||||
#
|
#
|
||||||
@@ -16,6 +21,7 @@
|
|||||||
# - prebuilt iOS libraries under love-src/platform/xcode/ios/libraries/
|
# - prebuilt iOS libraries under love-src/platform/xcode/ios/libraries/
|
||||||
#
|
#
|
||||||
# Output: dist/ios/<Config>-<sdk>/gen1recomp.app (convenience copy)
|
# 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
|
# mobile/ios/build/Build/Products/<Config>-<sdk>/gen1recomp.app
|
||||||
|
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
@@ -35,7 +41,19 @@ LIBS_DIR="$XCODE_DIR/ios/libraries"
|
|||||||
|
|
||||||
APP_NAME="gen1recomp"
|
APP_NAME="gen1recomp"
|
||||||
DISPLAY_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)"
|
LOVE_VERSION="$(tr -d '[:space:]' < "$IOS_DIR/LOVE_VERSION" 2>/dev/null || echo 11.5)"
|
||||||
IOS_SOURCE_ZIP="love-${LOVE_VERSION}-ios-source.zip"
|
IOS_SOURCE_ZIP="love-${LOVE_VERSION}-ios-source.zip"
|
||||||
APPLE_LIBS_ZIP="love-${LOVE_VERSION}-apple-libraries.zip"
|
APPLE_LIBS_ZIP="love-${LOVE_VERSION}-apple-libraries.zip"
|
||||||
@@ -46,6 +64,12 @@ FETCH=false
|
|||||||
DEVICE=false
|
DEVICE=false
|
||||||
RELEASE=false
|
RELEASE=false
|
||||||
PACKAGE_ONLY=false
|
PACKAGE_ONLY=false
|
||||||
|
INSTALL=false
|
||||||
|
# Last resort for an incomplete source export, mirroring build_android.sh.
|
||||||
|
MANIFEST_BASE_URL="${MANIFEST_BASE_URL:-https://raw.githubusercontent.com/bryanthaboi/gen1recomp/main}"
|
||||||
|
MANIFESTS=""
|
||||||
|
|
||||||
|
VERSION=""
|
||||||
|
|
||||||
say() { printf '\033[1;32m==>\033[0m %s\n' "$*"; }
|
say() { printf '\033[1;32m==>\033[0m %s\n' "$*"; }
|
||||||
warn() { printf '\033[1;33mwarn:\033[0m %s\n' "$*" >&2; }
|
warn() { printf '\033[1;33mwarn:\033[0m %s\n' "$*" >&2; }
|
||||||
@@ -57,15 +81,64 @@ while [ $# -gt 0 ]; do
|
|||||||
--device) DEVICE=true ;;
|
--device) DEVICE=true ;;
|
||||||
--release) RELEASE=true ;;
|
--release) RELEASE=true ;;
|
||||||
--package-only) PACKAGE_ONLY=true ;;
|
--package-only) PACKAGE_ONLY=true ;;
|
||||||
|
--install) INSTALL=true ;;
|
||||||
|
--version) VERSION="$2"; shift ;;
|
||||||
-h|--help)
|
-h|--help)
|
||||||
sed -n '2,22p' "$0"
|
sed -n '2,24p' "$0"
|
||||||
exit 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
|
esac
|
||||||
shift
|
shift
|
||||||
done
|
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
|
# --------------------------------------------------------------- host checks
|
||||||
if [ "$(uname -s)" != "Darwin" ]; then
|
if [ "$(uname -s)" != "Darwin" ]; then
|
||||||
fail "iOS builds require macOS (Darwin). This host is $(uname -s).
|
fail "iOS builds require macOS (Darwin). This host is $(uname -s).
|
||||||
@@ -160,23 +233,104 @@ apply_ios_branding() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
# --------------------------------------------------------------- game.love
|
# --------------------------------------------------------------- game.love
|
||||||
|
# Every version's import manifest has to ship or that game's ROM import fails in
|
||||||
|
# the built app: decodeManifest (src/import/RomImporter.lua) errors outright when
|
||||||
|
# one is absent, and dev reads them off the source tree, so the miss only ever
|
||||||
|
# shows up in a build. iOS shipped without the Yellow one in 0.1.45 to 0.1.47
|
||||||
|
# for exactly that reason.
|
||||||
|
#
|
||||||
|
# The list is READ OUT OF src/core/GameVersion.lua rather than hand-kept here, so
|
||||||
|
# a fourth version cannot silently ship without its manifest, and a missing file
|
||||||
|
# is recovered from Git or the project repo the same way build_android.sh already
|
||||||
|
# recovers Yellow's. Recovery is a last resort for an incomplete source export:
|
||||||
|
# a manifest carries extraction metadata only, never a ROM or game data.
|
||||||
|
manifest_paths() {
|
||||||
|
python3 - "$ROOT/src/core/GameVersion.lua" <<'PY'
|
||||||
|
import re, sys
|
||||||
|
src = open(sys.argv[1]).read()
|
||||||
|
print(" ".join(dict.fromkeys(re.findall(r'manifest\s*=\s*"([^"]+)"', src))))
|
||||||
|
PY
|
||||||
|
}
|
||||||
|
|
||||||
|
manifest_is_valid() {
|
||||||
|
python3 - "$1" <<'PY'
|
||||||
|
import json, pathlib, sys
|
||||||
|
try:
|
||||||
|
m = json.loads(pathlib.Path(sys.argv[1]).read_text())
|
||||||
|
except (OSError, ValueError):
|
||||||
|
raise SystemExit(1)
|
||||||
|
sha = m.get("romSha1")
|
||||||
|
raise SystemExit(0 if isinstance(sha, str) and len(sha) == 40 else 1)
|
||||||
|
PY
|
||||||
|
}
|
||||||
|
|
||||||
|
ensure_manifests() {
|
||||||
|
MANIFESTS="$(manifest_paths)"
|
||||||
|
[ -n "$MANIFESTS" ] \
|
||||||
|
|| fail "could not read any manifest path out of src/core/GameVersion.lua"
|
||||||
|
local rel staged
|
||||||
|
for rel in $MANIFESTS; do
|
||||||
|
if manifest_is_valid "$ROOT/$rel"; then continue; fi
|
||||||
|
warn "$rel is missing or invalid; recovering it before packaging"
|
||||||
|
staged="$(mktemp)"
|
||||||
|
if git -C "$ROOT" show "HEAD:$rel" > "$staged" 2>/dev/null \
|
||||||
|
&& manifest_is_valid "$staged"; then
|
||||||
|
mkdir -p "$ROOT/$(dirname "$rel")"
|
||||||
|
mv "$staged" "$ROOT/$rel"
|
||||||
|
say "restored $rel from this checkout's Git data"
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
if command -v curl >/dev/null 2>&1 \
|
||||||
|
&& curl --fail --location --retry 2 --connect-timeout 15 \
|
||||||
|
--output "$staged" "$MANIFEST_BASE_URL/$rel" \
|
||||||
|
&& manifest_is_valid "$staged"; then
|
||||||
|
mkdir -p "$ROOT/$(dirname "$rel")"
|
||||||
|
mv "$staged" "$ROOT/$rel"
|
||||||
|
say "downloaded $rel from the project repository"
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
rm -f "$staged"
|
||||||
|
fail "$rel is unavailable: Git recovery failed and $MANIFEST_BASE_URL/$rel could not be downloaded"
|
||||||
|
done
|
||||||
|
say "import manifests: $MANIFESTS"
|
||||||
|
}
|
||||||
|
|
||||||
pack_game_love() {
|
pack_game_love() {
|
||||||
say "packing game.love for love-ios resources"
|
say "packing game.love for love-ios resources"
|
||||||
mkdir -p "$RESOURCES_DIR"
|
mkdir -p "$RESOURCES_DIR"
|
||||||
rm -f "$LOVE_FILE"
|
rm -f "$LOVE_FILE"
|
||||||
# Same payload as scripts/build.sh / build_android.sh: game sources plus
|
# Same payload as scripts/build.sh / build_android.sh: game sources plus
|
||||||
# tools/save-editor, which the launcher's Edit button opens in-process.
|
# 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.
|
||||||
|
# shellcheck disable=SC2086 # MANIFESTS is a deliberate word list
|
||||||
(cd "$ROOT" && zip -q -9 -r "$LOVE_FILE" \
|
(cd "$ROOT" && zip -q -9 -r "$LOVE_FILE" \
|
||||||
main.lua conf.lua src data assets tools/save-editor \
|
main.lua conf.lua src data assets tools/save-editor \
|
||||||
tools/rom_manifest.json tools/rom_manifest_blue.json \
|
$MANIFESTS \
|
||||||
-x '*.DS_Store' -x '*/.git/*' -x '*/.DS_Store' \
|
-x '*.DS_Store' -x '*/.git/*' -x '*/.DS_Store' \
|
||||||
-x 'data/generated/*' -x 'assets/generated/*')
|
-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" \
|
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"
|
fail "game.love unexpectedly contains generated ROM data"
|
||||||
fi
|
fi
|
||||||
unzip -Z1 "$LOVE_FILE" | grep -qx 'tools/save-editor/App.lua' \
|
# Same required-file gate as scripts/build.sh and scripts/build_android.sh.
|
||||||
|| fail "game.love is missing the save editor (Edit on a save row would crash)"
|
# iOS only checked App.lua, which is why the Yellow manifest shipped missing
|
||||||
|
# in 0.1.45 through 0.1.47: decodeManifest (src/import/RomImporter.lua) errors
|
||||||
|
# outright when a version's manifest is absent, so Import ROM on Yellow died
|
||||||
|
# in the built app while dev, which reads the source tree, stayed green.
|
||||||
|
archive_entries="$(unzip -Z1 "$LOVE_FILE")"
|
||||||
|
# shellcheck disable=SC2086 # MANIFESTS is a deliberate word list
|
||||||
|
for required in tools/save-editor/App.lua tools/save-editor/Kit.lua \
|
||||||
|
tools/save-editor/panels/Party.lua $MANIFESTS; do
|
||||||
|
printf '%s\n' "$archive_entries" | grep -qx "$required" \
|
||||||
|
|| fail "game.love is missing $required"
|
||||||
|
done
|
||||||
say "game.love: $(du -h "$LOVE_FILE" | cut -f1) -> $LOVE_FILE"
|
say "game.love: $(du -h "$LOVE_FILE" | cut -f1) -> $LOVE_FILE"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -271,6 +425,43 @@ PY
|
|||||||
}
|
}
|
||||||
|
|
||||||
# --------------------------------------------------------------- xcodebuild
|
# --------------------------------------------------------------- xcodebuild
|
||||||
|
# love.system.pickFile and createFile are a native bridge compiled in by
|
||||||
|
# mobile/ios/patch_love_src.py, not part of LÖVE. A build that skipped the
|
||||||
|
# patch still links and still runs, then finds the field nil the moment anyone
|
||||||
|
# taps Import ROM (#482). #539 made that degrade to the copy-into-Files flow
|
||||||
|
# rather than crash, which is the right floor, but a build with no picker at all
|
||||||
|
# is a silent downgrade, so fail here instead of shipping one.
|
||||||
|
#
|
||||||
|
# Checked against the built binary rather than the source, because patching
|
||||||
|
# love-src proves nothing about what Xcode actually compiled: the shipped
|
||||||
|
# 0.1.45/0.1.46/0.1.47 IPAs all DO carry the bridge, so the reports that blamed
|
||||||
|
# a missing patch step were self-built IPAs, exactly the case this catches.
|
||||||
|
verify_native_bridge() {
|
||||||
|
local app="$1"
|
||||||
|
local exe bin missing=""
|
||||||
|
exe="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleExecutable' \
|
||||||
|
"$app/Info.plist" 2>/dev/null || true)"
|
||||||
|
bin="$app/${exe:-love}"
|
||||||
|
[ -f "$bin" ] || bin="$app/love"
|
||||||
|
if [ ! -f "$bin" ]; then
|
||||||
|
warn "no executable inside $(basename "$app"); skipping native bridge check"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
# grep -q here would race pipefail the same way pack_game_love documents:
|
||||||
|
# it exits on first match, strings dies of SIGPIPE, the pipeline "fails"
|
||||||
|
# nondeterministically. >/dev/null keeps grep reading the whole stream.
|
||||||
|
for sym in pickFile createFile; do
|
||||||
|
strings -a "$bin" | grep -x "$sym" >/dev/null || missing="$missing $sym"
|
||||||
|
done
|
||||||
|
if [ -n "$missing" ]; then
|
||||||
|
fail "built app has no native bridge (missing:$missing).
|
||||||
|
Import ROM would fall back to copy-into-Files instead of opening the picker.
|
||||||
|
mobile/ios/patch_love_src.py did not take. Re-run:
|
||||||
|
scripts/build_ios.sh --fetch && scripts/build_ios.sh"
|
||||||
|
fi
|
||||||
|
say "native bridge present (pickFile, createFile)"
|
||||||
|
}
|
||||||
|
|
||||||
run_xcodebuild() {
|
run_xcodebuild() {
|
||||||
local config sdk destination
|
local config sdk destination
|
||||||
if $RELEASE; then
|
if $RELEASE; then
|
||||||
@@ -291,6 +482,15 @@ run_xcodebuild() {
|
|||||||
|
|
||||||
# Prefer -target + SYMROOT over -derivedDataPath: modern Xcode requires
|
# Prefer -target + SYMROOT over -derivedDataPath: modern Xcode requires
|
||||||
# -scheme whenever -derivedDataPath is set, and love-ios ships no shared schemes.
|
# -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=(
|
local args=(
|
||||||
-project "$PROJECT"
|
-project "$PROJECT"
|
||||||
-target love-ios
|
-target love-ios
|
||||||
@@ -300,18 +500,26 @@ run_xcodebuild() {
|
|||||||
SYMROOT="$BUILD_DIR/Build/Products"
|
SYMROOT="$BUILD_DIR/Build/Products"
|
||||||
OBJROOT="$BUILD_DIR/Build/Intermediates"
|
OBJROOT="$BUILD_DIR/Build/Intermediates"
|
||||||
PRODUCT_BUNDLE_IDENTIFIER="$BUNDLE_ID"
|
PRODUCT_BUNDLE_IDENTIFIER="$BUNDLE_ID"
|
||||||
PRODUCT_NAME="$APP_NAME"
|
MARKETING_VERSION="$marketing_version"
|
||||||
MARKETING_VERSION="$LOVE_VERSION"
|
CURRENT_PROJECT_VERSION="$project_version"
|
||||||
ONLY_ACTIVE_ARCH=NO
|
ONLY_ACTIVE_ARCH=NO
|
||||||
)
|
)
|
||||||
|
|
||||||
if ! $DEVICE; then
|
if ! $DEVICE; then
|
||||||
# Simulator: no signing required
|
# Simulator: ad-hoc signing (no certificate needed). A plain unsigned
|
||||||
args+=(CODE_SIGNING_ALLOWED=NO CODE_SIGNING_REQUIRED=NO CODE_SIGN_IDENTITY=)
|
# 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
|
else
|
||||||
warn "device build: configure signing in Xcode or set DEVELOPMENT_TEAM / CODE_SIGN_IDENTITY"
|
warn "device build: configure signing in Xcode or set DEVELOPMENT_TEAM / CODE_SIGN_IDENTITY"
|
||||||
if [ -n "${DEVELOPMENT_TEAM:-}" ]; then
|
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
|
fi
|
||||||
if [ -n "${CODE_SIGN_IDENTITY:-}" ]; then
|
if [ -n "${CODE_SIGN_IDENTITY:-}" ]; then
|
||||||
args+=(CODE_SIGN_IDENTITY="$CODE_SIGN_IDENTITY")
|
args+=(CODE_SIGN_IDENTITY="$CODE_SIGN_IDENTITY")
|
||||||
@@ -361,23 +569,80 @@ run_xcodebuild() {
|
|||||||
cp "$LOVE_FILE" "$app/game.love"
|
cp "$LOVE_FILE" "$app/game.love"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
verify_native_bridge "$app"
|
||||||
|
|
||||||
local dist_dir="$DIST/${config}-${sdk}"
|
local dist_dir="$DIST/${config}-${sdk}"
|
||||||
rm -rf "$dist_dir"
|
rm -rf "$dist_dir"
|
||||||
mkdir -p "$dist_dir"
|
mkdir -p "$dist_dir"
|
||||||
cp -R "$app" "$dist_dir/"
|
cp -R "$app" "$dist_dir/$APP_NAME.app"
|
||||||
say "copied to $dist_dir/$(basename "$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 "iOS app: $app"
|
||||||
say "bundle id: $BUNDLE_ID display: $DISPLAY_NAME"
|
say "bundle id: $BUNDLE_ID display: $DISPLAY_NAME"
|
||||||
if $DEVICE; then
|
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
|
else
|
||||||
say "simulator tip: xcrun simctl install booted \"$app\""
|
say "simulator tip: xcrun simctl install booted \"$app\""
|
||||||
fi
|
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
|
# --------------------------------------------------------------- main
|
||||||
apply_ios_branding
|
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"
|
||||||
|
ensure_manifests
|
||||||
pack_game_love
|
pack_game_love
|
||||||
ensure_game_love_in_xcode
|
ensure_game_love_in_xcode
|
||||||
|
|
||||||
|
|||||||
Executable
+23
@@ -0,0 +1,23 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Static analysis for the engine Lua (config: .luacheckrc).
|
||||||
|
#
|
||||||
|
# Complements scripts/test.sh: the tests prove behavior, luacheck catches the
|
||||||
|
# defects that never run in a green test -- undefined globals/locals (the
|
||||||
|
# class that hid a music.volume crash: a hook read a `state` that was still
|
||||||
|
# the nil global), unused values, unreachable code. The .luacheckrc mutes the
|
||||||
|
# cosmetic categories the codebase lives with, so what prints is worth a look.
|
||||||
|
#
|
||||||
|
# scripts/lint.sh lint src/
|
||||||
|
# scripts/lint.sh src tools lint specific paths
|
||||||
|
#
|
||||||
|
# Install once with: luarocks install luacheck
|
||||||
|
|
||||||
|
set -uo pipefail
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
|
||||||
|
if ! command -v luacheck >/dev/null 2>&1; then
|
||||||
|
echo "luacheck not found on PATH (install: luarocks install luacheck)" >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
luacheck "${@:-src}"
|
||||||
+226
-56
@@ -4,7 +4,7 @@
|
|||||||
-- Flow: intro -> menu (FIGHT/PKMN/ITEM/RUN) -> move select -> turn
|
-- Flow: intro -> menu (FIGHT/PKMN/ITEM/RUN) -> move select -> turn
|
||||||
-- resolution (a queue of messages/actions/UI pushes) -> back to menu,
|
-- resolution (a queue of messages/actions/UI pushes) -> back to menu,
|
||||||
-- until one side is out, then finish. Pops itself and calls
|
-- 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,
|
-- The Gen 1 move-effect pipeline (multi-hit, charge, trapping, thrash,
|
||||||
-- bide, recharge, confusion, screens, substitute, transform, ...) is
|
-- bide, recharge, confusion, screens, substitute, transform, ...) is
|
||||||
@@ -188,6 +188,36 @@ local function namedPalette(data, name)
|
|||||||
return { name = key, colors = colors }
|
return { name = key, colors = colors }
|
||||||
end
|
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
|
-- The battle-BGP fade variant of a pic (AnimationFlashScreen and the
|
||||||
-- SetAnimationBGPalette effects remap the four BG shades; on the SGB
|
-- SetAnimationBGPalette effects remap the four BG shades; on the SGB
|
||||||
-- the colorizer then colors the REMAPPED shade, so a faded pic shows
|
-- the colorizer then colors the REMAPPED shade, so a faded pic shows
|
||||||
@@ -608,7 +638,9 @@ function BattleState.newTrainer(game, oppClass, partyIndex)
|
|||||||
-- MonsterPalettes[0] = PAL_MEWMON -- InitBattleCommon zeroes
|
-- MonsterPalettes[0] = PAL_MEWMON -- InitBattleCommon zeroes
|
||||||
-- wEnemyMonSpecies2 before the intro's SET_PAL_BATTLE
|
-- wEnemyMonSpecies2 before the intro's SET_PAL_BATTLE
|
||||||
-- (engine/battle/core.asm:6682, engine/gfx/palettes.asm SetPal_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)
|
self.introText = Strings("%s wants\nto fight!", self.trainer.name)
|
||||||
return self
|
return self
|
||||||
end
|
end
|
||||||
@@ -740,10 +772,17 @@ function BattleState:buildScreen(id, ...)
|
|||||||
end
|
end
|
||||||
|
|
||||||
-- insert a wait for the HP bars to finish draining (UpdateHPBar):
|
-- insert a wait for the HP bars to finish draining (UpdateHPBar):
|
||||||
-- the queue holds until every battler's displayed HP catches up
|
-- the queue holds until every battler's displayed HP catches up.
|
||||||
function BattleState:drainNext()
|
-- `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
|
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
|
end
|
||||||
|
|
||||||
-- One frame of the HP-bar drain (engine/gfx/hp_bar.asm UpdateHPBar):
|
-- One frame of the HP-bar drain (engine/gfx/hp_bar.asm UpdateHPBar):
|
||||||
@@ -752,14 +791,22 @@ end
|
|||||||
function BattleState:stepHPDrain()
|
function BattleState:stepHPDrain()
|
||||||
local busy = false
|
local busy = false
|
||||||
for _, b in ipairs({ self.player, self.enemy }) do
|
for _, b in ipairs({ self.player, self.enemy }) do
|
||||||
if b and b.shownHP and b.shownHP ~= b.mon.hp then
|
if b and b.shownHP then
|
||||||
local step = math.max(1, b.mon.stats.hp) / 96
|
-- drainFloor is the stop the running row carries (see drainNext)
|
||||||
if b.shownHP > b.mon.hp then
|
local goal = b.mon.hp
|
||||||
b.shownHP = math.max(b.mon.hp, b.shownHP - step)
|
if b.drainFloor and b.drainFloor > goal
|
||||||
else
|
and b.shownHP >= b.drainFloor then
|
||||||
b.shownHP = math.min(b.mon.hp, b.shownHP + step)
|
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
|
end
|
||||||
busy = busy or b.shownHP ~= b.mon.hp
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
return busy
|
return busy
|
||||||
@@ -837,6 +884,8 @@ function BattleState:updateQueue()
|
|||||||
if self.draining then
|
if self.draining then
|
||||||
if self:stepHPDrain() then return true end
|
if self:stepHPDrain() then return true end
|
||||||
self.draining = nil
|
self.draining = nil
|
||||||
|
if self.player then self.player.drainFloor = nil end
|
||||||
|
if self.enemy then self.enemy.drainFloor = nil end
|
||||||
end
|
end
|
||||||
-- a move animation holds the queue until it finishes; its screen
|
-- a move animation holds the queue until it finishes; its screen
|
||||||
-- effects (SE_*) and per-row sounds route into the fx layer as they
|
-- effects (SE_*) and per-row sounds route into the fx layer as they
|
||||||
@@ -875,6 +924,7 @@ function BattleState:updateQueue()
|
|||||||
end
|
end
|
||||||
if item.drain then
|
if item.drain then
|
||||||
self.draining = true
|
self.draining = true
|
||||||
|
if item.battler then item.battler.drainFloor = item.stopAt end
|
||||||
return true
|
return true
|
||||||
end
|
end
|
||||||
if item.wait then
|
if item.wait then
|
||||||
@@ -1097,10 +1147,34 @@ function BattleState:battleKind()
|
|||||||
end
|
end
|
||||||
|
|
||||||
function BattleState:enter()
|
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()
|
self.game.stack:pop()
|
||||||
Runtime.emit("battle.ended", { battle = self, result = "skipped" })
|
require("src.core.Music").restoreMap(self.data)
|
||||||
if self.onFinish then self.onFinish("skipped") end
|
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
|
return
|
||||||
end
|
end
|
||||||
local Music = require("src.core.Music")
|
local Music = require("src.core.Music")
|
||||||
@@ -1380,6 +1454,7 @@ function BattleState:update(dt)
|
|||||||
for _, b in ipairs({ self.player, self.enemy }) do
|
for _, b in ipairs({ self.player, self.enemy }) do
|
||||||
if b then
|
if b then
|
||||||
if b.shownHP then b.shownHP = b.mon.hp end
|
if b.shownHP then b.shownHP = b.mon.hp end
|
||||||
|
b.drainFloor = nil
|
||||||
b.shownStatus = b.mon.status
|
b.shownStatus = b.mon.status
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -1432,10 +1507,14 @@ function BattleState:update(dt)
|
|||||||
end
|
end
|
||||||
local col = (self.menuIndex - 1) % 2
|
local col = (self.menuIndex - 1) % 2
|
||||||
local row = math.floor((self.menuIndex - 1) / 2)
|
local row = math.floor((self.menuIndex - 1) / 2)
|
||||||
if input:wasPressed("left") or input:wasPressed("right") then
|
if input:wasPressed("left") then
|
||||||
col = 1 - col
|
col = math.max(0, col - 1)
|
||||||
elseif input:wasPressed("up") or input:wasPressed("down") then
|
elseif input:wasPressed("right") then
|
||||||
row = 1 - row
|
col = math.min(1, col + 1)
|
||||||
|
elseif input:wasPressed("up") then
|
||||||
|
row = math.max(0, row - 1)
|
||||||
|
elseif input:wasPressed("down") then
|
||||||
|
row = math.min(1, row + 1)
|
||||||
end
|
end
|
||||||
self.menuIndex = row * 2 + col + 1
|
self.menuIndex = row * 2 + col + 1
|
||||||
if input:wasPressed("a") then
|
if input:wasPressed("a") then
|
||||||
@@ -1464,10 +1543,14 @@ function BattleState:update(dt)
|
|||||||
end
|
end
|
||||||
local col = (self.menuIndex - 1) % 2
|
local col = (self.menuIndex - 1) % 2
|
||||||
local row = math.floor((self.menuIndex - 1) / 2)
|
local row = math.floor((self.menuIndex - 1) / 2)
|
||||||
if input:wasPressed("left") or input:wasPressed("right") then
|
if input:wasPressed("left") then
|
||||||
col = 1 - col
|
col = math.max(0, col - 1)
|
||||||
elseif input:wasPressed("up") or input:wasPressed("down") then
|
elseif input:wasPressed("right") then
|
||||||
row = 1 - row
|
col = math.min(1, col + 1)
|
||||||
|
elseif input:wasPressed("up") then
|
||||||
|
row = math.max(0, row - 1)
|
||||||
|
elseif input:wasPressed("down") then
|
||||||
|
row = math.min(1, row + 1)
|
||||||
end
|
end
|
||||||
self.menuIndex = row * 2 + col + 1
|
self.menuIndex = row * 2 + col + 1
|
||||||
if input:wasPressed("a") then
|
if input:wasPressed("a") then
|
||||||
@@ -1937,6 +2020,15 @@ function BattleState:resolveSwitch(newMon)
|
|||||||
end
|
end
|
||||||
|
|
||||||
function BattleState:endOfTurn()
|
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
|
-- sideToxic mirrors w*ToxicCounter: it advances only while the
|
||||||
-- battler's badly-poisoned flag (toxicCounter) is set, an item/AI
|
-- battler's badly-poisoned flag (toxicCounter) is set, an item/AI
|
||||||
-- cure clears the flag but NOT the side counter, and a fresh Toxic
|
-- cure clears the flag but NOT the side counter, and a fresh Toxic
|
||||||
@@ -2154,6 +2246,31 @@ local function startPicKind(pf, kind)
|
|||||||
pf.hidden = nil
|
pf.hidden = nil
|
||||||
end
|
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
|
-- Route one AnimPlayer event into the fx layer. Frame counts and
|
||||||
-- amplitudes are the routines' own (engine/battle/animations.asm;
|
-- amplitudes are the routines' own (engine/battle/animations.asm;
|
||||||
-- shakes: engine/gfx/screen_effects.asm).
|
-- shakes: engine/gfx/screen_effects.asm).
|
||||||
@@ -2195,14 +2312,7 @@ function BattleState:applyAnimEffect(ev)
|
|||||||
|
|
||||||
-- ---------------------------------------------- screen shakes
|
-- ---------------------------------------------- screen shakes
|
||||||
elseif e == "SE_SHAKE_SCREEN" then
|
elseif e == "SE_SHAKE_SCREEN" then
|
||||||
-- PredefShakeScreenHorizontally b=8: the window jumps right by b
|
fx.shakeProg = fastShakeProg(8)
|
||||||
-- 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
|
|
||||||
elseif e == "SE_ROCK_SLIDE_SHAKE" then
|
elseif e == "SE_ROCK_SLIDE_SHAKE" then
|
||||||
-- DoRockSlideSpecialEffects: 1px horizontal then vertical rumble
|
-- DoRockSlideSpecialEffects: 1px horizontal then vertical rumble
|
||||||
fx.shakeProg = { { dx = 1, frames = 5 }, { dx = 0, frames = 4 },
|
fx.shakeProg = { { dx = 1, frames = 5 }, { dx = 0, frames = 4 },
|
||||||
@@ -2299,34 +2409,77 @@ function BattleState:applyAnimEffect(ev)
|
|||||||
-- battler.substituteHP is set (MoveEffects raises it with the move)
|
-- battler.substituteHP is set (MoveEffects raises it with the move)
|
||||||
end
|
end
|
||||||
|
|
||||||
-- The target's post-animation hit feedback (PlayApplyingAttackAnimation,
|
-- The post-animation applying-attack feedback (PlayApplyingAttackAnimation
|
||||||
-- engine/battle/animations.asm:475): the player's damaging moves blink
|
-- -> AnimationTypePointerTable, engine/battle/animations.asm:475-524).
|
||||||
-- the ENEMY pic; the enemy's damaging moves shake the screen vertically
|
-- hit.animType is wAnimationType, 1..6:
|
||||||
-- (ShakeScreenVertically -> PredefShakeScreenVertically b=8: the window
|
-- 1 enemy damaging, no added effect ShakeScreenVertically (b=8)
|
||||||
-- drops by b for 3 frames then home for 3, b counting down) -- the
|
-- 2 enemy damaging, added effect fast horizontal shake, b=8
|
||||||
-- player's pic never blinks. Damage sound with either. A hold keeps
|
-- 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.
|
-- the queue still until the effect finishes.
|
||||||
function BattleState:applyHitFx(hit)
|
function BattleState:applyHitFx(hit)
|
||||||
if hit.blink then
|
self.fx = self.fx or {}
|
||||||
self.fx = self.fx or {}
|
-- rows queued before animType existed carry only the blink target
|
||||||
if hit.blink.isPlayer then
|
local t = hit.animType
|
||||||
local prog = {}
|
if not t and hit.blink then t = hit.blink.isPlayer and 1 or 4 end
|
||||||
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
|
|
||||||
if hit.sfx then
|
if hit.sfx then
|
||||||
require("src.core.Sound").play(self.data, hit.sfx)
|
require("src.core.Sound").play(self.data, hit.sfx)
|
||||||
end
|
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
|
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
|
-- 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
|
-- 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.
|
-- block of its 7x7 tiles), 5 frames at 5/7 (5x5), then full size.
|
||||||
@@ -2546,6 +2699,12 @@ function BattleState:syncShownStatus()
|
|||||||
end
|
end
|
||||||
|
|
||||||
function BattleState:executeAction(user, target, action)
|
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 user.mon.hp <= 0 or target.mon.hp <= 0 then return end
|
||||||
if not action then return end
|
if not action then return end
|
||||||
|
|
||||||
@@ -2910,6 +3069,8 @@ function BattleState:performMove(user, target, moveInst, isCalled)
|
|||||||
-- (AlreadyAsleep / NothingHappened / ButItFailed print with no anim)
|
-- (AlreadyAsleep / NothingHappened / ButItFailed print with no anim)
|
||||||
if primaryEffectFailed(msgs) then
|
if primaryEffectFailed(msgs) then
|
||||||
self:cancelMoveAnim()
|
self:cancelMoveAnim()
|
||||||
|
elseif SLOW_SHAKE_EFFECTS[move.effect] and self.moveAnimRow then
|
||||||
|
self.moveAnimRow.hit = { animType = user.isPlayer and 6 or 3 }
|
||||||
end
|
end
|
||||||
for _, m in ipairs(msgs) do
|
for _, m in ipairs(msgs) do
|
||||||
self:sayNext(m)
|
self:sayNext(m)
|
||||||
@@ -2963,6 +3124,10 @@ function BattleState:continueBide(user, target)
|
|||||||
self:sayNext(Strings("But, it failed!"))
|
self:sayNext(Strings("But, it failed!"))
|
||||||
return
|
return
|
||||||
end
|
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)
|
self:applyDamage(target, dmg)
|
||||||
if target.mon.hp <= 0 then self:onFaint(target) end
|
if target.mon.hp <= 0 then self:onFaint(target) end
|
||||||
end
|
end
|
||||||
@@ -2987,7 +3152,7 @@ function BattleState:applyDamage(target, dmg)
|
|||||||
end
|
end
|
||||||
local dealt = math.min(dmg, target.mon.hp)
|
local dealt = math.min(dmg, target.mon.hp)
|
||||||
target.mon.hp = target.mon.hp - dealt
|
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
|
if target.bideTurns then
|
||||||
target.bideDamage = (target.bideDamage or 0) + dealt
|
target.bideDamage = (target.bideDamage or 0) + dealt
|
||||||
end
|
end
|
||||||
@@ -3834,12 +3999,15 @@ function BattleState:throwBall(ball)
|
|||||||
self:act(function() self:endOfTurn() end)
|
self:act(function() self:endOfTurn() end)
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
if self.ghost then
|
if self.ghost or self.noCatch then
|
||||||
-- ItemUseBall's can't-be-caught path (item_effects.asm:149-153):
|
-- ItemUseBall's can't-be-caught path (item_effects.asm:149-153):
|
||||||
-- the ball is thrown (TossBallAnimation still picks the arc from
|
-- the ball is thrown (TossBallAnimation still picks the arc from
|
||||||
-- wCurItem, so a Master/Ultra toss keeps its flicker), dodged
|
-- wCurItem, so a Master/Ultra toss keeps its flicker), dodged
|
||||||
-- ($10 anim data, no wobbles), and the turn is spent like any
|
-- ($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:animNext(self:tossAnimFor(ball), true, nil, ball)
|
||||||
self:sayNext(Strings("It dodged the\nthrown BALL!"))
|
self:sayNext(Strings("It dodged the\nthrown BALL!"))
|
||||||
self:sayNext(Strings("This POKéMON\ncan't be caught!"))
|
self:sayNext(Strings("This POKéMON\ncan't be caught!"))
|
||||||
@@ -4323,7 +4491,9 @@ function BattleState:sgbBattlePals()
|
|||||||
-- true white, which is what drew a white box around each pic on the pink
|
-- true white, which is what drew a white box around each pic on the pink
|
||||||
-- field. Snap every zone's color 0/3 to the global GBC white/black; the
|
-- field. Snap every zone's color 0/3 to the global GBC white/black; the
|
||||||
-- mid shades (and the green bar the user prefers) stay untouched.
|
-- mid shades (and the green bar the user prefers) stay untouched.
|
||||||
if PaletteFX.mode == "ogred" then
|
-- Boot-ROM OG only (Red/Blue): snap zone paper to the global GBC white/black.
|
||||||
|
-- OG YELLOW keeps each CGBBasePalettes endpoint (already near-white / near-black).
|
||||||
|
if PaletteFX.mode == "ogred" and not require("src.core.GameVersion").isYellow() then
|
||||||
local white, black = PaletteFX.GBC_BG[1], PaletteFX.GBC_BG[4]
|
local white, black = PaletteFX.GBC_BG[1], PaletteFX.GBC_BG[4]
|
||||||
for i = 0, 3 do
|
for i = 0, 3 do
|
||||||
local c = out[i]
|
local c = out[i]
|
||||||
|
|||||||
@@ -93,6 +93,12 @@ function EffectRegistry.runDamaging(battle, ctx, record)
|
|||||||
-- Explosion/Selfdestruct still animate on a miss (HandleIfPlayerMoveMissed)
|
-- Explosion/Selfdestruct still animate on a miss (HandleIfPlayerMoveMissed)
|
||||||
if not (record and record.explode) then battle:cancelMoveAnim() end
|
if not (record and record.explode) then battle:cancelMoveAnim() end
|
||||||
battle:sayNext(Strings("%s's\nattack missed!", displayName(user)))
|
battle:sayNext(Strings("%s's\nattack missed!", displayName(user)))
|
||||||
|
-- MoveHitTest's INVULNERABLE branch sets the same wMoveMissed as a
|
||||||
|
-- failed accuracy roll (core.asm:5260), and the miss handler still
|
||||||
|
-- runs the explode effect ("even if Explosion or Selfdestruct
|
||||||
|
-- missed, its effect still needs to be activated", core.asm:3223),
|
||||||
|
-- so the user faints against a mid-Fly/Dig target too (#528)
|
||||||
|
if record and record.onMiss then record.onMiss(ctx, "invulnerable") end
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -186,7 +192,15 @@ function EffectRegistry.runDamaging(battle, ctx, record)
|
|||||||
-- hitRow carries the blink instead.
|
-- hitRow carries the blink instead.
|
||||||
local hitSfx = info.typeMult > 10 and "Super_Effective"
|
local hitSfx = info.typeMult > 10 and "Super_Effective"
|
||||||
or info.typeMult < 10 and "Not_Very_Effective" or "Damage"
|
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,
|
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 }
|
blink = battle:animationsOn() and target or nil }
|
||||||
|
|
||||||
local totalDealt = 0
|
local totalDealt = 0
|
||||||
|
|||||||
@@ -609,10 +609,16 @@ MoveEffects.full = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
BIDE_EFFECT = {
|
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)
|
perform = function(ctx)
|
||||||
local user = ctx.user
|
local user = ctx.user
|
||||||
user.bideTurns = ctx.rng(2, 3)
|
user.bideTurns = ctx.rng(2, 3)
|
||||||
user.bideDamage = 0
|
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)))
|
ctx.say(Strings("%s\nis storing energy!", displayName(user)))
|
||||||
end,
|
end,
|
||||||
},
|
},
|
||||||
|
|||||||
+39
-5
@@ -32,17 +32,20 @@ local MUSIC_BUFFER_COUNT = ChipSynth.MUSIC_BUFFER_COUNT
|
|||||||
-- [1] pulse 1 [2] pulse 2 [3] wave [4] noise / drums
|
-- [1] pulse 1 [2] pulse 2 [3] wave [4] noise / drums
|
||||||
-- Volume: 1 = authentic, 0 = mute, >1 boosts
|
-- Volume: 1 = authentic, 0 = mute, >1 boosts
|
||||||
-- Pitch: 1 = authentic, 2 = +1 octave, 0.5 = -1 octave
|
-- 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 = {
|
local CHANNEL_VOLUME = {
|
||||||
[1] = 1, -- pulse 1
|
[1] = 1, -- pulse 1
|
||||||
[2] = 1, -- pulse 2
|
[2] = 1, -- pulse 2
|
||||||
[3] = 0.25, -- wave
|
[3] = 1, -- wave
|
||||||
[4] = 1, -- noise / drums
|
[4] = 1, -- noise / drums
|
||||||
}
|
}
|
||||||
local CHANNEL_PITCH = {
|
local CHANNEL_PITCH = {
|
||||||
[1] = 1, -- pulse 1
|
[1] = 1, -- pulse 1
|
||||||
[2] = 1, -- pulse 2
|
[2] = 1, -- pulse 2
|
||||||
[3] = 0.5, -- wave
|
[3] = 1, -- wave
|
||||||
[4] = 1, -- noise / drums
|
[4] = 1, -- noise / drums
|
||||||
}
|
}
|
||||||
ChipSynth.setChannelVolumes(CHANNEL_VOLUME)
|
ChipSynth.setChannelVolumes(CHANNEL_VOLUME)
|
||||||
@@ -55,6 +58,14 @@ local currentMusic
|
|||||||
local pendingBuf -- a current-gen buffer popped from the worker but not yet
|
local pendingBuf -- a current-gen buffer popped from the worker but not yet
|
||||||
-- queued because the Source was momentarily full
|
-- 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
|
-- worker management
|
||||||
-- ---------------------------------------------------------------------------
|
-- ---------------------------------------------------------------------------
|
||||||
@@ -148,7 +159,7 @@ local function playMusicSync(data, header, allowLoops)
|
|||||||
currentMusic = { source = source, engine = engine, threaded = false,
|
currentMusic = { source = source, engine = engine, threaded = false,
|
||||||
started = true, finished = false }
|
started = true, finished = false }
|
||||||
fillSync(MUSIC_FILL_INITIAL)
|
fillSync(MUSIC_FILL_INITIAL)
|
||||||
source:play()
|
if not musicHeld then source:play() end
|
||||||
return source
|
return source
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -222,7 +233,7 @@ local function updateThreaded()
|
|||||||
end
|
end
|
||||||
end
|
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
|
if (MUSIC_BUFFER_COUNT - m.source:getFreeBufferCount()) > 0 then
|
||||||
pcall(function() m.source:play() end)
|
pcall(function() m.source:play() end)
|
||||||
m.started = true
|
m.started = true
|
||||||
@@ -245,7 +256,7 @@ end
|
|||||||
-- pause/resume behavior.
|
-- pause/resume behavior.
|
||||||
function ChipAudio.ensureMusicPlaying()
|
function ChipAudio.ensureMusicPlaying()
|
||||||
local m = currentMusic
|
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 m.threaded then
|
||||||
if not m.started then return end
|
if not m.started then return end
|
||||||
local ok, playing = pcall(function() return m.source:isPlaying() end)
|
local ok, playing = pcall(function() return m.source:isPlaying() end)
|
||||||
@@ -263,6 +274,18 @@ function ChipAudio.ensureMusicPlaying()
|
|||||||
end
|
end
|
||||||
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
|
-- Threaded playMusic returns an empty QueueableSource and only calls
|
||||||
-- Source:play once the first worker buffer lands (~1 frame later). Until
|
-- Source:play once the first worker buffer lands (~1 frame later). Until
|
||||||
-- then Source:isPlaying is false -- callers that treat that as "song over"
|
-- then Source:isPlaying is false -- callers that treat that as "song over"
|
||||||
@@ -303,6 +326,17 @@ function ChipAudio.invalidate()
|
|||||||
if workerReady and cmdCh then cmdCh:push({ cmd = "invalidate" }) end
|
if workerReady and cmdCh then cmdCh:push({ cmd = "invalidate" }) end
|
||||||
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
|
-- 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.
|
-- synthesized buffer (live music) and on any SFX/cry rendered after the call.
|
||||||
function ChipAudio.setChannelVolume(hw, scale)
|
function ChipAudio.setChannelVolume(hw, scale)
|
||||||
|
|||||||
+31
-8
@@ -14,12 +14,12 @@ local MODULES = {
|
|||||||
-- Optional for compatibility with developer and stale caches.
|
-- Optional for compatibility with developer and stale caches.
|
||||||
local OPTIONAL = { "audio", "palettes", "icons" }
|
local OPTIONAL = { "audio", "palettes", "icons" }
|
||||||
|
|
||||||
-- The rules the engine still carries as literals. The constants registry
|
-- Vanilla defaults for rules exposed through the constants registry. A
|
||||||
-- deep-merges over these, so a value has to exist before a mod can patch
|
-- value has to exist before a mod can patch it; each one matches the
|
||||||
-- it; each one is the number the engine hard-codes today, so seeding them
|
-- engine's no-mod behavior, so seeding them changes nothing on a vanilla
|
||||||
-- changes nothing on a mod-free boot.
|
-- boot.
|
||||||
local CONSTANT_DEFAULTS = {
|
local CONSTANT_DEFAULTS = {
|
||||||
bagSize = 20, -- BAG_ITEM_CAPACITY (src/inventory/Bag.lua)
|
bagSize = 20, -- BAG_ITEM_CAPACITY (Bag.capacity fallback)
|
||||||
partyMax = 6, -- PARTY_LENGTH (src/pokemon/Party.lua)
|
partyMax = 6, -- PARTY_LENGTH (src/pokemon/Party.lua)
|
||||||
boxCount = 12, boxSize = 20, -- Bill's PC (src/pokemon/Boxes.lua)
|
boxCount = 12, boxSize = 20, -- Bill's PC (src/pokemon/Boxes.lua)
|
||||||
moveMax = 4,
|
moveMax = 4,
|
||||||
@@ -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
|
-- 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.
|
-- values match what SaveData.newGame and the Oak speech used to inline.
|
||||||
local BOOT_DEFAULTS = {
|
local BOOT_DEFAULTS = {
|
||||||
@@ -58,6 +74,12 @@ local function copy(value)
|
|||||||
return out
|
return out
|
||||||
end
|
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
|
-- Fills only what the cache is missing, so an importer that learns to
|
||||||
-- stamp one of these keys silently takes over from the engine.
|
-- stamp one of these keys silently takes over from the engine.
|
||||||
function Data:seedDefaults()
|
function Data:seedDefaults()
|
||||||
@@ -77,6 +99,7 @@ function Data:seedDefaults()
|
|||||||
if constants.dexDigits == nil then
|
if constants.dexDigits == nil then
|
||||||
constants.dexDigits = math.max(3, #tostring(constants.dexSize))
|
constants.dexDigits = math.max(3, #tostring(constants.dexSize))
|
||||||
end
|
end
|
||||||
|
self:applyVersionedFieldData()
|
||||||
local boot = self.field.boot
|
local boot = self.field.boot
|
||||||
if boot == nil then
|
if boot == nil then
|
||||||
boot = {}
|
boot = {}
|
||||||
@@ -121,8 +144,8 @@ end
|
|||||||
-- no def_trainers row (DisplayTextID routes to his ASM script), so the
|
-- no def_trainers row (DisplayTextID routes to his ASM script), so the
|
||||||
-- extractor emits headers only for the four blackbelts ([2]..[5]). Seed
|
-- extractor emits headers only for the four blackbelts ([2]..[5]). Seed
|
||||||
-- object index [1] so he behaves like the real leader:
|
-- object index [1] so he behaves like the real leader:
|
||||||
-- * range 4 (matches the strongest blackbelt) -> CheckFightingMapTrainers
|
-- * range 0: FightingDojoDefaultScript, not trainer sight, starts his
|
||||||
-- spots the player in his DOWN line and he challenges on sight (#197),
|
-- battle from the single tile to his left (#495),
|
||||||
-- * battle = his pre-battle challenge, won = "Hwa! Arrgh! Beaten!",
|
-- * battle = his pre-battle challenge, won = "Hwa! Arrgh! Beaten!",
|
||||||
-- * after = the "Stay and train at Karate with us!" re-talk line.
|
-- * after = the "Stay and train at Karate with us!" re-talk line.
|
||||||
-- Deliberately NO `event`: EVENT_BEAT_KARATE_MASTER is owned by
|
-- Deliberately NO `event`: EVENT_BEAT_KARATE_MASTER is owned by
|
||||||
@@ -137,7 +160,7 @@ function Data:seedFightingDojoKarateMaster()
|
|||||||
headers.FightingDojo = headers.FightingDojo or {}
|
headers.FightingDojo = headers.FightingDojo or {}
|
||||||
if headers.FightingDojo[1] then return end
|
if headers.FightingDojo[1] then return end
|
||||||
headers.FightingDojo[1] = {
|
headers.FightingDojo[1] = {
|
||||||
range = 4,
|
range = 0,
|
||||||
battle = "_FightingDojoKarateMasterText",
|
battle = "_FightingDojoKarateMasterText",
|
||||||
won = "_FightingDojoKarateMasterDefeatedText",
|
won = "_FightingDojoKarateMasterDefeatedText",
|
||||||
after = "_FightingDojoKarateMasterStayAndTrainWithUsText",
|
after = "_FightingDojoKarateMasterStayAndTrainWithUsText",
|
||||||
|
|||||||
+70
-2
@@ -172,6 +172,11 @@ function Game:returnToTitle()
|
|||||||
end
|
end
|
||||||
|
|
||||||
function Game:step(dt)
|
function Game:step(dt)
|
||||||
|
-- Tool mods (autoplay, accessibility drivers, input visualizers) act on
|
||||||
|
-- the same fixed-step boundary as a physical controller. Run them before
|
||||||
|
-- Input:step promotes queued edges so a button chosen here is visible to
|
||||||
|
-- this logic tick, not one tick later. With no wrapper this is a no-op.
|
||||||
|
ModRuntime.call("input.step", function() end, self, dt)
|
||||||
self.input:step()
|
self.input:step()
|
||||||
-- serviced unconditionally: a link battle's ENet transport must not
|
-- serviced unconditionally: a link battle's ENet transport must not
|
||||||
-- stall just because PartyMenu/ChoiceBox/NamingScreen is temporarily
|
-- stall just because PartyMenu/ChoiceBox/NamingScreen is temporarily
|
||||||
@@ -339,7 +344,13 @@ function Game:draw()
|
|||||||
if worldBelow and self.overworld.sgbWorldZones then
|
if worldBelow and self.overworld.sgbWorldZones then
|
||||||
worldZones = self.overworld:sgbWorldZones()
|
worldZones = self.overworld:sgbWorldZones()
|
||||||
end
|
end
|
||||||
Renderer:endFrame(zones, worldZones)
|
local viewport = Renderer:endFrame(zones, worldZones)
|
||||||
|
-- Persistent tool status is screen-space UI: draw it over the completed
|
||||||
|
-- render pipeline with exact playfield/margin geometry, but below mobile
|
||||||
|
-- controls. It never becomes an updating game state.
|
||||||
|
if ModRuntime.wantsHook("render.hud") then
|
||||||
|
ModRuntime.call("render.hud", function() end, self, viewport)
|
||||||
|
end
|
||||||
-- on-screen mobile controls: pure screen-space, over the finished frame
|
-- on-screen mobile controls: pure screen-space, over the finished frame
|
||||||
TouchControls:draw()
|
TouchControls:draw()
|
||||||
end
|
end
|
||||||
@@ -491,6 +502,40 @@ function Game:gamepadaxis(joystick, axis, value)
|
|||||||
Input:gamepadaxis(joystick, axis, value)
|
Input:gamepadaxis(joystick, axis, value)
|
||||||
end
|
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
|
-- Window focus/visibility flips: a release due while unfocused/hidden can
|
||||||
-- be swallowed by the OS. Reset on both edges -- gaining focus with a
|
-- be swallowed by the OS. Reset on both edges -- gaining focus with a
|
||||||
-- physically held key won't re-fire keypressed, so trusting leftover
|
-- physically held key won't re-fire keypressed, so trusting leftover
|
||||||
@@ -544,6 +589,11 @@ end
|
|||||||
-- Capture the live world state into the save table and persist it.
|
-- Capture the live world state into the save table and persist it.
|
||||||
-- Options are flushed to options.lua as part of SaveData.save.
|
-- Options are flushed to options.lua as part of SaveData.save.
|
||||||
function Game:writeSave()
|
function Game:writeSave()
|
||||||
|
-- Tool sessions can be deliberately ephemeral. Give them one narrow veto
|
||||||
|
-- before captureSave mutates the snapshot or any progress bytes reach disk.
|
||||||
|
if ModRuntime.call("save.write", function() return true end, self) == false then
|
||||||
|
return false
|
||||||
|
end
|
||||||
if self.overworld and self.overworld.captureSave then
|
if self.overworld and self.overworld.captureSave then
|
||||||
self.overworld:captureSave(self.save)
|
self.overworld:captureSave(self.save)
|
||||||
end
|
end
|
||||||
@@ -554,7 +604,7 @@ function Game:writeSave()
|
|||||||
if ModRuntime.wants("save.writing") then
|
if ModRuntime.wants("save.writing") then
|
||||||
ModRuntime.emit("save.writing", { save = self.save, meta = self.save.meta })
|
ModRuntime.emit("save.writing", { save = self.save, meta = self.save.meta })
|
||||||
end
|
end
|
||||||
SaveData.save(self.save)
|
return SaveData.save(self.save)
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Persist options.lua only (Options menu / hotkeys 2-5). Keeps settings
|
-- Persist options.lua only (Options menu / hotkeys 2-5). Keeps settings
|
||||||
@@ -584,7 +634,25 @@ function Game:applyOptions(opts)
|
|||||||
-- normalizes a nil/garbage cap to the 60 default, so old saves with no
|
-- normalizes a nil/garbage cap to the 60 default, so old saves with no
|
||||||
-- fpsCap key pace at the standard rate (issue #88)
|
-- fpsCap key pace at the standard rate (issue #88)
|
||||||
require("src.core.FrameCap").applyOptions(opts)
|
require("src.core.FrameCap").applyOptions(opts)
|
||||||
|
-- Scale the optional presentation extras to the device's performance
|
||||||
|
-- tier. Every heavy feature was just applied from the stored options
|
||||||
|
-- above; here we clamp the *live* state down for a weaker device without
|
||||||
|
-- rewriting what the player saved, so raising the tier later restores
|
||||||
|
-- their exact TILT / GBC FX / ZOOM / MAX FPS choices. A HIGH tier (the
|
||||||
|
-- default on a normal desktop, and every options.lua predating this
|
||||||
|
-- option) clamps nothing, so it is a no-op for the common case.
|
||||||
|
local caps = require("src.core.Performance").applyOptions(opts)
|
||||||
|
if not caps.tilt then require("src.render.Tilt").setLevel(0) end
|
||||||
|
if not caps.gbcfx then require("src.render.GBCFX").setLevel(0) end
|
||||||
|
local Zoom = require("src.render.Zoom")
|
||||||
|
Zoom.allowSurvey = caps.survey
|
||||||
|
if not caps.survey and Zoom.offset < 0 then Zoom.offset = 0 end
|
||||||
|
if caps.fpsMax then
|
||||||
|
local FrameCap = require("src.core.FrameCap")
|
||||||
|
if FrameCap.current > caps.fpsMax then FrameCap.apply(caps.fpsMax) end
|
||||||
|
end
|
||||||
Input:applyBindings(opts.bindings)
|
Input:applyBindings(opts.bindings)
|
||||||
|
TouchControls:applyOptions(opts)
|
||||||
-- heal soft-bricked APK installs that already saved gbcfx > 0 (#136)
|
-- heal soft-bricked APK installs that already saved gbcfx > 0 (#136)
|
||||||
if gbcCleared then self:writeOptions() end
|
if gbcCleared then self:writeOptions() end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -39,6 +39,20 @@ local DEFAULT_GAMEPAD_BINDINGS = {
|
|||||||
local STICK_ON = 0.5
|
local STICK_ON = 0.5
|
||||||
local STICK_OFF = 0.3
|
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()
|
function Input:init()
|
||||||
self:applyBindings(nil)
|
self:applyBindings(nil)
|
||||||
self:reset()
|
self:reset()
|
||||||
@@ -79,6 +93,7 @@ function Input:reset()
|
|||||||
self.sources = {}
|
self.sources = {}
|
||||||
self.stickAxis = { x = 0, y = 0 }
|
self.stickAxis = { x = 0, y = 0 }
|
||||||
self.stickDir = nil
|
self.stickDir = nil
|
||||||
|
self.hatDirs = {}
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Multiple physical sources (W + Up, d-pad + stick, etc.) can claim the
|
-- Multiple physical sources (W + Up, d-pad + stick, etc.) can claim the
|
||||||
@@ -179,6 +194,16 @@ function Input:gamepadreleased(joystick, button)
|
|||||||
end
|
end
|
||||||
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
|
-- 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.
|
-- the touch swipe d-pad: whichever axis has the larger magnitude wins.
|
||||||
function Input:gamepadaxis(joystick, axis, value)
|
function Input:gamepadaxis(joystick, axis, value)
|
||||||
@@ -214,6 +239,26 @@ function Input:gamepadaxis(joystick, axis, value)
|
|||||||
end
|
end
|
||||||
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)
|
function Input:isDown(btn)
|
||||||
return self.state[btn] or false
|
return self.state[btn] or false
|
||||||
end
|
end
|
||||||
|
|||||||
+12
-1
@@ -23,6 +23,13 @@ local volumeScale = 1
|
|||||||
local FILTER_HIGHGAIN = { 0.4, 0.16, 0.064 }
|
local FILTER_HIGHGAIN = { 0.4, 0.16, 0.064 }
|
||||||
local filterLevel = 0
|
local filterLevel = 0
|
||||||
|
|
||||||
|
-- Forward-declared here so applyVolume (below) closes over the real playback
|
||||||
|
-- state rather than a nil global: the table literal is assigned further down,
|
||||||
|
-- but a `local state = {}` there would leave every reference above it bound
|
||||||
|
-- to the global `state`. Before this, registering the `music.volume` mod
|
||||||
|
-- hook crashed applyVolume on `state.current` (a nil index).
|
||||||
|
local state
|
||||||
|
|
||||||
local function applyVolume(src)
|
local function applyVolume(src)
|
||||||
if not src then return end
|
if not src then return end
|
||||||
local vol = VOLUME * volumeScale
|
local vol = VOLUME * volumeScale
|
||||||
@@ -63,7 +70,7 @@ local function applyFilter(src)
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
local state = {
|
state = {
|
||||||
current = nil, -- song label
|
current = nil, -- song label
|
||||||
chip = false, -- the playing song is a synthesized channel program
|
chip = false, -- the playing song is a synthesized channel program
|
||||||
source = nil, -- currently playing source
|
source = nil, -- currently playing source
|
||||||
@@ -85,6 +92,7 @@ local function fanfareActive()
|
|||||||
local ok, playing = pcall(src.isPlaying, src)
|
local ok, playing = pcall(src.isPlaying, src)
|
||||||
if ok and playing then return true end
|
if ok and playing then return true end
|
||||||
state.fanfare = nil
|
state.fanfare = nil
|
||||||
|
require("src.core.ChipAudio").holdMusic(false)
|
||||||
return false
|
return false
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -94,6 +102,9 @@ end
|
|||||||
function Music.duckForFanfare(src)
|
function Music.duckForFanfare(src)
|
||||||
if not src then return end
|
if not src then return end
|
||||||
state.fanfare = src
|
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
|
if state.source then
|
||||||
local ok, playing = pcall(state.source.isPlaying, state.source)
|
local ok, playing = pcall(state.source.isPlaying, state.source)
|
||||||
if ok and playing then
|
if ok and playing then
|
||||||
|
|||||||
@@ -0,0 +1,156 @@
|
|||||||
|
-- Graphics performance tier: one knob that scales the port's optional,
|
||||||
|
-- non-faithful presentation extras down for weaker hardware.
|
||||||
|
--
|
||||||
|
-- The heavy extras are all things the Game Boy never had and this port
|
||||||
|
-- adds on top: the whole-screen 3D TILT (transforms the entire map as a
|
||||||
|
-- ground plane), the GBC FX post-process shader (a fullscreen pass), and
|
||||||
|
-- survey ZOOM (zooming out renders the connected neighbor maps -- a lot of
|
||||||
|
-- extra overdraw). A hard FPS ceiling caps present cost on top of that.
|
||||||
|
-- None of this touches game logic, which is fixed-step off dt
|
||||||
|
-- (src/core/FixedStep.lua), so every tier plays identically; they differ
|
||||||
|
-- only in how much optional eye-candy the renderer is allowed to do.
|
||||||
|
--
|
||||||
|
-- The tier is persisted as save.options.performance:
|
||||||
|
-- auto pick a default from the device (see detect())
|
||||||
|
-- high everything on -- the historical behavior
|
||||||
|
-- balanced no TILT, no GBC FX (kept: survey zoom, colors, uncapped FPS)
|
||||||
|
-- low no TILT, no GBC FX, no survey zoom, FPS capped
|
||||||
|
--
|
||||||
|
-- AUTO only chooses the *default* for the current device; every tier is
|
||||||
|
-- selectable in OPTIONS, so a heuristic that guesses wrong is one row away
|
||||||
|
-- from being overridden. The clamps are applied live in Game:applyOptions
|
||||||
|
-- against the stored options without rewriting them, so raising the tier
|
||||||
|
-- restores exactly the TILT / GBC FX / ZOOM / FPS the player had.
|
||||||
|
--
|
||||||
|
-- Zero requires: loads during love.conf and under plain Lua for tools and
|
||||||
|
-- tests, the same way src/core/GameVersion.lua does.
|
||||||
|
|
||||||
|
local Performance = {}
|
||||||
|
|
||||||
|
-- Option-row order (auto first, then most to least capable).
|
||||||
|
Performance.TIERS = { "auto", "high", "balanced", "low" }
|
||||||
|
|
||||||
|
Performance.LABELS = {
|
||||||
|
auto = "AUTO",
|
||||||
|
high = "HIGH",
|
||||||
|
balanced = "BALANCED",
|
||||||
|
low = "LOW",
|
||||||
|
}
|
||||||
|
|
||||||
|
-- What each concrete tier permits. `auto` is resolved to one of these
|
||||||
|
-- before caps are read, so it has no row here. fpsMax = false means no
|
||||||
|
-- extra ceiling (the player's own MAX FPS still applies).
|
||||||
|
Performance.CAPS = {
|
||||||
|
high = { tilt = true, gbcfx = true, survey = true, fpsMax = false },
|
||||||
|
balanced = { tilt = false, gbcfx = false, survey = true, fpsMax = false },
|
||||||
|
low = { tilt = false, gbcfx = false, survey = false, fpsMax = 60 },
|
||||||
|
}
|
||||||
|
|
||||||
|
-- Live resolved tier (never "auto"); Game:applyOptions sets it and the
|
||||||
|
-- renderer / options row read it back. Defaults to the unclamped tier so
|
||||||
|
-- a pre-boot launcher behaves exactly as it always did.
|
||||||
|
Performance.tier = "high"
|
||||||
|
|
||||||
|
local function loveOS()
|
||||||
|
if love and love.system and love.system.getOS then
|
||||||
|
return love.system.getOS()
|
||||||
|
end
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
local function processorCount()
|
||||||
|
if love and love.system and love.system.getProcessorCount then
|
||||||
|
return love.system.getProcessorCount()
|
||||||
|
end
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
-- CPU architecture via LuaJIT's jit.arch when present: "arm"/"arm64" on
|
||||||
|
-- phones and PortMaster handhelds, "x86"/"x64" on desktops. nil under a
|
||||||
|
-- plain-Lua tool or test with no jit table, which resolves to HIGH (no
|
||||||
|
-- clamping) so tooling is never surprised.
|
||||||
|
local function cpuArch()
|
||||||
|
return (jit and jit.arch) or nil
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Default tier for the current device. Deliberately conservative: only
|
||||||
|
-- the platforms that are reliably weak drop below HIGH, and AUTO is always
|
||||||
|
-- overridable, so a wrong guess costs one OPTIONS row. A normal
|
||||||
|
-- multi-core desktop -- the common case, and every existing install whose
|
||||||
|
-- options.lua predates this option -- resolves to HIGH and behaves exactly
|
||||||
|
-- as before.
|
||||||
|
function Performance.detect()
|
||||||
|
local os = loveOS()
|
||||||
|
local arch = cpuArch()
|
||||||
|
local isArm = arch == "arm" or arch == "arm64"
|
||||||
|
local cores = processorCount()
|
||||||
|
|
||||||
|
-- PortMaster-style ARM Linux handhelds (e.g. the RG34XXSP the project
|
||||||
|
-- already ships a build for): the weakest target here.
|
||||||
|
if isArm and os ~= "Android" and os ~= "iOS" then
|
||||||
|
return "low"
|
||||||
|
end
|
||||||
|
-- Phones and tablets: GBC FX is already force-disabled here (issue #136);
|
||||||
|
-- balanced additionally drops the 3D tilt, the heaviest remaining extra.
|
||||||
|
if os == "Android" or os == "iOS" then
|
||||||
|
return "balanced"
|
||||||
|
end
|
||||||
|
-- Desktop: a dual-core (or single-core) box is the low-end line.
|
||||||
|
if cores and cores <= 2 then
|
||||||
|
return "balanced"
|
||||||
|
end
|
||||||
|
return "high"
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Fold a stored option value to a concrete tier, resolving "auto" (and any
|
||||||
|
-- hand-edited garbage) through detect().
|
||||||
|
function Performance.resolve(value)
|
||||||
|
if value == "high" or value == "balanced" or value == "low" then
|
||||||
|
return value
|
||||||
|
end
|
||||||
|
return Performance.detect()
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Normalize a stored value to a valid option id (keeps "auto"); a bad value
|
||||||
|
-- degrades to auto so a corrupt options.lua still lands on something sane.
|
||||||
|
function Performance.normalize(value)
|
||||||
|
for _, id in ipairs(Performance.TIERS) do
|
||||||
|
if value == id then return id end
|
||||||
|
end
|
||||||
|
return "auto"
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Row label for a stored value: the tier's own name (AUTO / HIGH / ...).
|
||||||
|
function Performance.label(value)
|
||||||
|
return Performance.LABELS[Performance.normalize(value)]
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Concrete caps for a stored option value (resolving auto).
|
||||||
|
function Performance.caps(value)
|
||||||
|
return Performance.CAPS[Performance.resolve(value)]
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Resolve the tier for these options, record it live, and return its caps.
|
||||||
|
-- Called from Game:applyOptions, which clamps the live presentation modules
|
||||||
|
-- against the returned caps.
|
||||||
|
function Performance.applyOptions(opts)
|
||||||
|
local tier = Performance.resolve(opts and opts.performance)
|
||||||
|
Performance.tier = tier
|
||||||
|
return Performance.CAPS[tier]
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Cycle the OPTIONS row: auto -> high -> balanced -> low -> auto (dir -1
|
||||||
|
-- reverses). Lua's `%` is non-negative for a positive modulus, so a
|
||||||
|
-- negative dir wraps correctly.
|
||||||
|
function Performance.cycle(value, dir)
|
||||||
|
value = Performance.normalize(value)
|
||||||
|
local i = 1
|
||||||
|
for idx, id in ipairs(Performance.TIERS) do
|
||||||
|
if id == value then i = idx break end
|
||||||
|
end
|
||||||
|
local n = #Performance.TIERS
|
||||||
|
local nextIdx = (i - 1 + (dir or 1)) % n + 1
|
||||||
|
return Performance.TIERS[nextIdx]
|
||||||
|
end
|
||||||
|
|
||||||
|
return Performance
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
-- Usable window rect for mobile chrome (notch / Dynamic Island / home
|
||||||
|
-- indicator / Android display cutouts). Wraps love.window.getSafeArea when
|
||||||
|
-- the engine provides it; otherwise the full graphics window.
|
||||||
|
--
|
||||||
|
-- Desktop and headless stubs return the full window, so callers can always
|
||||||
|
-- layout against this rect without platform branches. Interactive chrome
|
||||||
|
-- (touch overlay, launcher) should prefer this over getDimensions; the game
|
||||||
|
-- canvas may still letterbox into the full framebuffer for immersion.
|
||||||
|
|
||||||
|
local SafeArea = {}
|
||||||
|
|
||||||
|
function SafeArea.rect()
|
||||||
|
local ww, wh = 0, 0
|
||||||
|
if love and love.graphics and love.graphics.getDimensions then
|
||||||
|
ww, wh = love.graphics.getDimensions()
|
||||||
|
end
|
||||||
|
if ww <= 0 then ww = 1 end
|
||||||
|
if wh <= 0 then wh = 1 end
|
||||||
|
|
||||||
|
if not (love and love.window and love.window.getSafeArea) then
|
||||||
|
return 0, 0, ww, wh
|
||||||
|
end
|
||||||
|
|
||||||
|
local x, y, w, h = love.window.getSafeArea()
|
||||||
|
if type(x) ~= "number" or type(y) ~= "number"
|
||||||
|
or type(w) ~= "number" or type(h) ~= "number"
|
||||||
|
or w <= 0 or h <= 0 then
|
||||||
|
return 0, 0, ww, wh
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Clamp to the drawable window so a bad / mid-rotation backend cannot
|
||||||
|
-- push layout outside the surface.
|
||||||
|
x = math.max(0, math.min(x, ww))
|
||||||
|
y = math.max(0, math.min(y, wh))
|
||||||
|
w = math.max(1, math.min(w, ww - x))
|
||||||
|
h = math.max(1, math.min(h, wh - y))
|
||||||
|
return x, y, w, h
|
||||||
|
end
|
||||||
|
|
||||||
|
return SafeArea
|
||||||
+27
-1
@@ -223,6 +223,9 @@ function SaveData.defaultOptions()
|
|||||||
-- 0-7 like the GB's NR50 master volume
|
-- 0-7 like the GB's NR50 master volume
|
||||||
musicVol = 7,
|
musicVol = 7,
|
||||||
sfxVol = 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,
|
musicFilter = 0,
|
||||||
-- logic fast-forward multiplier; audio is unaffected (GameSpeed.lua)
|
-- logic fast-forward multiplier; audio is unaffected (GameSpeed.lua)
|
||||||
speed = 1,
|
speed = 1,
|
||||||
@@ -238,6 +241,11 @@ function SaveData.defaultOptions()
|
|||||||
videoMode = "windowed",
|
videoMode = "windowed",
|
||||||
-- hard render frame-rate cap; render-only pacing (issue #88, FrameCap.lua)
|
-- hard render frame-rate cap; render-only pacing (issue #88, FrameCap.lua)
|
||||||
fpsCap = 60,
|
fpsCap = 60,
|
||||||
|
-- graphics performance tier: auto | high | balanced | low. "auto"
|
||||||
|
-- picks a default from the device (ARM handhelds/phones drop the heavy
|
||||||
|
-- extras); scales TILT / GBC FX / survey ZOOM / FPS but never game
|
||||||
|
-- logic. See src/core/Performance.lua.
|
||||||
|
performance = "auto",
|
||||||
-- Per-pipeline display levels, keyed by render_pipelines id (see
|
-- Per-pipeline display levels, keyed by render_pipelines id (see
|
||||||
-- src/render/Pipelines.lua). A level for a mod that is not installed
|
-- src/render/Pipelines.lua). A level for a mod that is not installed
|
||||||
-- is kept rather than pruned, so re-enabling the mod restores the mode
|
-- is kept rather than pruned, so re-enabling the mod restores the mode
|
||||||
@@ -246,6 +254,24 @@ function SaveData.defaultOptions()
|
|||||||
-- Native mod enablement is an installation option, not save-slot data.
|
-- Native mod enablement is an installation option, not save-slot data.
|
||||||
-- Missing entries mean enabled so newly installed mods work by default.
|
-- Missing entries mean enabled so newly installed mods work by default.
|
||||||
mods = {},
|
mods = {},
|
||||||
|
-- GitHub release checks for mods with a manifest "github" field
|
||||||
|
-- (src/mods/ModUpdate.lua). Keyed by owner/repo; TTL is six hours.
|
||||||
|
modUpdateCache = {},
|
||||||
|
-- Community mod indexes the player has chosen to browse
|
||||||
|
-- (src/mods/ModIndex.lua), in the order they added them. Empty by
|
||||||
|
-- default and never populated automatically: adding an index is how a
|
||||||
|
-- player says they trust whoever publishes it, so the launcher asks
|
||||||
|
-- rather than shipping one. Rows are { url, feed, base, fallback,
|
||||||
|
-- label }.
|
||||||
|
modIndexes = {},
|
||||||
|
-- Parsed index listings keyed by feed URL; TTL is 24 hours, matching how
|
||||||
|
-- often the feeds themselves rebuild.
|
||||||
|
modIndexCache = {},
|
||||||
|
-- On-screen touch overlay (Android/iOS; see src/core/TouchControls.lua).
|
||||||
|
-- enabled=false hides it permanently (distinct from auto-hide-on-gamepad).
|
||||||
|
-- positions are optional normalized centers {x=0..1, y=0..1} per control
|
||||||
|
-- (dpad/a/b/start/select); nil means the default layout.
|
||||||
|
touchControls = { enabled = true },
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -1032,7 +1058,7 @@ local function reclaim(save, data, report)
|
|||||||
if type(entry) == "table" and known(data.items, entry.id) then
|
if type(entry) == "table" and known(data.items, entry.id) then
|
||||||
table.remove(orphaned.items, i)
|
table.remove(orphaned.items, i)
|
||||||
if entry.from == "pcItems" or type(save.inventory) ~= "table"
|
if entry.from == "pcItems" or type(save.inventory) ~= "table"
|
||||||
or not Bag.add(save, entry.id, entry.count or 1) then
|
or not Bag.add(save, entry.id, entry.count or 1, data) then
|
||||||
save.pcItems = save.pcItems or {}
|
save.pcItems = save.pcItems or {}
|
||||||
save.pcItems[entry.id] = (save.pcItems[entry.id] or 0) + (entry.count or 1)
|
save.pcItems[entry.id] = (save.pcItems[entry.id] or 0) + (entry.count or 1)
|
||||||
end
|
end
|
||||||
|
|||||||
+51
-10
@@ -16,6 +16,30 @@ local cache = {}
|
|||||||
-- scaling the 0.8 base every source gets
|
-- scaling the 0.8 base every source gets
|
||||||
local BASE_VOLUME = 0.8
|
local BASE_VOLUME = 0.8
|
||||||
local volumeScale = 1
|
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
|
-- Fanfares occupy the music's tone channels on the Game Boy: their sfx
|
||||||
-- headers claim channels 5-7 (= hardware channels 1-3), silencing the
|
-- 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)
|
reportBadDef("sfx", key, owner(data, "sfx", key), err)
|
||||||
return nil
|
return nil
|
||||||
end
|
end
|
||||||
s:setVolume(BASE_VOLUME * volumeScale)
|
s:setVolume(volumeFor(key))
|
||||||
cache[key] = s
|
cache[key] = s
|
||||||
src = s
|
src = s
|
||||||
end
|
end
|
||||||
@@ -111,6 +135,9 @@ local function played(kind, name, species)
|
|||||||
Runtime.emit("sound.played", { kind = kind, name = name, species = species })
|
Runtime.emit("sound.played", { kind = kind, name = name, species = species })
|
||||||
end
|
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)
|
function Sound.play(data, name)
|
||||||
local sfx = data.audio and data.audio.sfx
|
local sfx = data.audio and data.audio.sfx
|
||||||
local def = sfx and sfx[name]
|
local def = sfx and sfx[name]
|
||||||
@@ -120,6 +147,7 @@ function Sound.play(data, name)
|
|||||||
require("src.core.Music").duckForFanfare(src)
|
require("src.core.Music").duckForFanfare(src)
|
||||||
end
|
end
|
||||||
played("sfx", name)
|
played("sfx", name)
|
||||||
|
return src
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Play a move's sound with its MoveSoundTable pitch/tempo modifiers
|
-- Play a move's sound with its MoveSoundTable pitch/tempo modifiers
|
||||||
@@ -211,7 +239,7 @@ function Sound.playPikaCry(data, n)
|
|||||||
cache[key] = false
|
cache[key] = false
|
||||||
return nil
|
return nil
|
||||||
end
|
end
|
||||||
s:setVolume(BASE_VOLUME * volumeScale)
|
s:setVolume(volumeFor(key))
|
||||||
cache[key] = s
|
cache[key] = s
|
||||||
src = s
|
src = s
|
||||||
end
|
end
|
||||||
@@ -245,7 +273,7 @@ function Sound.playCry(data, species)
|
|||||||
owner(data, "cries", species), err)
|
owner(data, "cries", species), err)
|
||||||
return nil
|
return nil
|
||||||
end
|
end
|
||||||
s:setVolume(BASE_VOLUME * volumeScale)
|
s:setVolume(volumeFor(key))
|
||||||
cache[key] = s
|
cache[key] = s
|
||||||
src = s
|
src = s
|
||||||
end
|
end
|
||||||
@@ -321,7 +349,7 @@ function Sound.startLoop(data, name)
|
|||||||
return
|
return
|
||||||
end
|
end
|
||||||
s:setLooping(true)
|
s:setLooping(true)
|
||||||
s:setVolume(BASE_VOLUME * volumeScale)
|
s:setVolume(volumeFor(name))
|
||||||
loopCache[name] = s
|
loopCache[name] = s
|
||||||
src = s
|
src = s
|
||||||
end
|
end
|
||||||
@@ -345,14 +373,26 @@ end
|
|||||||
-- 0-7 SFX volume level (0 mutes); cached sources (menu beeps, cries,
|
-- 0-7 SFX volume level (0 mutes); cached sources (menu beeps, cries,
|
||||||
-- the low-health alarm loop) update immediately so the change is heard
|
-- the low-health alarm loop) update immediately so the change is heard
|
||||||
-- on the next play
|
-- 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)
|
function Sound.setVolumeLevel(level)
|
||||||
volumeScale = math.max(0, math.min(7, level or 7)) / 7
|
volumeScale = math.max(0, math.min(7, level or 7)) / 7
|
||||||
for _, src in pairs(cache) do
|
reapplyVolumes()
|
||||||
if src then pcall(src.setVolume, src, BASE_VOLUME * volumeScale) end
|
end
|
||||||
end
|
|
||||||
for _, src in pairs(loopCache) do
|
-- 0-7 Pikachu-voice trim on top of the SFX level (7 = no trim, 0 mutes the
|
||||||
if src then pcall(src.setVolume, src, BASE_VOLUME * volumeScale) end
|
-- clips while the rest of the SFX bus keeps its level). Yellow only: on
|
||||||
end
|
-- 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
|
end
|
||||||
|
|
||||||
-- hot reload / jukebox A-B: drop one key's sources (its pitch-tempo
|
-- hot reload / jukebox A-B: drop one key's sources (its pitch-tempo
|
||||||
@@ -386,6 +426,7 @@ Assets.register(Sound.invalidate)
|
|||||||
-- loading a save)
|
-- loading a save)
|
||||||
function Sound.applyOptions(opts)
|
function Sound.applyOptions(opts)
|
||||||
Sound.setVolumeLevel(opts and opts.sfxVol or 7)
|
Sound.setVolumeLevel(opts and opts.sfxVol or 7)
|
||||||
|
Sound.setPikaVolumeLevel(opts and opts.pikaVol or 7)
|
||||||
end
|
end
|
||||||
|
|
||||||
return Sound
|
return Sound
|
||||||
|
|||||||
+213
-50
@@ -12,12 +12,18 @@
|
|||||||
-- (main.lua then drives it with the mouse); POKEPORT_TOUCH=0 forces it
|
-- (main.lua then drives it with the mouse); POKEPORT_TOUCH=0 forces it
|
||||||
-- off everywhere.
|
-- off everywhere.
|
||||||
--
|
--
|
||||||
|
-- Player preferences (options.touchControls) can permanently disable the
|
||||||
|
-- overlay and/or override per-control positions as normalized window
|
||||||
|
-- fractions. The launcher editor (src/ui/TouchControlsEditor.lua) writes
|
||||||
|
-- those; applyOptions reads them at boot and whenever options change.
|
||||||
|
--
|
||||||
-- Controls press GB buttons through Input:overlayPressed/Released -- their
|
-- Controls press GB buttons through Input:overlayPressed/Released -- their
|
||||||
-- own input source, not a keyboard alias -- so a held overlay direction
|
-- own input source, not a keyboard alias -- so a held overlay direction
|
||||||
-- merges cleanly with a keyboard key or stick holding the same button,
|
-- merges cleanly with a keyboard key or stick holding the same button,
|
||||||
-- and a player rebind can never detach the overlay.
|
-- and a player rebind can never detach the overlay.
|
||||||
|
|
||||||
local Input = require("src.core.Input")
|
local Input = require("src.core.Input")
|
||||||
|
local SafeArea = require("src.core.SafeArea")
|
||||||
|
|
||||||
local TouchControls = {}
|
local TouchControls = {}
|
||||||
|
|
||||||
@@ -39,6 +45,7 @@ local DPAD_DEAD = 0.16
|
|||||||
local SLOP = { a = 1.3, b = 1.3, start = 1.4, select = 1.4 }
|
local SLOP = { a = 1.3, b = 1.3, start = 1.4, select = 1.4 }
|
||||||
|
|
||||||
local BUTTONS = { "a", "b", "start", "select" }
|
local BUTTONS = { "a", "b", "start", "select" }
|
||||||
|
local CONTROLS = { "dpad", "a", "b", "start", "select" }
|
||||||
|
|
||||||
local IMAGES = {
|
local IMAGES = {
|
||||||
dpad = "assets/touch/dpad.png",
|
dpad = "assets/touch/dpad.png",
|
||||||
@@ -52,6 +59,12 @@ local IMAGES = {
|
|||||||
select = "assets/touch/select.png",
|
select = "assets/touch/select.png",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
local function clamp01(v)
|
||||||
|
if v < 0 then return 0 end
|
||||||
|
if v > 1 then return 1 end
|
||||||
|
return v
|
||||||
|
end
|
||||||
|
|
||||||
local function wantsOverlay()
|
local function wantsOverlay()
|
||||||
local env = os.getenv("POKEPORT_TOUCH")
|
local env = os.getenv("POKEPORT_TOUCH")
|
||||||
if env == "1" then return true end
|
if env == "1" then return true end
|
||||||
@@ -60,8 +73,62 @@ local function wantsOverlay()
|
|||||||
return osName == "Android" or osName == "iOS"
|
return osName == "Android" or osName == "iOS"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- Normalize a persisted touchControls table into {enabled, positions}.
|
||||||
|
-- Unknown / garbage keys are dropped so a bad options.lua cannot brick
|
||||||
|
-- the overlay.
|
||||||
|
function TouchControls.normalizeConfig(tc)
|
||||||
|
local out = { enabled = true, positions = nil }
|
||||||
|
if type(tc) ~= "table" then return out end
|
||||||
|
if tc.enabled == false then out.enabled = false end
|
||||||
|
if type(tc.positions) == "table" then
|
||||||
|
local pos = {}
|
||||||
|
for _, name in ipairs(CONTROLS) do
|
||||||
|
local p = tc.positions[name]
|
||||||
|
if type(p) == "table" and type(p.x) == "number" and type(p.y) == "number" then
|
||||||
|
pos[name] = { x = clamp01(p.x), y = clamp01(p.y) }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if next(pos) then out.positions = pos end
|
||||||
|
end
|
||||||
|
return out
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Pure default layout in LOVE units for a usable rect of size ww x wh at
|
||||||
|
-- origin (ox, oy). Shared by layout() and the editor's Reset path so
|
||||||
|
-- defaults stay in one place. ox/oy default to 0 for the headless tests
|
||||||
|
-- and for callers that already pass a full-window size.
|
||||||
|
function TouchControls.defaultLayout(ww, wh, ox, oy)
|
||||||
|
ox, oy = ox or 0, oy or 0
|
||||||
|
local short = math.min(ww, wh)
|
||||||
|
local dpadW = math.min(180, short * 0.34)
|
||||||
|
local abW = dpadW * 0.46
|
||||||
|
local ssW = dpadW * 0.30
|
||||||
|
local margin = dpadW * 0.12
|
||||||
|
return {
|
||||||
|
dpad = { cx = ox + margin + dpadW / 2, cy = oy + wh - margin - dpadW / 2, w = dpadW },
|
||||||
|
a = { cx = ox + ww - margin - abW * 0.55, cy = oy + wh - margin - abW * 1.75, w = abW },
|
||||||
|
b = { cx = ox + ww - margin - abW * 1.60, cy = oy + wh - margin - abW * 0.55, w = abW },
|
||||||
|
start = { cx = ox + ww / 2 + ssW * 0.60, cy = oy + wh - margin - ssW * 0.95, w = ssW },
|
||||||
|
select = { cx = ox + ww / 2 - ssW * 0.60, cy = oy + wh - margin - ssW * 0.95, w = ssW },
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
local function loadImages()
|
||||||
|
local img = {}
|
||||||
|
for name, path in pairs(IMAGES) do
|
||||||
|
local ok, im = pcall(love.graphics.newImage, path)
|
||||||
|
if not ok then return nil end
|
||||||
|
im:setFilter("linear", "linear")
|
||||||
|
img[name] = im
|
||||||
|
end
|
||||||
|
return img
|
||||||
|
end
|
||||||
|
|
||||||
function TouchControls:init()
|
function TouchControls:init()
|
||||||
self.active = wantsOverlay()
|
self.active = wantsOverlay()
|
||||||
|
self.enabled = true
|
||||||
|
self.positions = nil
|
||||||
|
self.preview = false
|
||||||
self.controllerHidden = false
|
self.controllerHidden = false
|
||||||
self.touches = {}
|
self.touches = {}
|
||||||
-- per-GB-button owner count: two fingers on A must not double-press it,
|
-- per-GB-button owner count: two fingers on A must not double-press it,
|
||||||
@@ -70,51 +137,94 @@ function TouchControls:init()
|
|||||||
self.dpadTouch = nil
|
self.dpadTouch = nil
|
||||||
self.layoutW, self.layoutH = nil, nil
|
self.layoutW, self.layoutH = nil, nil
|
||||||
self.img = nil
|
self.img = nil
|
||||||
if not self.active then return end
|
-- Images load whenever the platform wants the overlay OR the launcher
|
||||||
-- soft-fail: a missing/corrupt PNG must never block boot; the overlay
|
-- editor forces a preview (desktop testing of the editor).
|
||||||
-- stays off and keyboard/controller play still works
|
if self.active then
|
||||||
local img = {}
|
self.img = loadImages()
|
||||||
for name, path in pairs(IMAGES) do
|
end
|
||||||
local ok, im = pcall(love.graphics.newImage, path)
|
end
|
||||||
if not ok then
|
|
||||||
img = nil
|
-- Ensure art is loaded for the launcher editor even when wantsOverlay()
|
||||||
break
|
-- is false (desktop without POKEPORT_TOUCH).
|
||||||
end
|
function TouchControls:ensureImages()
|
||||||
-- smooth UI icons; the global default filter is nearest for GB pixels
|
if self.img then return true end
|
||||||
im:setFilter("linear", "linear")
|
self.img = loadImages()
|
||||||
img[name] = im
|
return self.img ~= nil
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Apply options.touchControls. Called from Game:applyOptions and from
|
||||||
|
-- the launcher editor after a save.
|
||||||
|
function TouchControls:applyOptions(opts)
|
||||||
|
local cfg = TouchControls.normalizeConfig(opts and opts.touchControls)
|
||||||
|
self.enabled = cfg.enabled
|
||||||
|
self.positions = cfg.positions
|
||||||
|
self.layoutW, self.layoutH = nil, nil
|
||||||
|
self.layoutOx, self.layoutOy = nil, nil
|
||||||
|
if not self.enabled then
|
||||||
|
self.controllerHidden = false
|
||||||
|
self:reset()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
function TouchControls:config()
|
||||||
|
return {
|
||||||
|
enabled = self.enabled ~= false,
|
||||||
|
positions = self.positions,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Preview mode: force-draw the overlay for the layout editor, ignoring
|
||||||
|
-- platform / enabled / gamepad gates. Gameplay input still respects
|
||||||
|
-- enabled via touchpressed.
|
||||||
|
function TouchControls:setPreview(on)
|
||||||
|
self.preview = on and true or false
|
||||||
|
if on then
|
||||||
|
self:ensureImages()
|
||||||
|
self.controllerHidden = false
|
||||||
end
|
end
|
||||||
self.img = img
|
|
||||||
end
|
end
|
||||||
|
|
||||||
function TouchControls:visible()
|
function TouchControls:visible()
|
||||||
return self.active and self.img ~= nil and not self.controllerHidden
|
if self.preview then return self.img ~= nil end
|
||||||
|
return self.active and self.enabled ~= false and self.img ~= nil
|
||||||
|
and not self.controllerHidden
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Keep a control fully inside the usable rect [x0,y0]..[x1,y1].
|
||||||
|
local function clampZone(zone, x0, y0, x1, y1)
|
||||||
|
local half = zone.w * 0.5
|
||||||
|
zone.cx = math.max(x0 + half, math.min(x1 - half, zone.cx))
|
||||||
|
zone.cy = math.max(y0 + half, math.min(y1 - half, zone.cy))
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Layout in LOVE units (density-independent on mobile), recomputed when
|
-- Layout in LOVE units (density-independent on mobile), recomputed when
|
||||||
-- the window size changes (rotation, resize). D-pad bottom-left, B/A
|
-- the window or safe area changes (rotation, resize, notch insets).
|
||||||
-- bottom-right with A above B (the Game Boy diagonal), START/SELECT
|
-- Default: d-pad bottom-left, B/A bottom-right with A above B (the Game Boy
|
||||||
-- flanking the bottom center.
|
-- diagonal), START/SELECT flanking the bottom center -- all inside the
|
||||||
|
-- device safe area so thumbs clear the home indicator / cutouts.
|
||||||
|
-- Custom positions (normalized 0..1 within the safe rect) override centers
|
||||||
|
-- while sizes stay derived from the short edge.
|
||||||
function TouchControls:layout()
|
function TouchControls:layout()
|
||||||
local ww, wh = love.graphics.getDimensions()
|
local ox, oy, sw, sh = SafeArea.rect()
|
||||||
if self.layoutW == ww and self.layoutH == wh then return self.L end
|
if self.layoutW == sw and self.layoutH == sh
|
||||||
self.layoutW, self.layoutH = ww, wh
|
and self.layoutOx == ox and self.layoutOy == oy and self.L then
|
||||||
local short = math.min(ww, wh)
|
return self.L
|
||||||
-- ~a third of the short edge, capped so tablets don't get a dinner plate
|
end
|
||||||
local dpadW = math.min(180, short * 0.34)
|
self.layoutW, self.layoutH = sw, sh
|
||||||
local abW = dpadW * 0.46
|
self.layoutOx, self.layoutOy = ox, oy
|
||||||
local ssW = dpadW * 0.30
|
self.L = TouchControls.defaultLayout(sw, sh, ox, oy)
|
||||||
local margin = dpadW * 0.12
|
if self.positions then
|
||||||
-- START/SELECT hug the bottom center: on a narrow portrait phone the
|
for _, name in ipairs(CONTROLS) do
|
||||||
-- d-pad and B leave little room, so a tight pair is what keeps them off
|
local p = self.positions[name]
|
||||||
-- the neighboring controls
|
local zone = self.L[name]
|
||||||
self.L = {
|
if p and zone then
|
||||||
dpad = { cx = margin + dpadW / 2, cy = wh - margin - dpadW / 2, w = dpadW },
|
zone.cx = ox + p.x * sw
|
||||||
a = { cx = ww - margin - abW * 0.55, cy = wh - margin - abW * 1.75, w = abW },
|
zone.cy = oy + p.y * sh
|
||||||
b = { cx = ww - margin - abW * 1.60, cy = wh - margin - abW * 0.55, w = abW },
|
clampZone(zone, ox, oy, ox + sw, oy + sh)
|
||||||
start = { cx = ww / 2 + ssW * 0.60, cy = wh - margin - ssW * 0.95, w = ssW },
|
end
|
||||||
select = { cx = ww / 2 - ssW * 0.60, cy = wh - margin - ssW * 0.95, w = ssW },
|
end
|
||||||
}
|
end
|
||||||
|
local ssW = self.L.start.w
|
||||||
local fontSize = math.max(8, math.floor(ssW * 0.26))
|
local fontSize = math.max(8, math.floor(ssW * 0.26))
|
||||||
if not self.labelFont or self.fontSize ~= fontSize then
|
if not self.labelFont or self.fontSize ~= fontSize then
|
||||||
self.fontSize = fontSize
|
self.fontSize = fontSize
|
||||||
@@ -123,12 +233,49 @@ function TouchControls:layout()
|
|||||||
return self.L
|
return self.L
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- Move one control to a screen-space point and persist its normalized
|
||||||
|
-- position within the safe rect. Used by the layout editor while dragging.
|
||||||
|
function TouchControls:setControlCenter(name, cx, cy)
|
||||||
|
local ox, oy, sw, sh = SafeArea.rect()
|
||||||
|
local L = self:layout()
|
||||||
|
local zone = L[name]
|
||||||
|
if not zone then return end
|
||||||
|
zone.cx, zone.cy = cx, cy
|
||||||
|
clampZone(zone, ox, oy, ox + sw, oy + sh)
|
||||||
|
self.positions = self.positions or {}
|
||||||
|
self.positions[name] = {
|
||||||
|
x = sw > 0 and (zone.cx - ox) / sw or 0,
|
||||||
|
y = sh > 0 and (zone.cy - oy) / sh or 0,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
function TouchControls:clearPositions()
|
||||||
|
self.positions = nil
|
||||||
|
self.layoutW, self.layoutH = nil, nil
|
||||||
|
self.layoutOx, self.layoutOy = nil, nil
|
||||||
|
end
|
||||||
|
|
||||||
local function inCircle(zone, x, y, slop)
|
local function inCircle(zone, x, y, slop)
|
||||||
local r = zone.w * 0.5 * slop
|
local r = zone.w * 0.5 * slop
|
||||||
local dx, dy = x - zone.cx, y - zone.cy
|
local dx, dy = x - zone.cx, y - zone.cy
|
||||||
return dx * dx + dy * dy <= r * r
|
return dx * dx + dy * dy <= r * r
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- Which control (if any) contains (x, y). Prefer face buttons over the
|
||||||
|
-- d-pad when they overlap, matching touchpressed's order.
|
||||||
|
function TouchControls:hitTest(x, y)
|
||||||
|
local L = self:layout()
|
||||||
|
for _, btn in ipairs(BUTTONS) do
|
||||||
|
if inCircle(L[btn], x, y, SLOP[btn]) then return btn end
|
||||||
|
end
|
||||||
|
local dz = L.dpad
|
||||||
|
local half = dz.w * 0.65
|
||||||
|
if math.abs(x - dz.cx) <= half and math.abs(y - dz.cy) <= half then
|
||||||
|
return "dpad"
|
||||||
|
end
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
local function dpadDir(zone, x, y)
|
local function dpadDir(zone, x, y)
|
||||||
local dx, dy = x - zone.cx, y - zone.cy
|
local dx, dy = x - zone.cx, y - zone.cy
|
||||||
local dead = zone.w * DPAD_DEAD
|
local dead = zone.w * DPAD_DEAD
|
||||||
@@ -165,7 +312,9 @@ local function setDpad(self, touch, dir)
|
|||||||
end
|
end
|
||||||
|
|
||||||
function TouchControls:touchpressed(id, x, y)
|
function TouchControls:touchpressed(id, x, y)
|
||||||
if not (self.active and self.img) then return end
|
-- preview mode is layout-edit only: never press GB buttons
|
||||||
|
if self.preview then return end
|
||||||
|
if not (self.active and self.enabled ~= false and self.img) then return end
|
||||||
-- a controller hid the overlay; the first touch only brings it back
|
-- a controller hid the overlay; the first touch only brings it back
|
||||||
if self.controllerHidden then
|
if self.controllerHidden then
|
||||||
self.controllerHidden = false
|
self.controllerHidden = false
|
||||||
@@ -192,6 +341,7 @@ function TouchControls:touchpressed(id, x, y)
|
|||||||
end
|
end
|
||||||
|
|
||||||
function TouchControls:touchmoved(id, x, y)
|
function TouchControls:touchmoved(id, x, y)
|
||||||
|
if self.preview then return end
|
||||||
local touch = self.touches[id]
|
local touch = self.touches[id]
|
||||||
-- only the d-pad tracks movement (slide between directions without
|
-- only the d-pad tracks movement (slide between directions without
|
||||||
-- lifting); buttons hold until release wherever the finger wanders
|
-- lifting); buttons hold until release wherever the finger wanders
|
||||||
@@ -200,6 +350,7 @@ function TouchControls:touchmoved(id, x, y)
|
|||||||
end
|
end
|
||||||
|
|
||||||
function TouchControls:touchreleased(id, x, y)
|
function TouchControls:touchreleased(id, x, y)
|
||||||
|
if self.preview then return end
|
||||||
local touch = self.touches[id]
|
local touch = self.touches[id]
|
||||||
if not touch then return end
|
if not touch then return end
|
||||||
self.touches[id] = nil
|
self.touches[id] = nil
|
||||||
@@ -216,7 +367,7 @@ end
|
|||||||
-- touchreleased and would strand its button held forever. Called from
|
-- touchreleased and would strand its button held forever. Called from
|
||||||
-- Game alongside Input:reset() on focus/visibility loss.
|
-- Game alongside Input:reset() on focus/visibility loss.
|
||||||
function TouchControls:reset()
|
function TouchControls:reset()
|
||||||
for btn in pairs(self.held) do
|
for btn in pairs(self.held or {}) do
|
||||||
Input:overlayReleased(btn)
|
Input:overlayReleased(btn)
|
||||||
end
|
end
|
||||||
self.held = {}
|
self.held = {}
|
||||||
@@ -225,9 +376,13 @@ function TouchControls:reset()
|
|||||||
end
|
end
|
||||||
|
|
||||||
-- a gamepad is being used: hide the overlay (dropping anything it held)
|
-- a gamepad is being used: hide the overlay (dropping anything it held)
|
||||||
-- until the next screen touch asks for it back
|
-- until the next screen touch asks for it back. No-op when the player
|
||||||
|
-- permanently disabled the overlay -- there is nothing to hide, and a
|
||||||
|
-- later accidental touch must not resurrect it.
|
||||||
function TouchControls:noteGamepad()
|
function TouchControls:noteGamepad()
|
||||||
if not self.active or self.controllerHidden then return end
|
if not self.active or self.enabled == false or self.controllerHidden then
|
||||||
|
return
|
||||||
|
end
|
||||||
self.controllerHidden = true
|
self.controllerHidden = true
|
||||||
self:reset()
|
self:reset()
|
||||||
end
|
end
|
||||||
@@ -242,40 +397,46 @@ function TouchControls:joystickremoved()
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
local function drawIcon(img, zone, pressed)
|
local function drawIcon(img, zone, pressed, alphaMul)
|
||||||
love.graphics.setColor(1, 1, 1, pressed and BACK_PRESSED or BACK)
|
alphaMul = alphaMul or 1
|
||||||
|
love.graphics.setColor(1, 1, 1, (pressed and BACK_PRESSED or BACK) * alphaMul)
|
||||||
love.graphics.circle("fill", zone.cx, zone.cy, zone.w * 0.58)
|
love.graphics.circle("fill", zone.cx, zone.cy, zone.w * 0.58)
|
||||||
local scale = zone.w / img:getWidth()
|
local scale = zone.w / img:getWidth()
|
||||||
love.graphics.setColor(1, 1, 1, pressed and ALPHA_PRESSED or ALPHA)
|
love.graphics.setColor(1, 1, 1, (pressed and ALPHA_PRESSED or ALPHA) * alphaMul)
|
||||||
love.graphics.draw(img, zone.cx - zone.w / 2,
|
love.graphics.draw(img, zone.cx - zone.w / 2,
|
||||||
zone.cy - img:getHeight() * scale / 2, 0, scale, scale)
|
zone.cy - img:getHeight() * scale / 2, 0, scale, scale)
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Screen-space, called by Game:draw after Renderer:endFrame so the
|
-- Screen-space, called by Game:draw after Renderer:endFrame so the
|
||||||
-- overlay rides on top of everything (world, UI, CRT/GBC FX included).
|
-- overlay rides on top of everything (world, UI, CRT/GBC FX included).
|
||||||
|
-- Also used by the launcher layout editor under preview mode.
|
||||||
function TouchControls:draw()
|
function TouchControls:draw()
|
||||||
if not self:visible() then return end
|
if not self:visible() then return end
|
||||||
local L = self:layout()
|
local L = self:layout()
|
||||||
|
-- when the player disabled the overlay but the editor is previewing,
|
||||||
|
-- draw dimmed so the layout is still editable
|
||||||
|
local alphaMul = (self.preview and self.enabled == false) and 0.45 or 1
|
||||||
love.graphics.push("all")
|
love.graphics.push("all")
|
||||||
love.graphics.origin()
|
love.graphics.origin()
|
||||||
|
|
||||||
local dpadTouch = self.dpadTouch and self.touches[self.dpadTouch]
|
local dpadTouch = self.dpadTouch and self.touches[self.dpadTouch]
|
||||||
local dir = dpadTouch and dpadTouch.dir
|
local dir = dpadTouch and dpadTouch.dir
|
||||||
drawIcon(dir and self.img["dpad_" .. dir] or self.img.dpad, L.dpad,
|
drawIcon(dir and self.img["dpad_" .. dir] or self.img.dpad, L.dpad,
|
||||||
dir ~= nil)
|
dir ~= nil, alphaMul)
|
||||||
for _, btn in ipairs(BUTTONS) do
|
for _, btn in ipairs(BUTTONS) do
|
||||||
drawIcon(self.img[btn], L[btn], self.held[btn] ~= nil)
|
drawIcon(self.img[btn], L[btn], self.held[btn] ~= nil, alphaMul)
|
||||||
end
|
end
|
||||||
|
|
||||||
-- the +/- glyphs alone don't say which is which; shadowed so the text
|
-- the +/- glyphs alone don't say which is which; shadowed so the text
|
||||||
-- reads on both the black letterbox and battle's white one
|
-- reads on both the black letterbox and battle's white one. Each label
|
||||||
|
-- tracks its own control's cy/w so dragging START cannot move SELECT.
|
||||||
love.graphics.setFont(self.labelFont)
|
love.graphics.setFont(self.labelFont)
|
||||||
local ly = L.start.cy + L.start.w * 0.66
|
|
||||||
local function label(text, zone)
|
local function label(text, zone)
|
||||||
|
local ly = zone.cy + zone.w * 0.66
|
||||||
local w = self.labelFont:getWidth(text)
|
local w = self.labelFont:getWidth(text)
|
||||||
love.graphics.setColor(0, 0, 0, 0.6)
|
love.graphics.setColor(0, 0, 0, 0.6 * alphaMul)
|
||||||
love.graphics.print(text, zone.cx - w / 2 + 1, ly + 1)
|
love.graphics.print(text, zone.cx - w / 2 + 1, ly + 1)
|
||||||
love.graphics.setColor(1, 1, 1, ALPHA + 0.2)
|
love.graphics.setColor(1, 1, 1, (ALPHA + 0.2) * alphaMul)
|
||||||
love.graphics.print(text, zone.cx - w / 2, ly)
|
love.graphics.print(text, zone.cx - w / 2, ly)
|
||||||
end
|
end
|
||||||
label("START", L.start)
|
label("START", L.start)
|
||||||
@@ -284,4 +445,6 @@ function TouchControls:draw()
|
|||||||
love.graphics.pop()
|
love.graphics.pop()
|
||||||
end
|
end
|
||||||
|
|
||||||
|
TouchControls.CONTROLS = CONTROLS
|
||||||
|
|
||||||
return TouchControls
|
return TouchControls
|
||||||
|
|||||||
+1
-1
@@ -214,7 +214,7 @@ function VERBS.give(self, rest)
|
|||||||
end
|
end
|
||||||
elseif game.data.items and game.data.items[id] then
|
elseif game.data.items and game.data.items[id] then
|
||||||
local n = tonumber(count) or 1
|
local n = tonumber(count) or 1
|
||||||
if require("src.inventory.Bag").add(save, id, n) then
|
if require("src.inventory.Bag").add(save, id, n, game.data) then
|
||||||
self:print(("%s x%d added"):format(id, n))
|
self:print(("%s x%d added"):format(id, n))
|
||||||
else
|
else
|
||||||
self:print("bag full")
|
self:print("bag full")
|
||||||
|
|||||||
+42
-3
@@ -179,6 +179,38 @@ local function resolveUnmount()
|
|||||||
return physfsUnmountFn
|
return physfsUnmountFn
|
||||||
end
|
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.
|
-- The portable game folder when the cache should live there, else nil.
|
||||||
-- Resolved (and, for a fused build, mounted) once and cached. Requires a
|
-- Resolved (and, for a fused build, mounted) once and cached. Requires a
|
||||||
-- desktop portable install (SaveData) and a working windowless mkdir.
|
-- 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
|
-- resolves -- which is what makes it safe to point at a folder whose contents
|
||||||
-- nobody has validated.
|
-- nobody has validated.
|
||||||
--
|
--
|
||||||
-- Returns nil when the mount is unavailable (no ffi, no PHYSFS symbol, or the
|
-- Returns nil when the mount is unavailable (no ffi, no PHYSFS symbol, the
|
||||||
-- mount was refused), which callers must treat as "could not look", not as
|
-- mount was refused, or `dir` is already on the read path), which callers must
|
||||||
-- "nothing there". An error inside fn still unmounts before it propagates.
|
-- treat as "could not look", not as "nothing there". An error inside fn still
|
||||||
|
-- unmounts before it propagates.
|
||||||
function CacheFs.withMounted(dir, mountPoint, fn)
|
function CacheFs.withMounted(dir, mountPoint, fn)
|
||||||
if not dir or dir == "" then return nil end
|
if not dir or dir == "" then return nil end
|
||||||
local mount, unmount = resolveMount(), resolveUnmount()
|
local mount, unmount = resolveMount(), resolveUnmount()
|
||||||
if not (mount and unmount) then return nil end
|
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
|
if not mount(dir, mountPoint, true) then return nil end
|
||||||
local ok, res = pcall(fn)
|
local ok, res = pcall(fn)
|
||||||
unmount(dir)
|
unmount(dir)
|
||||||
|
|||||||
@@ -1307,6 +1307,16 @@ function RomExtractor:extractTrainers()
|
|||||||
}
|
}
|
||||||
self:tick("Trainers", index, #order)
|
self:tick("Trainers", index, #order)
|
||||||
end
|
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)
|
self:write("trainers", out)
|
||||||
return out
|
return out
|
||||||
end
|
end
|
||||||
|
|||||||
+1963
-154
File diff suppressed because it is too large
Load Diff
@@ -75,7 +75,10 @@ function SaveFileIO.importToSlot(source, version)
|
|||||||
return false, ("A save file must be %d bytes (32 KB); this one is %d.")
|
return false, ("A save file must be %d bytes (32 KB); this one is %d.")
|
||||||
:format(SAVE_SIZE, #bytes)
|
:format(SAVE_SIZE, #bytes)
|
||||||
end
|
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
|
if not save then return false, convertErr end
|
||||||
-- Tag the game version and normalize the meta stamp: SaveConvert leaves
|
-- Tag the game version and normalize the meta stamp: SaveConvert leaves
|
||||||
-- meta.format = "gen1_import", but SaveData.load's migration pass compares
|
-- meta.format = "gen1_import", but SaveData.load's migration pass compares
|
||||||
@@ -103,7 +106,7 @@ function SaveFileIO.exportActiveSlot(version)
|
|||||||
version = version or GameVersion.get()
|
version = version or GameVersion.get()
|
||||||
local save = SaveData.load(version)
|
local save = SaveData.load(version)
|
||||||
if not save then return false, "this game has no save to export yet" end
|
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
|
if not bytes then return false, exportErr end
|
||||||
local slotId = SaveData.activeSlot(version) or "save"
|
local slotId = SaveData.activeSlot(version) or "save"
|
||||||
local fs = love and love.filesystem
|
local fs = love and love.filesystem
|
||||||
|
|||||||
+23
-7
@@ -1,11 +1,26 @@
|
|||||||
-- The 20-slot bag (BAG_ITEM_CAPACITY, constants/menu_constants.asm):
|
-- The bag defaults to 20 slots (BAG_ITEM_CAPACITY,
|
||||||
-- a distinct item id occupies one slot regardless of quantity; badges
|
-- constants/menu_constants.asm), but mods may replace that limit through
|
||||||
-- live in the inventory table but are not bag items. save.bagOrder
|
-- Data.constants.bagSize. A distinct item id occupies one slot regardless
|
||||||
-- keeps acquisition order like wBagItems (SELECT can reorder it).
|
-- of quantity; badges live in the inventory table but are not bag items.
|
||||||
|
-- save.bagOrder keeps acquisition order like wBagItems (SELECT can reorder
|
||||||
|
-- it).
|
||||||
|
|
||||||
local Bag = {}
|
local Bag = {}
|
||||||
|
|
||||||
Bag.CAPACITY = 20
|
local DEFAULT_CAPACITY = 20
|
||||||
|
|
||||||
|
-- `data` is injectable for the save editor and headless mod tests. Normal
|
||||||
|
-- gameplay may omit it because the loader merges mods into the Data
|
||||||
|
-- singleton before any item can be added. The fallback keeps old/stale
|
||||||
|
-- generated caches and isolated callers at the vanilla limit.
|
||||||
|
function Bag.capacity(data)
|
||||||
|
data = data or require("src.core.Data")
|
||||||
|
local configured = data and data.constants and data.constants.bagSize
|
||||||
|
if type(configured) == "number" and configured >= 1 then
|
||||||
|
return math.floor(configured)
|
||||||
|
end
|
||||||
|
return DEFAULT_CAPACITY
|
||||||
|
end
|
||||||
|
|
||||||
local function isBadge(id)
|
local function isBadge(id)
|
||||||
return id:find("BADGE", 1, true) ~= nil
|
return id:find("BADGE", 1, true) ~= nil
|
||||||
@@ -55,9 +70,10 @@ end
|
|||||||
-- Add qty of an item; returns false (and adds nothing) when a new slot
|
-- Add qty of an item; returns false (and adds nothing) when a new slot
|
||||||
-- is needed and the bag is full, or when the stack would pass 99
|
-- is needed and the bag is full, or when the stack would pass 99
|
||||||
-- (AddItemToInventory's per-slot quantity cap).
|
-- (AddItemToInventory's per-slot quantity cap).
|
||||||
function Bag.add(save, id, qty)
|
function Bag.add(save, id, qty, data)
|
||||||
local inv = save.inventory
|
local inv = save.inventory
|
||||||
if not inv[id] and not isBadge(id) and Bag.slots(save) >= Bag.CAPACITY then
|
if not inv[id] and not isBadge(id)
|
||||||
|
and Bag.slots(save) >= Bag.capacity(data) then
|
||||||
return false
|
return false
|
||||||
end
|
end
|
||||||
if not isBadge(id) and (inv[id] or 0) + (qty or 1) > 99 then
|
if not isBadge(id) and (inv[id] or 0) + (qty or 1) > 99 then
|
||||||
|
|||||||
@@ -10,7 +10,6 @@
|
|||||||
-- "ball" caller must throw it (battle only)
|
-- "ball" caller must throw it (battle only)
|
||||||
-- "learn", moveId caller must run the learn-move flow
|
-- "learn", moveId caller must run the learn-move flow
|
||||||
|
|
||||||
local Pokemon = require("src.pokemon.Pokemon")
|
|
||||||
local Flags = require("src.script.Flags")
|
local Flags = require("src.script.Flags")
|
||||||
local Strings = require("src.core.Strings")
|
local Strings = require("src.core.Strings")
|
||||||
|
|
||||||
@@ -433,6 +432,13 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
|
|||||||
if battle then
|
if battle then
|
||||||
return "failed", { Strings("OAK: %s!\nThis isn't the\ntime to use that!", save.player.name) }
|
return "failed", { Strings("OAK: %s!\nThis isn't the\ntime to use that!", save.player.name) }
|
||||||
end
|
end
|
||||||
|
-- FishingInit (engine/items/item_effects.asm): cp wWalkBikeSurfState, 2
|
||||||
|
-- (surfing) sets carry, and every ItemUseXRod does jp c, ItemUseNotTime
|
||||||
|
-- on that carry -- surfing refuses the rod with the same OAK text as
|
||||||
|
-- the mid-battle case above, no rod-specific message (#533)
|
||||||
|
if ow and ow.player and ow.player.surfing then
|
||||||
|
return "failed", { Strings("OAK: %s!\nThis isn't the\ntime to use that!", save.player.name) }
|
||||||
|
end
|
||||||
return "fish", itemId
|
return "fish", itemId
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -128,7 +128,14 @@ end
|
|||||||
|
|
||||||
-- ------- the link surface
|
-- ------- the link surface
|
||||||
|
|
||||||
local SPECIES_FIELDS = { "baseStats", "types", "catchRate", "baseExp",
|
-- catchRate stays out (#511): no link mode ever reads it -- a ball thrown
|
||||||
|
-- in a link battle is a trainer-battle throw and always refused (pokered
|
||||||
|
-- engine/items/item_effects.asm ItemUseBall), and the trade rebuild never
|
||||||
|
-- touches it. Hashing it split Red/Blue from Yellow, whose only link
|
||||||
|
-- surface delta is the Dragonair/Dragonite catch-rate bytes
|
||||||
|
-- (data/pokemon/base_stats/dragonair.asm db 45 vs 27, dragonite.asm 45
|
||||||
|
-- vs 9), when the real cable links R/B/Y freely.
|
||||||
|
local SPECIES_FIELDS = { "baseStats", "types", "baseExp",
|
||||||
"growthRate", "evolutions" }
|
"growthRate", "evolutions" }
|
||||||
local MOVE_FIELDS = { "power", "type", "accuracy", "pp", "effect", "category",
|
local MOVE_FIELDS = { "power", "type", "accuracy", "pp", "effect", "category",
|
||||||
"priority", "highCrit", "fixedDamage", "multiHit",
|
"priority", "highCrit", "fixedDamage", "multiHit",
|
||||||
|
|||||||
+9
-12
@@ -178,19 +178,16 @@ end
|
|||||||
|
|
||||||
-- ------- subset negotiation
|
-- ------- subset negotiation
|
||||||
|
|
||||||
-- the species and moves this party actually references, so the exchange
|
-- every record hash, not just this party's slice: the receiver filters
|
||||||
-- stays small (six mons) instead of shipping the whole catalog
|
-- its OWN party against these (eligibleParty), so a message limited to the
|
||||||
|
-- sender's party read "not on the other game" for every species the two
|
||||||
|
-- parties did not happen to share, and a subset trade between different
|
||||||
|
-- games showed neither side (#511). The full catalog is ~300 short hash
|
||||||
|
-- strings -- still one message.
|
||||||
function Protocol.recordsMessage(data, party)
|
function Protocol.recordsMessage(data, party)
|
||||||
local species = Fingerprint.records(data, "pokemon")
|
return { type = "records",
|
||||||
local moves = Fingerprint.records(data, "moves")
|
pokemon = Fingerprint.records(data, "pokemon"),
|
||||||
local outSpecies, outMoves = {}, {}
|
moves = Fingerprint.records(data, "moves") }
|
||||||
for _, mon in ipairs(party or {}) do
|
|
||||||
if species[mon.species] then outSpecies[mon.species] = species[mon.species] end
|
|
||||||
for _, mv in ipairs(mon.moves or {}) do
|
|
||||||
if moves[mv.id] then outMoves[mv.id] = moves[mv.id] end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
return { type = "records", pokemon = outSpecies, moves = outMoves }
|
|
||||||
end
|
end
|
||||||
|
|
||||||
-- a mon may cross the wire only if both peers rebuild it identically: the
|
-- a mon may cross the wire only if both peers rebuild it identically: the
|
||||||
|
|||||||
+10
-3
@@ -384,9 +384,16 @@ function Tournament:update(dt)
|
|||||||
else
|
else
|
||||||
self.pendingBattleOpts.seed = msg.seed
|
self.pendingBattleOpts.seed = msg.seed
|
||||||
end
|
end
|
||||||
local battle, why = self.isHost
|
-- Split rather than `cond and newHost() or newGuest()`: the and/or
|
||||||
and LinkBattle.newHost(self.game, self.net, self.pendingBattleOpts)
|
-- idiom truncates a call to its first result, so the second return
|
||||||
or LinkBattle.newGuest(self.game, self.net, self.pendingBattleOpts)
|
-- (the specific reason) was always dropped and every failure showed
|
||||||
|
-- the generic fallback instead of "same mods on both games" etc.
|
||||||
|
local battle, why
|
||||||
|
if self.isHost then
|
||||||
|
battle, why = LinkBattle.newHost(self.game, self.net, self.pendingBattleOpts)
|
||||||
|
else
|
||||||
|
battle, why = LinkBattle.newGuest(self.game, self.net, self.pendingBattleOpts)
|
||||||
|
end
|
||||||
if not battle then
|
if not battle then
|
||||||
self:exitWith(why or Strings("Link battle\ncan't start."))
|
self:exitWith(why or Strings("Link battle\ncan't start."))
|
||||||
return
|
return
|
||||||
|
|||||||
+124
-16
@@ -95,8 +95,15 @@ function LauncherMods.deriveList(manifests, options)
|
|||||||
local byId, enabledSet = {}, {}
|
local byId, enabledSet = {}, {}
|
||||||
for _, m in ipairs(ordered) do
|
for _, m in ipairs(ordered) do
|
||||||
byId[m.id] = m
|
byId[m.id] = m
|
||||||
-- missing entry means enabled, matching the loader's default
|
-- missing entry means enabled, matching the loader -- except experimental
|
||||||
if mods[m.id] ~= false then enabledSet[m.id] = true end
|
-- mods, which stay off until the player opts in
|
||||||
|
if mods[m.id] == false then
|
||||||
|
-- stay off
|
||||||
|
elseif mods[m.id] == true then
|
||||||
|
enabledSet[m.id] = true
|
||||||
|
elseif not m.experimental then
|
||||||
|
enabledSet[m.id] = true
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
local out = {}
|
local out = {}
|
||||||
@@ -104,16 +111,19 @@ function LauncherMods.deriveList(manifests, options)
|
|||||||
local enabled = enabledSet[m.id] == true
|
local enabled = enabledSet[m.id] == true
|
||||||
local status, detail = statusFor(byId, m.id, enabledSet, enabled)
|
local status, detail = statusFor(byId, m.id, enabledSet, enabled)
|
||||||
local raw = m.raw or {}
|
local raw = m.raw or {}
|
||||||
|
local badge = tostring(raw.category or m.profile or "MOD"):upper()
|
||||||
|
if m.experimental then badge = "EXPERIMENTAL" end
|
||||||
out[#out + 1] = {
|
out[#out + 1] = {
|
||||||
id = m.id,
|
id = m.id,
|
||||||
name = m.name or m.id,
|
name = m.name or m.id,
|
||||||
version = m.version,
|
version = m.version,
|
||||||
-- category, then profile, then a generic fallback -- uppercased
|
badge = badge,
|
||||||
badge = tostring(raw.category or m.profile or "MOD"):upper(),
|
|
||||||
description = m.description or "",
|
description = m.description or "",
|
||||||
enabled = enabled,
|
enabled = enabled,
|
||||||
status = status,
|
status = status,
|
||||||
statusDetail = detail,
|
statusDetail = detail,
|
||||||
|
github = m.github,
|
||||||
|
experimental = m.experimental == true,
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
return out
|
return out
|
||||||
@@ -168,6 +178,21 @@ function LauncherMods.pickStrays(found, installed)
|
|||||||
return out
|
return out
|
||||||
end
|
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)
|
-- ------- discovery (love.filesystem)
|
||||||
|
|
||||||
local function decodeManifest(raw, path)
|
local function decodeManifest(raw, path)
|
||||||
@@ -198,7 +223,9 @@ local function discover()
|
|||||||
for _, name in ipairs(fs.getDirectoryItems("mods")) do
|
for _, name in ipairs(fs.getDirectoryItems("mods")) do
|
||||||
local path = "mods/" .. name
|
local path = "mods/" .. name
|
||||||
local info = fs.getInfo(path)
|
local info = fs.getInfo(path)
|
||||||
if info and info.type == "directory" then
|
-- a dev-linked mod dir (ln -s) reports type "symlink" even with
|
||||||
|
-- setSymlinksEnabled(true); see the matching note in Loader:_discover.
|
||||||
|
if info and (info.type == "directory" or info.type == "symlink") then
|
||||||
local raw = fs.read(path .. "/manifest.json")
|
local raw = fs.read(path .. "/manifest.json")
|
||||||
if raw then
|
if raw then
|
||||||
local manifest = decodeManifest(raw, path)
|
local manifest = decodeManifest(raw, path)
|
||||||
@@ -216,8 +243,15 @@ end
|
|||||||
-- options.mods enable-state the loader persists, so a toggle here is what the
|
-- options.mods enable-state the loader persists, so a toggle here is what the
|
||||||
-- game sees on its next boot.
|
-- game sees on its next boot.
|
||||||
function LauncherMods.list()
|
function LauncherMods.list()
|
||||||
local options = SaveData.loadOptions()
|
local ok, result = pcall(function()
|
||||||
return LauncherMods.deriveList(discover(), options)
|
local options = SaveData.loadOptions()
|
||||||
|
return LauncherMods.deriveList(discover(), options)
|
||||||
|
end)
|
||||||
|
if not ok then
|
||||||
|
-- a single bad options/mod file must not blank the launcher
|
||||||
|
return {}
|
||||||
|
end
|
||||||
|
return result or {}
|
||||||
end
|
end
|
||||||
|
|
||||||
-- setEnabled(id, enabled): persist options.mods[id] in the exact shape
|
-- setEnabled(id, enabled): persist options.mods[id] in the exact shape
|
||||||
@@ -349,17 +383,20 @@ end
|
|||||||
local STRAY_MOUNT = "stray_scan"
|
local STRAY_MOUNT = "stray_scan"
|
||||||
|
|
||||||
-- Run fn(mountedModsRoot) for each game folder that has a readable mods/
|
-- 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
|
-- directory, one mount at a time. Folders already on the read path are
|
||||||
-- are skipped: their mods/ is discoverable by definition, so anything there is
|
-- skipped (isReadableRoot): the physfs source, which is every `love <gamedir>`
|
||||||
-- installed already and not a stray (this is every `love <gamedir>` dev run).
|
-- 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 function eachStrayRoot(fn)
|
||||||
local SaveData_ = require("src.core.SaveData")
|
local SaveData_ = require("src.core.SaveData")
|
||||||
local fs = love and love.filesystem
|
local fs = love and love.filesystem
|
||||||
if not fs then return end
|
if not fs then return end
|
||||||
local source = fs.getSource and fs.getSource()
|
local source = fs.getSource and fs.getSource()
|
||||||
|
local cacheRoot = CacheFs.root()
|
||||||
local seen = {}
|
local seen = {}
|
||||||
for _, folder in ipairs(SaveData_.gameFolders() or {}) do
|
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
|
seen[folder] = true
|
||||||
CacheFs.withMounted(folder, STRAY_MOUNT, function()
|
CacheFs.withMounted(folder, STRAY_MOUNT, function()
|
||||||
local root = STRAY_MOUNT .. "/mods"
|
local root = STRAY_MOUNT .. "/mods"
|
||||||
@@ -436,13 +473,22 @@ function LauncherMods.strays() return scanStrays(false) end
|
|||||||
-- on the player's behalf is not this function's call to make.
|
-- on the player's behalf is not this function's call to make.
|
||||||
function LauncherMods.adoptStrays() return scanStrays(true) end
|
function LauncherMods.adoptStrays() return scanStrays(true) end
|
||||||
|
|
||||||
-- installZip(source) -> true, id | nil, errString
|
-- installZip(source [, opts]) -> true, id | nil, errString
|
||||||
-- source is an external path or a love DroppedFile. The archive is validated
|
-- source is an external path or a love DroppedFile. The archive is validated
|
||||||
-- BEFORE anything is copied; every path unmounts and clears the staged temp
|
-- BEFORE anything is copied; every path unmounts and clears the staged temp
|
||||||
-- file, and a failed copy rolls its partial tree back. A dropped file outside
|
-- file, and a failed copy rolls its partial tree back. A dropped file outside
|
||||||
-- the save dir is staged into a save-dir temp first, because
|
-- the save dir is staged into a save-dir temp first, because
|
||||||
-- love.filesystem.mount only reaches a save-directory-relative path.
|
-- love.filesystem.mount only reaches a save-directory-relative path.
|
||||||
function LauncherMods.installZip(source)
|
-- opts.replace = true uninstalls an existing same-id mod first (updates /
|
||||||
|
-- rollbacks). opts.expectId, when set, refuses a zip whose manifest id differs.
|
||||||
|
function LauncherMods.installZip(source, opts)
|
||||||
|
local ok, result, err = pcall(LauncherMods._installZipInner, source, opts)
|
||||||
|
if not ok then return nil, "import failed: " .. tostring(result) end
|
||||||
|
return result, err
|
||||||
|
end
|
||||||
|
|
||||||
|
function LauncherMods._installZipInner(source, opts)
|
||||||
|
opts = opts or {}
|
||||||
if not (love and love.filesystem) then
|
if not (love and love.filesystem) then
|
||||||
return nil, "mod install needs LOVE"
|
return nil, "mod install needs LOVE"
|
||||||
end
|
end
|
||||||
@@ -483,12 +529,24 @@ function LauncherMods.installZip(source)
|
|||||||
cleanup()
|
cleanup()
|
||||||
return nil, "invalid mod manifest: " .. tostring(manifestErr)
|
return nil, "invalid mod manifest: " .. tostring(manifestErr)
|
||||||
end
|
end
|
||||||
|
if opts.expectId and manifest.id ~= opts.expectId then
|
||||||
|
cleanup()
|
||||||
|
return nil, ("zip is for '%s', expected '%s'")
|
||||||
|
:format(manifest.id, opts.expectId)
|
||||||
|
end
|
||||||
|
|
||||||
-- reject a duplicate before touching the mods tree
|
|
||||||
local dest = "mods/" .. manifest.id
|
local dest = "mods/" .. manifest.id
|
||||||
if fs.getInfo(dest) then
|
if fs.getInfo(dest) then
|
||||||
cleanup()
|
if not opts.replace then
|
||||||
return nil, "a mod named '" .. manifest.id .. "' is already installed"
|
cleanup()
|
||||||
|
return nil, "a mod named '" .. manifest.id .. "' is already installed"
|
||||||
|
end
|
||||||
|
-- drop the old tree before copy; enable-flag is preserved (uninstall
|
||||||
|
-- would clear it, which would surprise an update)
|
||||||
|
local savedPrefix = CacheFs.prefix
|
||||||
|
CacheFs.prefix = ""
|
||||||
|
removeTree(dest)
|
||||||
|
CacheFs.prefix = savedPrefix
|
||||||
end
|
end
|
||||||
|
|
||||||
-- CacheFs.prefix steers ROM-cache writes into a version subtree (blue/...);
|
-- CacheFs.prefix steers ROM-cache writes into a version subtree (blue/...);
|
||||||
@@ -511,6 +569,56 @@ function LauncherMods.installZip(source)
|
|||||||
return true, manifest.id
|
return true, manifest.id
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- Install (or replace) a mod from a GitHub release zip URL.
|
||||||
|
-- Returns true, version | nil, errString. Soft-fails: download / install /
|
||||||
|
-- cleanup errors never throw into the launcher UI.
|
||||||
|
function LauncherMods.installFromRelease(modId, release)
|
||||||
|
local ok, result, err = pcall(function()
|
||||||
|
if type(modId) ~= "string" or modId == "" then
|
||||||
|
return nil, "missing mod id"
|
||||||
|
end
|
||||||
|
if type(release) ~= "table" or not release.zip or not release.zip.url then
|
||||||
|
return nil, "release has no downloadable .zip"
|
||||||
|
end
|
||||||
|
local ModUpdate = require("src.mods.ModUpdate")
|
||||||
|
local tmpName = ("mod_update_%s_%s.zip"):format(
|
||||||
|
tostring(modId), tostring(release.version or os.time()))
|
||||||
|
local localPath, dlErr = ModUpdate.downloadZip(release.zip.url, tmpName)
|
||||||
|
if not localPath then return nil, dlErr end
|
||||||
|
local installed, res = LauncherMods.installZip(localPath, {
|
||||||
|
replace = true, expectId = modId,
|
||||||
|
})
|
||||||
|
pcall(love.filesystem.remove, localPath)
|
||||||
|
if not installed then return nil, res end
|
||||||
|
return true, release.version or res
|
||||||
|
end)
|
||||||
|
if not ok then return nil, "install failed: " .. tostring(result) end
|
||||||
|
return result, err
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Install a mod listed in a community index (src/mods/ModIndex.lua).
|
||||||
|
-- The index only ever tells us WHERE the zip is; resolving that URL is
|
||||||
|
-- ModIndex's job and installing it is installFromRelease's, so this is the
|
||||||
|
-- seam between them and nothing about the archive is special-cased. expectId
|
||||||
|
-- comes from the listing, so a feed that points an entry at somebody else's
|
||||||
|
-- zip fails the manifest check instead of installing the wrong mod.
|
||||||
|
-- Returns true, version | nil, errString.
|
||||||
|
function LauncherMods.installFromIndex(entry)
|
||||||
|
local ok, result, err = pcall(function()
|
||||||
|
if type(entry) ~= "table" or type(entry.id) ~= "string" then
|
||||||
|
return nil, "index entry has no mod id"
|
||||||
|
end
|
||||||
|
local ModIndex = require("src.mods.ModIndex")
|
||||||
|
local release, why = ModIndex.releaseFor(entry)
|
||||||
|
if not release then
|
||||||
|
return nil, why or "this mod cannot be installed from the index"
|
||||||
|
end
|
||||||
|
return LauncherMods.installFromRelease(entry.id, release)
|
||||||
|
end)
|
||||||
|
if not ok then return nil, "install failed: " .. tostring(result) end
|
||||||
|
return result, err
|
||||||
|
end
|
||||||
|
|
||||||
-- uninstall(id) -> true | nil, errString
|
-- uninstall(id) -> true | nil, errString
|
||||||
-- Removes mods/<id>/ from wherever it was installed (the portable game folder
|
-- Removes mods/<id>/ from wherever it was installed (the portable game folder
|
||||||
-- or the save directory, CacheFs decides -- #330) and clears options.mods[id]
|
-- or the save directory, CacheFs decides -- #330) and clears options.mods[id]
|
||||||
|
|||||||
+17
-1
@@ -206,7 +206,11 @@ function Loader:_discover()
|
|||||||
for _, name in ipairs(self.fs.getDirectoryItems(root)) do
|
for _, name in ipairs(self.fs.getDirectoryItems(root)) do
|
||||||
local path = root .. "/" .. name
|
local path = root .. "/" .. name
|
||||||
local info = self.fs.getInfo(path)
|
local info = self.fs.getInfo(path)
|
||||||
if info and info.type == "directory" then
|
-- a dev-linked mod dir (ln -s) reports type "symlink" even with
|
||||||
|
-- setSymlinksEnabled(true) -- PhysFS never resolves the symlink's
|
||||||
|
-- own getInfo, only traversal into it. readManifest below still
|
||||||
|
-- correctly no-ops on a symlink that isn't a directory.
|
||||||
|
if info and (info.type == "directory" or info.type == "symlink") then
|
||||||
local manifest, err = readManifest(self.fs, path)
|
local manifest, err = readManifest(self.fs, path)
|
||||||
if manifest then
|
if manifest then
|
||||||
if self.mods[manifest.id] then
|
if self.mods[manifest.id] then
|
||||||
@@ -834,6 +838,18 @@ function Loader:load(data)
|
|||||||
require("src.mods.Builtins").install(self.content, data)
|
require("src.mods.Builtins").install(self.content, data)
|
||||||
self:_loadState()
|
self:_loadState()
|
||||||
self:_discover()
|
self:_discover()
|
||||||
|
-- Experimental mods stay off until the player opts in: a missing
|
||||||
|
-- options.mods entry normally means enabled, but experimental flips that.
|
||||||
|
do
|
||||||
|
local options = SaveData.loadOptions(self.fs)
|
||||||
|
local modsOpt = options.mods or {}
|
||||||
|
for id, mod in pairs(self.mods) do
|
||||||
|
if not self.disabled[id] and modsOpt[id] == nil
|
||||||
|
and mod.manifest.experimental then
|
||||||
|
self.disabled[id] = true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
for id, mod in pairs(self.mods) do
|
for id, mod in pairs(self.mods) do
|
||||||
mod.enabled = not self.disabled[id]
|
mod.enabled = not self.disabled[id]
|
||||||
mod.state = mod.enabled and "pending" or "disabled"
|
mod.state = mod.enabled and "pending" or "disabled"
|
||||||
|
|||||||
@@ -324,6 +324,12 @@ function ManagerState:detailRows(m)
|
|||||||
rows[#rows + 1] = { label = Strings("PERMISSIONS.."),
|
rows[#rows + 1] = { label = Strings("PERMISSIONS.."),
|
||||||
action = function() self:goTo("permissions") end }
|
action = function() self:goTo("permissions") end }
|
||||||
end
|
end
|
||||||
|
if m.github then
|
||||||
|
rows[#rows + 1] = { inert = true, label = "GH " .. m.github }
|
||||||
|
end
|
||||||
|
if m.experimental then
|
||||||
|
rows[#rows + 1] = { inert = true, label = "EXPERIMENTAL" }
|
||||||
|
end
|
||||||
if m.error then
|
if m.error then
|
||||||
rows[#rows + 1] = { label = Strings("VIEW ERROR.."),
|
rows[#rows + 1] = { label = Strings("VIEW ERROR.."),
|
||||||
action = function() self:goTo("errors") end }
|
action = function() self:goTo("errors") end }
|
||||||
@@ -607,13 +613,26 @@ function ManagerState:beginToggle(m)
|
|||||||
r = ManagerState.resolveToggle(self:manifestMap(), m.id, want,
|
r = ManagerState.resolveToggle(self:manifestMap(), m.id, want,
|
||||||
self:enabledSet())
|
self:enabledSet())
|
||||||
end
|
end
|
||||||
if #r.missing > 0 or #r.conflicts > 0 or #r.badVersion > 0 then
|
local function proceed()
|
||||||
self:openBlocked(r)
|
if #r.missing > 0 or #r.conflicts > 0 or #r.badVersion > 0 then
|
||||||
elseif #r.alsoEnable > 0 or #r.alsoDisable > 0 then
|
self:openBlocked(r)
|
||||||
self:openCascade(r, m, want)
|
elseif #r.alsoEnable > 0 or #r.alsoDisable > 0 then
|
||||||
else
|
self:openCascade(r, m, want)
|
||||||
self:commitToggle(r.apply)
|
else
|
||||||
|
self:commitToggle(r.apply)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
-- Experimental mods ask once on enable; disable is silent.
|
||||||
|
if want and m.experimental then
|
||||||
|
self:openConfirm({
|
||||||
|
"EXPERIMENTAL MOD",
|
||||||
|
"THIS MOD IS MARKED",
|
||||||
|
"EXPERIMENTAL.",
|
||||||
|
"ENABLE ANYWAY?",
|
||||||
|
}, proceed)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
proceed()
|
||||||
end
|
end
|
||||||
|
|
||||||
function ManagerState:commitToggle(apply)
|
function ManagerState:commitToggle(apply)
|
||||||
|
|||||||
+49
-2
@@ -50,6 +50,42 @@ local function parseSpecs(list, field)
|
|||||||
return specs
|
return specs
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- Optional GitHub repo for launcher auto-update / other-versions.
|
||||||
|
-- Accepts "owner/repo" or a github.com URL; empty/absent means no updates.
|
||||||
|
function Manifest.parseGithub(value)
|
||||||
|
if value == nil or value == "" then return nil end
|
||||||
|
assert(type(value) == "string", "github must be a string")
|
||||||
|
local trimmed = value:match("^%s*(.-)%s*$") or value
|
||||||
|
if trimmed == "" then return nil end
|
||||||
|
local owner, repo = trimmed:match(
|
||||||
|
"^https?://github%.com/([%w%._%-]+)/([%w%._%-]+)/?$")
|
||||||
|
if not owner then
|
||||||
|
owner, repo = trimmed:match(
|
||||||
|
"^https?://github%.com/([%w%._%-]+)/([%w%._%-]+)%.git/?$")
|
||||||
|
end
|
||||||
|
if not owner then
|
||||||
|
owner, repo = trimmed:match("^([%w%._%-]+)/([%w%._%-]+)$")
|
||||||
|
end
|
||||||
|
assert(owner and repo and owner ~= "" and repo ~= "",
|
||||||
|
"github must be owner/repo or a github.com URL")
|
||||||
|
repo = repo:gsub("%.git$", "")
|
||||||
|
return owner .. "/" .. repo
|
||||||
|
end
|
||||||
|
|
||||||
|
-- conflicts + incompatible (alias) merged, first-wins on duplicate ids
|
||||||
|
local function mergeConflictLists(conflicts, incompatible)
|
||||||
|
local seen, out = {}, {}
|
||||||
|
for _, list in ipairs({ array(conflicts), array(incompatible) }) do
|
||||||
|
for _, entry in ipairs(list) do
|
||||||
|
if not seen[entry] then
|
||||||
|
seen[entry] = true
|
||||||
|
out[#out + 1] = entry
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return out
|
||||||
|
end
|
||||||
|
|
||||||
function Manifest.validate(raw, path)
|
function Manifest.validate(raw, path)
|
||||||
assert(type(raw) == "table", "manifest must be an object")
|
assert(type(raw) == "table", "manifest must be an object")
|
||||||
assert(type(raw.id) == "string" and raw.id:match("^[%w_%-]+$"),
|
assert(type(raw.id) == "string" and raw.id:match("^[%w_%-]+$"),
|
||||||
@@ -86,6 +122,12 @@ function Manifest.validate(raw, path)
|
|||||||
assert(gameVersionOk, ("malformed game_version %q: %s")
|
assert(gameVersionOk, ("malformed game_version %q: %s")
|
||||||
:format(tostring(raw.game_version), tostring(gameVersionErr)))
|
:format(tostring(raw.game_version), tostring(gameVersionErr)))
|
||||||
|
|
||||||
|
local github = Manifest.parseGithub(raw.github)
|
||||||
|
|
||||||
|
assert(raw.experimental == nil or type(raw.experimental) == "boolean",
|
||||||
|
"experimental must be a boolean")
|
||||||
|
local experimental = raw.experimental == true
|
||||||
|
|
||||||
-- overhauls and total conversions are assumed to move the link
|
-- overhauls and total conversions are assumed to move the link
|
||||||
-- fingerprint unless the manifest says otherwise; content packs are not
|
-- fingerprint unless the manifest says otherwise; content packs are not
|
||||||
local affectsLink = profile ~= "content"
|
local affectsLink = profile ~= "content"
|
||||||
@@ -97,6 +139,8 @@ function Manifest.validate(raw, path)
|
|||||||
return value
|
return value
|
||||||
end
|
end
|
||||||
|
|
||||||
|
local conflicts = mergeConflictLists(raw.conflicts, raw.incompatible)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id = raw.id,
|
id = raw.id,
|
||||||
name = raw.name,
|
name = raw.name,
|
||||||
@@ -106,13 +150,16 @@ function Manifest.validate(raw, path)
|
|||||||
priority = tonumber(raw.priority) or 0,
|
priority = tonumber(raw.priority) or 0,
|
||||||
dependencies = array(raw.dependencies),
|
dependencies = array(raw.dependencies),
|
||||||
optional_dependencies = array(raw.optional_dependencies),
|
optional_dependencies = array(raw.optional_dependencies),
|
||||||
conflicts = array(raw.conflicts),
|
conflicts = conflicts,
|
||||||
|
incompatible = array(raw.incompatible),
|
||||||
dependencySpecs = parseSpecs(array(raw.dependencies), "dependencies"),
|
dependencySpecs = parseSpecs(array(raw.dependencies), "dependencies"),
|
||||||
optionalSpecs = parseSpecs(array(raw.optional_dependencies), "optional_dependencies"),
|
optionalSpecs = parseSpecs(array(raw.optional_dependencies), "optional_dependencies"),
|
||||||
conflictSpecs = parseSpecs(array(raw.conflicts), "conflicts"),
|
conflictSpecs = parseSpecs(conflicts, "conflicts"),
|
||||||
category = raw.category or "OTHER",
|
category = raw.category or "OTHER",
|
||||||
game_version = raw.game_version,
|
game_version = raw.game_version,
|
||||||
description = raw.description or "",
|
description = raw.description or "",
|
||||||
|
github = github,
|
||||||
|
experimental = experimental,
|
||||||
profile = profile,
|
profile = profile,
|
||||||
affects_link = affectsLink,
|
affects_link = affectsLink,
|
||||||
permissions = permissions,
|
permissions = permissions,
|
||||||
|
|||||||
@@ -0,0 +1,623 @@
|
|||||||
|
-- Community mod index consumer (the "Find mods" launcher tab).
|
||||||
|
--
|
||||||
|
-- An index is metadata only: a published index.json feed listing mods that
|
||||||
|
-- live in their authors' own repos. Nothing here clones or vendors the index
|
||||||
|
-- repo -- the feed is the whole contract, and every install still goes through
|
||||||
|
-- the same LauncherMods.installZip path an "Import mod .zip" does, so a listing
|
||||||
|
-- buys a mod no trust it would not otherwise have.
|
||||||
|
--
|
||||||
|
-- Shape of the split mirrors src/mods/ModUpdate.lua, which this borrows its
|
||||||
|
-- host I/O from: everything above "host I/O" is pure (no love, no filesystem,
|
||||||
|
-- no network) so the engine tier can table-drive it, and the fetch/cache half
|
||||||
|
-- reaches for curl and options.lua.
|
||||||
|
--
|
||||||
|
-- Sources are never added automatically. options.modIndexes is a player-built
|
||||||
|
-- list -- adding an index is a deliberate act of trusting whoever publishes it,
|
||||||
|
-- so the launcher ships with none and asks.
|
||||||
|
--
|
||||||
|
-- schema_version is a hard gate, not a hint: a bumped feed may reuse a field
|
||||||
|
-- name for something else, so an unknown version is refused outright rather
|
||||||
|
-- than parsed hopefully.
|
||||||
|
|
||||||
|
local ModIndex = {}
|
||||||
|
|
||||||
|
-- The feed is rebuilt on every push and refreshed nightly, so a day-old copy
|
||||||
|
-- is the worst a cached listing can be. ModUpdate's six hours is tuned for a
|
||||||
|
-- single repo's releases; a whole index is heavier and changes more slowly.
|
||||||
|
ModIndex.CACHE_TTL = 24 * 60 * 60
|
||||||
|
ModIndex.SCHEMA_VERSION = 1
|
||||||
|
|
||||||
|
-- ------- pure: source resolution
|
||||||
|
|
||||||
|
local function trim(s)
|
||||||
|
return (tostring(s):gsub("^%s+", ""):gsub("%s+$", ""))
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Split "owner/repo" out of the several ways a player can name a GitHub repo.
|
||||||
|
local function githubSlug(url)
|
||||||
|
local owner, repo = url:match("^https?://github%.com/([%w%-%.]+)/([%w%-%.]+)")
|
||||||
|
if not owner then
|
||||||
|
owner, repo = url:match("^([%w%-%.]+)/([%w%-%.]+)$")
|
||||||
|
end
|
||||||
|
if not owner then return nil end
|
||||||
|
repo = repo:gsub("%.git$", "")
|
||||||
|
return owner, repo
|
||||||
|
end
|
||||||
|
|
||||||
|
-- resolveSource(input) -> { feed, base, fallback, label } | nil, err
|
||||||
|
--
|
||||||
|
-- Players paste whichever URL they happened to have, so all four shapes of the
|
||||||
|
-- same index resolve to one source: the Pages root, the feed file itself, the
|
||||||
|
-- GitHub repo page, or a bare "owner/repo". `base` is what relative thumbnail
|
||||||
|
-- and description_url paths resolve against, and it always keeps its trailing
|
||||||
|
-- slash so joinUrl can stay a concatenation.
|
||||||
|
--
|
||||||
|
-- The raw.githubusercontent fallback exists because Pages deploys lag a push
|
||||||
|
-- by a minute or two; it is only ever consulted when the feed fetch fails.
|
||||||
|
function ModIndex.resolveSource(input)
|
||||||
|
if type(input) ~= "string" then return nil, "missing index URL" end
|
||||||
|
local url = trim(input)
|
||||||
|
if url == "" then return nil, "missing index URL" end
|
||||||
|
|
||||||
|
local owner, repo = githubSlug(url)
|
||||||
|
if owner then
|
||||||
|
return {
|
||||||
|
feed = ("https://%s.github.io/%s/data/index.json"):format(owner, repo),
|
||||||
|
base = ("https://%s.github.io/%s/"):format(owner, repo),
|
||||||
|
fallback = ("https://raw.githubusercontent.com/%s/%s/main/site/data/index.json")
|
||||||
|
:format(owner, repo),
|
||||||
|
label = owner .. "/" .. repo,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
if not url:match("^https?://") then
|
||||||
|
return nil, "index must be an http(s) URL or owner/repo"
|
||||||
|
end
|
||||||
|
|
||||||
|
-- A feed URL names the file; the Pages root is what is left once the
|
||||||
|
-- "data/index.json" tail comes off (any other .json keeps only its folder).
|
||||||
|
if url:match("%.json$") then
|
||||||
|
local base = url:match("^(.*/)data/index%.json$") or url:match("^(.*/)")
|
||||||
|
return { feed = url, base = base or url, label = ModIndex.labelFor(base or url) }
|
||||||
|
end
|
||||||
|
|
||||||
|
local base = url:match("/$") and url or (url .. "/")
|
||||||
|
return {
|
||||||
|
feed = base .. "data/index.json",
|
||||||
|
base = base,
|
||||||
|
label = ModIndex.labelFor(base),
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
-- A short human name for a source row: "owner/repo" for a Pages host, else the
|
||||||
|
-- host plus first path segment. Only ever cosmetic.
|
||||||
|
function ModIndex.labelFor(url)
|
||||||
|
url = tostring(url or "")
|
||||||
|
local owner, repo = url:match("^https?://([%w%-%.]+)%.github%.io/([%w%-%.]+)/")
|
||||||
|
if owner then return owner .. "/" .. repo end
|
||||||
|
local host, first = url:match("^https?://([^/]+)/([^/]*)")
|
||||||
|
if host and first and first ~= "" then return host .. "/" .. first end
|
||||||
|
return host or url
|
||||||
|
end
|
||||||
|
|
||||||
|
-- joinUrl(base, rel) -> absolute URL | nil
|
||||||
|
-- Relative feed paths resolve against the Pages root; an already-absolute one
|
||||||
|
-- is handed back untouched (the schema allows either), and anything else --
|
||||||
|
-- nil, "", a non-string -- is simply absent rather than an error, because a
|
||||||
|
-- missing thumbnail is not a broken index.
|
||||||
|
function ModIndex.joinUrl(base, rel)
|
||||||
|
if type(rel) ~= "string" or rel == "" then return nil end
|
||||||
|
if rel:match("^https?://") then return rel end
|
||||||
|
if type(base) ~= "string" or base == "" then return nil end
|
||||||
|
if not base:match("/$") then base = base .. "/" end
|
||||||
|
return base .. (rel:gsub("^/", ""))
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ------- pure: feed parsing
|
||||||
|
|
||||||
|
local function str(v)
|
||||||
|
return type(v) == "string" and v or nil
|
||||||
|
end
|
||||||
|
|
||||||
|
local function strArray(v)
|
||||||
|
local out = {}
|
||||||
|
if type(v) == "table" then
|
||||||
|
for _, entry in ipairs(v) do
|
||||||
|
if type(entry) == "string" then out[#out + 1] = entry end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return out
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Decode one release blob from the feed's `latest` field into the same shape
|
||||||
|
-- ModUpdate.parseRelease produces, so LauncherMods.installFromRelease takes it
|
||||||
|
-- without a translation layer.
|
||||||
|
local function parseLatest(raw)
|
||||||
|
if type(raw) ~= "table" then return nil end
|
||||||
|
local zip = nil
|
||||||
|
if type(raw.zip) == "table" and str(raw.zip.url) then
|
||||||
|
zip = { name = str(raw.zip.name), url = raw.zip.url,
|
||||||
|
size = tonumber(raw.zip.size) }
|
||||||
|
end
|
||||||
|
return {
|
||||||
|
version = str(raw.version),
|
||||||
|
tag = str(raw.tag),
|
||||||
|
name = str(raw.name),
|
||||||
|
prerelease = raw.prerelease == true,
|
||||||
|
published_at = str(raw.published_at),
|
||||||
|
zip = zip,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
local function parseEntry(raw)
|
||||||
|
if type(raw) ~= "table" or not str(raw.id) then return nil end
|
||||||
|
return {
|
||||||
|
folder = str(raw.folder),
|
||||||
|
id = raw.id,
|
||||||
|
title = str(raw.title) or raw.id,
|
||||||
|
author = str(raw.author),
|
||||||
|
version = str(raw.version),
|
||||||
|
summary = str(raw.summary) or "",
|
||||||
|
categories = strArray(raw.categories),
|
||||||
|
tags = strArray(raw.tags),
|
||||||
|
license = str(raw.license),
|
||||||
|
repo = str(raw.repo),
|
||||||
|
github = str(raw.github),
|
||||||
|
downloadURL = str(raw.downloadURL),
|
||||||
|
api = tonumber(raw.api),
|
||||||
|
game_version = str(raw.game_version),
|
||||||
|
profile = str(raw.profile),
|
||||||
|
affects_link = raw.affects_link == true,
|
||||||
|
experimental = raw.experimental == true,
|
||||||
|
permissions = strArray(raw.permissions),
|
||||||
|
dependencies = raw.dependencies,
|
||||||
|
conflicts = raw.conflicts,
|
||||||
|
thumbnail = str(raw.thumbnail),
|
||||||
|
description_url = str(raw.description_url),
|
||||||
|
latest = parseLatest(raw.latest),
|
||||||
|
update_check = str(raw.update_check) or "pending",
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
-- parse(jsonText [, Json]) -> { schemaVersion, generatedAt, categories, mods }
|
||||||
|
-- | nil, err
|
||||||
|
-- Never throws: a truncated download, an HTML error page, or a feed from a
|
||||||
|
-- future schema all come back as a message the panel can print.
|
||||||
|
function ModIndex.parse(jsonText, Json)
|
||||||
|
local ok, result, err = pcall(function()
|
||||||
|
Json = Json or require("src.link.Json")
|
||||||
|
local doc, decodeErr = Json.decode(jsonText)
|
||||||
|
if type(doc) ~= "table" then
|
||||||
|
return nil, decodeErr or "index.json is not an object"
|
||||||
|
end
|
||||||
|
local schema = tonumber(doc.schema_version)
|
||||||
|
if schema == nil then
|
||||||
|
return nil, "index.json has no schema_version"
|
||||||
|
end
|
||||||
|
if schema ~= ModIndex.SCHEMA_VERSION then
|
||||||
|
return nil, ("index schema %d is not supported (this build reads %d)")
|
||||||
|
:format(schema, ModIndex.SCHEMA_VERSION)
|
||||||
|
end
|
||||||
|
if type(doc.mods) ~= "table" then
|
||||||
|
return nil, "index.json has no mods array"
|
||||||
|
end
|
||||||
|
local mods = {}
|
||||||
|
for _, raw in ipairs(doc.mods) do
|
||||||
|
local entry = parseEntry(raw)
|
||||||
|
if entry then mods[#mods + 1] = entry end
|
||||||
|
end
|
||||||
|
return {
|
||||||
|
schemaVersion = schema,
|
||||||
|
generatedAt = str(doc.generated_at),
|
||||||
|
categories = strArray(doc.categories),
|
||||||
|
mods = mods,
|
||||||
|
}
|
||||||
|
end)
|
||||||
|
if not ok then return nil, "could not read the index: " .. tostring(result) end
|
||||||
|
return result, err
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ------- pure: install resolution
|
||||||
|
|
||||||
|
-- installUrl(entry) -> url, kind | nil, reason
|
||||||
|
--
|
||||||
|
-- The same order the engine's zip import already implies: a verified release
|
||||||
|
-- asset first, then the author's fixed downloadURL. A GitHub source-archive
|
||||||
|
-- URL is never invented -- codeload gives you the repo, not the built mod, and
|
||||||
|
-- the folder layout would be wrong even when the download succeeds.
|
||||||
|
function ModIndex.installUrl(entry)
|
||||||
|
if type(entry) ~= "table" then return nil, "no entry" end
|
||||||
|
if entry.update_check == "ok" and entry.latest and entry.latest.zip
|
||||||
|
and entry.latest.zip.url then
|
||||||
|
return entry.latest.zip.url, "release"
|
||||||
|
end
|
||||||
|
if entry.downloadURL and entry.downloadURL ~= "" then
|
||||||
|
return entry.downloadURL, "download"
|
||||||
|
end
|
||||||
|
if entry.update_check == "off" then
|
||||||
|
return nil, "the author does not publish installable releases"
|
||||||
|
end
|
||||||
|
if entry.update_check == "no installable release" then
|
||||||
|
return nil, "no release with a .zip asset yet"
|
||||||
|
end
|
||||||
|
if type(entry.update_check) == "string"
|
||||||
|
and entry.update_check:match("^error") then
|
||||||
|
return nil, entry.update_check
|
||||||
|
end
|
||||||
|
return nil, "nothing installable listed"
|
||||||
|
end
|
||||||
|
|
||||||
|
function ModIndex.canInstall(entry)
|
||||||
|
return ModIndex.installUrl(entry) ~= nil
|
||||||
|
end
|
||||||
|
|
||||||
|
-- The version to show on a card: the release the index resolved when it could
|
||||||
|
-- reach GitHub, else whatever meta.json declared.
|
||||||
|
function ModIndex.displayVersion(entry)
|
||||||
|
if type(entry) ~= "table" then return "?" end
|
||||||
|
if entry.update_check == "ok" and entry.latest and entry.latest.version then
|
||||||
|
return entry.latest.version
|
||||||
|
end
|
||||||
|
return entry.version or "?"
|
||||||
|
end
|
||||||
|
|
||||||
|
-- The release table LauncherMods.installFromRelease wants. A downloadURL
|
||||||
|
-- entry has no release behind it, so one is synthesised around the URL; the
|
||||||
|
-- installer still validates the manifest inside and still refuses a zip whose
|
||||||
|
-- id is not the one being installed.
|
||||||
|
function ModIndex.releaseFor(entry)
|
||||||
|
local url, kind = ModIndex.installUrl(entry)
|
||||||
|
if not url then return nil, kind end
|
||||||
|
if kind == "release" then return entry.latest end
|
||||||
|
return {
|
||||||
|
version = ModIndex.displayVersion(entry),
|
||||||
|
zip = { url = url, name = entry.id .. ".zip" },
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ------- pure: compatibility
|
||||||
|
|
||||||
|
-- compatIssues(entry, ctx) -> array of { level, text }
|
||||||
|
--
|
||||||
|
-- Soft gate by design: an index entry is metadata an author wrote, possibly
|
||||||
|
-- months ago, and hiding a mod because a range looks wrong is how a working
|
||||||
|
-- mod becomes invisible. Everything here warns; the confirm dialog shows the
|
||||||
|
-- list and the player decides. ctx carries { modApi, engineVersion,
|
||||||
|
-- installed = { id -> version }, enabled = { id -> true } }.
|
||||||
|
function ModIndex.compatIssues(entry, ctx)
|
||||||
|
local out = {}
|
||||||
|
if type(entry) ~= "table" then return out end
|
||||||
|
ctx = ctx or {}
|
||||||
|
local function warn(text) out[#out + 1] = { level = "warn", text = text } end
|
||||||
|
|
||||||
|
local modApi = tonumber(ctx.modApi)
|
||||||
|
if entry.api and modApi and entry.api > modApi then
|
||||||
|
warn(("Needs mod API %d; this build provides %d")
|
||||||
|
:format(entry.api, modApi))
|
||||||
|
end
|
||||||
|
|
||||||
|
if entry.game_version and ctx.engineVersion then
|
||||||
|
local okSemver, Semver = pcall(require, "src.mods.Semver")
|
||||||
|
if okSemver and not Semver.satisfies(ctx.engineVersion, entry.game_version) then
|
||||||
|
warn(("Needs engine %s (have %s)")
|
||||||
|
:format(entry.game_version, ctx.engineVersion))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
if entry.profile and entry.profile ~= "content" then
|
||||||
|
warn(("Profile '%s' changes engine behaviour beyond content")
|
||||||
|
:format(entry.profile))
|
||||||
|
end
|
||||||
|
if entry.affects_link then
|
||||||
|
warn("Changes link play; both sides need the same mods")
|
||||||
|
end
|
||||||
|
if entry.experimental then
|
||||||
|
warn("Marked experimental by its author")
|
||||||
|
end
|
||||||
|
|
||||||
|
for _, name in ipairs(entry.permissions or {}) do
|
||||||
|
warn("Requests permission: " .. name)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- dependencies / conflicts arrive as the manifest's own vocabulary: either
|
||||||
|
-- an array of "id" / "id@<range>" strings or an id -> range map.
|
||||||
|
local installed = ctx.installed or {}
|
||||||
|
local function eachSpec(spec, fn)
|
||||||
|
if type(spec) ~= "table" then return end
|
||||||
|
for k, v in pairs(spec) do
|
||||||
|
if type(k) == "number" and type(v) == "string" then
|
||||||
|
local id, range = v:match("^([^@]+)@(.+)$")
|
||||||
|
fn(id or v, range)
|
||||||
|
elseif type(k) == "string" then
|
||||||
|
fn(k, type(v) == "string" and v or nil)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
eachSpec(entry.dependencies, function(id, range)
|
||||||
|
if installed[id] == nil then
|
||||||
|
warn("Needs " .. id .. (range and (" " .. range) or "") .. " (not installed)")
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
eachSpec(entry.conflicts, function(id)
|
||||||
|
if installed[id] ~= nil then
|
||||||
|
warn("Conflicts with installed " .. id)
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
|
||||||
|
return out
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ------- pure: search / filter
|
||||||
|
|
||||||
|
local function haystack(entry)
|
||||||
|
return (tostring(entry.title or "") .. " " .. tostring(entry.author or "")
|
||||||
|
.. " " .. tostring(entry.summary or "") .. " " .. tostring(entry.id or ""))
|
||||||
|
:lower()
|
||||||
|
end
|
||||||
|
|
||||||
|
-- matches(entry, query) -> bool. Every whitespace-separated term must appear
|
||||||
|
-- somewhere in title / author / summary / id, so typing more narrows.
|
||||||
|
function ModIndex.matches(entry, query)
|
||||||
|
if type(query) ~= "string" or trim(query) == "" then return true end
|
||||||
|
local hay = haystack(entry)
|
||||||
|
for term in trim(query):lower():gmatch("%S+") do
|
||||||
|
if not hay:find(term, 1, true) then return false end
|
||||||
|
end
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
|
-- filter(mods, opts) -> a new array. opts = { query, category, tag }.
|
||||||
|
-- Category and tag compare case-insensitively; feed order (already sorted by
|
||||||
|
-- title) is preserved.
|
||||||
|
function ModIndex.filter(mods, opts)
|
||||||
|
opts = opts or {}
|
||||||
|
local want = opts.category and tostring(opts.category):lower() or nil
|
||||||
|
local wantTag = opts.tag and tostring(opts.tag):lower() or nil
|
||||||
|
local out = {}
|
||||||
|
for _, entry in ipairs(mods or {}) do
|
||||||
|
local keep = ModIndex.matches(entry, opts.query)
|
||||||
|
if keep and want then
|
||||||
|
keep = false
|
||||||
|
for _, c in ipairs(entry.categories or {}) do
|
||||||
|
if tostring(c):lower() == want then keep = true; break end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if keep and wantTag then
|
||||||
|
keep = false
|
||||||
|
for _, t in ipairs(entry.tags or {}) do
|
||||||
|
if tostring(t):lower() == wantTag then keep = true; break end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if keep then out[#out + 1] = entry end
|
||||||
|
end
|
||||||
|
return out
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Every category actually used by a feed, in the feed's declared order, with
|
||||||
|
-- anything an entry names that the header forgot appended. Drives the filter
|
||||||
|
-- row without hard-coding the vocabulary.
|
||||||
|
function ModIndex.categoriesIn(index)
|
||||||
|
local out, seen = {}, {}
|
||||||
|
if type(index) ~= "table" then return out end
|
||||||
|
local used = {}
|
||||||
|
for _, entry in ipairs(index.mods or {}) do
|
||||||
|
for _, c in ipairs(entry.categories or {}) do used[c] = true end
|
||||||
|
end
|
||||||
|
for _, c in ipairs(index.categories or {}) do
|
||||||
|
if used[c] and not seen[c] then seen[c] = true; out[#out + 1] = c end
|
||||||
|
end
|
||||||
|
for _, entry in ipairs(index.mods or {}) do
|
||||||
|
for _, c in ipairs(entry.categories or {}) do
|
||||||
|
if not seen[c] then seen[c] = true; out[#out + 1] = c end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return out
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ------- sources (options.modIndexes)
|
||||||
|
|
||||||
|
local function loadOptions()
|
||||||
|
return require("src.core.SaveData").loadOptions()
|
||||||
|
end
|
||||||
|
|
||||||
|
-- The player's index list, normalised. Rows are { url, feed, base, fallback,
|
||||||
|
-- label }; `url` is what they typed, kept so the row reads back the way they
|
||||||
|
-- entered it.
|
||||||
|
function ModIndex.sources()
|
||||||
|
local ok, opts = pcall(loadOptions)
|
||||||
|
if not ok or type(opts) ~= "table" then return {} end
|
||||||
|
local out = {}
|
||||||
|
for _, row in ipairs(opts.modIndexes or {}) do
|
||||||
|
if type(row) == "table" and type(row.feed) == "string" then
|
||||||
|
out[#out + 1] = row
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return out
|
||||||
|
end
|
||||||
|
|
||||||
|
-- addSource(input) -> row | nil, err. Idempotent on the resolved feed URL, so
|
||||||
|
-- pasting the repo page and the Pages root in either order adds one source.
|
||||||
|
function ModIndex.addSource(input)
|
||||||
|
local source, err = ModIndex.resolveSource(input)
|
||||||
|
if not source then return nil, err end
|
||||||
|
local ok, result, addErr = pcall(function()
|
||||||
|
local SaveData = require("src.core.SaveData")
|
||||||
|
local opts = loadOptions()
|
||||||
|
opts.modIndexes = opts.modIndexes or {}
|
||||||
|
for _, row in ipairs(opts.modIndexes) do
|
||||||
|
if row.feed == source.feed then
|
||||||
|
return nil, "that index is already added"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
source.url = trim(input)
|
||||||
|
opts.modIndexes[#opts.modIndexes + 1] = source
|
||||||
|
SaveData.saveOptions(opts)
|
||||||
|
return source
|
||||||
|
end)
|
||||||
|
if not ok then return nil, "could not save the index: " .. tostring(result) end
|
||||||
|
return result, addErr
|
||||||
|
end
|
||||||
|
|
||||||
|
-- removeSource(feed) -> true | nil, err. Drops the cached listing with it:
|
||||||
|
-- keeping a feed's mods around after its source is gone is how a stale card
|
||||||
|
-- outlives the index it came from.
|
||||||
|
function ModIndex.removeSource(feed)
|
||||||
|
if type(feed) ~= "string" or feed == "" then return nil, "missing index" end
|
||||||
|
local ok, result = pcall(function()
|
||||||
|
local SaveData = require("src.core.SaveData")
|
||||||
|
local opts = loadOptions()
|
||||||
|
local kept, found = {}, false
|
||||||
|
for _, row in ipairs(opts.modIndexes or {}) do
|
||||||
|
if row.feed == feed then found = true else kept[#kept + 1] = row end
|
||||||
|
end
|
||||||
|
if not found then return nil end
|
||||||
|
opts.modIndexes = kept
|
||||||
|
if type(opts.modIndexCache) == "table" then opts.modIndexCache[feed] = nil end
|
||||||
|
SaveData.saveOptions(opts)
|
||||||
|
return true
|
||||||
|
end)
|
||||||
|
if not ok then return nil, tostring(result) end
|
||||||
|
if not result then return nil, "that index is not in the list" end
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ------- cache (options.modIndexCache[feed])
|
||||||
|
|
||||||
|
function ModIndex.readCache(feed)
|
||||||
|
if type(feed) ~= "string" or feed == "" then return nil end
|
||||||
|
local ok, opts = pcall(loadOptions)
|
||||||
|
if not ok or type(opts) ~= "table" then return nil end
|
||||||
|
local entry = opts.modIndexCache and opts.modIndexCache[feed]
|
||||||
|
if type(entry) ~= "table" or type(entry.checkedAt) ~= "number" then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
if type(entry.mods) ~= "table" then return nil end
|
||||||
|
return entry
|
||||||
|
end
|
||||||
|
|
||||||
|
function ModIndex.cacheFresh(entry, now, ttl)
|
||||||
|
now = now or os.time()
|
||||||
|
ttl = ttl or ModIndex.CACHE_TTL
|
||||||
|
return entry ~= nil and type(entry.checkedAt) == "number"
|
||||||
|
and (now - entry.checkedAt) < ttl
|
||||||
|
end
|
||||||
|
|
||||||
|
function ModIndex.writeCache(feed, index)
|
||||||
|
if type(feed) ~= "string" or feed == "" then return false end
|
||||||
|
local ok = pcall(function()
|
||||||
|
local SaveData = require("src.core.SaveData")
|
||||||
|
local opts = loadOptions()
|
||||||
|
opts.modIndexCache = opts.modIndexCache or {}
|
||||||
|
opts.modIndexCache[feed] = {
|
||||||
|
checkedAt = os.time(),
|
||||||
|
generatedAt = index.generatedAt,
|
||||||
|
categories = index.categories,
|
||||||
|
mods = index.mods,
|
||||||
|
}
|
||||||
|
SaveData.saveOptions(opts)
|
||||||
|
end)
|
||||||
|
return ok
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ------- host I/O (curl, via ModUpdate's shell plumbing)
|
||||||
|
|
||||||
|
local function shq(s)
|
||||||
|
s = tostring(s)
|
||||||
|
if love and love.system and love.system.getOS
|
||||||
|
and love.system.getOS() == "Windows" then
|
||||||
|
return '"' .. s:gsub('"', '') .. '"'
|
||||||
|
end
|
||||||
|
return "'" .. s:gsub("'", "'\\''") .. "'"
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Plain GET returning the body. No GitHub Accept header: the feed and the
|
||||||
|
-- description markdown are static files on Pages, and the public feed is
|
||||||
|
-- explicitly unauthenticated.
|
||||||
|
function ModIndex.httpGet(url)
|
||||||
|
local ModUpdate = require("src.mods.ModUpdate")
|
||||||
|
if not ModUpdate.haveCurl() then
|
||||||
|
return nil, "curl is not available on this platform"
|
||||||
|
end
|
||||||
|
local HostShell = require("src.core.HostShell")
|
||||||
|
local cmd = "curl -fsSL --connect-timeout 10 --max-time 40 "
|
||||||
|
.. "-H " .. shq("User-Agent: gen1recomp-mod-index") .. " "
|
||||||
|
.. shq(url)
|
||||||
|
local pipeOk, pipe = pcall(HostShell.popen, cmd)
|
||||||
|
if not pipeOk or not pipe then return nil, "could not run curl" end
|
||||||
|
local readOk, out = pcall(function() return pipe:read("*a") end)
|
||||||
|
pcall(function() pipe:close() end)
|
||||||
|
if not readOk then return nil, "fetch failed: " .. tostring(out) end
|
||||||
|
if not out or out == "" then return nil, "empty response from " .. url end
|
||||||
|
return out
|
||||||
|
end
|
||||||
|
|
||||||
|
-- fetch(source, opts) -> index, err, meta
|
||||||
|
-- index is the parse() table; meta is { fromCache, stale }. opts.force skips
|
||||||
|
-- the 24h cache. A failed live fetch falls back to whatever is cached, marked
|
||||||
|
-- stale, so going offline degrades the listing rather than emptying it.
|
||||||
|
function ModIndex.fetch(source, opts)
|
||||||
|
opts = opts or {}
|
||||||
|
if type(source) ~= "table" or type(source.feed) ~= "string" then
|
||||||
|
return nil, "missing index source"
|
||||||
|
end
|
||||||
|
local feed = source.feed
|
||||||
|
|
||||||
|
local function cached(stale)
|
||||||
|
local entry = ModIndex.readCache(feed)
|
||||||
|
if not entry then return nil end
|
||||||
|
return {
|
||||||
|
schemaVersion = ModIndex.SCHEMA_VERSION,
|
||||||
|
generatedAt = entry.generatedAt,
|
||||||
|
categories = entry.categories or {},
|
||||||
|
mods = entry.mods or {},
|
||||||
|
}, nil, { fromCache = true, stale = stale, checkedAt = entry.checkedAt }
|
||||||
|
end
|
||||||
|
|
||||||
|
if not opts.force then
|
||||||
|
local entry = ModIndex.readCache(feed)
|
||||||
|
if ModIndex.cacheFresh(entry) then return cached(false) end
|
||||||
|
end
|
||||||
|
|
||||||
|
local body, err = ModIndex.httpGet(feed)
|
||||||
|
-- Pages deploys trail a push; the raw mirror is the same file, so a feed
|
||||||
|
-- that 404s right after a release is worth one retry elsewhere before it
|
||||||
|
-- counts as an outage.
|
||||||
|
if not body and source.fallback then
|
||||||
|
body = ModIndex.httpGet(source.fallback)
|
||||||
|
end
|
||||||
|
if not body then
|
||||||
|
local index, _, meta = cached(true)
|
||||||
|
if index then return index, nil, meta end
|
||||||
|
return nil, err
|
||||||
|
end
|
||||||
|
|
||||||
|
local index, parseErr = ModIndex.parse(body)
|
||||||
|
if not index then
|
||||||
|
local stale, _, meta = cached(true)
|
||||||
|
if stale then return stale, parseErr, meta end
|
||||||
|
return nil, parseErr
|
||||||
|
end
|
||||||
|
ModIndex.writeCache(feed, index)
|
||||||
|
return index, nil, { fromCache = false, checkedAt = os.time() }
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Fetch a description_url / any index-relative text file. Returns the raw
|
||||||
|
-- markdown; callers run it through ModUpdate.cleanBody for display.
|
||||||
|
function ModIndex.fetchText(url)
|
||||||
|
if type(url) ~= "string" or url == "" then return nil, "no description" end
|
||||||
|
return ModIndex.httpGet(url)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Download a thumbnail into the save directory and return the love.filesystem
|
||||||
|
-- relative path. Reuses ModUpdate.downloadZip, which is a plain curl -o with
|
||||||
|
-- a non-empty-file check -- nothing in it is zip-specific.
|
||||||
|
function ModIndex.downloadThumbnail(url, modId)
|
||||||
|
if type(url) ~= "string" or url == "" then return nil, "no thumbnail" end
|
||||||
|
local ModUpdate = require("src.mods.ModUpdate")
|
||||||
|
local ext = url:match("%.(%a%a%a?%a?)$") or "png"
|
||||||
|
local name = ("mod_thumb_%s.%s"):format(tostring(modId):gsub("[^%w%-_]", "_"), ext)
|
||||||
|
return ModUpdate.downloadZip(url, name)
|
||||||
|
end
|
||||||
|
|
||||||
|
return ModIndex
|
||||||
@@ -0,0 +1,361 @@
|
|||||||
|
-- GitHub release helpers for mod auto-update / other-versions.
|
||||||
|
-- Pure parsing is love-free; fetch/download use HostShell + curl when available.
|
||||||
|
-- Release lists are cached in options.modUpdateCache for CACHE_TTL seconds
|
||||||
|
-- (default 6 hours). The launcher owns UI and install.
|
||||||
|
|
||||||
|
local ModUpdate = {}
|
||||||
|
|
||||||
|
ModUpdate.CACHE_TTL = 6 * 60 * 60 -- six hours
|
||||||
|
|
||||||
|
local function stripV(tag)
|
||||||
|
return (tostring(tag):gsub("^[vV]", ""))
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Prefer "<id>-<version>.zip", then any "<id>*.zip", then the first .zip.
|
||||||
|
function ModUpdate.pickZipAsset(assets, modId, version)
|
||||||
|
if type(assets) ~= "table" then return nil end
|
||||||
|
local prefer = nil
|
||||||
|
if modId and version then
|
||||||
|
prefer = tostring(modId) .. "-" .. tostring(version) .. ".zip"
|
||||||
|
end
|
||||||
|
local idPrefix, idPrefixZip, anyZip = nil, nil, nil
|
||||||
|
if modId then idPrefix = tostring(modId):lower() end
|
||||||
|
for _, a in ipairs(assets) do
|
||||||
|
if type(a) == "table" and type(a.name) == "string" then
|
||||||
|
local name = a.name
|
||||||
|
if name:lower():match("%.zip$") then
|
||||||
|
local row = {
|
||||||
|
name = name,
|
||||||
|
url = a.browser_download_url,
|
||||||
|
size = tonumber(a.size),
|
||||||
|
}
|
||||||
|
if prefer and name == prefer then return row end
|
||||||
|
if idPrefix and not idPrefixZip
|
||||||
|
and name:lower():find(idPrefix, 1, true) == 1 then
|
||||||
|
idPrefixZip = row
|
||||||
|
end
|
||||||
|
if not anyZip then anyZip = row end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return idPrefixZip or anyZip
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Byte-length cut that never lands mid UTF-8 codepoint.
|
||||||
|
local function utf8Cut(s, maxBytes)
|
||||||
|
if type(s) ~= "string" or maxBytes <= 0 then return "" end
|
||||||
|
if #s <= maxBytes then return s end
|
||||||
|
s = s:sub(1, maxBytes)
|
||||||
|
-- Drop trailing continuation bytes (10xxxxxx).
|
||||||
|
while #s > 0 do
|
||||||
|
local b = s:byte(#s)
|
||||||
|
if b < 0x80 or b >= 0xC0 then break end
|
||||||
|
s = s:sub(1, #s - 1)
|
||||||
|
end
|
||||||
|
-- Drop a lead byte whose continuation was truncated away.
|
||||||
|
if #s > 0 then
|
||||||
|
local b = s:byte(#s)
|
||||||
|
if b >= 0xC0 then
|
||||||
|
s = s:sub(1, #s - 1)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return s
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Strip common markdown / HTML noise for the launcher changelog preview.
|
||||||
|
function ModUpdate.cleanBody(text, maxChars)
|
||||||
|
if type(text) ~= "string" or text == "" then return "" end
|
||||||
|
local s = text
|
||||||
|
s = s:gsub("\r\n", "\n"):gsub("\r", "\n")
|
||||||
|
s = s:gsub("<!%-%-.-%-%->", "")
|
||||||
|
s = s:gsub("<[^>]+>", "")
|
||||||
|
s = s:gsub("%[%!%[[^%]]*%]%([^%)]*%)%]", "") -- images 
|
||||||
|
s = s:gsub("%[([^%]]+)%]%([^%)]+%)", "%1") -- links [text](url) -> text
|
||||||
|
s = s:gsub("```[^\n]*\n(.-)```", "%1")
|
||||||
|
s = s:gsub("`([^`]+)`", "%1")
|
||||||
|
s = s:gsub("^#+%s*", "", 1)
|
||||||
|
s = s:gsub("\n#+%s*", "\n")
|
||||||
|
s = s:gsub("%*%*([^*]+)%*%*", "%1")
|
||||||
|
s = s:gsub("%*([^*]+)%*", "%1")
|
||||||
|
s = s:gsub("__([^_]+)__", "%1")
|
||||||
|
s = s:gsub("_([^_]+)_", "%1")
|
||||||
|
s = s:gsub("^\n+", ""):gsub("\n+$", "")
|
||||||
|
s = s:gsub("\n\n\n+", "\n\n")
|
||||||
|
maxChars = tonumber(maxChars) or 0
|
||||||
|
if maxChars > 0 and #s > maxChars then
|
||||||
|
-- ASCII "..." (not U+2026) so later pixel ellipsize stays byte-safe.
|
||||||
|
s = utf8Cut(s, maxChars):gsub("%s+%S*$", "") .. "..."
|
||||||
|
end
|
||||||
|
return s
|
||||||
|
end
|
||||||
|
|
||||||
|
-- One-line preview for tight UI rows: cleaned, newlines collapsed, ellipsized.
|
||||||
|
function ModUpdate.previewLine(text, maxChars)
|
||||||
|
local s = ModUpdate.cleanBody(text, 0)
|
||||||
|
if s == "" then return "" end
|
||||||
|
s = s:gsub("\n+", " "):gsub("%s+", " ")
|
||||||
|
s = s:match("^%s*(.-)%s*$") or s
|
||||||
|
maxChars = tonumber(maxChars) or 80
|
||||||
|
if #s > maxChars then
|
||||||
|
s = utf8Cut(s, maxChars):gsub("%s+%S*$", "") .. "..."
|
||||||
|
end
|
||||||
|
return s
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Decode one GitHub release object into { version, tag, zip, prerelease, body }.
|
||||||
|
function ModUpdate.parseRelease(doc, modId)
|
||||||
|
if type(doc) ~= "table" or not doc.tag_name then
|
||||||
|
return nil, "no tag_name in release"
|
||||||
|
end
|
||||||
|
local version = stripV(doc.tag_name)
|
||||||
|
if not version:match("^%d+%.%d+%.%d+") then
|
||||||
|
return nil, "release tag is not semver-like: " .. tostring(doc.tag_name)
|
||||||
|
end
|
||||||
|
local triple = version:match("^(%d+%.%d+%.%d+)")
|
||||||
|
local zip = ModUpdate.pickZipAsset(doc.assets, modId, triple)
|
||||||
|
local body = type(doc.body) == "string" and doc.body or ""
|
||||||
|
return {
|
||||||
|
version = triple,
|
||||||
|
tag = tostring(doc.tag_name),
|
||||||
|
zip = zip,
|
||||||
|
prerelease = doc.prerelease == true,
|
||||||
|
name = type(doc.name) == "string" and doc.name or triple,
|
||||||
|
body = body,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Decode a releases array (GET /repos/.../releases) into a sorted list
|
||||||
|
-- (newest first). Releases without a .zip asset are dropped. Never throws.
|
||||||
|
function ModUpdate.parseReleases(jsonText, modId, Json)
|
||||||
|
local ok, result, err = pcall(function()
|
||||||
|
Json = Json or require("src.link.Json")
|
||||||
|
local doc, decodeErr = Json.decode(jsonText)
|
||||||
|
if type(doc) ~= "table" then
|
||||||
|
return nil, decodeErr or "releases json is not an array"
|
||||||
|
end
|
||||||
|
if type(doc.message) == "string" and not doc.tag_name and not doc[1] then
|
||||||
|
return nil, "GitHub: " .. doc.message
|
||||||
|
end
|
||||||
|
if doc.tag_name then
|
||||||
|
local one, oneErr = ModUpdate.parseRelease(doc, modId)
|
||||||
|
if not one then return nil, oneErr end
|
||||||
|
if not one.zip then return nil, "latest release has no .zip asset" end
|
||||||
|
return { one }
|
||||||
|
end
|
||||||
|
local out = {}
|
||||||
|
for _, entry in ipairs(doc) do
|
||||||
|
local rel = ModUpdate.parseRelease(entry, modId)
|
||||||
|
if rel and rel.zip then out[#out + 1] = rel end
|
||||||
|
end
|
||||||
|
return out
|
||||||
|
end)
|
||||||
|
if not ok then return nil, "could not parse releases: " .. tostring(result) end
|
||||||
|
return result, err
|
||||||
|
end
|
||||||
|
|
||||||
|
function ModUpdate.apiReleasesUrl(repo)
|
||||||
|
return "https://api.github.com/repos/" .. repo .. "/releases?per_page=30"
|
||||||
|
end
|
||||||
|
|
||||||
|
function ModUpdate.apiLatestUrl(repo)
|
||||||
|
return "https://api.github.com/repos/" .. repo .. "/releases/latest"
|
||||||
|
end
|
||||||
|
|
||||||
|
function ModUpdate.isNewer(installed, candidate)
|
||||||
|
local Semver = require("src.mods.Semver")
|
||||||
|
if type(installed) ~= "string" or type(candidate) ~= "string" then
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
local a, b = Semver.parse(installed), Semver.parse(candidate)
|
||||||
|
if not a or not b then return false end
|
||||||
|
return Semver.compare(candidate, installed) > 0
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Newest non-prerelease, else newest overall.
|
||||||
|
function ModUpdate.pickBest(releases)
|
||||||
|
if type(releases) ~= "table" or #releases == 0 then return nil end
|
||||||
|
for _, rel in ipairs(releases) do
|
||||||
|
if not rel.prerelease then return rel end
|
||||||
|
end
|
||||||
|
return releases[1]
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ------- cache (options.modUpdateCache[repo])
|
||||||
|
|
||||||
|
local function cacheStore()
|
||||||
|
local SaveData = require("src.core.SaveData")
|
||||||
|
local opts = SaveData.loadOptions()
|
||||||
|
opts.modUpdateCache = opts.modUpdateCache or {}
|
||||||
|
return opts
|
||||||
|
end
|
||||||
|
|
||||||
|
function ModUpdate.readCache(repo)
|
||||||
|
if type(repo) ~= "string" or repo == "" then return nil end
|
||||||
|
local ok, opts = pcall(function()
|
||||||
|
return require("src.core.SaveData").loadOptions()
|
||||||
|
end)
|
||||||
|
if not ok or type(opts) ~= "table" then return nil end
|
||||||
|
local entry = opts.modUpdateCache and opts.modUpdateCache[repo]
|
||||||
|
if type(entry) ~= "table" or type(entry.checkedAt) ~= "number" then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
if type(entry.releases) ~= "table" then return nil end
|
||||||
|
return entry
|
||||||
|
end
|
||||||
|
|
||||||
|
function ModUpdate.cacheFresh(entry, now, ttl)
|
||||||
|
now = now or os.time()
|
||||||
|
ttl = ttl or ModUpdate.CACHE_TTL
|
||||||
|
return entry and type(entry.checkedAt) == "number"
|
||||||
|
and (now - entry.checkedAt) < ttl
|
||||||
|
end
|
||||||
|
|
||||||
|
function ModUpdate.writeCache(repo, releases)
|
||||||
|
if type(repo) ~= "string" or repo == "" then return false end
|
||||||
|
local ok = pcall(function()
|
||||||
|
local SaveData = require("src.core.SaveData")
|
||||||
|
local opts = cacheStore()
|
||||||
|
local best = ModUpdate.pickBest(releases)
|
||||||
|
-- Persist a lean copy: enough to paint the UI and reinstall without
|
||||||
|
-- re-fetching within the TTL.
|
||||||
|
local lean = {}
|
||||||
|
for i, rel in ipairs(releases or {}) do
|
||||||
|
lean[i] = {
|
||||||
|
version = rel.version,
|
||||||
|
tag = rel.tag,
|
||||||
|
name = rel.name,
|
||||||
|
prerelease = rel.prerelease == true,
|
||||||
|
body = type(rel.body) == "string" and rel.body or "",
|
||||||
|
zip = rel.zip and {
|
||||||
|
name = rel.zip.name,
|
||||||
|
url = rel.zip.url,
|
||||||
|
size = rel.zip.size,
|
||||||
|
} or nil,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
opts.modUpdateCache[repo] = {
|
||||||
|
checkedAt = os.time(),
|
||||||
|
latest = best and best.version or nil,
|
||||||
|
releases = lean,
|
||||||
|
}
|
||||||
|
SaveData.saveOptions(opts)
|
||||||
|
end)
|
||||||
|
return ok
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Status vs an installed version using a cache entry / release list.
|
||||||
|
-- Returns "available" | "current" | "unknown".
|
||||||
|
function ModUpdate.statusFor(installedVersion, releases)
|
||||||
|
local best = ModUpdate.pickBest(releases)
|
||||||
|
if not best then return "unknown", nil end
|
||||||
|
if ModUpdate.isNewer(installedVersion, best.version) then
|
||||||
|
return "available", best
|
||||||
|
end
|
||||||
|
return "current", best
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ------- host I/O (curl)
|
||||||
|
|
||||||
|
local function shq(s)
|
||||||
|
s = tostring(s)
|
||||||
|
if love and love.system and love.system.getOS
|
||||||
|
and love.system.getOS() == "Windows" then
|
||||||
|
return '"' .. s:gsub('"', '') .. '"'
|
||||||
|
end
|
||||||
|
return "'" .. s:gsub("'", "'\\''") .. "'"
|
||||||
|
end
|
||||||
|
|
||||||
|
local function curlCapture(url)
|
||||||
|
local HostShell = require("src.core.HostShell")
|
||||||
|
local cmd = "curl -fsSL --connect-timeout 10 --max-time 40 "
|
||||||
|
.. "-H " .. shq("User-Agent: gen1recomp-mod-updater") .. " "
|
||||||
|
.. "-H " .. shq("Accept: application/vnd.github+json") .. " "
|
||||||
|
.. shq(url)
|
||||||
|
local pipeOk, pipe = pcall(HostShell.popen, cmd)
|
||||||
|
if not pipeOk or not pipe then return nil, "could not run curl" end
|
||||||
|
local readOk, out = pcall(function() return pipe:read("*a") end)
|
||||||
|
pcall(function() pipe:close() end)
|
||||||
|
if not readOk then return nil, "curl read failed: " .. tostring(out) end
|
||||||
|
if not out or out == "" then return nil, "empty response from GitHub" end
|
||||||
|
return out
|
||||||
|
end
|
||||||
|
|
||||||
|
function ModUpdate.haveCurl()
|
||||||
|
local HostShell = require("src.core.HostShell")
|
||||||
|
local pipeOk, pipe = pcall(HostShell.popen, "curl --version")
|
||||||
|
if not pipeOk or not pipe then return false end
|
||||||
|
local readOk, out = pcall(function() return pipe:read("*a") end)
|
||||||
|
pcall(function() pipe:close() end)
|
||||||
|
return readOk and out ~= nil and out:find("curl", 1, true) ~= nil
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Fetch release list. opts.force bypasses the 6h cache.
|
||||||
|
-- Returns releases, err, meta where meta = { fromCache = bool }.
|
||||||
|
function ModUpdate.fetchReleases(repo, modId, opts)
|
||||||
|
opts = opts or {}
|
||||||
|
if type(repo) ~= "string" or repo == "" then
|
||||||
|
return nil, "missing github repo"
|
||||||
|
end
|
||||||
|
if not opts.force then
|
||||||
|
local cached = ModUpdate.readCache(repo)
|
||||||
|
if ModUpdate.cacheFresh(cached) then
|
||||||
|
return cached.releases, nil, { fromCache = true }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if not ModUpdate.haveCurl() then
|
||||||
|
-- Stale cache is better than nothing when offline
|
||||||
|
local cached = ModUpdate.readCache(repo)
|
||||||
|
if cached and cached.releases then
|
||||||
|
return cached.releases, nil, { fromCache = true, stale = true }
|
||||||
|
end
|
||||||
|
return nil, "curl is not available on this platform"
|
||||||
|
end
|
||||||
|
local body, curlErr = curlCapture(ModUpdate.apiReleasesUrl(repo))
|
||||||
|
if not body then
|
||||||
|
local cached = ModUpdate.readCache(repo)
|
||||||
|
if cached and cached.releases then
|
||||||
|
return cached.releases, nil, { fromCache = true, stale = true }
|
||||||
|
end
|
||||||
|
return nil, curlErr
|
||||||
|
end
|
||||||
|
local list, parseErr = ModUpdate.parseReleases(body, modId)
|
||||||
|
if not list then return nil, parseErr end
|
||||||
|
ModUpdate.writeCache(repo, list)
|
||||||
|
return list, nil, { fromCache = false }
|
||||||
|
end
|
||||||
|
|
||||||
|
function ModUpdate.downloadZip(url, destName)
|
||||||
|
if type(url) ~= "string" or url == "" then
|
||||||
|
return nil, "missing download url"
|
||||||
|
end
|
||||||
|
if not (love and love.filesystem) then
|
||||||
|
return nil, "download needs LOVE"
|
||||||
|
end
|
||||||
|
if not ModUpdate.haveCurl() then
|
||||||
|
return nil, "curl is not available on this platform"
|
||||||
|
end
|
||||||
|
local HostShell = require("src.core.HostShell")
|
||||||
|
local name = destName or ("mod_update_" .. tostring(os.time()) .. ".zip")
|
||||||
|
name = tostring(name):gsub("[/\\]", "_")
|
||||||
|
local saveOk, saveDir = pcall(function()
|
||||||
|
return love.filesystem.getSaveDirectory()
|
||||||
|
end)
|
||||||
|
if not saveOk or not saveDir or saveDir == "" then
|
||||||
|
return nil, "no save directory"
|
||||||
|
end
|
||||||
|
local abs = saveDir .. "/" .. name
|
||||||
|
local cmd = "curl -fsSL --connect-timeout 15 --max-time 300 -o "
|
||||||
|
.. shq(abs) .. " " .. shq(url)
|
||||||
|
local pipeOk, pipe = pcall(HostShell.popen, cmd)
|
||||||
|
if not pipeOk or not pipe then return nil, "could not start download" end
|
||||||
|
pcall(function() pipe:read("*a") end)
|
||||||
|
pcall(function() pipe:close() end)
|
||||||
|
local infoOk, info = pcall(love.filesystem.getInfo, name)
|
||||||
|
if not infoOk or not info or (info.size or 0) == 0 then
|
||||||
|
pcall(love.filesystem.remove, name)
|
||||||
|
return nil, "download failed"
|
||||||
|
end
|
||||||
|
return name
|
||||||
|
end
|
||||||
|
|
||||||
|
return ModUpdate
|
||||||
@@ -428,6 +428,7 @@ R.pokemon = {
|
|||||||
spriteFront = f.path, spriteBack = f.path, frontSize = f.int(1, 7),
|
spriteFront = f.path, spriteBack = f.path, frontSize = f.int(1, 7),
|
||||||
dexEntry = f.opt(f.rec{ kind = f.str, heightFt = f.int(0),
|
dexEntry = f.opt(f.rec{ kind = f.str, heightFt = f.int(0),
|
||||||
heightIn = f.int(0, 11), weight = f.num,
|
heightIn = f.int(0, 11), weight = f.num,
|
||||||
|
heightM = f.opt(f.num), weightKg = f.opt(f.num),
|
||||||
text = f.str }),
|
text = f.str }),
|
||||||
icon = f.opt(f.union{ f.str, f.rec{ image = f.path,
|
icon = f.opt(f.union{ f.str, f.rec{ image = f.path,
|
||||||
frames = f.opt(f.int(1)) } }),
|
frames = f.opt(f.int(1)) } }),
|
||||||
@@ -561,6 +562,11 @@ R.trainers = {
|
|||||||
index = f.opt(f.int(0, 255)),
|
index = f.opt(f.int(0, 255)),
|
||||||
-- unused vanilla classes ship without a pic, so it cannot be required
|
-- unused vanilla classes ship without a pic, so it cannot be required
|
||||||
pic = f.opt(f.path),
|
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)),
|
baseMoney = f.opt(f.int(0)),
|
||||||
parties = f.list(f.list(f.rec{ level = f.int(1),
|
parties = f.list(f.list(f.rec{ level = f.int(1),
|
||||||
species = f.id("pokemon") })),
|
species = f.id("pokemon") })),
|
||||||
@@ -580,6 +586,10 @@ R.sprites = {
|
|||||||
frames = f.int(1),
|
frames = f.int(1),
|
||||||
walker = f.opt(f.bool),
|
walker = f.opt(f.bool),
|
||||||
trueColor = 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 })',
|
example = 'mod.content.sprites:register("SPRITE_HERO", { image = "...", frames = 6 })',
|
||||||
}
|
}
|
||||||
|
|||||||
+149
-45
@@ -5,11 +5,13 @@
|
|||||||
-- then drawn once per zone through a shader that remaps the four DMG
|
-- then drawn once per zone through a shader that remaps the four DMG
|
||||||
-- shades to that zone's palette.
|
-- shades to that zone's palette.
|
||||||
--
|
--
|
||||||
-- Port display option: COLORS (OG RED / SGB / ADVANCED / OG / OG INV /
|
-- Port display option: COLORS (OG RED / OG BLUE / OG YELLOW / SGB /
|
||||||
-- SGB INV / CLASSIC) transforms every zone's palette at send time via
|
-- ADVANCED / OG / OG INV / SGB INV / CLASSIC) transforms every zone's
|
||||||
-- effectiveColors. ADVANCED (the `redpp` id below) swaps the named-palette
|
-- palette at send time via effectiveColors. ADVANCED (the `redpp` id
|
||||||
-- pack for pokered-gbc SuperPalettes (data/palettes_gbc.lua), including
|
-- below) swaps the named-palette pack for pokered-gbc SuperPalettes
|
||||||
-- per-species mon colors.
|
-- (data/palettes_gbc.lua), including per-species mon colors. OG YELLOW
|
||||||
|
-- (Yellow playthrough + `ogred` id) uses pokeyellow CGBBasePalettes
|
||||||
|
-- (data/palettes_yellow.lua / ROM cgbBase).
|
||||||
|
|
||||||
local GameVersion = require("src.core.GameVersion")
|
local GameVersion = require("src.core.GameVersion")
|
||||||
|
|
||||||
@@ -17,10 +19,11 @@ local PaletteFX = {}
|
|||||||
|
|
||||||
local shader -- false = unavailable (headless / no shader support)
|
local shader -- false = unavailable (headless / no shader support)
|
||||||
local gbcPack -- false = missing; nil = not loaded yet
|
local gbcPack -- false = missing; nil = not loaded yet
|
||||||
|
local yellowPack -- false = missing; nil = not loaded yet
|
||||||
|
|
||||||
-- Cycle order matches OptionsMenu / hotkey 2. The three real colorizations
|
-- Cycle order matches OptionsMenu / hotkey 2. The three real colorizations
|
||||||
-- come first (OG RED = GBC hardware, SGB = per-map Super Game Boy, ADVANCED =
|
-- come first (OG RED/BLUE/YELLOW = GBC hardware, SGB = per-map Super Game Boy,
|
||||||
-- pokered-gbc per-tile), then the DMG-shade novelty modes.
|
-- ADVANCED = pokered-gbc per-tile), then the DMG-shade novelty modes.
|
||||||
PaletteFX.MODES = { "ogred", "gbc", "redpp", "og", "og_inv", "gbc_inv", "classic" }
|
PaletteFX.MODES = { "ogred", "gbc", "redpp", "og", "og_inv", "gbc_inv", "classic" }
|
||||||
-- `gbc`/`gbc_inv`/`redpp` keep their save-value ids for back-compat while
|
-- `gbc`/`gbc_inv`/`redpp` keep their save-value ids for back-compat while
|
||||||
-- their LABELS say what the mode actually is: "SGB"/"SGB INV" because the old
|
-- their LABELS say what the mode actually is: "SGB"/"SGB INV" because the old
|
||||||
@@ -35,6 +38,48 @@ PaletteFX.MODE_LABELS = {
|
|||||||
}
|
}
|
||||||
PaletteFX.mode = "gbc"
|
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)
|
-- Classic DMG pea-soup greens (#9BBC0F / #8BAC0F / #306230 / #0F380F)
|
||||||
PaletteFX.CLASSIC = {
|
PaletteFX.CLASSIC = {
|
||||||
{ 155, 188, 15 }, { 139, 172, 15 }, { 48, 98, 48 }, { 15, 56, 15 },
|
{ 155, 188, 15 }, { 139, 172, 15 }, { 48, 98, 48 }, { 15, 56, 15 },
|
||||||
@@ -75,14 +120,19 @@ PaletteFX.GBC_OBJ_BLUE = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
-- The active game's OG boot-ROM background palette: blue for a Blue
|
-- The active game's OG boot-ROM background palette: blue for a Blue
|
||||||
-- playthrough, red otherwise. White (index 1) and black (index 4) are
|
-- playthrough, red for Red. White (index 1) and black (index 4) are
|
||||||
-- identical across versions, so callers that only touch the endpoints
|
-- identical across Red/Blue, so callers that only touch the endpoints
|
||||||
-- (e.g. BattleState's zone white/black snap) need no version branch.
|
-- (e.g. BattleState's zone white/black snap) need no version branch there.
|
||||||
-- Yellow is CGB-enhanced (pokeyellow CGBBasePalettes) and has no extracted
|
-- Yellow is CGB-enhanced (pokeyellow CGBBasePalettes): named zones go through
|
||||||
-- boot-ROM auto-palette here; keep the Red ramp -- never Blue's GBC_BG_BLUE.
|
-- pal() / usesYellowCgb(), and ogBg() falls back to CGBBase PAL_ROUTE for any
|
||||||
|
-- remaining whole-screen callers -- never Blue's GBC_BG_BLUE.
|
||||||
function PaletteFX.ogBg()
|
function PaletteFX.ogBg()
|
||||||
if GameVersion.isBlue() then return PaletteFX.GBC_BG_BLUE end
|
if GameVersion.isBlue() then return PaletteFX.GBC_BG_BLUE end
|
||||||
if GameVersion.isYellow() then return PaletteFX.GBC_BG end
|
if GameVersion.isYellow() then
|
||||||
|
local y = PaletteFX.yellowPack()
|
||||||
|
local route = y and y.cgbBase and y.cgbBase.ROUTE
|
||||||
|
if route then return route end
|
||||||
|
end
|
||||||
return PaletteFX.GBC_BG
|
return PaletteFX.GBC_BG
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -91,11 +141,13 @@ end
|
|||||||
-- version-distinct cache-group string, because SpriteRenderer.getObpImage keys
|
-- version-distinct cache-group string, because SpriteRenderer.getObpImage keys
|
||||||
-- its baked-image cache by (image path, group): a shared group would collide a
|
-- its baked-image cache by (image path, group): a shared group would collide a
|
||||||
-- Red bake with a Blue one and one version would show the other's colors.
|
-- 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.
|
-- Yellow OG does not bake a boot-ROM OBJ (usesSpriteObp is false); this path
|
||||||
|
-- is unused there and kept as Red green only as a safe leftover.
|
||||||
function PaletteFX.ogObj()
|
function PaletteFX.ogObj()
|
||||||
if GameVersion.isBlue() then return PaletteFX.GBC_OBJ_BLUE, "gbcobj_blue" end
|
if GameVersion.isBlue() then
|
||||||
if GameVersion.isYellow() then return PaletteFX.GBC_OBJ, "gbcobj" end
|
return PaletteFX.darkObp(PaletteFX.GBC_OBJ_BLUE, "gbcobj_blue")
|
||||||
return PaletteFX.GBC_OBJ, "gbcobj"
|
end
|
||||||
|
return PaletteFX.darkObp(PaletteFX.GBC_OBJ, "gbcobj")
|
||||||
end
|
end
|
||||||
|
|
||||||
-- The DMG object ramp every mode except OG RED bakes onto overworld sprites,
|
-- The DMG object ramp every mode except OG RED bakes onto overworld sprites,
|
||||||
@@ -114,7 +166,7 @@ PaletteFX.OBP0_SHADES = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function PaletteFX.dmgObj()
|
function PaletteFX.dmgObj()
|
||||||
return PaletteFX.OBP0_SHADES, "obp0"
|
return PaletteFX.darkObp(PaletteFX.OBP0_SHADES, "obp0")
|
||||||
end
|
end
|
||||||
|
|
||||||
local INV_MAP = { [0] = 3, [1] = 2, [2] = 1, [3] = 0 }
|
local INV_MAP = { [0] = 3, [1] = 2, [2] = 1, [3] = 0 }
|
||||||
@@ -225,13 +277,31 @@ function PaletteFX.gbcPack()
|
|||||||
return gbcPack or nil
|
return gbcPack or nil
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- pokeyellow SuperPalettes + CGBBasePalettes (committed; not wiped by import).
|
||||||
|
-- ROM import may also stamp palettes.cgbBase; yellowCgbNamedPal prefers that.
|
||||||
|
function PaletteFX.yellowPack()
|
||||||
|
if yellowPack == nil then
|
||||||
|
local ok, pack = pcall(require, "data.palettes_yellow")
|
||||||
|
yellowPack = ok and pack or false
|
||||||
|
end
|
||||||
|
return yellowPack or nil
|
||||||
|
end
|
||||||
|
|
||||||
function PaletteFX.usesGbcPack(mode)
|
function PaletteFX.usesGbcPack(mode)
|
||||||
mode = mode or PaletteFX.mode
|
mode = mode or PaletteFX.mode
|
||||||
return mode == "redpp"
|
return mode == "redpp"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- Yellow's authentic GBC look is CGBBasePalettes (per-map), not a boot-ROM
|
||||||
|
-- auto-palette. The shared `ogred` save id wears that table on a Yellow
|
||||||
|
-- playthrough and labels itself "OG YELLOW".
|
||||||
|
function PaletteFX.usesYellowCgb(mode)
|
||||||
|
mode = mode or PaletteFX.mode
|
||||||
|
return GameVersion.isYellow() and mode == "ogred"
|
||||||
|
end
|
||||||
|
|
||||||
-- Whether the active mode bakes a per-OBJ palette onto overworld sprites
|
-- Whether the active mode bakes a per-OBJ palette onto overworld sprites
|
||||||
-- (the OBP bake + post-zone redraw path). OG RED alone does: the Game Boy
|
-- (the OBP bake + post-zone redraw path). OG RED / OG BLUE do: the Game Boy
|
||||||
-- Color boot ROM hands the game one global object palette (PaletteFX.ogObj --
|
-- Color boot ROM hands the game one global object palette (PaletteFX.ogObj --
|
||||||
-- green over Red's red background, pink over Blue's blue background), so on
|
-- green over Red's red background, pink over Blue's blue background), so on
|
||||||
-- that machine the player and NPCs carry a fixed object color instead of
|
-- that machine the player and NPCs carry a fixed object color instead of
|
||||||
@@ -242,6 +312,10 @@ end
|
|||||||
-- regression. Terrain is unaffected -- pal() below still hands SGB its per-map
|
-- regression. Terrain is unaffected -- pal() below still hands SGB its per-map
|
||||||
-- BG palette; only OG RED short-circuits BG to the one global red palette.
|
-- BG palette; only OG RED short-circuits BG to the one global red palette.
|
||||||
--
|
--
|
||||||
|
-- OG YELLOW does NOT. Yellow ships CGB code and colors regions via
|
||||||
|
-- CGBBasePalettes the same way SGB mode colors SuperPalettes, so sprites tint
|
||||||
|
-- with the map zone (usesSpriteObp stays off).
|
||||||
|
--
|
||||||
-- SGB does NOT. The Super Game Boy colorizes the composited DMG picture it is
|
-- SGB does NOT. The Super Game Boy colorizes the composited DMG picture it is
|
||||||
-- handed and cannot tell an OBJ pixel from a BG one; pokered never sends the
|
-- handed and cannot tell an OBJ pixel from a BG one; pokered never sends the
|
||||||
-- OBJ_TRN packet that would enable SGB sprite mode (data/sgb/sgb_packets.asm
|
-- OBJ_TRN packet that would enable SGB sprite mode (data/sgb/sgb_packets.asm
|
||||||
@@ -260,7 +334,7 @@ end
|
|||||||
-- usesGbcPack() path in SpriteRenderer instead.
|
-- usesGbcPack() path in SpriteRenderer instead.
|
||||||
function PaletteFX.usesSpriteObp(mode)
|
function PaletteFX.usesSpriteObp(mode)
|
||||||
mode = mode or PaletteFX.mode
|
mode = mode or PaletteFX.mode
|
||||||
return mode == "ogred"
|
return mode == "ogred" and not GameVersion.isYellow()
|
||||||
end
|
end
|
||||||
|
|
||||||
-- ------- post-zone sprite redraw (OG RED)
|
-- ------- post-zone sprite redraw (OG RED)
|
||||||
@@ -317,8 +391,8 @@ end
|
|||||||
-- read these from the ROM-imported table or the title ribbon stays red
|
-- read these from the ROM-imported table or the title ribbon stays red
|
||||||
-- and the Game Corner reels keep Red's pink (issue #128).
|
-- and the Game Corner reels keep Red's pink (issue #128).
|
||||||
-- Yellow is intentionally NOT in BLUE_VERSIONED: skip Blue LOGO1/SLOTS*
|
-- Yellow is intentionally NOT in BLUE_VERSIONED: skip Blue LOGO1/SLOTS*
|
||||||
-- recolors. When CGBBasePalettes were imported (palettes.cgbBase), Yellow
|
-- recolors. OG YELLOW (usesYellowCgb) reads CGBBasePalettes; SGB mode on
|
||||||
-- prefers those over SGB SuperPalettes for named zones.
|
-- Yellow keeps SuperPalettes.
|
||||||
local BLUE_VERSIONED = {
|
local BLUE_VERSIONED = {
|
||||||
LOGO1 = true, SLOTS2 = true, SLOTS3 = true, SLOTS4 = true,
|
LOGO1 = true, SLOTS2 = true, SLOTS3 = true, SLOTS4 = true,
|
||||||
}
|
}
|
||||||
@@ -330,31 +404,42 @@ end
|
|||||||
|
|
||||||
local function yellowCgbNamedPal(data, name)
|
local function yellowCgbNamedPal(data, name)
|
||||||
local p = data and data.palettes
|
local p = data and data.palettes
|
||||||
return p and p.cgbBase and p.cgbBase[name]
|
local fromRom = p and p.cgbBase and p.cgbBase[name]
|
||||||
|
if fromRom then return fromRom end
|
||||||
|
local y = PaletteFX.yellowPack()
|
||||||
|
return y and y.cgbBase and y.cgbBase[name] or nil
|
||||||
end
|
end
|
||||||
|
|
||||||
-- named palette from the active pack (nil on stale builds / missing name).
|
-- named palette from the active pack (nil on stale builds / missing name).
|
||||||
-- RED++ falls back to the ROM pack for names the gbc table omits (rare).
|
-- RED++ falls back to the ROM pack for names the gbc table omits (rare).
|
||||||
-- OG RED short-circuits EVERY name to the one global GBC boot-ROM BG palette
|
-- OG RED / OG BLUE short-circuit EVERY name to the one global GBC boot-ROM
|
||||||
-- (the hardware had a single BGP for the whole game), so terrain zones,
|
-- BG palette (the hardware had a single BGP for the whole game), so terrain
|
||||||
-- battle HP bars / text, and menu boxes all come out red -- everything a
|
-- zones, battle HP bars / text, and menu boxes all come out red/blue --
|
||||||
-- background tile drew. Objects do not come through here (they bake
|
-- everything a background tile drew. Objects do not come through here
|
||||||
-- GBC_OBJ green), so this stays a BG-only hook.
|
-- (they bake GBC_OBJ), so this stays a BG-only hook.
|
||||||
|
-- OG YELLOW instead resolves each name through CGBBasePalettes.
|
||||||
function PaletteFX.pal(data, name)
|
function PaletteFX.pal(data, name)
|
||||||
if PaletteFX.mode == "ogred" then return PaletteFX.ogBg() end
|
if PaletteFX.mode == "ogred" and not GameVersion.isYellow() then
|
||||||
|
return PaletteFX.ogBg()
|
||||||
|
end
|
||||||
-- Blue-only ROM override for versioned SuperPals. Yellow (isYellow) and
|
-- Blue-only ROM override for versioned SuperPals. Yellow (isYellow) and
|
||||||
-- Red keep the active pack / Red-like path -- do not apply Blue recolors.
|
-- Red keep the active pack / Red-like path -- do not apply Blue recolors.
|
||||||
if GameVersion.isBlue() and BLUE_VERSIONED[name] then
|
if GameVersion.isBlue() and BLUE_VERSIONED[name] then
|
||||||
local fromRom = romNamedPal(data, name)
|
local fromRom = romNamedPal(data, name)
|
||||||
if fromRom then return fromRom end
|
if fromRom then return fromRom end
|
||||||
end
|
end
|
||||||
if GameVersion.isYellow() then
|
if PaletteFX.usesYellowCgb() then
|
||||||
local fromCgb = yellowCgbNamedPal(data, name)
|
local fromCgb = yellowCgbNamedPal(data, name)
|
||||||
if fromCgb then return fromCgb end
|
if fromCgb then return fromCgb end
|
||||||
end
|
end
|
||||||
local p = PaletteFX.pack(data)
|
local p = PaletteFX.pack(data)
|
||||||
local c = p and p.palettes[name]
|
local c = p and p.palettes[name]
|
||||||
if c then return c end
|
if c then return c end
|
||||||
|
if GameVersion.isYellow() then
|
||||||
|
local y = PaletteFX.yellowPack()
|
||||||
|
local yc = y and y.palettes and y.palettes[name]
|
||||||
|
if yc then return yc end
|
||||||
|
end
|
||||||
if PaletteFX.usesGbcPack() then
|
if PaletteFX.usesGbcPack() then
|
||||||
return romNamedPal(data, name)
|
return romNamedPal(data, name)
|
||||||
end
|
end
|
||||||
@@ -367,14 +452,20 @@ end
|
|||||||
-- Transformed mon's pic is tinted gray, not the copied species' own
|
-- Transformed mon's pic is tinted gray, not the copied species' own
|
||||||
-- SGB color). RED++ uses per-species pals from mon_palettes.asm.
|
-- SGB color). RED++ uses per-species pals from mon_palettes.asm.
|
||||||
function PaletteFX.monPal(data, species, transformed)
|
function PaletteFX.monPal(data, species, transformed)
|
||||||
-- OG RED: a battle mon pic is a BG tile on the Game Boy Color (drawn into
|
-- OG RED / OG BLUE: a battle mon pic is a BG tile on the Game Boy Color
|
||||||
-- the tilemap, colored by BGP), so it wears the global red BG palette, not
|
-- (drawn into the tilemap, colored by BGP), so it wears the global boot-ROM
|
||||||
-- a per-species one -- matching the hardware capture where both mons are
|
-- BG palette, not a per-species one -- matching the hardware capture where
|
||||||
-- red/pink on the white field.
|
-- both mons are red/pink (or blue/pink) on the white field.
|
||||||
if PaletteFX.mode == "ogred" then return PaletteFX.ogBg() end
|
-- OG YELLOW keeps per-species CGBBasePalettes (Yellow had real CGB code).
|
||||||
|
if PaletteFX.mode == "ogred" and not GameVersion.isYellow() then
|
||||||
|
return PaletteFX.ogBg()
|
||||||
|
end
|
||||||
local p = PaletteFX.pack(data)
|
local p = PaletteFX.pack(data)
|
||||||
if not p then return nil end
|
if not p then return nil end
|
||||||
if transformed then
|
if transformed then
|
||||||
|
if PaletteFX.usesYellowCgb() then
|
||||||
|
return PaletteFX.pal(data, "GRAYMON")
|
||||||
|
end
|
||||||
return p.palettes.GRAYMON
|
return p.palettes.GRAYMON
|
||||||
or (data and data.palettes and data.palettes.palettes.GRAYMON)
|
or (data and data.palettes and data.palettes.palettes.GRAYMON)
|
||||||
end
|
end
|
||||||
@@ -384,17 +475,32 @@ function PaletteFX.monPal(data, species, transformed)
|
|||||||
-- so fall back to it when the pack itself doesn't carry the name.
|
-- so fall back to it when the pack itself doesn't carry the name.
|
||||||
local def = data and data.pokemon and data.pokemon[species]
|
local def = data and data.pokemon and data.pokemon[species]
|
||||||
if def and def.palette then
|
if def and def.palette then
|
||||||
|
if PaletteFX.usesYellowCgb() then
|
||||||
|
local yc = PaletteFX.pal(data, def.palette)
|
||||||
|
if yc then return yc end
|
||||||
|
end
|
||||||
local pal = p.palettes[def.palette]
|
local pal = p.palettes[def.palette]
|
||||||
or (data and data.palettes and data.palettes.palettes[def.palette])
|
or (data and data.palettes and data.palettes.palettes[def.palette])
|
||||||
if pal then return pal end
|
if pal then return pal end
|
||||||
end
|
end
|
||||||
local name = p.pokemon[species] or "MEWMON"
|
local name = p.pokemon[species] or "MEWMON"
|
||||||
|
if PaletteFX.usesYellowCgb() then
|
||||||
|
local yc = PaletteFX.pal(data, name)
|
||||||
|
if yc then return yc end
|
||||||
|
end
|
||||||
local c = p.palettes[name]
|
local c = p.palettes[name]
|
||||||
if c then return c end
|
if c then return c end
|
||||||
if PaletteFX.usesGbcPack() and data and data.palettes then
|
if PaletteFX.usesGbcPack() and data and data.palettes then
|
||||||
name = data.palettes.pokemon[species] or "MEWMON"
|
name = data.palettes.pokemon[species] or "MEWMON"
|
||||||
return data.palettes.palettes[name]
|
return data.palettes.palettes[name]
|
||||||
end
|
end
|
||||||
|
if GameVersion.isYellow() then
|
||||||
|
local y = PaletteFX.yellowPack()
|
||||||
|
if y and y.pokemon then
|
||||||
|
name = y.pokemon[species] or "MEWMON"
|
||||||
|
return y.palettes and y.palettes[name]
|
||||||
|
end
|
||||||
|
end
|
||||||
return nil
|
return nil
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -503,7 +609,7 @@ function PaletteFX.worldGroupColors(data, tileset, mapId, playerCellY)
|
|||||||
local w = pack and pack.world
|
local w = pack and pack.world
|
||||||
local base = w and w.groupColors[tileset]
|
local base = w and w.groupColors[tileset]
|
||||||
if not base then return nil end
|
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
|
local roofMapId = mapId
|
||||||
if mapId == ROUTE_6_SAFFRON.mapId and playerCellY
|
if mapId == ROUTE_6_SAFFRON.mapId and playerCellY
|
||||||
and playerCellY < ROUTE_6_SAFFRON.cellYBelow then
|
and playerCellY < ROUTE_6_SAFFRON.cellYBelow then
|
||||||
@@ -511,7 +617,7 @@ function PaletteFX.worldGroupColors(data, tileset, mapId, playerCellY)
|
|||||||
end
|
end
|
||||||
local roofMap = data and data.maps and data.maps[roofMapId]
|
local roofMap = data and data.maps and data.maps[roofMapId]
|
||||||
local roof = roofMap and w.roofByMapIndex[roofMap.index]
|
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 = {}
|
local out = {}
|
||||||
for i = 1, 8 do out[i] = base[i] end
|
for i = 1, 8 do out[i] = base[i] end
|
||||||
-- LoadTownPalette only overwrites W2_BgPaletteData + $32, i.e. colors 1
|
-- LoadTownPalette only overwrites W2_BgPaletteData + $32, i.e. colors 1
|
||||||
@@ -521,7 +627,7 @@ function PaletteFX.worldGroupColors(data, tileset, mapId, playerCellY)
|
|||||||
-- material's 2 middle shades are town-specific
|
-- material's 2 middle shades are town-specific
|
||||||
local base4 = base[ROOF_GROUP + 1]
|
local base4 = base[ROOF_GROUP + 1]
|
||||||
out[ROOF_GROUP + 1] = { base4[1], roof[1], roof[2], base4[4] }
|
out[ROOF_GROUP + 1] = { base4[1], roof[1], roof[2], base4[4] }
|
||||||
return out
|
return darkGroups(out)
|
||||||
end
|
end
|
||||||
|
|
||||||
-- an overworld sprite's resolved 4-color OBJ palette (ColorOverworldSprite),
|
-- an overworld sprite's resolved 4-color OBJ palette (ColorOverworldSprite),
|
||||||
@@ -537,7 +643,7 @@ end
|
|||||||
function PaletteFX.spriteObp(spriteDef, seed)
|
function PaletteFX.spriteObp(spriteDef, seed)
|
||||||
local pack = PaletteFX.gbcPack()
|
local pack = PaletteFX.gbcPack()
|
||||||
local w = pack and pack.world
|
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
|
if not (w and src) then return nil end
|
||||||
local idx = tonumber(src:match("%[(%d+)%]"))
|
local idx = tonumber(src:match("%[(%d+)%]"))
|
||||||
-- RedBikeSprite loads outside SpriteSheetPointerTable
|
-- RedBikeSprite loads outside SpriteSheetPointerTable
|
||||||
@@ -552,7 +658,7 @@ function PaletteFX.spriteObp(spriteDef, seed)
|
|||||||
for i = 1, #seed do h = (h * 31 + seed:byte(i)) % 4294967296 end
|
for i = 1, #seed do h = (h * 31 + seed:byte(i)) % 4294967296 end
|
||||||
group = h % 4
|
group = h % 4
|
||||||
end
|
end
|
||||||
return w.spritePalettes[group], group
|
return PaletteFX.darkObp(w.spritePalettes[group], group)
|
||||||
end
|
end
|
||||||
|
|
||||||
-- GetHealthBarColor (home/palettes.asm) on the standard 48px bar
|
-- GetHealthBarColor (home/palettes.asm) on the standard 48px bar
|
||||||
@@ -661,12 +767,10 @@ end
|
|||||||
|
|
||||||
function PaletteFX.modeLabel(mode)
|
function PaletteFX.modeLabel(mode)
|
||||||
mode = mode or PaletteFX.mode
|
mode = mode or PaletteFX.mode
|
||||||
-- The GBC boot-ROM mode wears the running game's name: it is red for Red and
|
-- The GBC hardware mode wears the running game's name: OG RED / OG BLUE
|
||||||
-- blue for Blue (see ogBg), so a Blue playthrough shows "OG BLUE".
|
-- (boot-ROM auto-palette) or OG YELLOW (pokeyellow CGBBasePalettes).
|
||||||
-- Yellow still uses the Red boot-ROM ramp (no Yellow table yet), so keep
|
|
||||||
-- the "OG RED" label rather than inventing an "OG YELLOW" without colors.
|
|
||||||
if mode == "ogred" and GameVersion.isBlue() then return "OG BLUE" end
|
if mode == "ogred" and GameVersion.isBlue() then return "OG BLUE" end
|
||||||
if mode == "ogred" and GameVersion.isYellow() then return "OG RED" end
|
if mode == "ogred" and GameVersion.isYellow() then return "OG YELLOW" end
|
||||||
return PaletteFX.MODE_LABELS[mode] or "GBC"
|
return PaletteFX.MODE_LABELS[mode] or "GBC"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
+25
-4
@@ -437,12 +437,26 @@ function Renderer:drawTiltedWorld(zoneList, sx, sy, wox, woy, target)
|
|||||||
return true
|
return true
|
||||||
end
|
end
|
||||||
|
|
||||||
-- clamp a scissor rect to the viewport box
|
-- Clamp a scissor rect to the viewport box, then round it outward to whole
|
||||||
local function scissorClamped(x, y, w, h, ox, oy, vpw, vph)
|
-- 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)
|
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)
|
x, y = math.max(x, ox), math.max(y, oy)
|
||||||
if x2 <= x or y2 <= y then return false end
|
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
|
return true
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -572,7 +586,7 @@ function Renderer:endFrame(zones, worldZones)
|
|||||||
if not plain then PaletteFX.sendColors(shader, z.colors) end
|
if not plain then PaletteFX.sendColors(shader, z.colors) end
|
||||||
if scissorClamped(bx + z.x * zoneSx, by + z.y * zoneSy,
|
if scissorClamped(bx + z.x * zoneSx, by + z.y * zoneSy,
|
||||||
z.w * zoneSx, z.h * 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)
|
love.graphics.draw(canvas, bx, by, 0, sx, sy)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -706,6 +720,13 @@ function Renderer:endFrame(zones, worldZones)
|
|||||||
self.uprightActive = false
|
self.uprightActive = false
|
||||||
self.worldOverride = nil
|
self.worldOverride = nil
|
||||||
PaletteFX.setPass(nil)
|
PaletteFX.setPass(nil)
|
||||||
|
return {
|
||||||
|
width = ww, height = wh,
|
||||||
|
gameX = ox, gameY = oy,
|
||||||
|
gameWidth = vpw, gameHeight = vph,
|
||||||
|
scale = Sp,
|
||||||
|
dpiX = dpiX, dpiY = dpiY,
|
||||||
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
return Renderer
|
return Renderer
|
||||||
|
|||||||
@@ -135,7 +135,10 @@ local function blitFrame(image, quad, x, y, flip, redraw)
|
|||||||
end
|
end
|
||||||
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 x = math.floor(px - camX)
|
||||||
local y = math.floor(py - camY) - 4
|
local y = math.floor(py - camY) - 4
|
||||||
local image = self.image
|
local image = self.image
|
||||||
@@ -190,7 +193,39 @@ function SpriteRenderer:draw(px, py, camX, camY, facing, walkPhase, stepFlip)
|
|||||||
flip = true
|
flip = true
|
||||||
end
|
end
|
||||||
local quad = self.frames[frame] or self.frames[0]
|
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)
|
blitFrame(image, quad, x, y, flip, redraw)
|
||||||
end
|
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
|
return SpriteRenderer
|
||||||
|
|||||||
@@ -402,8 +402,15 @@ end
|
|||||||
-- the route's own default roof (Vermilion's) throughout.
|
-- the route's own default roof (Vermilion's) throughout.
|
||||||
local gbcAtlasCache = {}
|
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 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
|
if gbcAtlasCache[key] ~= nil then return gbcAtlasCache[key] or nil end
|
||||||
local img = false
|
local img = false
|
||||||
if love.image and love.image.newImageData then
|
if love.image and love.image.newImageData then
|
||||||
@@ -473,7 +480,7 @@ function TileRenderer.new(map, data)
|
|||||||
self.gbcAtlas = true
|
self.gbcAtlas = true
|
||||||
-- also recolors the animated water/flower entries below, so they
|
-- also recolors the animated water/flower entries below, so they
|
||||||
-- match the atlas's static tiles instead of showing raw grayscale
|
-- 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) }
|
groupColors = PaletteFX.worldGroupColors(data, map.tileset.id, map.id, nil) }
|
||||||
-- ...and feeds the color-0-keyed single tiles the feet overdraw needs
|
-- ...and feeds the color-0-keyed single tiles the feet overdraw needs
|
||||||
-- (see getKeyedTile): same source image and palette groups, so keep the
|
-- (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.imagePath = map.tileset.image
|
||||||
gbcCtx.perRow = map.tileset.tilesPerRow
|
gbcCtx.perRow = map.tileset.tilesPerRow
|
||||||
self.gbcCtx = gbcCtx
|
self.gbcCtx = gbcCtx
|
||||||
|
self.gbcAtlasKey = map.tileset.image .. gbcCtx.key
|
||||||
self.gbcKeyed = {}
|
self.gbcKeyed = {}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -582,7 +590,7 @@ local function ensureWaterBorderFill(self)
|
|||||||
local groupColors = PaletteFX.worldGroupColors(
|
local groupColors = PaletteFX.worldGroupColors(
|
||||||
self.data, map.tileset.id, map.id, nil)
|
self.data, map.tileset.id, map.id, nil)
|
||||||
colors = group and groupColors and groupColors[group + 1] or nil
|
colors = group and groupColors and groupColors[group + 1] or nil
|
||||||
gbcKey = "#gbc:" .. map.id
|
gbcKey = gbcKeyFor(map.id)
|
||||||
end
|
end
|
||||||
local textures = getShiftVariants(map.tileset.image, perRow, WATER_TILE,
|
local textures = getShiftVariants(map.tileset.image, perRow, WATER_TILE,
|
||||||
colors, gbcKey)
|
colors, gbcKey)
|
||||||
@@ -913,8 +921,9 @@ function TileRenderer:release()
|
|||||||
self.gbcCtx = nil
|
self.gbcCtx = nil
|
||||||
end
|
end
|
||||||
if self.gbcAtlas and self.image then
|
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
|
if gbcAtlasCache[key] == self.image then gbcAtlasCache[key] = nil end
|
||||||
|
self.gbcAtlasKey = nil
|
||||||
safeRelease(self.image)
|
safeRelease(self.image)
|
||||||
self.image = nil
|
self.image = nil
|
||||||
self.gbcAtlas = nil
|
self.gbcAtlas = nil
|
||||||
|
|||||||
@@ -10,6 +10,13 @@ local Zoom = {}
|
|||||||
|
|
||||||
Zoom.offset = 0
|
Zoom.offset = 0
|
||||||
|
|
||||||
|
-- Survey zoom (zooming out past FIT, which renders connected neighbor maps)
|
||||||
|
-- is the port's most expensive optional extra. The performance tier sets
|
||||||
|
-- this false on LOW hardware (Game:applyOptions); offsetRange then floors
|
||||||
|
-- the range at FIT so the option row, hotkey, and mouse wheel all stop at
|
||||||
|
-- close-up. Nil/true keeps the historical full range.
|
||||||
|
Zoom.allowSurvey = true
|
||||||
|
|
||||||
-- legal offset range for a given fit scale (vanilla: survey at 1 px/world
|
-- legal offset range for a given fit scale (vanilla: survey at 1 px/world
|
||||||
-- through 2× fit). zoom.range may widen or shrink the window.
|
-- through 2× fit). zoom.range may widen or shrink the window.
|
||||||
function Zoom.offsetRange(S)
|
function Zoom.offsetRange(S)
|
||||||
@@ -21,6 +28,9 @@ function Zoom.offsetRange(S)
|
|||||||
hi = math.floor(tonumber(hi) or S)
|
hi = math.floor(tonumber(hi) or S)
|
||||||
if lo > hi then lo, hi = hi, lo end
|
if lo > hi then lo, hi = hi, lo end
|
||||||
end
|
end
|
||||||
|
-- LOW performance tier: no survey (negative offsets), even if a mod's
|
||||||
|
-- zoom.range widened it. == false so nil/true stays permissive.
|
||||||
|
if Zoom.allowSurvey == false and lo < 0 then lo = 0 end
|
||||||
return lo, hi
|
return lo, hi
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -84,6 +84,15 @@ O.coins = O.mainData + 685 -- 2B BCD
|
|||||||
-- (wWalkBikeSurfState) + 10 = 359, so 685 + 359 = 1044 as well.
|
-- (wWalkBikeSurfState) + 10 = 359, so 685 + 359 = 1044 as well.
|
||||||
O.townVisited = O.mainData + 1044 -- 2B (flag_array NUM_CITY_MAPS)
|
O.townVisited = O.mainData + 1044 -- 2B (flag_array NUM_CITY_MAPS)
|
||||||
O.eventFlags = O.mainData + 1104 -- 320B (flag_array NUM_EVENTS = 2560 bits)
|
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
|
-- Play time (wPlayTimeHours/Maxed/Minutes/Seconds/Frames) lives INSIDE the
|
||||||
-- sMainData window (wMainDataStart..wMainDataEnd is copied verbatim into
|
-- sMainData window (wMainDataStart..wMainDataEnd is copied verbatim into
|
||||||
-- SRAM), 1866 bytes past wMainDataStart -- reached from the checksum-verified
|
-- SRAM), 1866 bytes past wMainDataStart -- reached from the checksum-verified
|
||||||
@@ -314,6 +323,34 @@ local BADGE_BY_BIT = {
|
|||||||
local BADGE_BY_BIT_SET = {}
|
local BADGE_BY_BIT_SET = {}
|
||||||
for _, name in pairs(BADGE_BY_BIT) do BADGE_BY_BIT_SET[name] = true end
|
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,
|
-- STATUS_* bits (constants/battle_constants.asm): 0-2 sleep-turns-left,
|
||||||
-- 3 PSN, 4 BRN, 5 FRZ, 6 PAR
|
-- 3 PSN, 4 BRN, 5 FRZ, 6 PAR
|
||||||
local STATUS_BIT = { PSN = 3, BRN = 4, FRZ = 5, PAR = 6 }
|
local STATUS_BIT = { PSN = 3, BRN = 4, FRZ = 5, PAR = 6 }
|
||||||
@@ -671,6 +708,14 @@ function GenSave.decode(bytes, data, opts)
|
|||||||
end
|
end
|
||||||
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:
|
-- FLY destinations. wTownVisitedFlag's bit index IS the town's map index:
|
||||||
-- engine/items/town_map.asm BuildFlyLocationsList loads the 16-bit value
|
-- 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
|
-- 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]
|
local bitIdx = events.byName[name]
|
||||||
if bitIdx then bitSet(buf, O.eventFlags, bitIdx, true) end
|
if bitIdx then bitSet(buf, O.eventFlags, bitIdx, true) end
|
||||||
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
|
end
|
||||||
|
|
||||||
-- FLY destinations back into wTownVisitedFlag (see the decode note), so a
|
-- 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
|
-- tables load through `require`, exactly how src/core/Data.lua pulls the
|
||||||
-- generated modules -- which resolves under both plain luajit (package.path
|
-- generated modules -- which resolves under both plain luajit (package.path
|
||||||
-- "./?.lua") for the headless CLI/tests and love.filesystem for a fused
|
-- "./?.lua") for the headless CLI/tests and love.filesystem for a fused
|
||||||
-- build, with an OS-path fallback for odd working directories. The only
|
-- build, with an OS-path fallback for odd working directories. `love` is only
|
||||||
-- place `love` is referenced is inside a guarded fallback, so running under
|
-- ever referenced inside guarded fallbacks, so running under stock Lua never
|
||||||
-- stock Lua never touches it.
|
-- 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")
|
local GenSave = require("src.save_convert.GenSave")
|
||||||
|
|
||||||
@@ -63,20 +68,59 @@ local function loadTable(requirePath, filePath)
|
|||||||
:format(requirePath, requirePath, filePath)
|
:format(requirePath, requirePath, filePath)
|
||||||
end
|
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 charmapReady
|
||||||
|
|
||||||
local function ensureData()
|
local function ensureData(gameVersion)
|
||||||
if not crosswalk then
|
local key = gameVersion or "*"
|
||||||
|
if not crosswalks[key] then
|
||||||
local data = {}
|
local data = {}
|
||||||
for key, spec in pairs(DATA_MODULES) do
|
for name, spec in pairs(DATA_MODULES) do
|
||||||
if key ~= "charmap" then
|
if name ~= "charmap" then
|
||||||
local mod, e = loadTable(spec[1], spec[2])
|
local mod = loadCacheTable(gameVersion, spec[2])
|
||||||
if not mod then return nil, e end
|
if not mod then
|
||||||
data[key] = mod
|
local e
|
||||||
|
mod, e = loadTable(spec[1], spec[2])
|
||||||
|
if not mod then return nil, e end
|
||||||
|
end
|
||||||
|
data[name] = mod
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
crosswalk = data
|
crosswalks[key] = data
|
||||||
end
|
end
|
||||||
if not charmapReady then
|
if not charmapReady then
|
||||||
local cm, err = loadTable(DATA_MODULES.charmap[1], DATA_MODULES.charmap[2])
|
local cm, err = loadTable(DATA_MODULES.charmap[1], DATA_MODULES.charmap[2])
|
||||||
@@ -84,13 +128,14 @@ local function ensureData()
|
|||||||
GenSave.setCharmap(cm)
|
GenSave.setCharmap(cm)
|
||||||
charmapReady = true
|
charmapReady = true
|
||||||
end
|
end
|
||||||
return crosswalk
|
return crosswalks[key]
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Exposed for the CLI/tests so they can share the exact data set the codec
|
-- 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.
|
-- uses (and so a caller can pre-warm the cache). gameVersion picks whose ROM
|
||||||
function SaveConvert.loadData()
|
-- cache the generated tables come from. Returns data, err.
|
||||||
return ensureData()
|
function SaveConvert.loadData(gameVersion)
|
||||||
|
return ensureData(gameVersion)
|
||||||
end
|
end
|
||||||
|
|
||||||
-- ------------------------------------------------------------------
|
-- ------------------------------------------------------------------
|
||||||
@@ -108,7 +153,8 @@ local function defaultsSave()
|
|||||||
options = {
|
options = {
|
||||||
textSpeed = 3, animations = true, battleStyle = "shift",
|
textSpeed = 3, animations = true, battleStyle = "shift",
|
||||||
battleLayout = "og",
|
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,
|
speed = 1, colors = "gbc", tilt = 0, gbcfx = 0,
|
||||||
videoMode = "windowed", mods = {},
|
videoMode = "windowed", mods = {},
|
||||||
},
|
},
|
||||||
@@ -138,20 +184,22 @@ SaveConvert.mergeDefaults = mergeDefaults
|
|||||||
-- Public API
|
-- 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
|
-- bytes: the raw 32768-byte SRAM string. Validates size and the main-data
|
||||||
-- checksum, decodes through GenSave, and returns a save table fully merged
|
-- checksum, decodes through GenSave, and returns a save table fully merged
|
||||||
-- over the new-game defaults and tagged with `version`, ready to hand to
|
-- 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
|
-- SaveSerializer.encode for a slot file. gameVersion ("red"/"blue"/"yellow")
|
||||||
-- message (never raises).
|
-- names the game the save is being imported for, which is what selects the
|
||||||
function SaveConvert.importSav(bytes, version)
|
-- 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
|
if type(bytes) ~= "string" then
|
||||||
return nil, "expected raw save bytes as a string"
|
return nil, "expected raw save bytes as a string"
|
||||||
end
|
end
|
||||||
if #bytes ~= GenSave.SAVE_SIZE then
|
if #bytes ~= GenSave.SAVE_SIZE then
|
||||||
return nil, ("save must be %d bytes, got %d"):format(GenSave.SAVE_SIZE, #bytes)
|
return nil, ("save must be %d bytes, got %d"):format(GenSave.SAVE_SIZE, #bytes)
|
||||||
end
|
end
|
||||||
local data, derr = ensureData()
|
local data, derr = ensureData(gameVersion)
|
||||||
if not data then return nil, derr end
|
if not data then return nil, derr end
|
||||||
|
|
||||||
local ok, decoded = pcall(GenSave.decode, bytes, data)
|
local ok, decoded = pcall(GenSave.decode, bytes, data)
|
||||||
@@ -170,16 +218,17 @@ function SaveConvert.importSav(bytes, version)
|
|||||||
return mergeDefaults(decoded, version)
|
return mergeDefaults(decoded, version)
|
||||||
end
|
end
|
||||||
|
|
||||||
-- exportSav(saveTable) -> bytes, err
|
-- exportSav(saveTable, gameVersion) -> bytes, err
|
||||||
-- Encodes a save table back to a raw 32768-byte SRAM image. Template-aware:
|
-- 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)
|
-- if the table still carries the stashed import template (saveTable.rawImport)
|
||||||
-- GenSave reproduces every unmodeled region from it; otherwise those regions
|
-- GenSave reproduces every unmodeled region from it; otherwise those regions
|
||||||
-- are zero-filled. On failure returns nil + a message (never raises).
|
-- are zero-filled. gameVersion selects the crosswalk tables exactly as in
|
||||||
function SaveConvert.exportSav(saveTable)
|
-- importSav. On failure returns nil + a message (never raises).
|
||||||
|
function SaveConvert.exportSav(saveTable, gameVersion)
|
||||||
if type(saveTable) ~= "table" then
|
if type(saveTable) ~= "table" then
|
||||||
return nil, "expected a save table"
|
return nil, "expected a save table"
|
||||||
end
|
end
|
||||||
local data, derr = ensureData()
|
local data, derr = ensureData(gameVersion)
|
||||||
if not data then return nil, derr end
|
if not data then return nil, derr end
|
||||||
local ok, bytes = pcall(GenSave.encode, saveTable, data, nil)
|
local ok, bytes = pcall(GenSave.encode, saveTable, data, nil)
|
||||||
if not ok then return nil, "encode failed: " .. tostring(bytes) end
|
if not ok then return nil, "encode failed: " .. tostring(bytes) end
|
||||||
|
|||||||
+35
-7
@@ -210,11 +210,12 @@ end
|
|||||||
-- text (label or literal; {RAM:wStringBuffer} becomes the item name);
|
-- text (label or literal; {RAM:wStringBuffer} becomes the item name);
|
||||||
-- pass false when the script shows its own received-text row.
|
-- pass false when the script shows its own received-text row.
|
||||||
function Commands.give_item(ctx, itemId, count, gotText)
|
function Commands.give_item(ctx, itemId, count, gotText)
|
||||||
-- the 20-slot bag can refuse (BAG_ITEM_CAPACITY): say so and halt
|
-- the bag can refuse at its configured capacity (20 in vanilla): halt
|
||||||
-- the script, so later set_flag rows don't burn the gift -- make
|
-- the script, so later set_flag rows don't burn the gift -- make
|
||||||
-- room and talk again, like the original (pokered's `jr nc, .bag_full`
|
-- room and talk again, like the original (pokered's `jr nc, .bag_full`
|
||||||
-- skips the received text entirely when AddItemToInventory refuses)
|
-- skips the received text entirely when AddItemToInventory refuses)
|
||||||
if not require("src.inventory.Bag").add(ctx.save, itemId, count or 1) then
|
if not require("src.inventory.Bag").add(
|
||||||
|
ctx.save, itemId, count or 1, ctx.game.data) then
|
||||||
Commands.show_text(ctx, ctx.game.data.text
|
Commands.show_text(ctx, ctx.game.data.text
|
||||||
and ctx.game.data.text._BagFullText or Strings("You can't carry\nany more items!"))
|
and ctx.game.data.text._BagFullText or Strings("You can't carry\nany more items!"))
|
||||||
return math.huge
|
return math.huge
|
||||||
@@ -225,11 +226,23 @@ function Commands.give_item(ctx, itemId, count, gotText)
|
|||||||
ctx.game.stringBuffer = def and def.name or itemId
|
ctx.game.stringBuffer = def and def.name or itemId
|
||||||
-- the jingle rides the box -- Sound.play routes fanfares through
|
-- the jingle rides the box -- Sound.play routes fanfares through
|
||||||
-- Music.duckForFanfare, like PlaySoundWaitForCurrent
|
-- Music.duckForFanfare, like PlaySoundWaitForCurrent
|
||||||
require("src.core.Sound").play(ctx.game.data,
|
local Sound = require("src.core.Sound")
|
||||||
(def and def.keyItem) and "Get_Key_Item" or "Get_Item1")
|
local jingle = (def and def.keyItem) and "Get_Key_Item" or "Get_Item1"
|
||||||
if gotText ~= false then
|
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
|
Commands.show_text(ctx, gotText
|
||||||
or Strings("{PLAYER} got\n%s!", ctx.game.stringBuffer))
|
or Strings("{PLAYER} got\n%s!", ctx.game.stringBuffer))
|
||||||
|
else
|
||||||
|
Sound.play(ctx.game.data, jingle)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -253,7 +266,18 @@ function Commands.start_battle(ctx, kind, a, b)
|
|||||||
ctx.lastBattleResult = result
|
ctx.lastBattleResult = result
|
||||||
ctx.lastCheck = result == "win"
|
ctx.lastCheck = result == "win"
|
||||||
if ctx.overworld then
|
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
|
end
|
||||||
runner:resume()
|
runner:resume()
|
||||||
end
|
end
|
||||||
@@ -711,11 +735,15 @@ function Commands.record_hall_of_fame(ctx)
|
|||||||
if game.overworld then
|
if game.overworld then
|
||||||
game.overworld.lastOutdoor = ctx.save.lastOutdoor
|
game.overworld.lastOutdoor = ctx.save.lastOutdoor
|
||||||
end
|
end
|
||||||
if game.writeSave then game:writeSave() end
|
local saveAllowed = true
|
||||||
|
if game.writeSave then saveAllowed = game:writeSave() ~= false end
|
||||||
-- writeSave's captureSave re-stamps the live HALL_OF_FAME coords;
|
-- writeSave's captureSave re-stamps the live HALL_OF_FAME coords;
|
||||||
-- re-apply home and persist so CONTINUE resumes in the bedroom.
|
-- re-apply home and persist so CONTINUE resumes in the bedroom.
|
||||||
SaveData.applyPostGameHome(ctx.save, boot)
|
SaveData.applyPostGameHome(ctx.save, boot)
|
||||||
SaveData.save(ctx.save)
|
-- A tool session may veto Game:writeSave to keep its in-memory run
|
||||||
|
-- isolated from the player's real save. The relocation write is part
|
||||||
|
-- of that same save operation and must honor the same decision.
|
||||||
|
if saveAllowed then SaveData.save(ctx.save) end
|
||||||
end)
|
end)
|
||||||
end)
|
end)
|
||||||
runner:yield()
|
runner:yield()
|
||||||
|
|||||||
@@ -125,6 +125,10 @@ function ScriptRunner:run(script, extra)
|
|||||||
end
|
end
|
||||||
self.co = coroutine.create(function()
|
self.co = coroutine.create(function()
|
||||||
self:exec(script, ctx)
|
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 ctx.onDone then ctx.onDone() end
|
||||||
if Runtime.wants("script.ended") then
|
if Runtime.wants("script.ended") then
|
||||||
Runtime.emit("script.ended", { ctx = ctx, completed = true })
|
Runtime.emit("script.ended", { ctx = ctx, completed = true })
|
||||||
|
|||||||
+16
-3
@@ -93,6 +93,17 @@ local function useOn(game, battle, id, target, list, moveIndex, picker)
|
|||||||
end
|
end
|
||||||
|
|
||||||
if result == "bicycle" then
|
if result == "bicycle" then
|
||||||
|
-- StartMenu_Item .useOrTossItem (engine/menus/start_sub_menus.asm):
|
||||||
|
-- while BIT_ALWAYS_ON_BIKE of wStatusFlags6 is set -- the Cycling Road,
|
||||||
|
-- armed by the forced-bike tiles and cleared by the Route 16/18 gate
|
||||||
|
-- scripts -- the BICYCLE refuses with _CannotGetOffHereText and jumps
|
||||||
|
-- back to ItemMenuLoop, so the bag stays open and no dismount happens
|
||||||
|
-- (#513). The gate sits ahead of UseItem, before ItemUseBicycle ever
|
||||||
|
-- runs, which is why it precedes list:close() here.
|
||||||
|
if game.save.forcedBike then
|
||||||
|
showMessages(game, { Strings("You can't get off\nhere.") })
|
||||||
|
return
|
||||||
|
end
|
||||||
list:close()
|
list:close()
|
||||||
local ow = game.overworld
|
local ow = game.overworld
|
||||||
local Music = require("src.core.Music")
|
local Music = require("src.core.Music")
|
||||||
@@ -305,9 +316,11 @@ local function useOn(game, battle, id, target, list, moveIndex, picker)
|
|||||||
-- HP medicine: fill the bar in the still-open picker first, then print
|
-- HP medicine: fill the bar in the still-open picker first, then print
|
||||||
-- and close, the order item_effects.asm .doneHealing runs in
|
-- and close, the order item_effects.asm .doneHealing runs in
|
||||||
-- (SFX_HEAL_HP -> UpdateHPBar2 -> RedrawPartyMenu prints the message).
|
-- (SFX_HEAL_HP -> UpdateHPBar2 -> RedrawPartyMenu prints the message).
|
||||||
-- picker is nil for every other item and for in-battle use, which keeps
|
-- Only a keepOpen picker is still on the stack to animate: every other
|
||||||
-- the pop-then-print path below. #252
|
-- item, and every in-battle use, popped it in PartyMenu before onSwitch,
|
||||||
if picker and extra and extra.healedFrom and target then
|
-- 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()
|
picker:animateTo(target, extra.healedFrom, function()
|
||||||
showMessages(game, payload, closePicker)
|
showMessages(game, payload, closePicker)
|
||||||
end)
|
end)
|
||||||
|
|||||||
+22
-2
@@ -64,9 +64,24 @@ function BindingsMenu.new(game)
|
|||||||
local self = setmetatable(ListMenu.new(game, "CONTROLS", items, {}),
|
local self = setmetatable(ListMenu.new(game, "CONTROLS", items, {}),
|
||||||
BindingsMenu)
|
BindingsMenu)
|
||||||
self.onChoose = function(item) self:beginCapture(item) end
|
self.onChoose = function(item) self:beginCapture(item) end
|
||||||
|
-- A rebind reaches Input only when this screen closes (#510). The menu
|
||||||
|
-- steers by the live map, so applying "B = Z" the instant it was captured
|
||||||
|
-- turned the player's next confirm press into a cancel and shut the
|
||||||
|
-- screen mid-swap. options.bindings is still written immediately, and
|
||||||
|
-- Game:applyOptions re-applies it on load, so a close that skips this
|
||||||
|
-- hook still ends up with the saved map.
|
||||||
|
self.onCancel = function() self:commitBindings() end
|
||||||
return self
|
return self
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- Cross-file contract with src/core/Input.lua: the saved overlay reaches
|
||||||
|
-- the live map here, on close, and nowhere else in this screen.
|
||||||
|
function BindingsMenu:commitBindings()
|
||||||
|
local game = self.game
|
||||||
|
local opts = game and game.save and game.save.options
|
||||||
|
if opts then Input:applyBindings(opts.bindings) end
|
||||||
|
end
|
||||||
|
|
||||||
-- the capture handlers are per-instance slots, so Game's raw-input
|
-- the capture handlers are per-instance slots, so Game's raw-input
|
||||||
-- routing only ever sees this screen while a capture is armed
|
-- routing only ever sees this screen while a capture is armed
|
||||||
function BindingsMenu:beginCapture(item)
|
function BindingsMenu:beginCapture(item)
|
||||||
@@ -75,7 +90,12 @@ function BindingsMenu:beginCapture(item)
|
|||||||
self.onGamepadPressed = BindingsMenu.capturePad
|
self.onGamepadPressed = BindingsMenu.capturePad
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- Escape is the capture's way out, so it is never captured: every other
|
||||||
|
-- key is bindable, which otherwise leaves an armed row with no exit but
|
||||||
|
-- to bind something (#510). Escape stays START in Input's default map,
|
||||||
|
-- which no rebind removes, so reserving it costs the player nothing.
|
||||||
function BindingsMenu:captureKey(key)
|
function BindingsMenu:captureKey(key)
|
||||||
|
if key == "escape" then return self:storeBinding("key", nil) end
|
||||||
self:storeBinding("key", key)
|
self:storeBinding("key", key)
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -100,7 +120,6 @@ function BindingsMenu:storeBinding(slot, value)
|
|||||||
b[slot] = value
|
b[slot] = value
|
||||||
opts.bindings[item.button.id] = b
|
opts.bindings[item.button.id] = b
|
||||||
item.right = boundRight(opts.bindings, item.button)
|
item.right = boundRight(opts.bindings, item.button)
|
||||||
Input:applyBindings(opts.bindings)
|
|
||||||
if game.writeOptions then game:writeOptions() end
|
if game.writeOptions then game:writeOptions() end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -112,9 +131,10 @@ end
|
|||||||
function BindingsMenu:draw()
|
function BindingsMenu:draw()
|
||||||
ListMenu.draw(self)
|
ListMenu.draw(self)
|
||||||
if self.capture then
|
if self.capture then
|
||||||
Font.drawBox(1, 6, 18, 4)
|
Font.drawBox(1, 6, 18, 5)
|
||||||
love.graphics.setColor(0, 0, 0, 1)
|
love.graphics.setColor(0, 0, 0, 1)
|
||||||
Font.draw(Strings("PRESS A BUTTON"), 24, 60)
|
Font.draw(Strings("PRESS A BUTTON"), 24, 60)
|
||||||
|
Font.draw(Strings("ESC TO CANCEL"), 24, 72)
|
||||||
love.graphics.setColor(1, 1, 1, 1)
|
love.graphics.setColor(1, 1, 1, 1)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -97,8 +97,13 @@ function DexEntryMenu.render(game, def, sprite, forceOwned, trueColor)
|
|||||||
-- feet/inches use the dex screen's ′/″ glyphs ("HT ?′??″" in
|
-- feet/inches use the dex screen's ′/″ glyphs ("HT ?′??″" in
|
||||||
-- pokedex.asm; the tiles come from gfx/pokedex/pokedex.png via
|
-- pokedex.asm; the tiles come from gfx/pokedex/pokedex.png via
|
||||||
-- engine/gfx/load_pokedex_tiles.asm)
|
-- engine/gfx/load_pokedex_tiles.asm)
|
||||||
Font.draw(Strings("HT %d′%02d″", e.heightFt, e.heightIn or 0), 72, 44)
|
if e.heightM then
|
||||||
Font.draw(Strings("WT %.1flb", (e.weight or 0) / 10), 72, 54)
|
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
|
end
|
||||||
local text = owned and e.text and game.data.text[e.text] or nil
|
local text = owned and e.text and game.data.text[e.text] or nil
|
||||||
local y = 72
|
local y = 72
|
||||||
|
|||||||
+23
-11
@@ -48,11 +48,11 @@ end
|
|||||||
local FLASH_FRAMES = 220
|
local FLASH_FRAMES = 220
|
||||||
|
|
||||||
local function frontSprite(game, species, mon)
|
local function frontSprite(game, species, mon)
|
||||||
local path = require("src.pokemon.Sprites").path(game.data, species, "front",
|
local path, trueColor = require("src.pokemon.Sprites").path(
|
||||||
{ mon = mon, kind = "evolution" })
|
game.data, species, "front", { mon = mon, kind = "evolution" })
|
||||||
if not path then return nil end
|
if not path then return nil, false end
|
||||||
local ok, img = pcall(love.graphics.newImage, path)
|
local ok, img = pcall(love.graphics.newImage, path)
|
||||||
return ok and img or nil
|
return ok and img or nil, ok and trueColor or false
|
||||||
end
|
end
|
||||||
|
|
||||||
function EvolutionState.new(game, mon, newSpecies, onDone, via)
|
function EvolutionState.new(game, mon, newSpecies, onDone, via)
|
||||||
@@ -69,8 +69,8 @@ function EvolutionState.new(game, mon, newSpecies, onDone, via)
|
|||||||
-- wForceEvolution clear and so honour B (#290, #213).
|
-- wForceEvolution clear and so honour B (#290, #213).
|
||||||
self.cancelable = (via ~= "TRADE" and via ~= "ITEM")
|
self.cancelable = (via ~= "TRADE" and via ~= "ITEM")
|
||||||
self.oldName = mon.nickname or game.data.pokemon[mon.species].name
|
self.oldName = mon.nickname or game.data.pokemon[mon.species].name
|
||||||
self.oldSprite = frontSprite(game, mon.species, mon)
|
self.oldSprite, self.oldSpriteTrueColor = frontSprite(game, mon.species, mon)
|
||||||
self.newSprite = frontSprite(game, newSpecies, mon)
|
self.newSprite, self.newSpriteTrueColor = frontSprite(game, newSpecies, mon)
|
||||||
self.t = 0
|
self.t = 0
|
||||||
self.done = false
|
self.done = false
|
||||||
self.canceled = false
|
self.canceled = false
|
||||||
@@ -127,18 +127,30 @@ function EvolutionState:draw()
|
|||||||
love.graphics.rectangle("fill", 0, 0, 160, 144)
|
love.graphics.rectangle("fill", 0, 0, 160, 144)
|
||||||
|
|
||||||
-- accelerating flash between the two forms
|
-- accelerating flash between the two forms
|
||||||
local sprite
|
local sprite, spriteTrueColor
|
||||||
if self.done then
|
if self.done then
|
||||||
-- a cancelled evolution settles back on the original form
|
-- a cancelled evolution settles back on the original form
|
||||||
sprite = self.canceled and self.oldSprite or self.newSprite
|
if self.canceled then
|
||||||
|
sprite, spriteTrueColor = self.oldSprite, self.oldSpriteTrueColor
|
||||||
|
else
|
||||||
|
sprite, spriteTrueColor = self.newSprite, self.newSpriteTrueColor
|
||||||
|
end
|
||||||
else
|
else
|
||||||
local period = math.max(4, 28 - math.floor(self.t / 40) * 6)
|
local period = math.max(4, 28 - math.floor(self.t / 40) * 6)
|
||||||
local showNew = math.floor(self.t / period) % 2 == 1
|
local showNew = math.floor(self.t / period) % 2 == 1
|
||||||
sprite = showNew and self.newSprite or self.oldSprite
|
if showNew then
|
||||||
|
sprite, spriteTrueColor = self.newSprite, self.newSpriteTrueColor
|
||||||
|
else
|
||||||
|
sprite, spriteTrueColor = self.oldSprite, self.oldSpriteTrueColor
|
||||||
|
end
|
||||||
end
|
end
|
||||||
if sprite then
|
if sprite then
|
||||||
love.graphics.draw(sprite, math.floor((160 - sprite:getWidth()) / 2),
|
local x = math.floor((160 - sprite:getWidth()) / 2)
|
||||||
math.max(8, 64 - sprite:getHeight()))
|
local y = math.max(8, 64 - sprite:getHeight())
|
||||||
|
love.graphics.draw(sprite, x, y)
|
||||||
|
if spriteTrueColor then
|
||||||
|
require("src.render.PaletteFX").markTrueColor(x, y, sprite:getDimensions())
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
love.graphics.setColor(0, 0, 0, 1)
|
love.graphics.setColor(0, 0, 0, 1)
|
||||||
|
|||||||
+1
-1
@@ -191,7 +191,7 @@ function ListMenu:draw()
|
|||||||
love.graphics.setColor(1, 1, 1, 1)
|
love.graphics.setColor(1, 1, 1, 1)
|
||||||
love.graphics.rectangle("fill", 0, 0, 160, 144)
|
love.graphics.rectangle("fill", 0, 0, 160, 144)
|
||||||
love.graphics.setColor(0, 0, 0, 1)
|
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
|
if #self.items == 0 then
|
||||||
Font.draw(Strings("Nothing here."), 16, 64)
|
Font.draw(Strings("Nothing here."), 16, 64)
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -19,6 +19,20 @@ function Menu.new(game, items, opts)
|
|||||||
self.tx = opts.tx or 10
|
self.tx = opts.tx or 10
|
||||||
self.ty = opts.ty or 0
|
self.ty = opts.ty or 0
|
||||||
self.tw = opts.tw or 10
|
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
|
self.rowStep = opts.rowStep or 2
|
||||||
-- maxVisible: cap the box to this many rows and scroll the rest instead
|
-- 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
|
-- of growing past it (e.g. the start menu, whose row count varies with
|
||||||
|
|||||||
@@ -183,7 +183,7 @@ function NamingScreen:draw()
|
|||||||
end
|
end
|
||||||
for r, row in ipairs(self:grid()) do
|
for r, row in ipairs(self:grid()) do
|
||||||
for c, cell in ipairs(row) 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
|
||||||
end
|
end
|
||||||
Font.drawCode(Theme.cursor, self.col * 16 - 8, 32 + self.row * 16)
|
Font.drawCode(Theme.cursor, self.col * 16 - 8, 32 + self.row * 16)
|
||||||
|
|||||||
@@ -486,7 +486,10 @@ end
|
|||||||
|
|
||||||
function OakSpeech:advance()
|
function OakSpeech:advance()
|
||||||
self.step = self.step + 1
|
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
|
local steps = self.steps
|
||||||
if not steps then
|
if not steps then
|
||||||
-- enter() builds steps; keep a path for callers that advance early
|
-- enter() builds steps; keep a path for callers that advance early
|
||||||
|
|||||||
+81
-6
@@ -2,7 +2,8 @@
|
|||||||
-- (engine/menus/main_menu.asm DisplayOptionMenu), the battle ruleset
|
-- (engine/menus/main_menu.asm DisplayOptionMenu), the battle ruleset
|
||||||
-- (cycles the merged rulesets registry; gen1_faithful keeps the original
|
-- (cycles the merged rulesets registry; gen1_faithful keeps the original
|
||||||
-- quirks), plus the port's audio rows and display rows: music/SFX
|
-- 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
|
-- GBC FX / ZOOM / VOID FILL / VIDEO MODE, and the MODS row that opens
|
||||||
-- the mod manager.
|
-- the mod manager.
|
||||||
-- Rows are descriptors fed through the ui.options.rows hook, so mods can
|
-- Rows are descriptors fed through the ui.options.rows hook, so mods can
|
||||||
@@ -16,8 +17,10 @@ local GBCFX = require("src.render.GBCFX")
|
|||||||
local Zoom = require("src.render.Zoom")
|
local Zoom = require("src.render.Zoom")
|
||||||
local TileRenderer = require("src.render.TileRenderer")
|
local TileRenderer = require("src.render.TileRenderer")
|
||||||
local GameSpeed = require("src.core.GameSpeed")
|
local GameSpeed = require("src.core.GameSpeed")
|
||||||
|
local GameVersion = require("src.core.GameVersion")
|
||||||
local VideoMode = require("src.core.VideoMode")
|
local VideoMode = require("src.core.VideoMode")
|
||||||
local FrameCap = require("src.core.FrameCap")
|
local FrameCap = require("src.core.FrameCap")
|
||||||
|
local Performance = require("src.core.Performance")
|
||||||
local Logger = require("src.core.Logger")
|
local Logger = require("src.core.Logger")
|
||||||
local Runtime = require("src.mods.Runtime")
|
local Runtime = require("src.mods.Runtime")
|
||||||
local OptionRows = require("src.ui.OptionRows")
|
local OptionRows = require("src.ui.OptionRows")
|
||||||
@@ -82,7 +85,7 @@ local function rulesetName(game)
|
|||||||
local ids = rulesetIds(game)
|
local ids = rulesetIds(game)
|
||||||
local id = ids[rulesetIndex(game, ids)] or game.save.options.ruleset
|
local id = ids[rulesetIndex(game, ids)] or game.save.options.ruleset
|
||||||
local record = id and rulesets[id]
|
local record = id and rulesets[id]
|
||||||
return record and record.name or id or "----"
|
return Strings(record and record.name or id or "----")
|
||||||
end
|
end
|
||||||
|
|
||||||
-- 0-7 volume level display (0 = OFF)
|
-- 0-7 volume level display (0 = OFF)
|
||||||
@@ -118,7 +121,7 @@ local function sameRows(_, rows) return rows end
|
|||||||
local function buildRows(game)
|
local function buildRows(game)
|
||||||
local rows = {
|
local rows = {
|
||||||
{ id = "textSpeed", label = Strings("TEXT SPEED"),
|
{ 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)
|
step = function(g)
|
||||||
local i = speedIndex(g) % #SPEEDS + 1
|
local i = speedIndex(g) % #SPEEDS + 1
|
||||||
g.save.options.textSpeed = SPEEDS[i][1]
|
g.save.options.textSpeed = SPEEDS[i][1]
|
||||||
@@ -126,7 +129,7 @@ local function buildRows(game)
|
|||||||
end },
|
end },
|
||||||
{ id = "animations", label = Strings("BATTLE ANIMATION"),
|
{ id = "animations", label = Strings("BATTLE ANIMATION"),
|
||||||
value = function(g)
|
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,
|
end,
|
||||||
step = function(g)
|
step = function(g)
|
||||||
local o = g.save.options
|
local o = g.save.options
|
||||||
@@ -135,7 +138,7 @@ local function buildRows(game)
|
|||||||
end },
|
end },
|
||||||
{ id = "battleStyle", label = Strings("BATTLE STYLE"),
|
{ id = "battleStyle", label = Strings("BATTLE STYLE"),
|
||||||
value = function(g)
|
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,
|
end,
|
||||||
step = function(g)
|
step = function(g)
|
||||||
local o = g.save.options
|
local o = g.save.options
|
||||||
@@ -146,7 +149,7 @@ local function buildRows(game)
|
|||||||
-- widescreen composition (src/battle/WideBattle.lua)
|
-- widescreen composition (src/battle/WideBattle.lua)
|
||||||
{ id = "battleLayout", label = Strings("BATTLE LAYOUT"),
|
{ id = "battleLayout", label = Strings("BATTLE LAYOUT"),
|
||||||
value = function(g)
|
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,
|
end,
|
||||||
step = function(g)
|
step = function(g)
|
||||||
local o = g.save.options
|
local o = g.save.options
|
||||||
@@ -178,6 +181,18 @@ local function buildRows(game)
|
|||||||
require("src.core.Sound").setVolumeLevel(o.sfxVol)
|
require("src.core.Sound").setVolumeLevel(o.sfxVol)
|
||||||
return true
|
return true
|
||||||
end },
|
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"),
|
{ id = "musicFilter", label = Strings("MUSIC FILTER"),
|
||||||
value = function(g)
|
value = function(g)
|
||||||
return FILTERS[(g.save.options.musicFilter or 0) + 1]
|
return FILTERS[(g.save.options.musicFilter or 0) + 1]
|
||||||
@@ -188,6 +203,21 @@ local function buildRows(game)
|
|||||||
require("src.core.Music").setFilterLevel(o.musicFilter)
|
require("src.core.Music").setFilterLevel(o.musicFilter)
|
||||||
return true
|
return true
|
||||||
end },
|
end },
|
||||||
|
-- Heads the port's display group: one tier that scales the heavy extras
|
||||||
|
-- (TILT / GBC FX / survey ZOOM) and the FPS ceiling for weaker devices.
|
||||||
|
-- AUTO picks a default from the hardware; every tier is overridable.
|
||||||
|
-- Re-applies live so the extras clamp (or, on a higher tier, restore to
|
||||||
|
-- the player's stored TILT / GBC FX / ZOOM) the moment the row changes.
|
||||||
|
{ id = "performance", label = Strings("PERFORMANCE"),
|
||||||
|
value = function(g)
|
||||||
|
return Strings(Performance.label(g.save.options.performance))
|
||||||
|
end,
|
||||||
|
step = function(g, dir)
|
||||||
|
local o = g.save.options
|
||||||
|
o.performance = Performance.cycle(o.performance, dir)
|
||||||
|
g:applyOptions(o)
|
||||||
|
return true
|
||||||
|
end },
|
||||||
{ id = "colors", label = Strings("COLORS"),
|
{ id = "colors", label = Strings("COLORS"),
|
||||||
value = function(g)
|
value = function(g)
|
||||||
return PaletteFX.modeLabel(g.save.options.colors or "gbc")
|
return PaletteFX.modeLabel(g.save.options.colors or "gbc")
|
||||||
@@ -308,6 +338,25 @@ local function buildRows(game)
|
|||||||
activate = function(g)
|
activate = function(g)
|
||||||
require("src.ui.Screens").push(g, "BindingsMenu")
|
require("src.ui.Screens").push(g, "BindingsMenu")
|
||||||
end },
|
end },
|
||||||
|
-- permanent on-screen pad toggle (#327); layout editing stays in the
|
||||||
|
-- launcher. Hidden where the overlay never appears (desktop without
|
||||||
|
-- POKEPORT_TOUCH), so the row costs a non-mobile install nothing.
|
||||||
|
{ id = "touchControls", label = Strings("TOUCH PAD"),
|
||||||
|
value = function(g)
|
||||||
|
local tc = g.save.options.touchControls
|
||||||
|
local on = not (type(tc) == "table" and tc.enabled == false)
|
||||||
|
return on and Strings("ON") or Strings("OFF")
|
||||||
|
end,
|
||||||
|
step = function(g)
|
||||||
|
local o = g.save.options
|
||||||
|
local tc = type(o.touchControls) == "table" and o.touchControls or {}
|
||||||
|
local on = not (tc.enabled == false)
|
||||||
|
tc.enabled = not on
|
||||||
|
-- keep any saved positions when toggling
|
||||||
|
o.touchControls = tc
|
||||||
|
require("src.core.TouchControls"):applyOptions(o)
|
||||||
|
return true
|
||||||
|
end },
|
||||||
}
|
}
|
||||||
-- issue #136: hide GBC FX on Android/iOS -- the present shader soft-bricks
|
-- issue #136: hide GBC FX on Android/iOS -- the present shader soft-bricks
|
||||||
if not GBCFX.isSupported() then
|
if not GBCFX.isSupported() then
|
||||||
@@ -317,6 +366,32 @@ local function buildRows(game)
|
|||||||
end
|
end
|
||||||
rows = filtered
|
rows = filtered
|
||||||
end
|
end
|
||||||
|
-- TOUCH PAD only where the overlay can appear (mobile, or desktop with
|
||||||
|
-- POKEPORT_TOUCH=1). POKEPORT_TOUCH=0 forces it off everywhere.
|
||||||
|
do
|
||||||
|
local env = os.getenv("POKEPORT_TOUCH")
|
||||||
|
local osName = love.system and love.system.getOS and love.system.getOS()
|
||||||
|
local show = env == "1"
|
||||||
|
or (env ~= "0" and (osName == "Android" or osName == "iOS"))
|
||||||
|
if not show then
|
||||||
|
local filtered = {}
|
||||||
|
for _, row in ipairs(rows) do
|
||||||
|
if row.id ~= "touchControls" then filtered[#filtered + 1] = row end
|
||||||
|
end
|
||||||
|
rows = filtered
|
||||||
|
end
|
||||||
|
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
|
-- 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
|
-- with it rather than at the end of the list where a mod's own
|
||||||
-- ui.options.rows additions land. Nothing registered means nothing
|
-- ui.options.rows additions land. Nothing registered means nothing
|
||||||
|
|||||||
+24
-15
@@ -351,18 +351,23 @@ function PartyMenu:update(dt)
|
|||||||
end })
|
end })
|
||||||
return
|
return
|
||||||
elseif action == "flash" then -- FLASH lights dark tunnels
|
elseif action == "flash" then -- FLASH lights dark tunnels
|
||||||
-- start_sub_menus.asm .flash: PrintText _FlashLightsAreaText, then
|
-- start_sub_menus.asm .flash: PrintText _FlashLightsAreaText runs
|
||||||
-- GBPalWhiteOutWithDelay3 + jp .goBackToMap
|
-- 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 ow = self.game.overworld
|
||||||
local TextBox = require("src.render.TextBox")
|
local TextBox = require("src.render.TextBox")
|
||||||
local Transition = require("src.render.Transition")
|
local Transition = require("src.render.Transition")
|
||||||
self.game.stack:pop()
|
|
||||||
ow.dark = false
|
|
||||||
self.game.save.flashLit = true
|
self.game.save.flashLit = true
|
||||||
self.game.stack:push(TextBox.new(self.game,
|
self.game.stack:push(TextBox.new(self.game,
|
||||||
self.game.data.text._FlashLightsAreaText
|
self.game.data.text._FlashLightsAreaText
|
||||||
or Strings("A blinding FLASH\nlights the area!"), function()
|
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))
|
end))
|
||||||
return
|
return
|
||||||
elseif action == "surf" then
|
elseif action == "surf" then
|
||||||
@@ -377,9 +382,11 @@ function PartyMenu:update(dt)
|
|||||||
local reason = ow:useSurfFieldMove()
|
local reason = ow:useSurfFieldMove()
|
||||||
local Transition = require("src.render.Transition")
|
local Transition = require("src.render.Transition")
|
||||||
if reason == "ok" then
|
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()
|
local fx, fy = ow.player:facingCell()
|
||||||
ow:trySurf(fx, fy)
|
ow:trySurf(fx, fy, function() self:close() end)
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
if reason == "dismount" then
|
if reason == "dismount" then
|
||||||
@@ -410,9 +417,10 @@ function PartyMenu:update(dt)
|
|||||||
-- .cannotStopSurfing prints _SurfingNoPlaceToGetOffText but
|
-- .cannotStopSurfing prints _SurfingNoPlaceToGetOffText but
|
||||||
-- never zeroes wActionResultOrTookBattleTurn, so unlike the
|
-- never zeroes wActionResultOrTookBattleTurn, so unlike the
|
||||||
-- other refusals the menu still closes afterwards
|
-- other refusals the menu still closes afterwards
|
||||||
-- (GBPalWhiteOutWithDelay3 + .goBackToMap)
|
-- (GBPalWhiteOutWithDelay3 + .goBackToMap), and the text prints
|
||||||
self.game.stack:pop()
|
-- over the still-open menu like every other .loop refusal (#385)
|
||||||
self.game.stack:push(TextBox.new(self.game, txt, function()
|
self.game.stack:push(TextBox.new(self.game, txt, function()
|
||||||
|
self:close()
|
||||||
self.game.stack:push(Transition.whiteFlash(self.game))
|
self.game.stack:push(Transition.whiteFlash(self.game))
|
||||||
end))
|
end))
|
||||||
return
|
return
|
||||||
@@ -454,18 +462,19 @@ function PartyMenu:update(dt)
|
|||||||
local Transition = require("src.render.Transition")
|
local Transition = require("src.render.Transition")
|
||||||
local def = self.game.data.pokemon[mon.species]
|
local def = self.game.data.pokemon[mon.species]
|
||||||
local name = mon.nickname or def.name
|
local name = mon.nickname or def.name
|
||||||
self.game.stack:pop() -- close the party menu (jp .goBackToMap)
|
|
||||||
ow.strengthActive = true
|
ow.strengthActive = true
|
||||||
local t1 = (self.game.data.text._UsedStrengthText
|
local t1 = (self.game.data.text._UsedStrengthText
|
||||||
or Strings("{RAM:wNameBuffer} used\nSTRENGTH.")):gsub("{RAM:wNameBuffer}", name)
|
or Strings("{RAM:wNameBuffer} used\nSTRENGTH.")):gsub("{RAM:wNameBuffer}", name)
|
||||||
local t2 = (self.game.data.text._CanMoveBouldersText
|
local t2 = (self.game.data.text._CanMoveBouldersText
|
||||||
or Strings("{RAM:wNameBuffer} can\nmove boulders.")):gsub("{RAM:wNameBuffer}", name)
|
or Strings("{RAM:wNameBuffer} can\nmove boulders.")):gsub("{RAM:wNameBuffer}", name)
|
||||||
-- like surf (#320): the blink belongs UNDER the texts, not as a
|
-- like surf (#320, #385): both texts print with the party menu
|
||||||
-- flashbang on the empty map after them; the stack only updates
|
-- still on screen, and the blink IS the menu closing afterwards,
|
||||||
-- the top state, so the flash holds until the texts close
|
-- not a flashbang on the empty map
|
||||||
self.game.stack:push(Transition.whiteFlash(self.game))
|
|
||||||
self.game.stack:push(TextBox.new(self.game, t1, function()
|
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()
|
end, { auto = { sound = function()
|
||||||
return require("src.core.Sound").playCry(self.game.data, mon.species)
|
return require("src.core.Sound").playCry(self.game.data, mon.species)
|
||||||
end } }))
|
end } }))
|
||||||
|
|||||||
+1
-1
@@ -76,7 +76,7 @@ local function withdraw(game)
|
|||||||
onChoose = function(item, list)
|
onChoose = function(item, list)
|
||||||
askQuantity(game, list, pc[item.value] or 1, item.value, function(qty)
|
askQuantity(game, list, pc[item.value] or 1, item.value, function(qty)
|
||||||
local Bag = require("src.inventory.Bag")
|
local Bag = require("src.inventory.Bag")
|
||||||
if not Bag.add(game.save, item.value, qty) then
|
if not Bag.add(game.save, item.value, qty, game.data) then
|
||||||
list.footer = Strings("You can't carry\nany more items.")
|
list.footer = Strings("You can't carry\nany more items.")
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|||||||
+1
-1
@@ -67,7 +67,7 @@ local function buy(game, stock)
|
|||||||
list.footer = notEnough
|
list.footer = notEnough
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
if not Bag.add(game.save, item.value, qty) then
|
if not Bag.add(game.save, item.value, qty, game.data) then
|
||||||
list.footer = txt(game, "_PokemartItemBagFullText",
|
list.footer = txt(game, "_PokemartItemBagFullText",
|
||||||
Strings("You can't carry\nany more items."))
|
Strings("You can't carry\nany more items."))
|
||||||
return
|
return
|
||||||
|
|||||||
+11
-4
@@ -43,12 +43,13 @@ function SummaryMenu.new(game, mon)
|
|||||||
Stats.ensure(game.data.pokemon[mon.species], mon)
|
Stats.ensure(game.data.pokemon[mon.species], mon)
|
||||||
local self = setmetatable({ game = game, mon = mon, page = 1 }, SummaryMenu)
|
local self = setmetatable({ game = game, mon = mon, page = 1 }, SummaryMenu)
|
||||||
local Sprites = require("src.pokemon.Sprites")
|
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" })
|
{ mon = mon, kind = "summary" })
|
||||||
if path then
|
if path then
|
||||||
local ok, img = pcall(love.graphics.newImage, path)
|
local ok, img = pcall(love.graphics.newImage, path)
|
||||||
self.sprite = ok and img or nil
|
self.sprite = ok and img or nil
|
||||||
end
|
end
|
||||||
|
self.spriteTrueColor = self.sprite and trueColor or false
|
||||||
require("src.core.Sound").playCry(game.data, mon.species)
|
require("src.core.Sound").playCry(game.data, mon.species)
|
||||||
return self
|
return self
|
||||||
end
|
end
|
||||||
@@ -111,8 +112,14 @@ function SummaryMenu:draw()
|
|||||||
-- the same routine the intro's NIDORINO show-off uses (OakSpeech picFlip:
|
-- the same routine the intro's NIDORINO show-off uses (OakSpeech picFlip:
|
||||||
-- negative x scale anchored at the pic's right edge). #280
|
-- negative x scale anchored at the pic's right edge). #280
|
||||||
if self.sprite then
|
if self.sprite then
|
||||||
love.graphics.draw(self.sprite, 8 + self.sprite:getWidth(),
|
local pw, ph = self.sprite:getDimensions()
|
||||||
math.max(0, 56 - self.sprite:getHeight()), 0, -1, 1)
|
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
|
end
|
||||||
local HudTiles = require("src.render.HudTiles")
|
local HudTiles = require("src.render.HudTiles")
|
||||||
love.graphics.setColor(0, 0, 0, 1)
|
love.graphics.setColor(0, 0, 0, 1)
|
||||||
@@ -146,7 +153,7 @@ function SummaryMenu:draw()
|
|||||||
}
|
}
|
||||||
for i, s in ipairs(stats) do
|
for i, s in ipairs(stats) do
|
||||||
local y = 72 + (i - 1) * 16
|
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)
|
Font.draw(("%3d"):format(s[2]), 48, y + 8)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
+25
-9
@@ -7,6 +7,8 @@ local Font = require("src.render.Font")
|
|||||||
local Music = require("src.core.Music")
|
local Music = require("src.core.Music")
|
||||||
local GameVersion = require("src.core.GameVersion")
|
local GameVersion = require("src.core.GameVersion")
|
||||||
local Strings = require("src.core.Strings")
|
local Strings = require("src.core.Strings")
|
||||||
|
local Runtime = require("src.mods.Runtime")
|
||||||
|
local Logger = require("src.core.Logger")
|
||||||
|
|
||||||
local TitleState = {}
|
local TitleState = {}
|
||||||
TitleState.__index = TitleState
|
TitleState.__index = TitleState
|
||||||
@@ -22,13 +24,17 @@ TitleState.isOpaque = true
|
|||||||
-- LOGO2 blue / LOGO1 red (issue #133). A trailing trueColor zone leaves the
|
-- 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.
|
-- 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
|
-- Every title SuperPal shares color 0 on hardware (sgb_palettes.asm: LOGO1,
|
||||||
-- come from the GBC pack (pure white) while Blue's LOGO1 stays on the ROM
|
-- LOGO2 and MEWMON all start RGB 31,29,31), so the ribbon band's white has to
|
||||||
-- pack (#128), so the version-ribbon row reads as a pink band. Force that
|
-- be the neighbouring zones' white. Under RED++, LOGO2/MEWMON come from the
|
||||||
-- slot to pure white; ink colors (Blue/Red "Version" text) stay intact.
|
-- GBC pack (pure white) while Blue's LOGO1 stays on the ROM pack (#128) and
|
||||||
local function withPureWhite(pal)
|
-- 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
|
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
|
end
|
||||||
|
|
||||||
function TitleState:sgbPalettes(game)
|
function TitleState:sgbPalettes(game)
|
||||||
@@ -46,9 +52,10 @@ function TitleState:sgbPalettes(game)
|
|||||||
P.zone(logoPal, 9, 8, 10, 8),
|
P.zone(logoPal, 9, 8, 10, 8),
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
|
local logoPal = P.pal(game.data, "LOGO2")
|
||||||
z = {
|
z = {
|
||||||
P.zone(P.pal(game.data, "LOGO2"), 0, 0, 19, 7),
|
P.zone(logoPal, 0, 0, 19, 7),
|
||||||
P.zone(withPureWhite(P.pal(game.data, "LOGO1")), 0, 8, 19, 9),
|
P.zone(withWhiteOf(P.pal(game.data, "LOGO1"), logoPal), 0, 8, 19, 9),
|
||||||
P.zone(P.pal(game.data, "MEWMON"), 0, 10, 19, 17),
|
P.zone(P.pal(game.data, "MEWMON"), 0, 10, 19, 17),
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
@@ -295,6 +302,8 @@ local function hasSave()
|
|||||||
return ok and info ~= nil
|
return ok and info ~= nil
|
||||||
end
|
end
|
||||||
|
|
||||||
|
local function sameItems(_, items) return items end
|
||||||
|
|
||||||
-- The CONTINUE info window (main_menu.asm DisplayContinueGameInfo):
|
-- The CONTINUE info window (main_menu.asm DisplayContinueGameInfo):
|
||||||
-- PLAYER / BADGES / POKéDEX / TIME over the title, shown after choosing
|
-- PLAYER / BADGES / POKéDEX / TIME over the title, shown after choosing
|
||||||
-- CONTINUE. A confirms and loads the game, B returns to the main menu.
|
-- CONTINUE. A confirms and loads the game, B returns to the main menu.
|
||||||
@@ -370,6 +379,13 @@ function TitleState:openMenu()
|
|||||||
love.event.quit()
|
love.event.quit()
|
||||||
end
|
end
|
||||||
end })
|
end })
|
||||||
|
local hooked = Runtime.call("ui.title_menu.items", sameItems, game, items)
|
||||||
|
if type(hooked) == "table" then
|
||||||
|
items = hooked
|
||||||
|
else
|
||||||
|
Logger.error("ui.title_menu.items returned %s; keeping the vanilla items",
|
||||||
|
type(hooked))
|
||||||
|
end
|
||||||
local th = #items * 2 + 2
|
local th = #items * 2 + 2
|
||||||
local menu = Menu.new(game, items, { tx = 0, ty = 0, tw = 13, th = th })
|
local menu = Menu.new(game, items, { tx = 0, ty = 0, tw = 13, th = th })
|
||||||
-- full-width title LOGO zones would recolor this box; see sgbPalettes
|
-- full-width title LOGO zones would recolor this box; see sgbPalettes
|
||||||
@@ -427,7 +443,7 @@ end
|
|||||||
-- the version ribbon at (7,8), Red's title art as OAM at px (82,80),
|
-- 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.
|
-- 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
|
-- 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()
|
function TitleState:draw()
|
||||||
love.graphics.setColor(1, 1, 1, 1)
|
love.graphics.setColor(1, 1, 1, 1)
|
||||||
love.graphics.rectangle("fill", 0, 0, 160, 144)
|
love.graphics.rectangle("fill", 0, 0, 160, 144)
|
||||||
|
|||||||
@@ -0,0 +1,277 @@
|
|||||||
|
-- Launcher touch-controls editor (#327): drag each on-screen button to a
|
||||||
|
-- new spot, toggle the overlay off entirely, Reset to defaults, Done to
|
||||||
|
-- persist into options.lua. Opened from the game panel's "Touch Controls"
|
||||||
|
-- button; main.lua suspends the launcher the same way it does for the
|
||||||
|
-- save editor.
|
||||||
|
--
|
||||||
|
-- Draws in full window LOVE units -- the same space TouchControls uses
|
||||||
|
-- after Renderer:endFrame -- so what you drag here is what you get in play.
|
||||||
|
|
||||||
|
local SaveData = require("src.core.SaveData")
|
||||||
|
local TouchControls = require("src.core.TouchControls")
|
||||||
|
|
||||||
|
local Editor = {}
|
||||||
|
|
||||||
|
local PAL = {
|
||||||
|
bgTop = { 8, 14, 36 },
|
||||||
|
bgBot = { 4, 8, 22 },
|
||||||
|
white = { 245, 248, 255 },
|
||||||
|
label = { 160, 175, 210 },
|
||||||
|
green = { 80, 220, 140 },
|
||||||
|
red = { 240, 90, 110 },
|
||||||
|
card = { 18, 28, 58 },
|
||||||
|
stroke = { 120, 150, 220 },
|
||||||
|
}
|
||||||
|
|
||||||
|
local function col(c, a)
|
||||||
|
love.graphics.setColor(c[1] / 255, c[2] / 255, c[3] / 255, a or 1)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function inside(r, x, y)
|
||||||
|
return r and x >= r.x and x <= r.x + r.w and y >= r.y and y <= r.y + r.h
|
||||||
|
end
|
||||||
|
|
||||||
|
local function roundRect(mode, x, y, w, h, r)
|
||||||
|
love.graphics.rectangle(mode, x, y, w, h, r, r)
|
||||||
|
end
|
||||||
|
|
||||||
|
function Editor.load(opts)
|
||||||
|
opts = opts or {}
|
||||||
|
Editor.onClose = opts.onClose
|
||||||
|
Editor.drag = nil
|
||||||
|
Editor.rects = {}
|
||||||
|
Editor.fonts = {
|
||||||
|
title = love.graphics.newFont(28),
|
||||||
|
body = love.graphics.newFont(16),
|
||||||
|
btn = love.graphics.newFont(18),
|
||||||
|
}
|
||||||
|
local optsTbl = SaveData.loadOptions()
|
||||||
|
TouchControls:init()
|
||||||
|
TouchControls:ensureImages()
|
||||||
|
TouchControls:applyOptions(optsTbl)
|
||||||
|
TouchControls:setPreview(true)
|
||||||
|
Editor.enabled = TouchControls.enabled ~= false
|
||||||
|
end
|
||||||
|
|
||||||
|
function Editor.unload()
|
||||||
|
TouchControls:setPreview(false)
|
||||||
|
TouchControls:reset()
|
||||||
|
Editor.drag = nil
|
||||||
|
Editor.onClose = nil
|
||||||
|
end
|
||||||
|
|
||||||
|
local function persist()
|
||||||
|
local opts = SaveData.loadOptions()
|
||||||
|
local cfg = TouchControls:config()
|
||||||
|
opts.touchControls = {
|
||||||
|
enabled = cfg.enabled,
|
||||||
|
positions = cfg.positions,
|
||||||
|
}
|
||||||
|
SaveData.saveOptions(opts)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function close()
|
||||||
|
persist()
|
||||||
|
local cb = Editor.onClose
|
||||||
|
Editor.unload()
|
||||||
|
if cb then cb() end
|
||||||
|
end
|
||||||
|
|
||||||
|
local function resetLayout()
|
||||||
|
TouchControls:clearPositions()
|
||||||
|
end
|
||||||
|
|
||||||
|
local function toggleEnabled()
|
||||||
|
Editor.enabled = not Editor.enabled
|
||||||
|
TouchControls.enabled = Editor.enabled
|
||||||
|
if not Editor.enabled then TouchControls:reset() end
|
||||||
|
end
|
||||||
|
|
||||||
|
function Editor.update(_dt)
|
||||||
|
-- drag follows the live pointer when love.touch / mouse is available;
|
||||||
|
-- touchmoved / mousemoved also update, so this is a belt-and-suspenders
|
||||||
|
-- path for Android where move events can be thin
|
||||||
|
if not Editor.drag then return end
|
||||||
|
local x, y
|
||||||
|
if love.touch and love.touch.getPosition and Editor.drag.touchId then
|
||||||
|
local ok, tx, ty = pcall(love.touch.getPosition, Editor.drag.touchId)
|
||||||
|
if ok and tx then x, y = tx, ty end
|
||||||
|
end
|
||||||
|
if not x and love.mouse then
|
||||||
|
x, y = love.mouse.getPosition()
|
||||||
|
end
|
||||||
|
if x then
|
||||||
|
TouchControls:setControlCenter(
|
||||||
|
Editor.drag.name,
|
||||||
|
x - Editor.drag.offX,
|
||||||
|
y - Editor.drag.offY)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
function Editor.draw()
|
||||||
|
local SafeArea = require("src.core.SafeArea")
|
||||||
|
local fullW, fullH = love.graphics.getDimensions()
|
||||||
|
local ox, oy, ww, wh = SafeArea.rect()
|
||||||
|
local s = math.max(0.75, math.min(1.4, wh / 768))
|
||||||
|
Editor.rects = {}
|
||||||
|
|
||||||
|
-- radial-ish navy field (two stacked fills; matches launcher atmosphere)
|
||||||
|
col(PAL.bgBot)
|
||||||
|
love.graphics.rectangle("fill", 0, 0, fullW, fullH)
|
||||||
|
col(PAL.bgTop, 0.85)
|
||||||
|
love.graphics.circle("fill", ox + ww * 0.5, oy + wh * 0.15, math.max(ww, wh) * 0.55)
|
||||||
|
|
||||||
|
local pad = 18 * s
|
||||||
|
local barH = 56 * s
|
||||||
|
local btnH = 40 * s
|
||||||
|
local btnW = 100 * s
|
||||||
|
|
||||||
|
-- top bar (inside the safe area so it clears the notch / status bar)
|
||||||
|
col(PAL.card, 0.92)
|
||||||
|
love.graphics.rectangle("fill", 0, 0, fullW, oy + barH + pad)
|
||||||
|
col(PAL.stroke, 0.35)
|
||||||
|
love.graphics.setLineWidth(1)
|
||||||
|
love.graphics.line(0, oy + barH + pad, fullW, oy + barH + pad)
|
||||||
|
|
||||||
|
love.graphics.setFont(Editor.fonts.title)
|
||||||
|
col(PAL.white)
|
||||||
|
love.graphics.print("Touch Controls", ox + pad, oy + pad + 4 * s)
|
||||||
|
|
||||||
|
-- Done / Reset
|
||||||
|
local done = { x = ox + ww - pad - btnW, y = oy + pad + (barH - btnH) / 2,
|
||||||
|
w = btnW, h = btnH }
|
||||||
|
local reset = { x = done.x - 10 * s - btnW, y = done.y, w = btnW, h = btnH }
|
||||||
|
Editor.rects.done, Editor.rects.reset = done, reset
|
||||||
|
|
||||||
|
local function chromeBtn(r, label, fill)
|
||||||
|
col(fill, 0.9)
|
||||||
|
roundRect("fill", r.x, r.y, r.w, r.h, 8 * s)
|
||||||
|
col(PAL.white, 0.2)
|
||||||
|
roundRect("line", r.x, r.y, r.w, r.h, 8 * s)
|
||||||
|
love.graphics.setFont(Editor.fonts.btn)
|
||||||
|
col(PAL.white)
|
||||||
|
local tw = Editor.fonts.btn:getWidth(label)
|
||||||
|
love.graphics.print(label, r.x + (r.w - tw) / 2,
|
||||||
|
r.y + (r.h - Editor.fonts.btn:getHeight()) / 2)
|
||||||
|
end
|
||||||
|
chromeBtn(reset, "Reset", { 60, 70, 110 })
|
||||||
|
chromeBtn(done, "Done", PAL.green)
|
||||||
|
|
||||||
|
-- enable toggle card
|
||||||
|
local cardY = oy + barH + pad + 14 * s
|
||||||
|
local cardH = 64 * s
|
||||||
|
local cardX, cardW = ox + pad, ww - 2 * pad
|
||||||
|
col(PAL.card, 0.88)
|
||||||
|
roundRect("fill", cardX, cardY, cardW, cardH, 12 * s)
|
||||||
|
col(PAL.stroke, 0.4)
|
||||||
|
roundRect("line", cardX, cardY, cardW, cardH, 12 * s)
|
||||||
|
|
||||||
|
love.graphics.setFont(Editor.fonts.body)
|
||||||
|
col(PAL.label)
|
||||||
|
love.graphics.print("On-screen controls", cardX + 16 * s,
|
||||||
|
cardY + 12 * s)
|
||||||
|
love.graphics.setFont(Editor.fonts.btn)
|
||||||
|
local on = Editor.enabled
|
||||||
|
col(on and PAL.green or PAL.red)
|
||||||
|
love.graphics.print(on and "ON" or "OFF", cardX + 16 * s,
|
||||||
|
cardY + 34 * s)
|
||||||
|
|
||||||
|
local toggleW = 110 * s
|
||||||
|
local toggle = {
|
||||||
|
x = cardX + cardW - 16 * s - toggleW,
|
||||||
|
y = cardY + (cardH - btnH) / 2,
|
||||||
|
w = toggleW, h = btnH,
|
||||||
|
}
|
||||||
|
Editor.rects.toggle = toggle
|
||||||
|
chromeBtn(toggle, on and "Disable" or "Enable",
|
||||||
|
on and PAL.red or PAL.green)
|
||||||
|
|
||||||
|
-- hint
|
||||||
|
love.graphics.setFont(Editor.fonts.body)
|
||||||
|
col(PAL.label, 0.9)
|
||||||
|
local hint = on
|
||||||
|
and "Drag each button to reposition. Layout is saved when you tap Done."
|
||||||
|
or "Controls are hidden in-game. Enable them to show and edit the layout."
|
||||||
|
love.graphics.printf(hint, ox + pad, cardY + cardH + 12 * s, ww - 2 * pad, "left")
|
||||||
|
|
||||||
|
-- the overlay itself (preview mode; dimmed when disabled)
|
||||||
|
TouchControls:draw()
|
||||||
|
|
||||||
|
-- highlight the control under drag
|
||||||
|
if Editor.drag then
|
||||||
|
local L = TouchControls:layout()
|
||||||
|
local zone = L[Editor.drag.name]
|
||||||
|
if zone then
|
||||||
|
love.graphics.setLineWidth(3 * s)
|
||||||
|
col(PAL.green, 0.85)
|
||||||
|
love.graphics.circle("line", zone.cx, zone.cy, zone.w * 0.62)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local function beginDrag(id, x, y)
|
||||||
|
-- chrome takes priority over controls
|
||||||
|
if inside(Editor.rects.done, x, y) then close(); return end
|
||||||
|
if inside(Editor.rects.reset, x, y) then resetLayout(); return end
|
||||||
|
if inside(Editor.rects.toggle, x, y) then toggleEnabled(); return end
|
||||||
|
|
||||||
|
local name = TouchControls:hitTest(x, y)
|
||||||
|
if not name then return end
|
||||||
|
local zone = TouchControls:layout()[name]
|
||||||
|
Editor.drag = {
|
||||||
|
name = name,
|
||||||
|
touchId = id,
|
||||||
|
offX = x - zone.cx,
|
||||||
|
offY = y - zone.cy,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
local function moveDrag(id, x, y)
|
||||||
|
local d = Editor.drag
|
||||||
|
if not d then return end
|
||||||
|
if d.touchId ~= nil and id ~= nil and d.touchId ~= id then return end
|
||||||
|
TouchControls:setControlCenter(d.name, x - d.offX, y - d.offY)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function endDrag(id)
|
||||||
|
local d = Editor.drag
|
||||||
|
if not d then return end
|
||||||
|
if d.touchId ~= nil and id ~= nil and d.touchId ~= id then return end
|
||||||
|
Editor.drag = nil
|
||||||
|
end
|
||||||
|
|
||||||
|
function Editor.mousepressed(x, y, button)
|
||||||
|
if button ~= 1 then return end
|
||||||
|
beginDrag("mouse", x, y)
|
||||||
|
end
|
||||||
|
|
||||||
|
function Editor.mousemoved(x, y)
|
||||||
|
if love.mouse.isDown(1) then moveDrag("mouse", x, y) end
|
||||||
|
end
|
||||||
|
|
||||||
|
function Editor.mousereleased(x, y, button)
|
||||||
|
if button ~= 1 then return end
|
||||||
|
endDrag("mouse")
|
||||||
|
end
|
||||||
|
|
||||||
|
function Editor.touchpressed(id, x, y)
|
||||||
|
beginDrag(id, x, y)
|
||||||
|
end
|
||||||
|
|
||||||
|
function Editor.touchmoved(id, x, y)
|
||||||
|
moveDrag(id, x, y)
|
||||||
|
end
|
||||||
|
|
||||||
|
function Editor.touchreleased(id, x, y)
|
||||||
|
endDrag(id)
|
||||||
|
end
|
||||||
|
|
||||||
|
function Editor.keypressed(key)
|
||||||
|
if key == "escape" or key == "return" or key == "space" then
|
||||||
|
close()
|
||||||
|
elseif key == "r" then
|
||||||
|
resetLayout()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
return Editor
|
||||||
+23
-9
@@ -49,9 +49,10 @@ local function dexOf(game, mon)
|
|||||||
end
|
end
|
||||||
|
|
||||||
local function spriteOf(game, mon)
|
local function spriteOf(game, mon)
|
||||||
local path = require("src.pokemon.Sprites").path(game.data, mon.species, "front",
|
local path, trueColor = require("src.pokemon.Sprites").path(
|
||||||
{ mon = mon, kind = "trade" })
|
game.data, mon.species, "front", { mon = mon, kind = "trade" })
|
||||||
return tryImage(path)
|
local image = tryImage(path)
|
||||||
|
return image, image and trueColor or false
|
||||||
end
|
end
|
||||||
|
|
||||||
local function expand(game, key, subs)
|
local function expand(game, key, subs)
|
||||||
@@ -110,8 +111,8 @@ function TradeAnim.new(game, opts)
|
|||||||
cableBallAlt = tryImage(art.cableBallAlt or DEFAULT_ART.cableBallAlt),
|
cableBallAlt = tryImage(art.cableBallAlt or DEFAULT_ART.cableBallAlt),
|
||||||
bubble = tryImage(art.bubble or DEFAULT_ART.bubble),
|
bubble = tryImage(art.bubble or DEFAULT_ART.bubble),
|
||||||
}
|
}
|
||||||
self.sentSprite = spriteOf(game, self.sent)
|
self.sentSprite, self.sentSpriteTrueColor = spriteOf(game, self.sent)
|
||||||
self.recvSprite = spriteOf(game, self.received)
|
self.recvSprite, self.recvSpriteTrueColor = spriteOf(game, self.received)
|
||||||
|
|
||||||
self.seq = 1
|
self.seq = 1
|
||||||
self.phase = SEQ[1]
|
self.phase = SEQ[1]
|
||||||
@@ -349,8 +350,8 @@ function TradeAnim:drawMonInfo(mon, ot, otId, boxTy)
|
|||||||
love.graphics.setColor(1, 1, 1, 1)
|
love.graphics.setColor(1, 1, 1, 1)
|
||||||
end
|
end
|
||||||
|
|
||||||
function TradeAnim:drawIconInBubble(mon, x, y)
|
function TradeAnim:drawIconInBubble(sprite, x, y)
|
||||||
local spr = (mon.species == self.received.species) and self.recvSprite or self.sentSprite
|
local spr = sprite
|
||||||
if spr then
|
if spr then
|
||||||
local sw, sh = spr:getDimensions()
|
local sw, sh = spr:getDimensions()
|
||||||
local s = 16 / math.max(sw, sh)
|
local s = 16 / math.max(sw, sh)
|
||||||
@@ -425,6 +426,10 @@ function TradeAnim:draw()
|
|||||||
-- hWY $50, so it sits in the bottom half of the screen
|
-- hWY $50, so it sits in the bottom half of the screen
|
||||||
if self.monVisible and self.sentSprite then
|
if self.monVisible and self.sentSprite then
|
||||||
love.graphics.draw(self.sentSprite, 56, 16)
|
love.graphics.draw(self.sentSprite, 56, 16)
|
||||||
|
if self.sentSpriteTrueColor then
|
||||||
|
require("src.render.PaletteFX").markTrueColor(
|
||||||
|
56 - self.scx, 16, self.sentSprite:getDimensions())
|
||||||
|
end
|
||||||
end
|
end
|
||||||
self:drawMonInfo(self.sent, self.playerOt, self.playerOtId, 10)
|
self:drawMonInfo(self.sent, self.playerOt, self.playerOtId, 10)
|
||||||
love.graphics.pop()
|
love.graphics.pop()
|
||||||
@@ -466,8 +471,13 @@ function TradeAnim:draw()
|
|||||||
love.graphics.translate(160, 0)
|
love.graphics.translate(160, 0)
|
||||||
self:drawRightGB()
|
self:drawRightGB()
|
||||||
love.graphics.pop()
|
love.graphics.pop()
|
||||||
local mon = (p == "transfer_lr") and self.sent or self.received
|
local sprite
|
||||||
self:drawIconInBubble(mon, self.monX, self.monY)
|
if p == "transfer_lr" then
|
||||||
|
sprite = self.sentSprite
|
||||||
|
else
|
||||||
|
sprite = self.recvSprite
|
||||||
|
end
|
||||||
|
self:drawIconInBubble(sprite, self.monX, self.monY)
|
||||||
if self.cableFlash then
|
if self.cableFlash then
|
||||||
love.graphics.setColor(1, 1, 1, 0.15)
|
love.graphics.setColor(1, 1, 1, 0.15)
|
||||||
love.graphics.rectangle("fill", 0, 32, 160, 8)
|
love.graphics.rectangle("fill", 0, 32, 160, 8)
|
||||||
@@ -477,6 +487,10 @@ function TradeAnim:draw()
|
|||||||
elseif p == "show_enemy" then
|
elseif p == "show_enemy" then
|
||||||
if self.monVisible and self.recvSprite then
|
if self.monVisible and self.recvSprite then
|
||||||
love.graphics.draw(self.recvSprite, 56, 16)
|
love.graphics.draw(self.recvSprite, 56, 16)
|
||||||
|
if self.recvSpriteTrueColor then
|
||||||
|
require("src.render.PaletteFX").markTrueColor(
|
||||||
|
56, 16, self.recvSprite:getDimensions())
|
||||||
|
end
|
||||||
end
|
end
|
||||||
self:drawMonInfo(self.received, self.enemyName, self.enemyOtId, 10)
|
self:drawMonInfo(self.received, self.enemyName, self.enemyOtId, 10)
|
||||||
end
|
end
|
||||||
|
|||||||
+104
-13
@@ -148,6 +148,37 @@ local SPEED_BARS = {
|
|||||||
{ 0xE0, 0x80, 4 }, { 0xF0, 0x90, 2 },
|
{ 0xE0, 0x80, 4 }, { 0xF0, 0x90, 2 },
|
||||||
}
|
}
|
||||||
|
|
||||||
|
-- PlayIntroScene's .loop (pokeyellow engine/movie/intro_yellow.asm) ends
|
||||||
|
-- every iteration with its own `call DelayFrame`, so one scene handler costs
|
||||||
|
-- one frame plus whatever DelayFrame calls it makes itself. The port runs a
|
||||||
|
-- handler inside a single update() tick, so every one of those internal
|
||||||
|
-- DelayFrame calls has to be spent explicitly or the movie runs short of
|
||||||
|
-- hardware. Three places owe frames:
|
||||||
|
--
|
||||||
|
-- SETUP_SCENE_DELAY scenes 2/4/6/8/10/12 open with
|
||||||
|
-- YellowIntro_BlankPalsDelay2AndDisableLCD: rBGP/rOBP0/
|
||||||
|
-- rOBP1 to $00 (color 0 in every shade -- the lightest,
|
||||||
|
-- white on DMG), two DelayFrame calls, then DisableLCD.
|
||||||
|
-- Two internal plus the loop's own is a 3-frame
|
||||||
|
-- iteration, after which the next wait scene takes its
|
||||||
|
-- first timer decrement. Func_f9e9a restores rBGP $e4
|
||||||
|
-- at the end of the same handler.
|
||||||
|
-- HEAD_FRAMES PlayIntroScene spends a DelayFrame between
|
||||||
|
-- InitYellowIntroGFXAndMusic (which ends in PlayMusic)
|
||||||
|
-- and the first .loop pass, and YellowIntroScene0 then
|
||||||
|
-- owns a whole iteration of its own.
|
||||||
|
-- EXIT_FRAMES .go_to_title_screen blanks the palettes and spends
|
||||||
|
-- four DelayFrame calls clearing the tilemap and OAM
|
||||||
|
-- before the title screen is built.
|
||||||
|
--
|
||||||
|
-- These restore the movie's real elapsed length; they do NOT lengthen the
|
||||||
|
-- intro song. title.asm's StopAllMusic before MUSIC_TITLE_SCREEN is
|
||||||
|
-- unconditional (only PikachuCry1 is waited on), so hardware cuts
|
||||||
|
-- Music_YellowIntro off mid-phrase too (#523).
|
||||||
|
local SETUP_SCENE_DELAY = 3
|
||||||
|
local HEAD_FRAMES = 2
|
||||||
|
local EXIT_FRAMES = 4
|
||||||
|
|
||||||
-- scene-6 sine (YellowIntro_Copy8BitSineWave.SineWave), signed SCY deltas
|
-- scene-6 sine (YellowIntro_Copy8BitSineWave.SineWave), signed SCY deltas
|
||||||
local WAVE = { 0, 0, 1, 2, 2, 3, 3, 3, 4, 3, 3, 3, 2, 2, 1, 0,
|
local WAVE = { 0, 0, 1, 2, 2, 3, 3, 3, 4, 3, 3, 3, 2, 2, 1, 0,
|
||||||
0, 0, -1, -2, -2, -3, -3, -3, -4, -3, -3, -3, -2, -2, -1, 0 }
|
0, 0, -1, -2, -2, -3, -3, -3, -4, -3, -3, -3, -2, -2, -1, 0 }
|
||||||
@@ -208,6 +239,8 @@ function YellowIntro.new(game, onDone)
|
|||||||
self.palName = "MEWMON" -- PalPacket_Generic
|
self.palName = "MEWMON" -- PalPacket_Generic
|
||||||
self.objects = {}
|
self.objects = {}
|
||||||
self.cloudFrame = 0
|
self.cloudFrame = 0
|
||||||
|
self.pendingThen = nil -- what enterDelay runs once pendingDelay hits 0 (#523)
|
||||||
|
self.pendingDelay = 0
|
||||||
|
|
||||||
self.atlas1 = tryImage("assets/generated/intro/yellow_intro_1.png")
|
self.atlas1 = tryImage("assets/generated/intro/yellow_intro_1.png")
|
||||||
self.atlas2 = tryImage("assets/generated/intro/yellow_intro_2.png")
|
self.atlas2 = tryImage("assets/generated/intro/yellow_intro_2.png")
|
||||||
@@ -559,6 +592,24 @@ function YellowIntro:startScene(scene)
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- Spend `frames` real frames, then run fn: the port's stand-in for the
|
||||||
|
-- DelayFrame calls a pokeyellow scene handler makes inside its own
|
||||||
|
-- PlayIntroScene .loop iteration (#523). fn may be nil to just burn time.
|
||||||
|
function YellowIntro:enterDelay(frames, fn)
|
||||||
|
self.pendingDelay = frames
|
||||||
|
self.pendingThen = fn
|
||||||
|
end
|
||||||
|
|
||||||
|
-- YellowIntro_BlankPalsDelay2AndDisableLCD blanks the palettes for the
|
||||||
|
-- delay; Func_f9e9a puts rBGP back to $e4 at the tail of the same handler.
|
||||||
|
function YellowIntro:enterSetupScene(scene)
|
||||||
|
self.bgp = 0x00
|
||||||
|
self:enterDelay(SETUP_SCENE_DELAY, function()
|
||||||
|
self.bgp = 0xE4
|
||||||
|
self:startScene(scene)
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
function YellowIntro:enter()
|
function YellowIntro:enter()
|
||||||
if self.pre then return end -- pre-roll first; beginScenes takes over
|
if self.pre then return end -- pre-roll first; beginScenes takes over
|
||||||
self:beginScenes()
|
self:beginScenes()
|
||||||
@@ -571,27 +622,63 @@ function YellowIntro:beginScenes()
|
|||||||
local song = songs and (songs.Music_YellowIntro and "Music_YellowIntro"
|
local song = songs and (songs.Music_YellowIntro and "Music_YellowIntro"
|
||||||
or songs.Music_IntroBattle and "Music_IntroBattle")
|
or songs.Music_IntroBattle and "Music_IntroBattle")
|
||||||
if song then pcall(Music.play, data, song, false) end
|
if song then pcall(Music.play, data, song, false) end
|
||||||
self:startScene(0)
|
-- HEAD_FRAMES: the DelayFrame between PlayMusic and the first .loop pass
|
||||||
|
-- plus YellowIntroScene0's own iteration. new() already laid down the
|
||||||
|
-- Func_f9e5f letterbox that InitYellowIntroGFXAndMusic writes, so these
|
||||||
|
-- frames show what hardware shows: the letterbox with no pika yet.
|
||||||
|
self:enterDelay(HEAD_FRAMES, function() self:startScene(0) end)
|
||||||
end
|
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()
|
function YellowIntro:finish()
|
||||||
if self.finished then return end
|
if self.finished then return end
|
||||||
self.finished = true
|
self.finished = true
|
||||||
pcall(Music.stop)
|
|
||||||
self.game.stack:pop()
|
self.game.stack:pop()
|
||||||
if self.onDone then self.onDone() end
|
if self.onDone then self.onDone() end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- Both scene-loop exits (scene 17 running out, and the A/B/START skip) land
|
||||||
|
-- on .go_to_title_screen, which calls YellowIntro_BlankPalettes and then
|
||||||
|
-- spends EXIT_FRAMES DelayFrame calls clearing the tilemap and the OAM
|
||||||
|
-- buffers before the title screen is built (#523). The pre-roll's own skip
|
||||||
|
-- is IntroMovie's path, not this one, and still finishes immediately.
|
||||||
|
function YellowIntro:exitToTitle()
|
||||||
|
if self.finished or self.exiting then return end
|
||||||
|
self.exiting = true
|
||||||
|
self.bgp = 0x00
|
||||||
|
self:clearObjects()
|
||||||
|
self:enterDelay(EXIT_FRAMES, function() self:finish() end)
|
||||||
|
end
|
||||||
|
|
||||||
function YellowIntro:update(dt)
|
function YellowIntro:update(dt)
|
||||||
if self.finished then return end
|
if self.finished then return end
|
||||||
if self.pre then
|
if self.pre then
|
||||||
self.pre:update(dt)
|
self.pre:update(dt)
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
-- Ahead of the skip poll on purpose: JoypadLowSensitivity runs once at the
|
||||||
|
-- top of each .loop iteration, never inside a handler, so hardware is deaf
|
||||||
|
-- for the DelayFrame calls these ticks stand in for (#523).
|
||||||
|
if self.pendingDelay > 0 then
|
||||||
|
self.pendingDelay = self.pendingDelay - 1
|
||||||
|
if self.pendingDelay == 0 then
|
||||||
|
local fn = self.pendingThen
|
||||||
|
self.pendingThen = nil
|
||||||
|
if fn then fn() end
|
||||||
|
end
|
||||||
|
self:updateObjects()
|
||||||
|
if self.bgDirty then self:rebuildBgCanvas() end
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
local input = self.game.input
|
local input = self.game.input
|
||||||
if input:wasPressed("a") or input:wasPressed("b")
|
if input:wasPressed("a") or input:wasPressed("b")
|
||||||
or input:wasPressed("start") then
|
or input:wasPressed("start") then
|
||||||
self:finish()
|
self:exitToTitle()
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -601,7 +688,7 @@ function YellowIntro:update(dt)
|
|||||||
self.timer = self.timer - 1
|
self.timer = self.timer - 1
|
||||||
else
|
else
|
||||||
self:clearObjects()
|
self:clearObjects()
|
||||||
self:startScene(scene + 1)
|
self:enterSetupScene(scene + 1) -- BlankPalsDelay2AndDisableLCD (#523)
|
||||||
end
|
end
|
||||||
elseif scene == 3 then
|
elseif scene == 3 then
|
||||||
if self.timer > 0 then
|
if self.timer > 0 then
|
||||||
@@ -609,7 +696,7 @@ function YellowIntro:update(dt)
|
|||||||
if self.scx ~= 0x68 then self.scx = self.scx + 4 end
|
if self.scx ~= 0x68 then self.scx = self.scx + 4 end
|
||||||
else
|
else
|
||||||
self:clearObjects()
|
self:clearObjects()
|
||||||
self:startScene(4)
|
self:enterSetupScene(4) -- BlankPalsDelay2AndDisableLCD (#523)
|
||||||
end
|
end
|
||||||
elseif scene == 7 then
|
elseif scene == 7 then
|
||||||
if self.timer > 0 then
|
if self.timer > 0 then
|
||||||
@@ -621,7 +708,7 @@ function YellowIntro:update(dt)
|
|||||||
self.wave[255] = first
|
self.wave[255] = first
|
||||||
else
|
else
|
||||||
self:clearObjects()
|
self:clearObjects()
|
||||||
self:startScene(8)
|
self:enterSetupScene(8) -- BlankPalsDelay2AndDisableLCD (#523)
|
||||||
end
|
end
|
||||||
elseif scene == 11 then
|
elseif scene == 11 then
|
||||||
if self.timer > 0 then
|
if self.timer > 0 then
|
||||||
@@ -636,7 +723,7 @@ function YellowIntro:update(dt)
|
|||||||
self.timer = self.timer - 1
|
self.timer = self.timer - 1
|
||||||
else
|
else
|
||||||
self:clearObjects()
|
self:clearObjects()
|
||||||
self:startScene(12)
|
self:enterSetupScene(12) -- BlankPalsDelay2AndDisableLCD (#523)
|
||||||
end
|
end
|
||||||
elseif scene == 13 then
|
elseif scene == 13 then
|
||||||
if self.timer > 0 then
|
if self.timer > 0 then
|
||||||
@@ -652,13 +739,17 @@ function YellowIntro:update(dt)
|
|||||||
if v then
|
if v then
|
||||||
self.bgp = v
|
self.bgp = v
|
||||||
else
|
else
|
||||||
-- .expired: everything despawns, letterbox returns, logo/face
|
-- .expired: everything despawns and the letterbox returns, then the
|
||||||
-- object $7 appears for the strobe scene
|
-- handler spends three DelayFrame calls with hAutoBGTransferEnabled
|
||||||
|
-- pushing that rebuilt tilemap to VRAM before it restores rBGP $e4
|
||||||
|
-- and spawns the logo/face object $7 for the strobe scene (#523).
|
||||||
self:clearObjects()
|
self:clearObjects()
|
||||||
self:bgLetterbox()
|
self:bgLetterbox()
|
||||||
self.bgp = 0xE4
|
self:enterDelay(3, function()
|
||||||
self:spawn(7, 0x58, 0x58)
|
self.bgp = 0xE4
|
||||||
self:startScene(15)
|
self:spawn(7, 0x58, 0x58)
|
||||||
|
self:startScene(15)
|
||||||
|
end)
|
||||||
end
|
end
|
||||||
elseif scene == 15 then
|
elseif scene == 15 then
|
||||||
if self.timer > 0 then
|
if self.timer > 0 then
|
||||||
@@ -683,7 +774,7 @@ function YellowIntro:update(dt)
|
|||||||
if self.timer > 0 then
|
if self.timer > 0 then
|
||||||
self.timer = self.timer - 1
|
self.timer = self.timer - 1
|
||||||
else
|
else
|
||||||
self:finish()
|
self:exitToTitle()
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
+3
-1
@@ -50,7 +50,9 @@ local CALLBACK_NAMES = {
|
|||||||
"keypressed", "keyreleased", "textinput",
|
"keypressed", "keyreleased", "textinput",
|
||||||
"mousepressed", "mousereleased", "mousemoved", "wheelmoved",
|
"mousepressed", "mousereleased", "mousemoved", "wheelmoved",
|
||||||
"touchpressed", "touchmoved", "touchreleased",
|
"touchpressed", "touchmoved", "touchreleased",
|
||||||
"gamepadpressed", "gamepadreleased", "gamepadaxis", "joystickremoved",
|
"gamepadpressed", "gamepadreleased", "gamepadaxis",
|
||||||
|
"joystickpressed", "joystickreleased", "joystickaxis", "joystickhat",
|
||||||
|
"joystickremoved",
|
||||||
"focus", "visible", "resize", "filedropped", "directorydropped",
|
"focus", "visible", "resize", "filedropped", "directorydropped",
|
||||||
"errorhandler", "threaderror", "lowmemory",
|
"errorhandler", "threaderror", "lowmemory",
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -174,4 +174,14 @@ function Check.download()
|
|||||||
cmdCh:push({ cmd = "download" })
|
cmdCh:push({ cmd = "download" })
|
||||||
end
|
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
|
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
|
-- 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
|
-- gets "PASSED_<mapId>" instead of this pre-v2 spelling
|
||||||
badgeGates = { ROUTE_22_GATE = { passedFlag = "PASSED_ROUTE22_GATE" } },
|
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
|
-- VermilionGymSetDoorTile opens the motorized door once both locks are hit
|
||||||
hiddenExtras = {
|
hiddenExtras = {
|
||||||
-- PrintTrashText bins (#188); seeds stale caches missing the key
|
-- PrintTrashText bins (#188); seeds stale caches missing the key
|
||||||
|
|||||||
+301
-164
@@ -3,6 +3,7 @@
|
|||||||
-- surfing, Cut trees, trainer sight lines, and dispatches interactions to
|
-- surfing, Cut trees, trainer sight lines, and dispatches interactions to
|
||||||
-- map scripts (data/scripts/), marts, nurses or extracted text.
|
-- map scripts (data/scripts/), marts, nurses or extracted text.
|
||||||
|
|
||||||
|
local Assets = require("src.render.Assets")
|
||||||
local Camera = require("src.render.Camera")
|
local Camera = require("src.render.Camera")
|
||||||
local Collision = require("src.world.Collision")
|
local Collision = require("src.world.Collision")
|
||||||
local Encounter = require("src.world.Encounter")
|
local Encounter = require("src.world.Encounter")
|
||||||
@@ -65,6 +66,16 @@ local ROD_OAM = {
|
|||||||
right = { dx = 16, dy = 4, tile = 1, flip = true }, -- dbsprite 11, 10, 0, 0, $fe, XFLIP
|
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
|
-- object_event spawn filter (toggleable_objects, items taken, beaten
|
||||||
-- static encounters), shared by the current map's real NPCs and the
|
-- static encounters), shared by the current map's real NPCs and the
|
||||||
-- visual-only ghosts on connected neighbor maps
|
-- visual-only ghosts on connected neighbor maps
|
||||||
@@ -188,8 +199,50 @@ function OverworldState:enter(mapId, x, y, facing)
|
|||||||
-- survives save/load: a loaded game may start inside a building whose
|
-- survives save/load: a loaded game may start inside a building whose
|
||||||
-- exit mat is a LAST_MAP warp
|
-- exit mat is a LAST_MAP warp
|
||||||
self.lastOutdoor = Game.save.lastOutdoor
|
self.lastOutdoor = Game.save.lastOutdoor
|
||||||
self.justWarped = false
|
|
||||||
self:setMap(mapId, x, y, facing, { via = "boot" })
|
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
|
end
|
||||||
|
|
||||||
function OverworldState:setMap(mapId, x, y, facing, opts)
|
function OverworldState:setMap(mapId, x, y, facing, opts)
|
||||||
@@ -222,6 +275,13 @@ function OverworldState:setMap(mapId, x, y, facing, opts)
|
|||||||
self.tileAnimOverride.tileset.animation = self.tileAnimOverride.animation
|
self.tileAnimOverride.tileset.animation = self.tileAnimOverride.animation
|
||||||
self.tileAnimOverride = nil
|
self.tileAnimOverride = nil
|
||||||
end
|
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)
|
self.map = MapLoader.load(Game.data, mapId)
|
||||||
-- STRENGTH deactivates on every real map load (home/overworld.asm
|
-- STRENGTH deactivates on every real map load (home/overworld.asm
|
||||||
-- EnterMap -> ResetUsingStrengthOutOfBattleBit clears BIT_STRENGTH_ACTIVE
|
-- EnterMap -> ResetUsingStrengthOutOfBattleBit clears BIT_STRENGTH_ACTIVE
|
||||||
@@ -241,38 +301,7 @@ function OverworldState:setMap(mapId, x, y, facing, opts)
|
|||||||
self.map.renderer:rebuild()
|
self.map.renderer:rebuild()
|
||||||
self.cutBlocks[mapId] = nil
|
self.cutBlocks[mapId] = nil
|
||||||
end
|
end
|
||||||
-- Silph Co card key doors + Rocket Hideout elevator gates: the .blk
|
self:stampClosedDoors()
|
||||||
-- 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
|
|
||||||
-- forced dismount only where riding is disallowed (IsBikeRidingAllowed,
|
-- forced dismount only where riding is disallowed (IsBikeRidingAllowed,
|
||||||
-- home/overworld.asm: bike_riding_tilesets.asm tilesets plus the
|
-- home/overworld.asm: bike_riding_tilesets.asm tilesets plus the
|
||||||
-- ROUTE_23/INDIGO_PLATEAU map exceptions)
|
-- ROUTE_23/INDIGO_PLATEAU map exceptions)
|
||||||
@@ -294,18 +323,11 @@ function OverworldState:setMap(mapId, x, y, facing, opts)
|
|||||||
-- Rock Tunnel darkness (wMapPalOffset, home/overworld.asm): dark
|
-- Rock Tunnel darkness (wMapPalOffset, home/overworld.asm): dark
|
||||||
-- until FLASH is used; the light persists between the tunnel floors
|
-- until FLASH is used; the light persists between the tunnel floors
|
||||||
-- and resets once outside
|
-- and resets once outside
|
||||||
local darkDef = Game.data.field.darkMaps
|
if isDarkMap(mapId) then
|
||||||
self.dark = false
|
self:setDark(not Game.save.flashLit)
|
||||||
if darkDef then
|
else
|
||||||
local isDark = false
|
Game.save.flashLit = nil
|
||||||
for _, m in ipairs(darkDef.maps) do
|
self:setDark(false)
|
||||||
if m == mapId then isDark = true break end
|
|
||||||
end
|
|
||||||
if isDark then
|
|
||||||
self.dark = not Game.save.flashLit
|
|
||||||
else
|
|
||||||
Game.save.flashLit = nil
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
if Game.data.field.flyWarps[mapId] then
|
if Game.data.field.flyWarps[mapId] then
|
||||||
Game.save.visited = Game.save.visited or {}
|
Game.save.visited = Game.save.visited or {}
|
||||||
@@ -338,6 +360,36 @@ function OverworldState:setMap(mapId, x, y, facing, opts)
|
|||||||
else
|
else
|
||||||
self.player = Player.new(Game.data, x, y, facing)
|
self.player = Player.new(Game.data, x, y, facing)
|
||||||
end
|
end
|
||||||
|
-- boot only: the original persists the surf state. wWalkBikeSurfState
|
||||||
|
-- (ram/wram.asm) lives inside wMainDataStart..wMainDataEnd, which
|
||||||
|
-- engine/menus/save.asm block-copies into sMainData on save and back out
|
||||||
|
-- on load (sram.asm declares sMainData as `ds wMainDataEnd -
|
||||||
|
-- wMainDataStart`), and Continue never clears it -- the only `xor a /
|
||||||
|
-- ld [wWalkBikeSurfState], a` on that path is the cable club's. Restore
|
||||||
|
-- it here, before Music.playMap reads it below and before
|
||||||
|
-- PikachuFollower.onMapEntered, matching LoadMapData calling
|
||||||
|
-- LoadPlayerSpriteGraphics ahead of PlayDefaultMusic (home/overworld.asm,
|
||||||
|
-- home/audio.asm). Without this the player resumed on foot on a water
|
||||||
|
-- cell, which softlocks here: Collision.canMove (src/world/Collision.lua)
|
||||||
|
-- picks land tile-pairs whenever mover.surfing is falsy, and land
|
||||||
|
-- tile-pairs never permit stepping off a water cell (#536). Same
|
||||||
|
-- boot-only shape as the refreshStandingOnWarp door-mat restore (#378).
|
||||||
|
if opts and opts.via == "boot" then
|
||||||
|
local ps = Game.save and Game.save.player
|
||||||
|
if ps and ps.surfing ~= nil then
|
||||||
|
self.player.surfing = ps.surfing and true or false
|
||||||
|
else
|
||||||
|
-- saves written before #536 carry no flag. Map:isWaterCell alone is
|
||||||
|
-- not self-sufficient (see src/world/Map.lua: water and shore share
|
||||||
|
-- one lookup, and no tileset stamps waterTiles, so tile $14 -- a
|
||||||
|
-- walkable floor in HOUSE/GATE/LOBBY/MANSION/MUSEUM -- reads as
|
||||||
|
-- water), so gate it the way facingIsShoreOrWater does and require a
|
||||||
|
-- cell you could not be standing on upright.
|
||||||
|
self.player.surfing = self:tilesetHasWater()
|
||||||
|
and not self.map:isWalkableCell(x, y)
|
||||||
|
and self.map:isWaterCell(x, y)
|
||||||
|
end
|
||||||
|
end
|
||||||
-- crossConnection re-arms this after setMap; clear so a warp/reload
|
-- crossConnection re-arms this after setMap; clear so a warp/reload
|
||||||
-- cannot leave a stale deferred PlayMapMusic pending
|
-- cannot leave a stale deferred PlayMapMusic pending
|
||||||
self.pendingSeamMusic = nil
|
self.pendingSeamMusic = nil
|
||||||
@@ -345,7 +397,7 @@ function OverworldState:setMap(mapId, x, y, facing, opts)
|
|||||||
for _, n in ipairs(self.npcs) do table.insert(self.entities, n) end
|
for _, n in ipairs(self.npcs) do table.insert(self.entities, n) end
|
||||||
-- Yellow's companion Pikachu trails the player (never in
|
-- Yellow's companion Pikachu trails the player (never in
|
||||||
-- self.entities: it does not block movement, pikachu_follow.asm)
|
-- 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
|
-- opts.keepMusic: the Oak-escort warp keeps MUSIC_MEET_PROF_OAK
|
||||||
-- playing into the lab (BIT_NO_MAP_MUSIC in wStatusFlags7);
|
-- playing into the lab (BIT_NO_MAP_MUSIC in wStatusFlags7);
|
||||||
@@ -529,9 +581,26 @@ end
|
|||||||
-- UI-pass palette (text boxes and menus tint with the current map). OG RED
|
-- UI-pass palette (text boxes and menus tint with the current map). OG RED
|
||||||
-- resolves every name to the one global red BG palette inside PaletteFX.pal,
|
-- resolves every name to the one global red BG palette inside PaletteFX.pal,
|
||||||
-- so this needs no mode-specific branch.
|
-- so this needs no mode-specific branch.
|
||||||
|
--
|
||||||
|
-- TalkToPikachu's framed frontpic is the one exception: pokeyellow
|
||||||
|
-- LoadOverworldPikachuFrontpicPalettes loads the map pal as slot 0 and
|
||||||
|
-- PAL_PIKACHU_PORTRAIT as slot 1, then ATTR_BLK's the 5x5 pic at
|
||||||
|
-- (7,6)-(11,10) onto slot 1 (engine/gfx/palettes.asm:345-391). Without
|
||||||
|
-- that zone the pic wears the route/town palette and looks washed out.
|
||||||
function OverworldState:sgbPalettes()
|
function OverworldState:sgbPalettes()
|
||||||
local PaletteFX = require("src.render.PaletteFX")
|
local PaletteFX = require("src.render.PaletteFX")
|
||||||
return PaletteFX.wholeNamed(Game.data, self:paletteNameFor(self.map))
|
local mapName = self:paletteNameFor(self.map)
|
||||||
|
if self.emote and self.emote.pikaPic then
|
||||||
|
local base = PaletteFX.pal(Game.data, mapName)
|
||||||
|
if not base then return nil end
|
||||||
|
local zones = { PaletteFX.whole(base) }
|
||||||
|
local portrait = PaletteFX.pal(Game.data, "PIKACHU_PORTRAIT")
|
||||||
|
if portrait then
|
||||||
|
zones[#zones + 1] = PaletteFX.zone(portrait, 7, 6, 11, 10)
|
||||||
|
end
|
||||||
|
return zones
|
||||||
|
end
|
||||||
|
return PaletteFX.wholeNamed(Game.data, mapName)
|
||||||
end
|
end
|
||||||
|
|
||||||
-- World-pass palette zones in world-canvas pixels: each visible map
|
-- World-pass palette zones in world-canvas pixels: each visible map
|
||||||
@@ -576,17 +645,19 @@ function OverworldState:sgbWorldZones()
|
|||||||
return zones
|
return zones
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Whether the dark-map shade shift (PaletteFX.DARK_BGP, armed in drawWorld)
|
-- wMapPalOffset, the one piece of state both halves of the darkness read:
|
||||||
-- can actually reach this frame's world pass. It cannot in RED++: that mode
|
-- drawWorld arms PaletteFX.DARK_BGP off self.dark for the shade-remapped
|
||||||
-- bakes real per-tile colour into the tileset atlas and sgbWorldZones returns
|
-- modes, and PaletteFX.setDarkWorld feeds the bakes ADVANCED does instead of
|
||||||
-- an EMPTY zone list above, so the world blits with no shade-remap shader at
|
-- shading (tileset atlas, sprite sheets) plus their cache keys. A bake cannot
|
||||||
-- all and there is no palette left to permute. Only then does fxDark
|
-- be re-shaded in place, so a change there rebuilds every resident map --
|
||||||
-- composite the darkness by hand (#322).
|
-- every dark floor, not just this one, since FLASH lights them all (#383).
|
||||||
function OverworldState:darkNeedsOverlay()
|
function OverworldState:setDark(on)
|
||||||
if not self.dark then return false end
|
on = on and true or false
|
||||||
local renderer = self.map and self.map.renderer
|
self.dark = on
|
||||||
return PaletteFX.usesGbcPack() and renderer ~= nil
|
if PaletteFX.setDarkWorld(on) and PaletteFX.usesGbcPack() and self.map then
|
||||||
and renderer.gbcAtlas ~= nil
|
MapLoader.invalidateAll()
|
||||||
|
self:reloadMap(self.map.id, "dark")
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
function OverworldState:npcByIndex(index)
|
function OverworldState:npcByIndex(index)
|
||||||
@@ -795,6 +866,15 @@ function OverworldState:update(dt)
|
|||||||
if ca.onDone then ca.onDone() end
|
if ca.onDone then ca.onDone() end
|
||||||
end
|
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
|
-- 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)
|
-- world for its arc, the same way the heal machine below does (#417)
|
||||||
if self.pikaHop then
|
if self.pikaHop then
|
||||||
@@ -867,7 +947,12 @@ function OverworldState:update(dt)
|
|||||||
-- the emotion-bubble pause holds the world for a beat
|
-- the emotion-bubble pause holds the world for a beat
|
||||||
if self.emote then
|
if self.emote then
|
||||||
self.emote.frames = self.emote.frames - 1
|
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
|
local done = self.emote.onDone
|
||||||
self.emote = nil
|
self.emote = nil
|
||||||
if done then done() end
|
if done then done() end
|
||||||
@@ -961,19 +1046,33 @@ function OverworldState:dirHeld()
|
|||||||
or input:isDown("left") or input:isDown("right")
|
or input:isDown("left") or input:isDown("right")
|
||||||
end
|
end
|
||||||
|
|
||||||
-- The warp cell the player WARPED IN on is inert until they physically step
|
-- BIT_STANDING_ON_WARP (wMovementFlags): the warp under the player's feet may
|
||||||
-- off it: standing on it, or bonking a wall/edge from it, must not re-fire a
|
-- only fire from a collision -- the blocked-step warp (handleInput) and the
|
||||||
-- warp (CheckWarpsNoCollision's arrival-disable; see setMap where
|
-- map-edge exit (checkEdgeExit) -- while this flag is set. pokered clears it
|
||||||
-- warpEntryCell/justWarped are set and onStepComplete where they clear).
|
-- on every completed step, sets it again when that step lands on a warp
|
||||||
-- Both stand-still warp triggers -- the map-edge exit (checkEdgeExit) and the
|
-- square, then clears it once more when the square is a warp-activating tile
|
||||||
-- blocked-step collision warp (handleInput) -- must consult this, or a corner
|
-- that is not also a door tile (CheckWarpsNoCollisionLoop ->
|
||||||
-- staircase whose warp tile sits on the map edge (Red's-house (7,1)) bounces
|
-- IsPlayerStandingOnDoorTileOrWarpTile, engine/overworld/player_state.asm);
|
||||||
-- floors every input frame (issue #230).
|
-- onStepComplete maintains it. ClearVariablesOnEnterMap does not clear
|
||||||
function OverworldState:onWarpArrivalCell()
|
-- wMovementFlags, so the flag rides through the warp itself: a house door
|
||||||
if self.justWarped then return true end
|
-- tile ($1B) leaves it set, so you land on the interior mat still able to
|
||||||
local entry = self.warpEntryCell
|
-- walk back out on that same tile (issue #378), while a staircase tile
|
||||||
return entry ~= nil and self.player.cellX == entry.x
|
-- ($1A/$1C) clears it and cannot bounce you between floors (issue #230).
|
||||||
and self.player.cellY == entry.y
|
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
|
end
|
||||||
|
|
||||||
function OverworldState:handleInput()
|
function OverworldState:handleInput()
|
||||||
@@ -989,19 +1088,38 @@ function OverworldState:handleInput()
|
|||||||
-- OverworldLoop (home/overworld.asm) gates ALL of JoypadOverworld on
|
-- OverworldLoop (home/overworld.asm) gates ALL of JoypadOverworld on
|
||||||
-- wWalkCounter == 0 ("if the player sprite has not yet completed the
|
-- wWalkCounter == 0 ("if the player sprite has not yet completed the
|
||||||
-- walking animation" it jumps straight to .moveAhead): A, START and
|
-- walking animation" it jumps straight to .moveAhead): A, START and
|
||||||
-- direction initiation are only ever looked at while the player stands
|
-- direction initiation are only ever ACTED ON while the player stands on
|
||||||
-- on a tile, and a button pressed mid-step is simply never seen.
|
-- a tile. Without this gate a mid-step A/START pushed its TextBox/
|
||||||
-- Without this gate a mid-step A/START pushed its TextBox/StartMenu
|
-- StartMenu right there and froze Red between tiles, mid-animation
|
||||||
-- right there and froze Red between tiles, mid-animation (#286). Held
|
-- (#286). Held directions need no buffering -- isDown below picks them
|
||||||
-- directions need no buffering -- isDown below picks them up on the
|
-- up on the landing frame.
|
||||||
-- landing frame.
|
--
|
||||||
if self.player.moving then return end
|
-- The original defers the poll rather than discarding it, though. Joypad
|
||||||
|
-- (engine/joypad.asm _Joypad) computes hJoyPressed against hJoyLast and
|
||||||
|
-- advances hJoyLast only when something calls it; the mid-step path never
|
||||||
|
-- does, and vblank's per-frame ReadJoypad refreshes hJoyInput alone.
|
||||||
|
-- hJoyLast is frozen for the whole animation, so a button pressed
|
||||||
|
-- mid-step and STILL HELD when the step lands reads as a fresh press at
|
||||||
|
-- the next poll -- one released before then is genuinely lost. Dropping
|
||||||
|
-- the edge outright made START a coin flip on the Cycling Road roll,
|
||||||
|
-- where the pull below re-arms a step on the single idle frame in
|
||||||
|
-- bikeStepFrames (#525).
|
||||||
|
if self.player.moving then
|
||||||
|
local held = self.joyLatch
|
||||||
|
if not held then held = {}; self.joyLatch = held end
|
||||||
|
if input:wasPressed("a") then held.a = true end
|
||||||
|
if input:wasPressed("start") then held.start = true end
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local latch = self.joyLatch
|
||||||
|
self.joyLatch = nil
|
||||||
|
|
||||||
if input:wasPressed("a") then
|
if input:wasPressed("a") or (latch and latch.a and input:isDown("a")) then
|
||||||
self:interact()
|
self:interact()
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
if input:wasPressed("start") then
|
if input:wasPressed("start")
|
||||||
|
or (latch and latch.start and input:isDown("start")) then
|
||||||
require("src.core.Sound").play(Game.data, "Start_Menu")
|
require("src.core.Sound").play(Game.data, "Start_Menu")
|
||||||
Screens.push(Game, "StartMenu")
|
Screens.push(Game, "StartMenu")
|
||||||
return
|
return
|
||||||
@@ -1017,9 +1135,9 @@ function OverworldState:handleInput()
|
|||||||
local result, why = self.player:tryMove(dir, self.map, self.entities)
|
local result, why = self.player:tryMove(dir, self.map, self.entities)
|
||||||
-- a collision while standing on a warp square fires the warp when the
|
-- a collision while standing on a warp square fires the warp when the
|
||||||
-- extra check passes (CheckWarpsCollision: route-gate doorways, dock
|
-- extra check passes (CheckWarpsCollision: route-gate doorways, dock
|
||||||
-- entrances, ...) -- but never on the inert cell we just warped in on
|
-- entrances, ...), and only while BIT_STANDING_ON_WARP is set (issue
|
||||||
-- (issue #230), which the completed-step path guards the same way.
|
-- #230), which the map-edge path guards the same way.
|
||||||
if result == "blocked" and not self:onWarpArrivalCell() then
|
if result == "blocked" and self:canCollisionWarp() then
|
||||||
local w = Warp.onCollision(self.map, Game.data.field.warpCarpets,
|
local w = Warp.onCollision(self.map, Game.data.field.warpCarpets,
|
||||||
self.player.cellX, self.player.cellY, dir)
|
self.player.cellX, self.player.cellY, dir)
|
||||||
if w then
|
if w then
|
||||||
@@ -1175,11 +1293,11 @@ function OverworldState:checkEdgeExit(dir)
|
|||||||
|
|
||||||
local w = Warp.onEdge(self.map, p.cellX, p.cellY, dir)
|
local w = Warp.onEdge(self.map, p.cellX, p.cellY, dir)
|
||||||
if w then
|
if w then
|
||||||
-- ...but not while still standing on the warp cell we just arrived on
|
-- ...but only with BIT_STANDING_ON_WARP set: a staircase tile clears it,
|
||||||
-- (issue #230): fall through so pushing into the edge bonks (SFX +
|
-- so pushing into the edge beside one bonks (SFX + walk-in-place) instead
|
||||||
-- walk-in-place) instead of instantly re-warping. A real step onto an
|
-- of bouncing floors (issue #230), while the door mat you warped in on
|
||||||
-- exit-carpet edge cleared warpEntryCell first, so those still fire.
|
-- keeps it and exits on that same tile (issue #378).
|
||||||
if self:onWarpArrivalCell() then return false end
|
if not self:canCollisionWarp() then return false end
|
||||||
self:takeWarp(w.def)
|
self:takeWarp(w.def)
|
||||||
return true
|
return true
|
||||||
end
|
end
|
||||||
@@ -1245,7 +1363,14 @@ function OverworldState:crossConnection(dir, conn)
|
|||||||
-- still protects the visible step when neighbor rebuild or the sync
|
-- still protects the visible step when neighbor rebuild or the sync
|
||||||
-- fallback stalls, and avoids the rare one-frame volume spike from a
|
-- fallback stalls, and avoids the rare one-frame volume spike from a
|
||||||
-- song swap mid-step.
|
-- 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
|
self.pendingSeamMusic = conn.map
|
||||||
-- place the player one cell before the seam (their old world spot,
|
-- place the player one cell before the seam (their old world spot,
|
||||||
-- which the neighbor strip renders identically) and start the step
|
-- which the neighbor strip renders identically) and start the step
|
||||||
@@ -1255,6 +1380,8 @@ function OverworldState:crossConnection(dir, conn)
|
|||||||
local d = DIRVEC[dir]
|
local d = DIRVEC[dir]
|
||||||
p.cellX, p.cellY = x - d[1], y - d[2]
|
p.cellX, p.cellY = x - d[1], y - d[2]
|
||||||
p.px, p.py = p.cellX * 16, p.cellY * 16
|
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)
|
self.camera:follow(p.px, p.py)
|
||||||
p.facing = dir
|
p.facing = dir
|
||||||
p.targetX, p.targetY = x, y
|
p.targetX, p.targetY = x, y
|
||||||
@@ -1405,6 +1532,7 @@ function OverworldState:goFishing(rod)
|
|||||||
-- the bobber waits a beat before the verdict (the original's
|
-- the bobber waits a beat before the verdict (the original's
|
||||||
-- FishingInit dot animation); the rod pose draws in the meantime
|
-- FishingInit dot animation); the rod pose draws in the meantime
|
||||||
self.fishing = { facing = self.player.facing }
|
self.fishing = { facing = self.player.facing }
|
||||||
|
self.player.fishing = true
|
||||||
Game.stack:push(TextBox.new(Game, ". . .", function()
|
Game.stack:push(TextBox.new(Game, ". . .", function()
|
||||||
-- FishingAnim (engine/overworld/player_animations.asm) holds
|
-- FishingAnim (engine/overworld/player_animations.asm) holds
|
||||||
-- BIT_LEDGE_OR_FISHING -- the rod OAM and the fishing pose -- through
|
-- BIT_LEDGE_OR_FISHING -- the rod OAM and the fishing pose -- through
|
||||||
@@ -1412,12 +1540,20 @@ function OverworldState:goFishing(rod)
|
|||||||
-- must NOT vanish with the dots box (#321).
|
-- must NOT vanish with the dots box (#321).
|
||||||
if not enc then
|
if not enc then
|
||||||
Game.stack:push(TextBox.new(Game, Strings("Not even a nibble!"), function()
|
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.fishing = nil
|
||||||
|
self.fishPose = 10
|
||||||
end))
|
end))
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
Game.stack:push(TextBox.new(Game, Strings("Oh!\nIt's a bite!"), function()
|
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.fishing = nil
|
||||||
|
self.player.fishing = nil
|
||||||
local BattleState = require("src.battle.BattleState")
|
local BattleState = require("src.battle.BattleState")
|
||||||
local battle = BattleState.newWild(Game, enc.species, enc.level, { hooked = true })
|
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
|
if Game.save.safari and Map.inRegion(self.map.def, "SAFARI", "SAFARI_ZONE") then
|
||||||
@@ -1667,7 +1803,7 @@ function OverworldState:tryHiddenObject(fx, fy)
|
|||||||
if h.x == fx and h.y == fy then
|
if h.x == fx and h.y == fy then
|
||||||
save.hiddenTaken = save.hiddenTaken or {}
|
save.hiddenTaken = save.hiddenTaken or {}
|
||||||
if save.hiddenTaken[key] then return false end
|
if save.hiddenTaken[key] then return false end
|
||||||
if not require("src.inventory.Bag").add(save, h.item, 1) then
|
if not require("src.inventory.Bag").add(save, h.item, 1, Game.data) then
|
||||||
Game.stack:push(TextBox.new(Game, Strings("You can't carry\nany more items!")))
|
Game.stack:push(TextBox.new(Game, Strings("You can't carry\nany more items!")))
|
||||||
return true
|
return true
|
||||||
end
|
end
|
||||||
@@ -2024,6 +2160,7 @@ function OverworldState:billsHouseBillExits()
|
|||||||
local Commands = require("src.script.Commands")
|
local Commands = require("src.script.Commands")
|
||||||
local ctx = { game = Game, save = Game.save, overworld = self }
|
local ctx = { game = Game, save = Game.save, overworld = self }
|
||||||
Commands.show_object(ctx, "BILLS_HOUSE", "BILLSHOUSE_BILL1")
|
Commands.show_object(ctx, "BILLS_HOUSE", "BILLSHOUSE_BILL1")
|
||||||
|
require("src.world.PikachuFollower").onBillExitedMachine(Game, self)
|
||||||
local function done()
|
local function done()
|
||||||
Game.save.flags.EVENT_MET_BILL = true
|
Game.save.flags.EVENT_MET_BILL = true
|
||||||
Game.save.flags.EVENT_MET_BILL_2 = true
|
Game.save.flags.EVENT_MET_BILL_2 = true
|
||||||
@@ -2098,25 +2235,26 @@ end
|
|||||||
-- Gen 1 has no confirmation prompt: using SURF gets straight on
|
-- Gen 1 has no confirmation prompt: using SURF gets straight on
|
||||||
-- (_SurfingGotOnText, item_effects.asm .surf). Called from the party
|
-- (_SurfingGotOnText, item_effects.asm .surf). Called from the party
|
||||||
-- menu's SURF action (via useSurfFieldMove) once the facing tile has been
|
-- menu's SURF action (via useSurfFieldMove) once the facing tile has been
|
||||||
-- confirmed to be water -- there is no overworld A-press hook.
|
-- confirmed to be water -- there is no overworld A-press hook. onClose is
|
||||||
function OverworldState:trySurf(fx, fy)
|
-- 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")
|
local mon = self:partyKnows("SURF")
|
||||||
if not mon then return end
|
if not mon then return end
|
||||||
local name = mon.nickname or Game.data.pokemon[mon.species].name
|
local name = mon.nickname or Game.data.pokemon[mon.species].name
|
||||||
local p = self.player
|
local p = self.player
|
||||||
local text = (Game.data.text._SurfingGotOnText or Strings("{PLAYER} got on\n{RAM:wNameBuffer}!"))
|
local text = (Game.data.text._SurfingGotOnText or Strings("{PLAYER} got on\n{RAM:wNameBuffer}!"))
|
||||||
:gsub("{RAM:wNameBuffer}", name)
|
:gsub("{RAM:wNameBuffer}", name)
|
||||||
-- GBPalWhiteOutWithDelay3 runs while the got-on text is still up
|
-- UseItem prints the got-on text with the party menu still on screen and
|
||||||
-- (start_sub_menus.asm .surf), so the blink reads as a text flash
|
-- GBPalWhiteOutWithDelay3 + .goBackToMap only run after it
|
||||||
-- instead of a flashbang on the empty map (#320). The flash sits
|
-- (start_sub_menus.asm .surf), so the text reads over the menu and the
|
||||||
-- under the textbox on the stack: only the top state updates, so it
|
-- blink is the menu closing, not a flashbang on the empty map (#320,
|
||||||
-- holds its frames until the text closes. surfing (and the sprite
|
-- #385). The mount rides the blink, so nothing paddles on land.
|
||||||
-- swap) only applies when the step happens -- no paddling on land.
|
|
||||||
Game.stack:push(require("src.render.Transition").whiteFlash(Game))
|
|
||||||
Game.stack:push(TextBox.new(Game, text, function()
|
Game.stack:push(TextBox.new(Game, text, function()
|
||||||
|
if onClose then onClose() end
|
||||||
p.surfing = true
|
p.surfing = true
|
||||||
require("src.core.Music").setSurfing(Game.data, 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))
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -2285,7 +2423,7 @@ function OverworldState:talkTo(npc)
|
|||||||
-- (e.g. Blue's House wall Town Map / walking Daisy, #11). Lua treats
|
-- (e.g. Blue's House wall Town Map / walking Daisy, #11). Lua treats
|
||||||
-- the string "0" as truthy, so screen it out and fall through to text.
|
-- the string "0" as truthy, so screen it out and fall through to text.
|
||||||
if d.item and d.item ~= "0" and d.item ~= 0 then
|
if d.item and d.item ~= "0" and d.item ~= 0 then
|
||||||
if not require("src.inventory.Bag").add(Game.save, d.item, 1) then
|
if not require("src.inventory.Bag").add(Game.save, d.item, 1, Game.data) then
|
||||||
Game.stack:push(TextBox.new(Game, Strings("You can't carry\nany more items!")))
|
Game.stack:push(TextBox.new(Game, Strings("You can't carry\nany more items!")))
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
@@ -3050,18 +3188,16 @@ function OverworldState:onStepComplete()
|
|||||||
entry = nil
|
entry = nil
|
||||||
end
|
end
|
||||||
-- The arrival disable is POSITIONAL: warpEntryCell above is the whole
|
-- The arrival disable is POSITIONAL: warpEntryCell above is the whole
|
||||||
-- test. justWarped only records that an arrival happened (it still
|
-- test. Consuming a completed step with a one-shot "just warped" counter
|
||||||
-- backs onWarpArrivalCell's bonk guard for issue #230), so consuming a
|
-- instead swallowed the warp under the player's feet, which is why a
|
||||||
-- completed step with it swallowed the warp under the player's feet,
|
-- second ladder one cell from the first did nothing (Seafoam B3F has warp
|
||||||
-- which is why a second ladder one cell from the first did nothing
|
-- tiles on (25,3) and (25,4)) -- issue #265. pokered has no such counter:
|
||||||
-- (Seafoam B3F has warp tiles on (25,3) and (25,4)) -- issue #265.
|
-- every completed step runs CheckWarpsNoCollision (home/overworld.asm).
|
||||||
-- pokered has no such counter: every completed step runs
|
-- That same step is where BIT_STANDING_ON_WARP is maintained: cleared
|
||||||
-- CheckWarpsNoCollision (home/overworld.asm), and BIT_STANDING_ON_WARP,
|
-- before the check (home/overworld.asm:324), set again while standing on a
|
||||||
-- the flag the bonk path needs, is only set by
|
-- warp square, then cleared once more when the square is a warp-activating
|
||||||
-- CheckWarpsNoCollisionLoop itself or by IsPlayerStandingOnWarp from
|
-- tile that is not a door tile (IsPlayerStandingOnDoorTileOrWarpTile).
|
||||||
-- MapEntryAfterBattle, never on a plain warp arrival -- which is
|
self:refreshStandingOnWarp()
|
||||||
-- exactly what warpEntryCell reproduces.
|
|
||||||
self.justWarped = false
|
|
||||||
if entry then
|
if entry then
|
||||||
-- still standing on the warp we arrived through; do not re-trigger it
|
-- still standing on the warp we arrived through; do not re-trigger it
|
||||||
else
|
else
|
||||||
@@ -3499,6 +3635,10 @@ function OverworldState:afterBattle(result, battle)
|
|||||||
{ save = Game.save, healTarget = self:healPoint() })
|
{ save = Game.save, healTarget = self:healPoint() })
|
||||||
self:warpToHealPoint(evolutions)
|
self:warpToHealPoint(evolutions)
|
||||||
else
|
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
|
-- throwing the last SAFARI BALL ends the game
|
||||||
if Game.save.safari and Game.save.safari.balls <= 0 then
|
if Game.save.safari and Game.save.safari.balls <= 0 then
|
||||||
self:safariGameOver(Strings("PA: You're out of\nSAFARI BALLs!"))
|
self:safariGameOver(Strings("PA: You're out of\nSAFARI BALLs!"))
|
||||||
@@ -3632,12 +3772,12 @@ function OverworldState:startWarpTo(mapId, x, y, facing, onDone, opts)
|
|||||||
self.arriveWarp = nil
|
self.arriveWarp = nil
|
||||||
Game.stack:push(Transition.new(Game, function()
|
Game.stack:push(Transition.new(Game, function()
|
||||||
self:setMap(mapId, x, y, facing or "down", opts)
|
self:setMap(mapId, x, y, facing or "down", opts)
|
||||||
self.justWarped = true
|
-- The warp we land ON stays inert for the completed-step check until we
|
||||||
-- The warp we land ON stays inert until we physically step off it, so a
|
-- physically step off it, so a warp whose destination cell is itself a
|
||||||
-- warp whose destination cell is itself a warp cannot bounce us straight
|
-- warp cannot bounce us straight back (elevator cars, stacked stair/door
|
||||||
-- back (elevator cars, stacked stair/door mats). This generalizes the
|
-- mats). BIT_STANDING_ON_WARP is deliberately NOT touched here:
|
||||||
-- one-step justWarped guard, which only skipped the very next frame's
|
-- ClearVariablesOnEnterMap leaves wMovementFlags alone, so the flag the
|
||||||
-- check and so let a mon walked back onto the pad re-trigger it.
|
-- departing tile set rides through the warp (issue #378).
|
||||||
self.warpEntryCell = { x = x, y = y }
|
self.warpEntryCell = { x = x, y = y }
|
||||||
-- Fly/Teleport/Dig/Escape-Rope landings poof the player back in
|
-- Fly/Teleport/Dig/Escape-Rope landings poof the player back in
|
||||||
-- (player_animations.asm EnterMapAnim). Blackouts and ordinary
|
-- (player_animations.asm EnterMapAnim). Blackouts and ordinary
|
||||||
@@ -3663,8 +3803,8 @@ function OverworldState:startWarpTo(mapId, x, y, facing, onDone, opts)
|
|||||||
-- PlayerStepOutFromDoor (engine/overworld/auto_movement.asm): any
|
-- PlayerStepOutFromDoor (engine/overworld/auto_movement.asm): any
|
||||||
-- warp that lands on a door tile auto-steps south once, indoor or
|
-- warp that lands on a door tile auto-steps south once, indoor or
|
||||||
-- outdoor. Auto-walk leaves the mat, so the arrival disable
|
-- outdoor. Auto-walk leaves the mat, so the arrival disable
|
||||||
-- (warpEntryCell / justWarped) is unnecessary -- and would let you
|
-- (warpEntryCell) is unnecessary -- and would let you stand on the
|
||||||
-- stand on the door without re-entering if you hold back into it.
|
-- door without re-entering if you hold back into it.
|
||||||
-- The walk-out is a simulated d-pad press (wSimulatedJoypadStates),
|
-- The walk-out is a simulated d-pad press (wSimulatedJoypadStates),
|
||||||
-- not a forced move, so it obeys collision: on a landing with a
|
-- not a forced move, so it obeys collision: on a landing with a
|
||||||
-- solid cell south of the door (the mansion stair landings back
|
-- solid cell south of the door (the mansion stair landings back
|
||||||
@@ -3673,7 +3813,6 @@ function OverworldState:startWarpTo(mapId, x, y, facing, onDone, opts)
|
|||||||
if self.map:isDoorTileCell(self.player.cellX, self.player.cellY) then
|
if self.map:isDoorTileCell(self.player.cellX, self.player.cellY) then
|
||||||
if Collision.canMove(self.map, self.entities, self.player, "down") then
|
if Collision.canMove(self.map, self.entities, self.player, "down") then
|
||||||
self.warpEntryCell = nil
|
self.warpEntryCell = nil
|
||||||
self.justWarped = false
|
|
||||||
self:scriptMove(self.player, "down", 1)
|
self:scriptMove(self.player, "down", 1)
|
||||||
else
|
else
|
||||||
self.player.facing = "down"
|
self.player.facing = "down"
|
||||||
@@ -3843,6 +3982,31 @@ function OverworldState:draw()
|
|||||||
self:drawUI()
|
self:drawUI()
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- The emote sheet is OBJ art (engine/overworld/emotion_bubbles.asm builds the
|
||||||
|
-- bubble out of shadow OAM), so it renders through OBP0, and GBPalNormal
|
||||||
|
-- (home/palettes.asm:20-26 `ld a, %11010000 ; 3100 / ldh [rOBP0], a`) holds
|
||||||
|
-- OBP0 at "3100": OBJ color 1 shows as shade 0, color 2 as shade 1, color 3
|
||||||
|
-- as shade 3. Blitting the raw sheet skipped that lift and left the "!"
|
||||||
|
-- bubble's interior (color 1) at DMG shade 1 grey instead of white (#505).
|
||||||
|
-- Same CPU-remap bake as SpriteRenderer.getObpImage and PartyMenu's obpIcon,
|
||||||
|
-- and it resolves through Assets so a mod's emotes.png override still wins.
|
||||||
|
-- Color 0's alpha (a tRNS entry on the extracted png) is what keys the
|
||||||
|
-- bubble's corners out, so carry it through untouched.
|
||||||
|
local function obpEmoteImage(path)
|
||||||
|
if not (love.image and love.image.newImageData) then
|
||||||
|
return love.graphics.newImage(Assets.resolve(path)) -- headless stub
|
||||||
|
end
|
||||||
|
local id = Assets.imageData(path)
|
||||||
|
id:mapPixel(function(_, _, r, _, _, a)
|
||||||
|
local v = 0
|
||||||
|
if r > 0.5 then v = 1 -- OBJ colors 0 and 1 -> shade 0
|
||||||
|
elseif r > 0.17 then v = 170 / 255 -- OBJ color 2 -> shade 1
|
||||||
|
end -- OBJ color 3 -> shade 3
|
||||||
|
return v, v, v, a
|
||||||
|
end)
|
||||||
|
return love.graphics.newImage(id)
|
||||||
|
end
|
||||||
|
|
||||||
-- The SGB palette a tilt-mode billboard at flat foot (fx, fy) sits under.
|
-- The SGB palette a tilt-mode billboard at flat foot (fx, fy) sits under.
|
||||||
-- World zones are rectangles in flat world-canvas space (the current map's
|
-- World zones are rectangles in flat world-canvas space (the current map's
|
||||||
-- base fills the view; neighbour maps stack on top), so the last zone that
|
-- base fills the view; neighbour maps stack on top), so the last zone that
|
||||||
@@ -4082,7 +4246,7 @@ function OverworldState:drawWorld()
|
|||||||
local drawn = false
|
local drawn = false
|
||||||
if bubble and bubble.path then
|
if bubble and bubble.path then
|
||||||
local ok, img = pcall(function()
|
local ok, img = pcall(function()
|
||||||
self.emoteImg = self.emoteImg or love.graphics.newImage(bubble.path)
|
self.emoteImg = self.emoteImg or obpEmoteImage(bubble.path)
|
||||||
return self.emoteImg
|
return self.emoteImg
|
||||||
end)
|
end)
|
||||||
-- EXCLAMATION_BUBBLE is index 0 -> first crop; the emote command
|
-- EXCLAMATION_BUBBLE is index 0 -> first crop; the emote command
|
||||||
@@ -4116,23 +4280,6 @@ function OverworldState:drawWorld()
|
|||||||
end
|
end
|
||||||
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
|
-- the FLY bird sweeping off with the player
|
||||||
local function fxBird()
|
local function fxBird()
|
||||||
if not self.flyAnim then return end
|
if not self.flyAnim then return end
|
||||||
@@ -4210,7 +4357,7 @@ function OverworldState:drawWorld()
|
|||||||
return PaletteFX.pal(Game.data, self:paletteNameFor(map or self.map))
|
return PaletteFX.pal(Game.data, self:paletteNameFor(map or self.map))
|
||||||
end,
|
end,
|
||||||
fx = { heal = fxHeal, dust = fxDust, cutTree = fxCutTree,
|
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)`
|
-- 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
|
-- maps a world point to canvas pixels (nil when it is behind the
|
||||||
@@ -4261,17 +4408,6 @@ function OverworldState:drawWorld()
|
|||||||
if self.fishing then
|
if self.fishing then
|
||||||
at(fxRod, self.player.px + 8, self.player.py + 16)
|
at(fxRod, self.player.px + 8, self.player.py + 16)
|
||||||
end
|
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
|
end
|
||||||
override = Pipelines.drawWorld(pipelineId, ctx)
|
override = Pipelines.drawWorld(pipelineId, ctx)
|
||||||
-- world post-processes (a miniature-diorama blur, a colour grade) fold
|
-- world post-processes (a miniature-diorama blur, a colour grade) fold
|
||||||
@@ -4334,7 +4470,6 @@ function OverworldState:drawWorld()
|
|||||||
fxDust()
|
fxDust()
|
||||||
fxCutTree()
|
fxCutTree()
|
||||||
fxEmote()
|
fxEmote()
|
||||||
fxDark()
|
|
||||||
fxBird()
|
fxBird()
|
||||||
fxRod()
|
fxRod()
|
||||||
else
|
else
|
||||||
@@ -4423,12 +4558,6 @@ function OverworldState:drawWorld()
|
|||||||
self:billboard(fx, fy, vw, vh, zoneColorsAt(zones, fx, fy), false, fxRod)
|
self:billboard(fx, fy, vw, vh, zoneColorsAt(zones, fx, fy), false, fxRod)
|
||||||
end
|
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()
|
Game.renderer:endUprightPass()
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -4439,9 +4568,11 @@ function OverworldState:drawUI()
|
|||||||
-- TalkToPikachu's picture box (engine/pikachu/pikachu_pic_animation.asm
|
-- TalkToPikachu's picture box (engine/pikachu/pikachu_pic_animation.asm
|
||||||
-- PlacePikapicTextBoxBorder: TextBoxBorder at (6,5) with b,c = 5,5, so a
|
-- PlacePikapicTextBoxBorder: TextBoxBorder at (6,5) with b,c = 5,5, so a
|
||||||
-- 7x7 box holding the 5x5 pic at (7,6) -- PikaAnimTilemap_1). The
|
-- 7x7 box holding the 5x5 pic at (7,6) -- PikaAnimTilemap_1). The
|
||||||
-- per-emotion frame gfx (gfx/pikachu/unknown_*) are not extracted, so
|
-- per-emotion frame gfx (gfx/pikachu/unknown_*) are not extracted, so the
|
||||||
-- the front pic holds for the whole beat while the cry and any emote
|
-- front pic stands in for every frame of the script; PikachuFollower
|
||||||
-- bubble play over the world below (#407).
|
-- .picLift lifts it on the runs that draw the alternate pose, and the
|
||||||
|
-- script's own duration times the beat (#407, #424). Palette zone
|
||||||
|
-- PAL_PIKACHU_PORTRAIT covers (7,6)-(11,10) via sgbPalettes above.
|
||||||
if self.emote and self.emote.pikaPic then
|
if self.emote and self.emote.pikaPic then
|
||||||
require("src.render.Font").drawBox(6, 5, 7, 7)
|
require("src.render.Font").drawBox(6, 5, 7, 7)
|
||||||
-- one image per path, cached: this draws every frame of the hold, and
|
-- one image per path, cached: this draws every frame of the hold, and
|
||||||
@@ -4455,8 +4586,9 @@ function OverworldState:drawUI()
|
|||||||
if img then
|
if img then
|
||||||
love.graphics.setColor(1, 1, 1, 1)
|
love.graphics.setColor(1, 1, 1, 1)
|
||||||
local w, h = img:getDimensions()
|
local w, h = img:getDimensions()
|
||||||
|
local lift = require("src.world.PikachuFollower").picLift(self.emote)
|
||||||
love.graphics.draw(img, math.floor(56 + (40 - w) / 2),
|
love.graphics.draw(img, math.floor(56 + (40 - w) / 2),
|
||||||
math.floor(48 + (40 - h) / 2))
|
math.floor(48 + (40 - h) / 2) - lift)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -4478,6 +4610,11 @@ function OverworldState:captureSave(save)
|
|||||||
save.player.x = self.player.cellX
|
save.player.x = self.player.cellX
|
||||||
save.player.y = self.player.cellY
|
save.player.y = self.player.cellY
|
||||||
save.player.facing = self.player.facing
|
save.player.facing = self.player.facing
|
||||||
|
-- wWalkBikeSurfState (ram/wram.asm) sits inside the wMainDataStart..
|
||||||
|
-- wMainDataEnd range engine/menus/save.asm block-copies into sMainData,
|
||||||
|
-- so the original saves and restores the surf state; setMap's boot path
|
||||||
|
-- reads this back (#536).
|
||||||
|
save.player.surfing = self.player.surfing and true or false
|
||||||
end
|
end
|
||||||
|
|
||||||
return OverworldState
|
return OverworldState
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user