mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-13 17:00:58 +02:00
Compare commits
93 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| 5c0a7d1456 | |||
| e10f6e2032 | |||
| c61643edaa | |||
| 0b7b0b48d1 | |||
| 2ff459e2c4 | |||
| 8c877a64f0 | |||
| 8fdfd5c843 | |||
| 579c1744bd | |||
| 118c45f8f6 | |||
| beaa502ff9 | |||
| 730b7b842c | |||
| 784832ca95 | |||
| bbe894fb4f | |||
| 4c17e606fd | |||
| 73d81a72b8 | |||
| 8b0f770c57 | |||
| cd33858020 | |||
| fb58fa788b | |||
| d09cf80da5 | |||
| aab980b05a |
@@ -40,6 +40,7 @@ body:
|
||||
- Windows
|
||||
- Linux
|
||||
- Android
|
||||
- iOS
|
||||
- Multiple platforms
|
||||
validations:
|
||||
required: true
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
name: Release
|
||||
|
||||
# Builds the macOS, Windows, and Linux desktop apps, an Android APK, and the
|
||||
# Anbernic RG34XXSP (Stock OS 64-bit MOD / PortMaster) port on the self-hosted
|
||||
# Mac runner, and publishes them as a GitHub Release.
|
||||
# Builds the macOS, Windows, and Linux desktop apps, an Android APK, an iOS
|
||||
# IPA, and the Anbernic RG34XXSP (Stock OS 64-bit MOD / PortMaster) port on
|
||||
# the self-hosted Mac runner, and publishes them as a GitHub Release.
|
||||
#
|
||||
# Versioning:
|
||||
# - First ever release is 0.1.0.
|
||||
@@ -170,6 +170,15 @@ jobs:
|
||||
set -euo pipefail
|
||||
scripts/build_android.sh --version "${{ steps.ver.outputs.version }}"
|
||||
|
||||
- name: Build iOS
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Device Release IPA; signs with the Apple Development identity on
|
||||
# the runner (auto team detection). Users on other Apple IDs still
|
||||
# re-sign or build via docs/ios-install.md.
|
||||
scripts/build_ios.sh --fetch --device --release \
|
||||
--version "${{ steps.ver.outputs.version }}"
|
||||
|
||||
- name: Build Anbernic RG34XXSP port
|
||||
run: |
|
||||
set -euo pipefail
|
||||
@@ -226,6 +235,10 @@ jobs:
|
||||
[ -n "$apk" ] || { echo "::error::no Android APK found under dist/android/debug"; exit 1; }
|
||||
cp "$apk" "$outdir/gen1recomp-${v}-android.apk"
|
||||
|
||||
ipa="dist/ios/gen1recomp.ipa"
|
||||
[ -f "$ipa" ] || { echo "::error::$ipa not found (expected from scripts/build_ios.sh --device)"; exit 1; }
|
||||
cp "$ipa" "$outdir/gen1recomp-${v}-ios.ipa"
|
||||
|
||||
# Anbernic handheld port (suffix names the CFW it targets, so a
|
||||
# future RG35XX/other-CFW pack can ship alongside it).
|
||||
rg34="dist/rg34xxsp/gen1recomp-rg34xxsp-stockos64-mod.zip"
|
||||
@@ -344,6 +357,7 @@ jobs:
|
||||
"dist/release/gen1recomp-${v}-windows.zip" \
|
||||
"dist/release/gen1recomp-${v}-linux.zip" \
|
||||
"dist/release/gen1recomp-${v}-android.apk" \
|
||||
"dist/release/gen1recomp-${v}-ios.ipa" \
|
||||
"dist/release/gen1recomp-${v}-rg34xxsp-stockos64-mod.zip" \
|
||||
"dist/release/gen1recomp-${v}.love" \
|
||||
"dist/release/sha256sums.txt"
|
||||
|
||||
@@ -16,6 +16,7 @@ __pycache__/
|
||||
.*
|
||||
!.github/
|
||||
!.gitignore
|
||||
!.luacheckrc
|
||||
|
||||
# Android build outputs / local SDK path / packaged payload (keep love-android sources)
|
||||
mobile/android/app/build/
|
||||
@@ -39,3 +40,6 @@ mobile/dist/
|
||||
# Tiled map-editing workspace (tools/tiled_export.py). Derived from the imported
|
||||
# ROM cache exactly like data/generated/, so it is never committable.
|
||||
/build/tiled/
|
||||
|
||||
# per-machine iOS bundle-id pin (see scripts/build_ios.sh)
|
||||
mobile/ios/bundle_id.local
|
||||
|
||||
+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
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
Copyright 2026 BOIS CLUB GAMES, LLC
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
@@ -1,31 +1,75 @@
|
||||
# Gen1Recomp
|
||||
|
||||
A native LÖVE2D recreation of Poke Red and Blue. The engine and map
|
||||
A native LÖVE2D recreation of Poke Red, Blue and Yellow. The engine and map
|
||||
behavior are hand-written Lua; game data and graphics are decoded from a ROM
|
||||
supplied by the player.
|
||||
|
||||
SUPPORT AND ANNOUNCEMENTS: [Discord](https://bois.icu)
|
||||
<p align="center"><img src="https://raw.githubusercontent.com/bryanthaboi/gen1recomp/refs/heads/dev/assets/logo/logo.png"></p>
|
||||
|
||||
**SUPPORT / ANNOUNCEMENTS / MODS:** [Discord](https://bois.icu)
|
||||
|
||||
<p align="center">
|
||||
|
||||
<a href="https://www.youtube.com/@bryanthaboi">
|
||||
<img src="https://img.shields.io/badge/YouTube-FF0000?style=for-the-badge&logo=youtube&logoColor=white" alt="YouTube">
|
||||
</a>
|
||||
<a href="https://www.tiktok.com/@bryanthaboi">
|
||||
<img src="https://img.shields.io/badge/TikTok-000000?style=for-the-badge&logo=tiktok&logoColor=white" alt="TikTok">
|
||||
</a>
|
||||
<a href="https://x.com/bryanthaboi">
|
||||
<img src="https://img.shields.io/badge/X-000000?style=for-the-badge&logo=x&logoColor=white" alt="X">
|
||||
</a>
|
||||
<a href="https://bsky.app/profile/bryanthaboi.live">
|
||||
<img src="https://img.shields.io/badge/Bluesky-0285FF?style=for-the-badge&logo=bluesky&logoColor=white" alt="Bluesky">
|
||||
</a>
|
||||
<a href="https://www.instagram.com/bryanthaboi">
|
||||
<img src="https://img.shields.io/badge/Instagram-E4405F?style=for-the-badge&logo=instagram&logoColor=white" alt="Instagram">
|
||||
</a>
|
||||
|
||||
</p>
|
||||
|
||||
|
||||
<p align="center"> <a href="https://www.polygon.com/pokemon-red-blue-3d-voxel-mod-battle-pixels-gameplay-footage-remake/"> <img src="https://img.shields.io/badge/AS%20SEEN%20ON-POLYGON-ea2e49?style=for-the-badge" alt="As seen on Polygon"> </a>
|
||||
<a href="https://kotaku.com/pokemon-red-blue-recompilation-project-voxel-3d-mod-2000720281"> <img src="https://img.shields.io/badge/AS%20SEEN%20ON-KOTAKU-ea2e49?style=for-the-badge" alt="As seen on KOTAKU"> </a>
|
||||
|
||||
<a href="https://www.digitalfoundry.net/news/2026/07/pokemon-yellow-voxel-mod-turns-the-original-gameboy-code-into-a-stunning-world">
|
||||
<img src="https://img.shields.io/badge/AS%20SEEN%20ON-DIGITAL%20FOUNDRY-ea2e49?style=for-the-badge" alt="As seen on Digital Foundry">
|
||||
</a>
|
||||
<a href="https://www.androidauthority.com/unofficial-android-port-pokemon-red-blue-yellow-3692724/">
|
||||
<img src="https://img.shields.io/badge/AS%20SEEN%20ON-ANDROID%20AUTHORITY-ea2e49?style=for-the-badge" alt="As seen on Android Authority">
|
||||
</a>
|
||||
|
||||
<a href="https://www.xda-developers.com/this-amazing-pokemon-red-and-blue-voxel-mod-adds-a-3d-perspective-without-an-emulator/">
|
||||
<img src="https://img.shields.io/badge/AS%20SEEN%20ON-XDA%20DEVELOPERS-ea2e49?style=for-the-badge" alt="As seen on XDA Developers">
|
||||
</a>
|
||||
</p>
|
||||
|
||||
### Watch the latest update video
|
||||
|
||||
[](https://www.youtube.com/watch?v=8IOgqbe4YvA)
|
||||
|
||||
|
||||
This project does not include a ROM, emulate the Game Boy, transpile assembly,
|
||||
or download a disassembly. A canonical US Poke Red or Blue ROM is the only
|
||||
game content input.
|
||||
or download a disassembly. A canonical US Poke Red, Blue, or Yellow ROM is the
|
||||
only game content input.
|
||||
|
||||
The ROM is verified, used during import, and then released from memory. It is
|
||||
not copied into the cache. Later launches load the private generated cache and
|
||||
do not ask for the ROM again. Red and Blue can both be imported and played
|
||||
side by side.
|
||||
do not ask for the ROM again. Red, Blue, and Yellow can all be imported and
|
||||
played side by side.
|
||||
|
||||
## Quick Start
|
||||
|
||||
Open the desktop app. On first boot, choose your legally obtained `.gb` file
|
||||
or drop it onto the window. Import takes a few seconds and the game starts
|
||||
automatically.
|
||||
Open the desktop app. On first boot, choose your legally obtained `.gb` /
|
||||
`.gbc` file or drop it onto the window. Import takes a few seconds and the
|
||||
game starts automatically.
|
||||
|
||||
Only the canonical 1 MiB US Red and Blue ROMs are accepted. The importer
|
||||
verifies SHA-1 before creating any game data:
|
||||
Only the canonical 1 MiB US Red, Blue, and Yellow ROMs are accepted. The
|
||||
importer verifies SHA-1 before creating any game data:
|
||||
|
||||
- Red: `ea9bcae617fdf159b045185467ae58b2e4a48b9a`
|
||||
- Blue: `d7037c83e1ae5b39bde3c30787637ba1d4c48ce2`
|
||||
- Yellow: `cc7d03262ebfaf2f06772c1a480c7d9d5f4a38e1`
|
||||
|
||||
The packaged app contains neither a ROM nor pre-extracted game data. Music,
|
||||
sound effects, and cries are synthesized while the game runs from compact
|
||||
@@ -64,6 +108,17 @@ supported out of the box.
|
||||
COLORS, TILT, ZOOM, GBC FX, and VOID FILL are also in the Options menu
|
||||
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
|
||||
|
||||
**OPTIONS → RULESET** picks which set of Gen 1 battle behaviors to run.
|
||||
@@ -98,11 +153,11 @@ famous bugs included:
|
||||
|
||||
## Running From Source
|
||||
|
||||
Requires LÖVE 11.x. Place a Red or Blue ROM in the project folder and
|
||||
Requires LÖVE 11.x. Place a Red, Blue, or Yellow ROM in the project folder and
|
||||
double-click `Play-Mac.command` or `Play-Windows.bat`, or run:
|
||||
|
||||
```sh
|
||||
scripts/setup.sh --rom "/path/to/Poke Red.gb" # or Poke Blue.gb
|
||||
scripts/setup.sh --rom "/path/to/Poke Red.gb" # or Blue.gb / Yellow.gbc
|
||||
scripts/run.sh
|
||||
```
|
||||
|
||||
@@ -133,6 +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
|
||||
over yourself if you want to carry existing progress across the switch.
|
||||
|
||||
## iOS
|
||||
|
||||
Every release ships `gen1recomp-*-ios.ipa`. Sideload it with AltStore
|
||||
(Windows or Mac) — see [docs/ios-sideload.md](docs/ios-sideload.md). To
|
||||
build and install from source on a Mac instead, see
|
||||
[docs/ios-install.md](docs/ios-install.md).
|
||||
|
||||
## Handhelds
|
||||
|
||||
A PortMaster-style port for the **Anbernic RG34XXSP** on Stock OS 64-bit MOD
|
||||
@@ -185,4 +247,6 @@ request with real detail is one that can actually get built.
|
||||
|
||||
This project would not be possible without [pret](https://github.com/pret) >
|
||||
the pret band of decompiling maniacs > and their
|
||||
[pokered](https://github.com/pret/pokered) disassembly.
|
||||
[pokered](https://github.com/pret/pokered) disassembly.
|
||||
|
||||
<p align="center"><a href="https://boisclub.games"><img src="https://raw.githubusercontent.com/bryanthaboi/gen1recomp/refs/heads/dev/assets/logo/bcg.png"></a></p>
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
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 developer = os.getenv("POKEPORT_DEV") == "1"
|
||||
if arg then
|
||||
@@ -62,7 +67,10 @@ function love.conf(t)
|
||||
-- the game follow the device. The renderer letterboxes the 160x144
|
||||
-- viewport into whatever size results, and the on-screen touch controls
|
||||
-- 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).
|
||||
t.window.resizable = true
|
||||
-- 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
|
||||
-- picker (LOVE 11.5 on Android has none -- see src/import/RomImporter.lua).
|
||||
t.externalstorage = osName == "Android"
|
||||
-- LOVE 11.x exposes the phone's accelerometer as a 3-axis joystick by
|
||||
-- default. Gravity keeps one axis pinned near +/-1.0 whenever the phone
|
||||
-- is held upright, so it streams past-deadzone joystickaxis events that
|
||||
-- both hid the touch overlay every instant (it reads as "a controller is
|
||||
-- in use") and, via the generic-joystick axis mapping (#459), walked the
|
||||
-- player around by tilt. Nothing in the game reads the accelerometer,
|
||||
-- so drop the device entirely (#468).
|
||||
t.accelerometerjoystick = false
|
||||
else
|
||||
t.window.resizable = true
|
||||
end
|
||||
|
||||
@@ -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
|
||||
-- older saves (flag set before the port hid the ball) by hiding the
|
||||
-- leftover ball on the next interaction.
|
||||
--
|
||||
-- The room's two readables (data/events/hidden_events.asm,
|
||||
-- hidden_events_for CELADON_MANSION_ROOF_HOUSE):
|
||||
-- hidden_text_predef 3, 0 / 4, 0 PrintBlackboardLinkCableText, LinkCableHelp
|
||||
-- hidden_text_predef 3, 4 PrintNotebookText, TMNotebook
|
||||
-- tools/extract/field.py only parses `hidden_event` rows, so no
|
||||
-- hidden_text_predef row reaches data/generated/field.lua and both tiles
|
||||
-- were dead A presses (#391). Same hook shape as the bedroom SNES in
|
||||
-- data/scripts/flavor/reds_house_2f.lua (#135). hidden_text_predef puts
|
||||
-- the predef id in the facing byte, so neither tile gates on facing.
|
||||
|
||||
local Menu = require("src.ui.Menu")
|
||||
local TextBox = require("src.render.TextBox")
|
||||
|
||||
-- TMNotebookText (data/text/text_2.asm) has no leading underscore, so the
|
||||
-- extractor never collects it and the pamphlet's text is inlined.
|
||||
local TM_NOTEBOOK_TEXT = "It's a pamphlet\non TMs.\f...\f"
|
||||
.. "There are 50 TMs\nin all.\f"
|
||||
.. "There are also 5\nHMs that can be\vused repeatedly.\f"
|
||||
.. "SILPH CO."
|
||||
|
||||
-- LinkCableHelp (engine/events/hidden_events/school_blackboard.asm):
|
||||
-- HowToLinkText's four headings in the 15x10 box at the top left; picking
|
||||
-- a heading prints its LinkCableInfoText and returns to the menu, B or
|
||||
-- STOP READING closes.
|
||||
local LINK_HEADINGS = { "HOW TO LINK", "COLOSSEUM", "TRADE CENTER" }
|
||||
|
||||
local function linkCableHelp(game)
|
||||
local text = game.data.text or {}
|
||||
local items, showMenu, askHeading
|
||||
function showMenu()
|
||||
game.stack:push(Menu.new(game, items,
|
||||
{ tx = 0, ty = 0, tw = 15, th = 10, rowStep = 1 }))
|
||||
end
|
||||
function askHeading()
|
||||
game.stack:push(TextBox.new(game,
|
||||
text._LinkCableHelpText2 or "Which heading do\nyou want to read?",
|
||||
showMenu))
|
||||
end
|
||||
items = {}
|
||||
for i, label in ipairs(LINK_HEADINGS) do
|
||||
items[i] = { label = label, onSelect = function()
|
||||
game.stack:push(TextBox.new(game,
|
||||
text["_LinkCableInfoText" .. i] or label, askHeading))
|
||||
end }
|
||||
end
|
||||
items[#items + 1] = { label = "STOP READING" }
|
||||
game.stack:push(TextBox.new(game,
|
||||
text._LinkCableHelpText1 or "TRAINER TIPS\fUsing a Game Link\nCable",
|
||||
askHeading))
|
||||
end
|
||||
|
||||
return {
|
||||
onInteract = function(game, ow, fx, fy)
|
||||
if fy == 0 and (fx == 3 or fx == 4) then
|
||||
linkCableHelp(game)
|
||||
return true
|
||||
end
|
||||
if fx == 3 and fy == 4 then
|
||||
game.stack:push(TextBox.new(game, TM_NOTEBOOK_TEXT))
|
||||
return true
|
||||
end
|
||||
return false
|
||||
end,
|
||||
talk = {
|
||||
TEXT_CELADONMANSION_ROOF_HOUSE_EEVEE_POKEBALL = {
|
||||
{ "check_flag", "EVENT_GOT_EEVEE" }, -- 1
|
||||
@@ -22,11 +84,16 @@ return {
|
||||
{ "jump", 13 }, -- 4
|
||||
{ "give_pokemon", "EEVEE", 25 }, -- 5
|
||||
{ "jump_if_false", 12 }, -- 6 (party+box full)
|
||||
{ "play_sound", "Get_Item1" }, -- 7 (GotMonText jingle)
|
||||
{ "show_text", "_GotMonText", { RAM = "EEVEE" } }, -- 8
|
||||
{ "set_flag", "EVENT_GOT_EEVEE" }, -- 9
|
||||
-- flag + HideObject before the jingle and GotMonText: the nickname
|
||||
-- prompt inside give_pokemon and the text row both yield, and a
|
||||
-- script that dies there would leave the EEVEE taken with the ball
|
||||
-- still on the table and the gift claimable again (#426). The row
|
||||
-- count is unchanged, so the numeric jump targets still hold.
|
||||
{ "set_flag", "EVENT_GOT_EEVEE" }, -- 7
|
||||
{ "hide_object", "CELADON_MANSION_ROOF_HOUSE",
|
||||
"CELADONMANSION_ROOF_HOUSE_EEVEE_POKEBALL" }, -- 10
|
||||
"CELADONMANSION_ROOF_HOUSE_EEVEE_POKEBALL" }, -- 8
|
||||
{ "play_sound", "Get_Item1" }, -- 9 (GotMonText jingle)
|
||||
{ "show_text", "_GotMonText", { RAM = "EEVEE" } }, -- 10
|
||||
{ "jump", 13 }, -- 11
|
||||
{ "show_text", "_BoxIsFullText" }, -- 12
|
||||
},
|
||||
|
||||
@@ -42,6 +42,7 @@ local function middleAgedMan(game, ow, npc, done)
|
||||
end
|
||||
local menu = ListMenu.new(game, "", items, {
|
||||
onChoose = function(item)
|
||||
game.stack:pop()
|
||||
push(game, t[BADGE_TEXT[item.value]], loop)
|
||||
end,
|
||||
onCancel = function()
|
||||
|
||||
@@ -3,6 +3,10 @@
|
||||
-- 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
|
||||
-- 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 = {}
|
||||
|
||||
@@ -12,6 +16,29 @@ local function push(game, ow, npc, done, text)
|
||||
game.stack:push(TextBox.new(game, text, done))
|
||||
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 = {
|
||||
talk = {
|
||||
-- CeruleanCityCooltrainerF1Text (scripts/CeruleanCity.asm:362-393)
|
||||
@@ -23,32 +50,20 @@ M.CERULEAN_CITY = {
|
||||
local text
|
||||
if roll >= 180 then
|
||||
text = t._CeruleanCityCooltrainerF1SlowbroUseSonicboomText
|
||||
or t._CeruleanCityCooltrainerF1ElectrodeUseSonicboomText
|
||||
elseif roll >= 100 then
|
||||
text = t._CeruleanCityCooltrainerF1SlowbroPunchText
|
||||
or t._CeruleanCityCooltrainerF1ElectrodePunchText
|
||||
else
|
||||
text = t._CeruleanCityCooltrainerF1SlowbroWithdrawText
|
||||
or t._CeruleanCityCooltrainerF1ElectrodeWithdrawText
|
||||
end
|
||||
push(game, ow, npc, done, text)
|
||||
end,
|
||||
|
||||
-- CeruleanCitySlowbroText (scripts/CeruleanCity.asm:395-436)
|
||||
-- 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.
|
||||
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,
|
||||
TEXT_CERULEANCITY_SLOWBRO = talkMon,
|
||||
-- Yellow map object uses TEXT_CERULEANCITY_ELECTRODE
|
||||
TEXT_CERULEANCITY_ELECTRODE = talkMon,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -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.viridian_city",
|
||||
"data.scripts.flavor.viridian_nickname_house",
|
||||
"data.scripts.flavor.viridian_school_house", -- #503
|
||||
"data.scripts.flavor.wardens_house",
|
||||
}
|
||||
|
||||
|
||||
+92
-19
@@ -183,6 +183,7 @@ M.BILLS_HOUSE = {
|
||||
overworld = ow },
|
||||
"BILLS_HOUSE", "BILLSHOUSE_BILL_POKEMON")
|
||||
game.save.flags.EVENT_BILL_SAID_USE_CELL_SEPARATOR = true
|
||||
require("src.world.PikachuFollower").onBillEnteredMachine(game, ow)
|
||||
done()
|
||||
end
|
||||
if ow.player.facing == "down" then
|
||||
@@ -273,6 +274,9 @@ M.BILLS_HOUSE = {
|
||||
Commands.hide_object(ctx, "BILLS_HOUSE", "BILLSHOUSE_BILL1")
|
||||
Commands.show_object(ctx, "BILLS_HOUSE", "BILLSHOUSE_BILL2")
|
||||
end
|
||||
if not flags.EVENT_MET_BILL_2 then
|
||||
require("src.world.PikachuFollower").onBillsHouseEnter(game, ow)
|
||||
end
|
||||
end,
|
||||
}
|
||||
|
||||
@@ -643,7 +647,12 @@ M.WARDENS_HOUSE = {
|
||||
TEXT_WARDENSHOUSE_WARDEN = {
|
||||
{ "face_player" }, -- 1
|
||||
{ "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
|
||||
{ "jump_if_false", 15 }, -- 5
|
||||
{ "show_text", "_WardensHouseWardenGaveTheGoldTeethText" }, -- 6
|
||||
@@ -654,9 +663,15 @@ M.WARDENS_HOUSE = {
|
||||
{ "give_item", "HM_STRENGTH", 1, false }, -- 10
|
||||
{ "show_text", "_WardensHouseWardenReceivedHM04Text" }, -- 11
|
||||
{ "set_flag", "EVENT_GOT_HM04" }, -- 12
|
||||
{ "jump", 16 }, -- 13 (already got it)
|
||||
{ "jump", 16 }, -- 14 (unused)
|
||||
{ "jump", 18 }, -- 13 (already got it this convo; jp .done)
|
||||
{ "jump", 18 }, -- 14 (unused)
|
||||
{ "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)
|
||||
-- -------------------------------------------------------------------
|
||||
|
||||
-- SilphCo11FTeamRocketLeavesScript (scripts/SilphCo11F.asm) hides every
|
||||
-- ROCKET and rocket-aligned SCIENTIST toggle on 2F-11F the moment Giovanni
|
||||
-- falls; the item balls, the 2F/10F rescued workers and the 7F rival keep
|
||||
-- theirs. Names and order are data/maps/toggleable_objects.asm's
|
||||
-- TOGGLE_SILPH_CO_<floor>_n entries. Saffron City's half of the same
|
||||
-- script lives in M.SAFFRON_CITY (story4.lua) (#392).
|
||||
local SILPH_ROCKET_OBJECTS = {
|
||||
{ "SILPH_CO_2F", { "SILPHCO2F_SCIENTIST1", "SILPHCO2F_SCIENTIST2",
|
||||
"SILPHCO2F_ROCKET1", "SILPHCO2F_ROCKET2" } },
|
||||
{ "SILPH_CO_3F", { "SILPHCO3F_ROCKET", "SILPHCO3F_SCIENTIST" } },
|
||||
{ "SILPH_CO_4F", { "SILPHCO4F_ROCKET1", "SILPHCO4F_SCIENTIST",
|
||||
"SILPHCO4F_ROCKET2" } },
|
||||
{ "SILPH_CO_5F", { "SILPHCO5F_ROCKET1", "SILPHCO5F_SCIENTIST",
|
||||
"SILPHCO5F_ROCKER", "SILPHCO5F_ROCKET2" } },
|
||||
{ "SILPH_CO_6F", { "SILPHCO6F_ROCKET1", "SILPHCO6F_SCIENTIST",
|
||||
"SILPHCO6F_ROCKET2" } },
|
||||
{ "SILPH_CO_7F", { "SILPHCO7F_ROCKET1", "SILPHCO7F_SCIENTIST",
|
||||
"SILPHCO7F_ROCKET2", "SILPHCO7F_ROCKET3" } },
|
||||
{ "SILPH_CO_8F", { "SILPHCO8F_ROCKET1", "SILPHCO8F_SCIENTIST",
|
||||
"SILPHCO8F_ROCKET2" } },
|
||||
{ "SILPH_CO_9F", { "SILPHCO9F_ROCKET1", "SILPHCO9F_SCIENTIST",
|
||||
"SILPHCO9F_ROCKET2" } },
|
||||
{ "SILPH_CO_10F", { "SILPHCO10F_ROCKET", "SILPHCO10F_SCIENTIST" } },
|
||||
{ "SILPH_CO_11F", { "SILPHCO11F_GIOVANNI", "SILPHCO11F_ROCKET1",
|
||||
"SILPHCO11F_ROCKET2" } },
|
||||
}
|
||||
|
||||
-- hide_object writes save.objectToggles, so the single pass at the win
|
||||
-- covers floors the player is not standing on; onlyMap is the per-floor
|
||||
-- repair path below.
|
||||
local function silphRocketsLeave(game, ow, onlyMap)
|
||||
local Commands = require("src.script.Commands")
|
||||
local ctx = { game = game, save = game.save, overworld = ow }
|
||||
for _, floor in ipairs(SILPH_ROCKET_OBJECTS) do
|
||||
if not onlyMap or onlyMap == floor[1] then
|
||||
for _, name in ipairs(floor[2]) do
|
||||
Commands.hide_object(ctx, floor[1], name)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
M.SILPH_CO_11F = {
|
||||
-- Giovanni's battle is a COORDINATE TRIGGER, not a talk.
|
||||
-- SilphCo11FDefaultScript (scripts/SilphCo11F.asm) checks
|
||||
@@ -692,13 +749,11 @@ M.SILPH_CO_11F = {
|
||||
ow:scriptMove(gio, "down", 3, function()
|
||||
gio:facePlayer(ow.player)
|
||||
ow:engageTrainer(gio, function()
|
||||
-- SilphCo11FTeamRocketLeavesScript: Giovanni leaves the floor
|
||||
-- SilphCo11FTeamRocketLeavesScript: every Silph rocket leaves
|
||||
-- after the loss (the street rockets are handled by
|
||||
-- M.SAFFRON_CITY.onEnter in story4.lua).
|
||||
if game.save.flags.EVENT_BEAT_SILPH_CO_GIOVANNI then
|
||||
local Commands = require("src.script.Commands")
|
||||
local ctx = { game = game, save = game.save, overworld = ow }
|
||||
Commands.hide_object(ctx, "SILPH_CO_11F", "SILPHCO11F_GIOVANNI")
|
||||
silphRocketsLeave(game, ow)
|
||||
end
|
||||
end)
|
||||
end)
|
||||
@@ -706,28 +761,46 @@ M.SILPH_CO_11F = {
|
||||
end,
|
||||
onEnter = function(game, ow)
|
||||
if game.save.flags.EVENT_BEAT_SILPH_CO_GIOVANNI then
|
||||
local Commands = require("src.script.Commands")
|
||||
local ctx = { game = game, save = game.save, overworld = ow }
|
||||
Commands.hide_object(ctx, "SILPH_CO_11F", "SILPHCO11F_GIOVANNI")
|
||||
silphRocketsLeave(game, ow)
|
||||
end
|
||||
end,
|
||||
talk = {
|
||||
-- SilphCo11FSilphPresidentText branches on EVENT_GOT_MASTER_BALL only:
|
||||
-- the teleport pads reach him without passing Giovanni's trigger, and
|
||||
-- afterwards he describes the ball forever. The old rows fell off the
|
||||
-- end of the script on both branches, so a second talk was silence
|
||||
-- (#392).
|
||||
TEXT_SILPHCO11F_SILPH_PRESIDENT = {
|
||||
{ "face_player" }, -- 1
|
||||
{ "check_flag", "EVENT_BEAT_SILPH_CO_GIOVANNI" }, -- 2
|
||||
{ "jump_if_false", 10 }, -- 3
|
||||
{ "check_flag", "EVENT_GOT_MASTER_BALL" }, -- 4
|
||||
{ "jump_if_true", 10 }, -- 5
|
||||
{ "show_text", "_SilphCo11FSilphPresidentText" }, -- 6
|
||||
{ "check_flag", "EVENT_GOT_MASTER_BALL" }, -- 2
|
||||
{ "jump_if_true", 9 }, -- 3
|
||||
{ "show_text", "_SilphCo11FSilphPresidentText" }, -- 4
|
||||
-- give-then-print like scripts/SilphCo11F.asm
|
||||
{ "give_item", "MASTER_BALL", 1, false }, -- 7
|
||||
{ "show_text", "_SilphCo11FSilphPresidentReceivedMasterBallText" }, -- 8
|
||||
{ "set_flag", "EVENT_GOT_MASTER_BALL" }, -- 9
|
||||
{ "jump", 11 }, -- 10
|
||||
{ "give_item", "MASTER_BALL", 1, false }, -- 5
|
||||
{ "show_text", "_SilphCo11FSilphPresidentReceivedMasterBallText" }, -- 6
|
||||
{ "set_flag", "EVENT_GOT_MASTER_BALL" }, -- 7
|
||||
{ "jump", "end" }, -- 8
|
||||
{ "show_text",
|
||||
"_SilphCo11FSilphPresidentMasterBallDescriptionText" }, -- 9
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
-- Floors 2F-10F carry no other onEnter, so this repairs saves that beat
|
||||
-- Giovanni before the hide list existed (same shape as
|
||||
-- M.POKEMON_TOWER_7F.onEnter); 11F hides its own inside the table above.
|
||||
for _, floor in ipairs(SILPH_ROCKET_OBJECTS) do
|
||||
local mapId = floor[1]
|
||||
if mapId ~= "SILPH_CO_11F" then
|
||||
M[mapId] = M[mapId] or {}
|
||||
M[mapId].onEnter = function(game, ow)
|
||||
if game.save.flags.EVENT_BEAT_SILPH_CO_GIOVANNI then
|
||||
silphRocketsLeave(game, ow, mapId)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- -------------------------------------------------------------------
|
||||
-- Victory Road boulder switches (scripts/VictoryRoad1F/2F/3F.asm):
|
||||
-- a boulder resting on a switch removes a barrier block; the 3F hole
|
||||
|
||||
+108
-112
@@ -128,6 +128,11 @@ M.POKEMON_TOWER_6F = {
|
||||
t._PokemonTower6FBeGoneText or "Be gone...\nIntruders...", function()
|
||||
local BattleState = require("src.battle.BattleState")
|
||||
local battle = BattleState.newWild(game, "MAROWAK", 30)
|
||||
-- ItemUseBall .notOldManBattle (item_effects.asm:166-175): a ball
|
||||
-- thrown on POKEMON_TOWER_6F at the RESTLESS SOUL is dodged whether
|
||||
-- or not the scope revealed it, so the "can't be caught" state rides
|
||||
-- the battle instead of IsGhostBattle alone (#444)
|
||||
battle.noCatch = true
|
||||
if not game.save.inventory.SILPH_SCOPE then
|
||||
battle:makeGhost()
|
||||
end
|
||||
@@ -182,17 +187,16 @@ M.POKEMON_TOWER_6F = {
|
||||
-- preFrames: the shake's lead-in delays -- ShakeElevator's own Delay3s
|
||||
-- come to 9 frames; Silph/Rocket's ...ShakeScript prefixes another
|
||||
-- Delay3 (12) while CeladonMartElevatorShakeScript farjps straight in
|
||||
-- The panel is a bg_event, not a map-entry script: data/maps/objects/
|
||||
-- CeladonMartElevator.asm has `bg_event 3, 0, TEXT_CELADONMARTELEVATOR`
|
||||
-- and CeladonMartElevatorText is what runs DisplayElevatorFloorMenu, so
|
||||
-- the menu waits for the player to face the panel and press A (#395).
|
||||
-- Map entry only stores the car's exit warps (CeladonMartElevator
|
||||
-- StoreWarpEntriesScript), which is elevatorSeedExit below.
|
||||
-- After the ride the original does NOT jump-cut to the floor: choosing a
|
||||
-- floor in engine/events/elevator.asm DisplayElevatorFloorMenu rewrites
|
||||
-- the elevator car's own warp entries (wWarpEntries, via .UpdateWarp) to
|
||||
-- the chosen floor's exit warp, then the player walks out of the car onto
|
||||
-- that warp themselves (scripts/SilphCoElevator.asm etc.). Reproduce
|
||||
-- that here: rewrite the car's exit warps, then drive a short scripted
|
||||
-- walk-out onto an exit tile and take the (now rewritten) warp, reusing
|
||||
-- ow:scriptMove / ow:takeWarp (the Oak-escort primitives).
|
||||
local WALK_DIRVEC = { up = { 0, -1 }, down = { 0, 1 }, left = { -1, 0 }, right = { 1, 0 } }
|
||||
local WALK_OPP = { up = "down", down = "up", left = "right", right = "left" }
|
||||
local WALK_ORDER = { "up", "down", "left", "right" }
|
||||
-- floor rewrites the elevator car's own warp entries (wWarpEntries, via
|
||||
-- .UpdateWarp) to the chosen floor's exit warp and returns control, and
|
||||
-- the player walks out of the car onto that warp themselves.
|
||||
|
||||
-- .UpdateWarp: point EVERY car exit warp at the same floor's elevator
|
||||
-- door (warp id, map id). Shared generated map data, but the car's
|
||||
@@ -205,46 +209,6 @@ local function elevatorSetExit(ow, floor)
|
||||
end
|
||||
end
|
||||
|
||||
local function elevatorWalkOut(ow, floor)
|
||||
local m, p = ow.map, ow.player
|
||||
elevatorSetExit(ow, floor)
|
||||
-- leave by the exit tile under the player (they warped in onto one),
|
||||
-- else the nearest
|
||||
local door
|
||||
for _, w in ipairs(m.def.warps) do
|
||||
if w.x == p.cellX and w.y == p.cellY then door = w break end
|
||||
end
|
||||
if not door then
|
||||
local best
|
||||
for _, w in ipairs(m.def.warps) do
|
||||
local d = math.abs(w.x - p.cellX) + math.abs(w.y - p.cellY)
|
||||
if not best or d < best then best, door = d, w end
|
||||
end
|
||||
end
|
||||
-- the car interior sits on the door's walkable side; "out" is the
|
||||
-- doorway direction (the map edge for Silph/Celadon, the top doorway
|
||||
-- for the Rocket car). Fixed direction order keeps the step
|
||||
-- deterministic when a door tile has several walkable neighbours
|
||||
-- (Silph/Celadon step up into the car, the Rocket car steps down).
|
||||
local into
|
||||
for _, dir in ipairs(WALK_ORDER) do
|
||||
local v = WALK_DIRVEC[dir]
|
||||
local nx, ny = door.x + v[1], door.y + v[2]
|
||||
if m:inBounds(nx, ny) and m:isWalkableCell(nx, ny) then into = dir break end
|
||||
end
|
||||
into = into or "up"
|
||||
local out = WALK_OPP[into]
|
||||
local function leave()
|
||||
ow:takeWarp(door) -- door SFX + warp to the rewritten floor target
|
||||
end
|
||||
-- step one tile into the car, then walk back through the doorway onto
|
||||
-- the exit tile and take the warp: a visible walk-out on valid tiles
|
||||
-- for either door orientation, instead of a jump cut
|
||||
ow:scriptMove(p, into, 1, function()
|
||||
ow:scriptMove(p, out, 1, leave)
|
||||
end)
|
||||
end
|
||||
|
||||
local function elevatorFloors(elevatorMapId, game)
|
||||
local floors = {}
|
||||
for mapId, def in pairs(game.data.maps) do
|
||||
@@ -272,9 +236,9 @@ local function elevatorFloors(elevatorMapId, game)
|
||||
end
|
||||
|
||||
local function elevatorSeedExit(ow, floors, fromMapId)
|
||||
-- Seed a walk-out destination before the menu (or key-gate text):
|
||||
-- entry floor when known, else the first listed floor (1F). Choosing
|
||||
-- a floor still rewrites via elevatorWalkOut; B-cancel / no-key leave
|
||||
-- Seed a walk-out destination on entry (before the panel is ever
|
||||
-- read): entry floor when known, else the first listed floor (1F).
|
||||
-- Choosing a floor rewrites it again; B-cancel / no-key leave
|
||||
-- keeps this seed so walking out of the car cannot hit a missing ROM
|
||||
-- placeholder (#123) or the car's static default floor (#90: Rocket
|
||||
-- Hideout defaults to B1F even when entered from B2F/B4F).
|
||||
@@ -288,68 +252,68 @@ local function elevatorSeedExit(ow, floors, fromMapId)
|
||||
return exitFloor
|
||||
end
|
||||
|
||||
local function elevator(elevatorMapId, keyGate, preFrames)
|
||||
local function elevator(elevatorMapId, panelText, keyGate, preFrames)
|
||||
-- the panel bg_event: DisplayElevatorFloorMenu runs from this text
|
||||
-- script, never from map entry (#395)
|
||||
local function panel(game, ow, npc, done)
|
||||
done = done or function() end
|
||||
local floors = elevatorFloors(elevatorMapId, game)
|
||||
-- Rocket Hideout: without LIFT_KEY the panel only prints the need-
|
||||
-- a-key line and shows no floor menu
|
||||
-- (scripts/RocketHideoutElevator.asm).
|
||||
if keyGate and not game.save.inventory[keyGate.item] then
|
||||
local TextBox = require("src.render.TextBox")
|
||||
game.stack:push(TextBox.new(game,
|
||||
game.data.text[keyGate.text] or "It appears to\nneed a key.", done))
|
||||
return
|
||||
end
|
||||
local items = {}
|
||||
for _, f in ipairs(floors) do
|
||||
table.insert(items, { label = f.token, value = f })
|
||||
end
|
||||
local ListMenu = require("src.ui.ListMenu")
|
||||
game.stack:push(ListMenu.new(game, "WHICH FLOOR?", items, {
|
||||
onChoose = function(item, list)
|
||||
list:close()
|
||||
-- the whole ShakeElevator ride runs in place -- music stop, 100
|
||||
-- collision-thud scroll bounces, the PA chime -- and only then
|
||||
-- does .UpdateWarp's rewrite land, with the player still stood
|
||||
-- at the panel: they walk out to the car door themselves
|
||||
local ElevatorShake = require("src.world.ElevatorShake")
|
||||
game.stack:push(ElevatorShake.new(game, ow, {
|
||||
preFrames = preFrames,
|
||||
onDone = function()
|
||||
elevatorSetExit(ow, item.value)
|
||||
done()
|
||||
end,
|
||||
}))
|
||||
end,
|
||||
onCancel = function()
|
||||
-- DisplayElevatorFloorMenu: `ret c` on B -- no warp, nothing
|
||||
-- happens, the player just stays in the car (exit warps were
|
||||
-- already seeded on entry)
|
||||
done()
|
||||
end,
|
||||
}))
|
||||
end
|
||||
return {
|
||||
-- fromMapId: the floor the player just left (setMap passes it), so a
|
||||
-- B-cancel can still walk out onto a real map. Silph's ROM car warps
|
||||
-- default to UNUSED_MAP_ED, which is not in Data.maps -- Warp.resolve
|
||||
-- asserted and hard-crashed (#123).
|
||||
onEnter = function(game, ow, fromMapId)
|
||||
local floors = elevatorFloors(elevatorMapId, game)
|
||||
elevatorSeedExit(ow, floors, fromMapId)
|
||||
-- Rocket Hideout: without LIFT_KEY the panel only prints the need-
|
||||
-- a-key line (scripts/RocketHideoutElevator.asm). Exit warps are
|
||||
-- still seeded above so walking out returns to the entry floor
|
||||
-- instead of the car's ROM default (B1F) -- #90 / #105.
|
||||
if keyGate and not game.save.inventory[keyGate.item] then
|
||||
local TextBox = require("src.render.TextBox")
|
||||
game.stack:push(TextBox.new(game,
|
||||
game.data.text[keyGate.text] or "It appears to\nneed a key."))
|
||||
return
|
||||
end
|
||||
local items = {}
|
||||
for _, f in ipairs(floors) do
|
||||
table.insert(items, { label = f.token, value = f })
|
||||
end
|
||||
local ListMenu = require("src.ui.ListMenu")
|
||||
game.stack:push(ListMenu.new(game, "WHICH FLOOR?", items, {
|
||||
onChoose = function(item, list)
|
||||
list:close()
|
||||
-- the map-entry Transition (startWarpTo) calls onEnter from
|
||||
-- its OWN midpoint callback, so this menu was pushed on top
|
||||
-- of that still-active Transition; only the top state
|
||||
-- updates, so it froze mid-fade instead of finishing. Left
|
||||
-- alone it would resurface after the ride and play a stray
|
||||
-- fade at the wrong time -- pop it now, before it can happen.
|
||||
local Transition = require("src.render.Transition")
|
||||
if getmetatable(game.stack:top()) == Transition then
|
||||
game.stack:pop()
|
||||
end
|
||||
-- the whole ShakeElevator ride runs in place -- music stop,
|
||||
-- 100 collision-thud scroll bounces, the PA chime -- and only
|
||||
-- then does the player walk out of the car onto the chosen
|
||||
-- floor (elevatorWalkOut rewrites the car's exit warps first)
|
||||
local ElevatorShake = require("src.world.ElevatorShake")
|
||||
game.stack:push(ElevatorShake.new(game, ow, {
|
||||
preFrames = preFrames,
|
||||
onDone = function()
|
||||
elevatorWalkOut(ow, item.value)
|
||||
end,
|
||||
}))
|
||||
end,
|
||||
onCancel = function()
|
||||
-- DisplayElevatorFloorMenu: `ret c` on B -- no warp, nothing
|
||||
-- happens, the player just stays in the car (exit warps were
|
||||
-- already seeded to the entry floor above)
|
||||
end,
|
||||
}))
|
||||
elevatorSeedExit(ow, elevatorFloors(elevatorMapId, game), fromMapId)
|
||||
end,
|
||||
talk = { [panelText] = panel },
|
||||
}
|
||||
end
|
||||
|
||||
M.SILPH_CO_ELEVATOR = elevator("SILPH_CO_ELEVATOR")
|
||||
M.CELADON_MART_ELEVATOR = elevator("CELADON_MART_ELEVATOR", nil, 9)
|
||||
M.SILPH_CO_ELEVATOR = elevator("SILPH_CO_ELEVATOR",
|
||||
"TEXT_SILPHCOELEVATOR_ELEVATOR")
|
||||
M.CELADON_MART_ELEVATOR = elevator("CELADON_MART_ELEVATOR",
|
||||
"TEXT_CELADONMARTELEVATOR", nil, 9)
|
||||
M.ROCKET_HIDEOUT_ELEVATOR = elevator("ROCKET_HIDEOUT_ELEVATOR",
|
||||
"TEXT_ROCKETHIDEOUTELEVATOR",
|
||||
{ item = "LIFT_KEY", text = "_RocketHideoutElevatorAppearsToNeedKeyText" })
|
||||
|
||||
-- -------------------------------------------------------------------
|
||||
@@ -695,6 +659,16 @@ local DOCK_SHIP_BLOCKS = {
|
||||
{ bx = 7, by = 2, water = 13 }, { bx = 8, by = 2, water = 13 },
|
||||
}
|
||||
|
||||
-- her four hull columns bow-to-stern (upper-half / lower-half block ids)
|
||||
-- and the open-water ids of the rows she sits in
|
||||
local DOCK_SHIP_COLUMNS = {
|
||||
{ bx = 5, top = 4, bottom = 8 },
|
||||
{ bx = 6, top = 5, bottom = 9 },
|
||||
{ bx = 7, top = 6, bottom = 10 },
|
||||
{ bx = 8, top = 7, bottom = 11 },
|
||||
}
|
||||
local DOCK_WATER_TOP, DOCK_WATER_BOTTOM = 1, 13
|
||||
|
||||
M.VERMILION_DOCK = {
|
||||
onEnter = function(game, ow)
|
||||
local Flags = require("src.script.Flags")
|
||||
@@ -729,17 +703,39 @@ M.VERMILION_DOCK = {
|
||||
ow:startDustAnim(cx, 1, function() puff(n - 1, cx + 2) end)
|
||||
end
|
||||
puff(3, 15)
|
||||
-- VermilionDock_EraseSSAnne deliberately leaves the blocks under the
|
||||
-- player alone ("south of the player and won't be redrawn"), so skip
|
||||
-- his own block: he must not spend the walk-out standing on water
|
||||
local pbx = math.floor(ow.player.cellX / 2)
|
||||
local pby = math.floor(ow.player.cellY / 2)
|
||||
local rows = {}
|
||||
rows[#rows + 1] = { "wait", 100 }
|
||||
rows[#rows + 1] = { "play_sound", "SS_Anne_Horn" }
|
||||
for _, b in ipairs(DOCK_SHIP_BLOCKS) do
|
||||
rows[#rows + 1] = { "replace_block", b.bx, b.by, b.water }
|
||||
local function setBlock(bx, by, block)
|
||||
if bx < 1 or bx > 8 then return end
|
||||
if bx == pbx and by == pby then return end
|
||||
rows[#rows + 1] = { "replace_block", bx, by, block }
|
||||
end
|
||||
rows[#rows + 1] = { "wait", 30 }
|
||||
rows[#rows + 1] = { "wait", 120 }
|
||||
rows[#rows + 1] = { "play_sound", "SS_Anne_Horn" }
|
||||
-- .shift_columns_up slides her tile columns west behind a mid-frame
|
||||
-- rSCX split; with no split scroll here she sails one block per beat
|
||||
-- and the water closes in astern (#360)
|
||||
for step = 1, 8 do
|
||||
for _, col in ipairs(DOCK_SHIP_COLUMNS) do
|
||||
setBlock(col.bx - step, 1, col.top)
|
||||
setBlock(col.bx - step, 2, col.bottom)
|
||||
end
|
||||
setBlock(9 - step, 1, DOCK_WATER_TOP)
|
||||
setBlock(9 - step, 2, DOCK_WATER_BOTTOM)
|
||||
rows[#rows + 1] = { "wait", 20 }
|
||||
end
|
||||
-- the second horn as she clears the dock, then EraseSSAnne's 120
|
||||
-- frames before the walk out
|
||||
rows[#rows + 1] = { "play_sound", "SS_Anne_Horn" }
|
||||
rows[#rows + 1] = { "wait", 120 }
|
||||
rows[#rows + 1] = { "move_player", "up", 2 }
|
||||
-- keep Music_Surfing across the city warp (pokered stays on it
|
||||
-- through the exit-ship walk)
|
||||
rows[#rows + 1] = { "play_music", "Music_Surfing", { keep = true } }
|
||||
-- no keepMusic on this warp: Music_Surfing belongs to the dock's
|
||||
-- cutscene, and VERMILION_CITY's own theme has to take over as the
|
||||
-- player crosses in (EnterMap's PlayDefaultMusic)
|
||||
rows[#rows + 1] = { "warp", "VERMILION_CITY", 18, 31, "up" }
|
||||
rows[#rows + 1] = { "move_player", "up", 2 }
|
||||
ow:queueScript(rows)
|
||||
|
||||
@@ -171,6 +171,28 @@ local function dojoBall(species, ownBall, otherBall, askKey)
|
||||
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 = {
|
||||
talk = {
|
||||
TEXT_FIGHTINGDOJO_HITMONLEE_POKE_BALL =
|
||||
@@ -197,6 +219,7 @@ M.FIGHTING_DOJO = {
|
||||
end
|
||||
return false
|
||||
end,
|
||||
onStep = dojoMasterGate,
|
||||
}
|
||||
|
||||
-- -------------------------------------------------------------------
|
||||
|
||||
+32
-4
@@ -135,6 +135,9 @@ M.SILPH_CO_2F = {
|
||||
talk = {
|
||||
TEXT_SILPHCO2F_SILPH_WORKER_F = gift({
|
||||
flag = "EVENT_GOT_TM36", item = "TM_SELFDESTRUCT",
|
||||
-- the label carries no leading underscore: pokered keeps this one in
|
||||
-- the script bank, not the far-text bank (#393)
|
||||
pre = "SilphCo2FSilphWorkerFPleaseTakeThisText",
|
||||
received = "_SilphCo2FSilphWorkerFReceivedTM36Text",
|
||||
explain = "_SilphCo2FSilphWorkerFTM36ExplanationText",
|
||||
noRoom = "_SilphCo2FSilphWorkerFTM36NoRoomText",
|
||||
@@ -787,8 +790,19 @@ local function bikeGateGuard(coords, stopText, explainText)
|
||||
local t = text(game)
|
||||
push(game, t[stopText] or "Hey! Wait up!", 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
|
||||
ow:scriptMove(ow.player, "up", dist)
|
||||
ow:scriptMove(ow.player, "up", dist, shoveRight)
|
||||
else
|
||||
shoveRight()
|
||||
end
|
||||
end)
|
||||
end)
|
||||
@@ -836,6 +850,19 @@ M.SILPH_CO_7F = {
|
||||
end,
|
||||
}
|
||||
|
||||
-- SSAnne2FRivalAfterBattleScript's exit walk is keyed on the player's X,
|
||||
-- not on where the rival stopped: from (37,8) the rival stands below him on
|
||||
-- (36,8) and takes .RivalDownFourMovement straight down out of the room;
|
||||
-- from (36,8) he stands above him on (36,7) and takes
|
||||
-- .RivalWalkAroundPlayerMovement, which steps RIGHT and then falls through
|
||||
-- into the same four DOWNs, so five downs in all (#360).
|
||||
local function ssAnne2FRivalExitDirs(onLeft)
|
||||
if onLeft then
|
||||
return { "right", "down", "down", "down", "down", "down" }
|
||||
end
|
||||
return { "down", "down", "down", "down" }
|
||||
end
|
||||
|
||||
-- S.S. Anne 2F rival ambush (scripts/SSAnne2F.asm; coords 36/37,8)
|
||||
M.SS_ANNE_2F = {
|
||||
onStep = function(game, ow, x, y)
|
||||
@@ -848,11 +875,12 @@ M.SS_ANNE_2F = {
|
||||
{ "face_object", 2, onLeft and "down" or "right" }, -- 3
|
||||
{ "show_text", "_SSAnne2FRivalText" }, -- 4
|
||||
{ "rival_battle", "OPP_RIVAL2", 1 }, -- 5
|
||||
{ "jump_if_false", 10 }, -- 6
|
||||
{ "jump_if_false", 11 }, -- 6
|
||||
{ "set_flag", "EVENT_BEAT_SS_ANNE_RIVAL" }, -- 7
|
||||
{ "show_text", "_SSAnne2FRivalDefeatedText" }, -- 8
|
||||
{ "move_npc_to", 2, 36, 4 }, -- 9
|
||||
{ "hide_object", "SS_ANNE_2F", "SSANNE2F_RIVAL" }, -- 10
|
||||
{ "show_text", "_SSAnne2FRivalCutMasterText" }, -- 9
|
||||
{ "walk_npc", 2, ssAnne2FRivalExitDirs(onLeft) }, -- 10
|
||||
{ "hide_object", "SS_ANNE_2F", "SSANNE2F_RIVAL" }, -- 11
|
||||
}, onLeft and "up" or "left")
|
||||
end,
|
||||
}
|
||||
|
||||
@@ -23,10 +23,15 @@ M.CERULEAN_MELANIES_HOUSE = {
|
||||
rows[#rows + 1] = { "jump_if_false", "declined" }
|
||||
rows[#rows + 1] = { "give_pokemon", "BULBASAUR", 10 }
|
||||
rows[#rows + 1] = { "jump_if_false", "end" } -- party + box full
|
||||
rows[#rows + 1] = { "show_text", "MelanieText3" }
|
||||
-- Event and HideObject before MelanieText3: give_pokemon's nickname
|
||||
-- prompt and the received text both yield, and a script that dies
|
||||
-- in that window used to leave the BULBASAUR given with the event
|
||||
-- clear, so Melanie offered a second one (#426). The flag is only
|
||||
-- read at script entry, so the order is invisible in play.
|
||||
rows[#rows + 1] = { "set_flag", "EVENT_GOT_BULBASAUR_IN_CERULEAN" }
|
||||
rows[#rows + 1] = { "hide_object", "CERULEAN_MELANIES_HOUSE",
|
||||
"CERULEANMELANIESHOUSE_BULBASAUR" }
|
||||
rows[#rows + 1] = { "set_flag", "EVENT_GOT_BULBASAUR_IN_CERULEAN" }
|
||||
rows[#rows + 1] = { "show_text", "MelanieText3" }
|
||||
rows[#rows + 1] = { "jump", "end" }
|
||||
rows[#rows + 1] = { "label", "declined" }
|
||||
rows[#rows + 1] = { "show_text", "MelanieText5" }
|
||||
@@ -60,8 +65,13 @@ M.ROUTE_24 = {
|
||||
{ "jump_if_false", "declined" },
|
||||
{ "give_pokemon", "CHARMANDER", 10 },
|
||||
{ "jump_if_false", "end" },
|
||||
{ "show_text", "_Route24DamianText2" },
|
||||
-- SetEvent hoisted ahead of the received text (Route24.asm writes it
|
||||
-- after Route24Text_515e3, where nothing in between can fail): the
|
||||
-- nickname prompt and the text both yield, and a script death in that
|
||||
-- window left the CHARMANDER given with EVENT_54F clear, so Damian
|
||||
-- handed out a second one on the next talk (#426).
|
||||
{ "set_flag", "EVENT_54F" },
|
||||
{ "show_text", "_Route24DamianText2" },
|
||||
{ "jump", "end" },
|
||||
{ "label", "declined" },
|
||||
{ "show_text", "_Route24DamianText3" },
|
||||
@@ -87,9 +97,10 @@ M.VERMILION_CITY = {
|
||||
rows[#rows + 1] = { "jump_if_false", "declined" }
|
||||
rows[#rows + 1] = { "give_pokemon", "SQUIRTLE", 10 }
|
||||
rows[#rows + 1] = { "jump_if_false", "end" }
|
||||
rows[#rows + 1] = { "show_text", "_OfficerJennyText3" }
|
||||
-- event before the received text, as above (#426)
|
||||
rows[#rows + 1] = { "set_flag",
|
||||
"EVENT_GOT_SQUIRTLE_FROM_OFFICER_JENNY" }
|
||||
rows[#rows + 1] = { "show_text", "_OfficerJennyText3" }
|
||||
rows[#rows + 1] = { "jump", "end" }
|
||||
rows[#rows + 1] = { "label", "declined" }
|
||||
rows[#rows + 1] = { "show_text", "_OfficerJennyText4" }
|
||||
|
||||
@@ -48,7 +48,19 @@ M.MT_MOON_B2F = {
|
||||
{ "show_object", "MT_MOON_B2F", "MTMOONB2F_JESSIE" },
|
||||
{ "show_object", "MT_MOON_B2F", "MTMOONB2F_JAMES" },
|
||||
{ "show_text", "_MtMoonJessieJamesText1" },
|
||||
{ "face_player_dir", "right" },
|
||||
{ "face_player_dir", "up" },
|
||||
{ "emote", "player", "shock", 30 },
|
||||
-- MtMoonB2FScript_49e15 simulates PAD_UP for one player step, then
|
||||
-- Script6/Script9 walk Jessie (object 2, MovementData_f9e65: six
|
||||
-- $06) and James (object 6, f9e66: five $06); both objects' movement
|
||||
-- byte 2 is LEFT, so .determineDirection makes those LEFT steps --
|
||||
-- Jessie (9,3)->(3,3) above the player at (3,4), James (9,4)->(4,4)
|
||||
-- beside him. #423
|
||||
{ "walk_npc", "player", { "up" } },
|
||||
{ "walk_npc", 2, { "left", "left", "left", "left", "left", "left" } },
|
||||
{ "face_object", 2, "down" },
|
||||
{ "walk_npc", 6, { "left", "left", "left", "left", "left" } },
|
||||
{ "face_object", 6, "left" },
|
||||
{ "show_text", "_MtMoonJessieJamesText2" },
|
||||
{ "start_battle", "trainer", "OPP_ROCKET", 42 },
|
||||
{ "check_battle_result", "win" },
|
||||
@@ -57,8 +69,10 @@ M.MT_MOON_B2F = {
|
||||
{ "show_text", "_MtMoonJessieJamesText4" },
|
||||
{ "stop_music" },
|
||||
{ "play_music", "Music_MeetJessieJames" },
|
||||
{ "fade", "out" },
|
||||
{ "hide_object", "MT_MOON_B2F", "MTMOONB2F_JESSIE" },
|
||||
{ "hide_object", "MT_MOON_B2F", "MTMOONB2F_JAMES" },
|
||||
{ "fade", "in" },
|
||||
{ "play_default_music" },
|
||||
{ "set_flag", "EVENT_BEAT_MT_MOON_3_JESSIE_JAMES" },
|
||||
}, {})
|
||||
|
||||
@@ -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
|
||||
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
|
||||
[PortMaster](https://portmaster.games/), so the device does not need a
|
||||
separate `love_11.5` runtime download on first launch. The launcher resolves
|
||||
|
||||
@@ -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.
|
||||
- `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
|
||||
that reach outside their permission set.
|
||||
|
||||
+76
-6
@@ -86,14 +86,20 @@ Game Boy equivalent:
|
||||
## Colors 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:
|
||||
|
||||
- **OG RED**: the Game Boy Color boot-ROM look for Pokemon Red -- one global
|
||||
red BG palette + one green OBJ palette, every map, no per-map variation
|
||||
(Pokemon Red has no CGB code, so on a GBC the boot ROM colors it globally).
|
||||
The player/NPCs stay green over the red terrain via the OBP bake +
|
||||
post-zone redraw (`PaletteFX.GBC_BG` / `GBC_OBJ`).
|
||||
- **OG RED** / **OG BLUE**: the Game Boy Color boot-ROM look for that cart --
|
||||
one global BG palette + one OBJ palette, every map, no per-map variation
|
||||
(Red/Blue ship no CGB code, so on a GBC the boot ROM colors them globally).
|
||||
The player/NPCs keep the boot-ROM OBJ color over the terrain via the OBP
|
||||
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
|
||||
(`data/sgb/sgb_palettes.asm`). Sprites tint with the region palette, as on
|
||||
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
|
||||
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)
|
||||
|
||||
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
|
||||
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
|
||||
|
||||
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.
|
||||
|
||||
- Music / SFX volume
|
||||
- PIKACHU VOL (0-7, Yellow only): trims Pikachu's PCM voice clips under the
|
||||
SFX level, so the follower's constant chatter, the title-screen cry and
|
||||
every in-battle "Pika!" can be pulled down (or muted at 0) without
|
||||
quieting the rest of the sound effects. The row is hidden on Red/Blue,
|
||||
which have no voice clips.
|
||||
- Music Filter
|
||||
- OG GLITCHES on / off (Gen 1 quirks vs. modern-clean battle rules)
|
||||
- BATTLE LAYOUT (OG / WIDE); see "Widescreen battle layout" below
|
||||
@@ -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
|
||||
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
|
||||
|
||||
Every string the player can read is now reachable from a mod, so a
|
||||
@@ -362,3 +423,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.
|
||||
No printer hardware or link cable emulation involved; the file is the
|
||||
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 Game, EditorApp, Importer
|
||||
local Game, EditorApp, Importer, TouchEditor
|
||||
|
||||
local autopilot -- optional scripted-input dev tool (tests/autopilot.lua)
|
||||
local driverCo -- optional frame-driver (POKEPORT_DRIVER=file.lua): a
|
||||
@@ -134,6 +134,26 @@ function closeEditor()
|
||||
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)
|
||||
-- The launcher hands us the chosen game (Red / Blue / Yellow); scripted and
|
||||
-- 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 = nil
|
||||
bootGame(version)
|
||||
end, { launcher = true, forceImport = forceImport, onEditSave = openEditor })
|
||||
end, {
|
||||
launcher = true,
|
||||
forceImport = forceImport,
|
||||
onEditSave = openEditor,
|
||||
onEditTouchControls = openTouchControlsEditor,
|
||||
})
|
||||
end
|
||||
|
||||
function love.update(dt)
|
||||
if editorMode then return EditorApp.update(dt) end
|
||||
if TouchEditor then return TouchEditor.update(dt) end
|
||||
if Importer then return Importer:update(dt) end
|
||||
|
||||
-- Scripted runs (autopilot / POKEPORT_DRIVER) observe and act exactly
|
||||
@@ -283,6 +309,7 @@ end
|
||||
|
||||
function love.draw()
|
||||
if editorMode then return EditorApp.draw() end
|
||||
if TouchEditor then return TouchEditor.draw() end
|
||||
if Importer then return Importer:draw() end
|
||||
|
||||
Game:draw()
|
||||
@@ -303,36 +330,61 @@ end
|
||||
|
||||
function love.keypressed(key, scancode, isrepeat)
|
||||
if editorMode then return EditorApp.keypressed(key) end
|
||||
if TouchEditor then return TouchEditor.keypressed(key) end
|
||||
if Importer then return Importer:keypressed(key) end
|
||||
Game:keypressed(key)
|
||||
end
|
||||
|
||||
function love.keyreleased(key)
|
||||
if editorMode then return end
|
||||
if editorMode or TouchEditor then return end
|
||||
if Importer then return end
|
||||
Game:keyreleased(key)
|
||||
end
|
||||
|
||||
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
|
||||
Game:gamepadpressed(joystick, button)
|
||||
end
|
||||
|
||||
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
|
||||
Game:gamepadreleased(joystick, button)
|
||||
end
|
||||
|
||||
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
|
||||
Game:gamepadaxis(joystick, axis, value)
|
||||
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)
|
||||
if editorMode then return end
|
||||
if editorMode or TouchEditor then return end
|
||||
if Importer then return end
|
||||
Game:joystickremoved(joystick)
|
||||
end
|
||||
@@ -341,7 +393,7 @@ end
|
||||
-- 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.
|
||||
function love.focus(f)
|
||||
if editorMode then return end
|
||||
if editorMode or TouchEditor then return end
|
||||
if Importer then
|
||||
if Importer.focus then Importer:focus(f) end
|
||||
return
|
||||
@@ -351,25 +403,50 @@ end
|
||||
|
||||
-- v is true when the window becomes visible again, false on minimize.
|
||||
function love.visible(v)
|
||||
if editorMode then return end
|
||||
if editorMode or TouchEditor then return end
|
||||
if Importer then return end
|
||||
Game:visible(v)
|
||||
end
|
||||
|
||||
function love.touchpressed(id, x, y, dx, dy, pressure)
|
||||
if editorMode then return end
|
||||
if Importer then return Importer:mousepressed(x, y, 1) end
|
||||
if 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)
|
||||
end
|
||||
|
||||
function love.touchmoved(id, x, y, dx, dy, pressure)
|
||||
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
|
||||
Game:touchmoved(id, x, y)
|
||||
end
|
||||
|
||||
function love.touchreleased(id, x, y, dx, dy, pressure)
|
||||
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
|
||||
Game:touchreleased(id, x, y)
|
||||
end
|
||||
@@ -379,11 +456,18 @@ function love.wheelmoved(x, y)
|
||||
if EditorApp.wheelmoved then return EditorApp.wheelmoved(x, y) end
|
||||
return
|
||||
end
|
||||
if TouchEditor then return end
|
||||
if Importer then return end
|
||||
Game:wheelmoved(x, y)
|
||||
end
|
||||
|
||||
function love.mousepressed(x, y, button)
|
||||
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 return Importer:mousepressed(x, y, button) end
|
||||
if editorMode and EditorApp.mousepressed then
|
||||
return EditorApp.mousepressed(x, y, button)
|
||||
@@ -394,6 +478,10 @@ function love.mousepressed(x, y, button)
|
||||
end
|
||||
|
||||
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 editorMode and EditorApp.mousereleased then
|
||||
return EditorApp.mousereleased(x, y, button)
|
||||
@@ -404,6 +492,10 @@ function love.mousereleased(x, y, button)
|
||||
end
|
||||
|
||||
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 mouseTouch and Game and love.mouse.isDown(1) then
|
||||
Game:touchmoved("mouse", x, y)
|
||||
@@ -411,6 +503,7 @@ function love.mousemoved(x, y)
|
||||
end
|
||||
|
||||
function love.textinput(text)
|
||||
if TouchEditor then return end
|
||||
if Importer then return Importer:textinput(text) end
|
||||
if editorMode and EditorApp.textinput then
|
||||
return EditorApp.textinput(text)
|
||||
@@ -424,6 +517,16 @@ function love.quit()
|
||||
pcall(function()
|
||||
require("src.core.DiscordPresence").shutdown()
|
||||
end)
|
||||
-- LOVE waits for every live love.thread before the process exits, and both
|
||||
-- background workers idle in a loop that only a "quit" command breaks, so
|
||||
-- without this the process outlived the window and the next launch re-entered
|
||||
-- the dead one instead of starting fresh (#339)
|
||||
if package.loaded["src.core.ChipAudio"] then
|
||||
pcall(package.loaded["src.core.ChipAudio"].shutdown)
|
||||
end
|
||||
if package.loaded["src.update.Check"] then
|
||||
pcall(package.loaded["src.update.Check"].shutdown)
|
||||
end
|
||||
end
|
||||
|
||||
function love.filedropped(file)
|
||||
@@ -461,6 +564,13 @@ function love.run()
|
||||
for name, a, b, c, d, e, f in love.event.poll() do
|
||||
if name == "quit" then
|
||||
if not love.quit or not love.quit() then
|
||||
-- Android keeps the process and its task alive after LOVE's own
|
||||
-- teardown, so the relaunched task re-enters an activity whose
|
||||
-- native main already returned; end the process outright once the
|
||||
-- love.quit hook has run (#339)
|
||||
if love.system and love.system.getOS() == "Android" then
|
||||
os.exit(a or 0)
|
||||
end
|
||||
return a or 0
|
||||
end
|
||||
end
|
||||
|
||||
+5
-2
@@ -70,7 +70,10 @@ The APK lands under `app/build/outputs/apk/embedNoRecord/debug/`.
|
||||
### Payload path
|
||||
|
||||
`app/src/embed/assets/game.love` - zip of `main.lua`, `conf.lua`, `src/`,
|
||||
`data/`, `assets/`, and `tools/rom_manifest.json`. Generated game data,
|
||||
`data/`, `assets/`, and the Red, Blue, and Yellow ROM manifests. The Android
|
||||
packer verifies the Yellow manifest before it packages; if a partial source
|
||||
export omitted it, it restores the file from this checkout's Git data and then
|
||||
falls back to the project's GitHub copy. Generated game data,
|
||||
scripts, tests, and mobile build sources are excluded.
|
||||
|
||||
## Branding (applied by the build script)
|
||||
@@ -79,7 +82,7 @@ scripts, tests, and mobile build sources are excluded.
|
||||
| --- | --- |
|
||||
| `app.application_id` | `com.theboisclub.pokemonred` |
|
||||
| `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 |
|
||||
| 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.DialogInterface;
|
||||
import android.content.Intent;
|
||||
import android.content.pm.ActivityInfo;
|
||||
import android.content.pm.ApplicationInfo;
|
||||
import android.content.res.AssetManager;
|
||||
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 PENDING_EXPORT_FILENAME = "pending_export.sav";
|
||||
private static final String EXPORT_DONE_FILENAME = "export_done.flag";
|
||||
// Written when a SAF pick cannot be read at all, with the destination
|
||||
// basename as its body, so RomImporter:focus can say so in the launcher
|
||||
// instead of leaving the player on "No ROM imported" (issue #442).
|
||||
private static final String PICK_ERROR_FILENAME = "pick_error.flag";
|
||||
// Destination basename for the in-flight SAF pick (set by showFilePicker).
|
||||
// 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 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).
|
||||
private String pendingCreateSuggestedName = "export.sav";
|
||||
private static boolean immersiveActive = false;
|
||||
@@ -144,6 +157,14 @@ public class GameActivity extends SDLActivity {
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
// Set low-latency audio values
|
||||
@@ -299,6 +320,54 @@ public class GameActivity extends SDLActivity {
|
||||
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
|
||||
public void setImmersiveMode(boolean immersive_mode) {
|
||||
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);
|
||||
}
|
||||
|
||||
/** Drops a small flag file in the save identity for Lua to consume on focus. */
|
||||
private void writeSaveDirFlag(String name, String body) {
|
||||
try {
|
||||
FileOutputStream fos = new FileOutputStream(new File(saveIdentityDir(), name), false);
|
||||
fos.write(body.getBytes());
|
||||
fos.close();
|
||||
} catch (IOException e) {
|
||||
Log.d("GameActivity", "could not write " + name + ": " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private boolean copyFileToUri(File source, Uri destUri) {
|
||||
InputStream in = null;
|
||||
OutputStream out = null;
|
||||
@@ -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
|
||||
protected void onActivityResult(int requestCode, int resultCode, Intent data) {
|
||||
super.onActivityResult(requestCode, resultCode, data);
|
||||
@@ -491,14 +578,7 @@ public class GameActivity extends SDLActivity {
|
||||
Uri uri = data.getData();
|
||||
if (copyFileToUri(source, uri)) {
|
||||
// Signal Lua on next focus that the SAF export finished.
|
||||
File flag = new File(saveIdentityDir(), EXPORT_DONE_FILENAME);
|
||||
try {
|
||||
FileOutputStream fos = new FileOutputStream(flag, false);
|
||||
fos.write("ok".getBytes());
|
||||
fos.close();
|
||||
} catch (IOException e) {
|
||||
Log.d("GameActivity", "could not write export_done flag: " + e.getMessage());
|
||||
}
|
||||
writeSaveDirFlag(EXPORT_DONE_FILENAME, "ok");
|
||||
// Keep pending_export.sav so a retry still works; Lua may remove it.
|
||||
} else {
|
||||
Log.d("GameActivity", "could not write export to " + uri);
|
||||
@@ -521,19 +601,36 @@ public class GameActivity extends SDLActivity {
|
||||
? pendingPickFilename : PICKED_ROM_FILENAME;
|
||||
File destFile = new File(destDir, destName);
|
||||
|
||||
InputStream source;
|
||||
// ACTION_OPEN_DOCUMENT is meant to land in the system documents UI, but
|
||||
// some OEM shells (ColorOS) offer third-party file managers in a
|
||||
// chooser, and those hand back either a provider URI this app has no
|
||||
// grant for (SecurityException / FileNotFoundException) or a bare
|
||||
// file:// path (unreadable without storage permission on targetSdk 34).
|
||||
// Try the resolver, then the path, then tell Lua why nothing imported.
|
||||
InputStream source = null;
|
||||
try {
|
||||
source = getContentResolver().openInputStream(uri);
|
||||
} catch (FileNotFoundException e) {
|
||||
} catch (Exception e) {
|
||||
Log.d("GameActivity", "could not open picked file: " + e.getMessage());
|
||||
return;
|
||||
}
|
||||
if (source == null && "file".equals(uri.getScheme()) && uri.getPath() != null) {
|
||||
try {
|
||||
source = new FileInputStream(uri.getPath());
|
||||
} catch (FileNotFoundException e) {
|
||||
Log.d("GameActivity", "could not open picked path: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
if (source == null) {
|
||||
Log.d("GameActivity", "ContentResolver returned no stream for picked file");
|
||||
Log.d("GameActivity", "no readable stream for picked file " + uri);
|
||||
writeSaveDirFlag(PICK_ERROR_FILENAME, destName);
|
||||
return;
|
||||
}
|
||||
if (!copyAssetFile(source, destFile.getPath())) {
|
||||
Log.d("GameActivity", "could not copy picked file to " + destFile);
|
||||
// A truncated pick would only fail verification later, so drop it
|
||||
// and report instead.
|
||||
destFile.delete();
|
||||
writeSaveDirFlag(PICK_ERROR_FILENAME, destName);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,25 @@
|
||||
# iOS build (LÖVE 11.5)
|
||||
|
||||
> **Native ROM/mod/save import.** The iOS build ships a Swift
|
||||
> document-picker bridge (`native/GRPickerBridge.swift` + `GRBootstrap.m`)
|
||||
> that `patch_love_src.py` wires into the LÖVE tree on every build:
|
||||
>
|
||||
> - `love.system.pickFile("rom"|"mod"|"sav")` and `love.system.createFile`
|
||||
> are exposed to Lua on iOS (same contract as love-android's SAF picker:
|
||||
> picks land in the save dir as `picked_rom.gb` / `picked_mod.zip` /
|
||||
> `picked_save.sav`; exports signal via `export_done.flag`).
|
||||
> - The Info.plist overlay enables `UIFileSharingEnabled` +
|
||||
> `LSSupportsOpeningDocumentsInPlace`, and `GRBootstrap.m` sweeps
|
||||
> `.gb/.gbc/.zip/.sav` files dropped in Documents (Files app / Finder)
|
||||
> into the LÖVE save dir on every activation — drop a ROM, open the app,
|
||||
> and it imports with no taps.
|
||||
> - `src/import/RomImporter.lua` treats iOS as a mobile platform and polls
|
||||
> for picker results (iOS pickers are in-process modals, so Android's
|
||||
> refocus rescan never fires).
|
||||
>
|
||||
> The note below about a missing "UIDocumentPicker handoff" is
|
||||
> resolved by this bridge.
|
||||
|
||||
macOS + Xcode only. Pins the official **LÖVE 11.5** iOS Xcode tree
|
||||
(`love-11.5-ios-source.zip` from [love2d/love releases](https://github.com/love2d/love/releases/tag/11.5)),
|
||||
matching `conf.lua`'s `t.version = "11.5"`.
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
// gen1recomp iOS native bridge bootstrap.
|
||||
//
|
||||
// Runs the Files-app inbox sweep (GRPickerBridge.sweepInbox) every time the
|
||||
// app becomes active, so ROMs/mods/saves dropped into the app's Documents
|
||||
// folder land in the LÖVE save directory before the Lua importer rescans.
|
||||
// Registered from a constructor so no LÖVE/SDL source needs to know about it.
|
||||
|
||||
#import <UIKit/UIKit.h>
|
||||
|
||||
__attribute__((constructor))
|
||||
static void GRBootstrapInstall(void)
|
||||
{
|
||||
[[NSNotificationCenter defaultCenter]
|
||||
addObserverForName:UIApplicationDidBecomeActiveNotification
|
||||
object:nil
|
||||
queue:[NSOperationQueue mainQueue]
|
||||
usingBlock:^(NSNotification *note) {
|
||||
Class bridge = NSClassFromString(@"GRPickerBridge");
|
||||
if ([bridge respondsToSelector:@selector(sweepInbox)]) {
|
||||
[bridge performSelector:@selector(sweepInbox)];
|
||||
}
|
||||
}];
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
// gen1recomp iOS native bridge: Apple Health step sync (Pokéwalker mode).
|
||||
//
|
||||
// Reached from liblove's wrap_System.cpp through the Objective-C runtime as
|
||||
// love.system.syncHealthSteps() — see mobile/ios/patch_love_src.py. The Lua
|
||||
// side (mods/pokewalker) opts in, calls sync, and later consumes
|
||||
// steps_pending.json from the LÖVE save directory:
|
||||
//
|
||||
// { "steps": 4312, "from": "<ISO8601>", "to": "<ISO8601>" }
|
||||
//
|
||||
// Steps are counted from a persisted anchor (last successful sync; first
|
||||
// run starts at midnight today) so the same walk is never credited twice.
|
||||
// An unconsumed pending file is merged, not overwritten, so steps survive
|
||||
// the player quitting before entering the overworld.
|
||||
|
||||
import Foundation
|
||||
import HealthKit
|
||||
|
||||
@objc(GRHealthBridge)
|
||||
public final class GRHealthBridge: NSObject {
|
||||
|
||||
private static let store = HKHealthStore()
|
||||
private static let anchorKey = "GRHealthLastSyncDate"
|
||||
private static let pendingName = "steps_pending.json"
|
||||
// Sanity clamp per sync: guards against absurd backlogs (device clock
|
||||
// changes, months-old anchors) turning into instant level-100 parties.
|
||||
private static let maxStepsPerSync = 50_000
|
||||
|
||||
/// love.system.syncHealthSteps() -> bool ("sync started").
|
||||
/// Requests read authorization on first use (system sheet appears over
|
||||
/// the game), then asynchronously writes the pending-steps file.
|
||||
@objc(syncStepsWithCommand:saveDir:)
|
||||
public static func syncSteps(command: UnsafePointer<CChar>?,
|
||||
saveDir: UnsafePointer<CChar>?) -> Bool {
|
||||
guard HKHealthStore.isHealthDataAvailable(),
|
||||
let stepType = HKQuantityType.quantityType(forIdentifier: .stepCount)
|
||||
else { return false }
|
||||
let dir = saveDir.map { String(cString: $0) } ?? ""
|
||||
guard !dir.isEmpty else { return false }
|
||||
|
||||
store.requestAuthorization(toShare: nil, read: [stepType]) { _, error in
|
||||
if let error = error {
|
||||
NSLog("GRHealthBridge: authorization error: \(error)")
|
||||
return
|
||||
}
|
||||
// Read authorization is intentionally opaque (a denied query
|
||||
// just returns no samples), so always run the query.
|
||||
runQuery(stepType: stepType, dir: dir)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// MARK: - Query
|
||||
|
||||
private static func runQuery(stepType: HKQuantityType, dir: String) {
|
||||
let defaults = UserDefaults.standard
|
||||
let now = Date()
|
||||
let anchor = (defaults.object(forKey: anchorKey) as? Date)
|
||||
?? Calendar.current.startOfDay(for: now)
|
||||
guard anchor < now else { return }
|
||||
|
||||
let predicate = HKQuery.predicateForSamples(withStart: anchor, end: now,
|
||||
options: .strictStartDate)
|
||||
let query = HKStatisticsQuery(quantityType: stepType,
|
||||
quantitySamplePredicate: predicate,
|
||||
options: .cumulativeSum) { _, stats, error in
|
||||
if let error = error {
|
||||
// Typical on first run before the user answers the sheet,
|
||||
// or when access is denied; the anchor is left untouched so
|
||||
// the next sync retries the same window.
|
||||
NSLog("GRHealthBridge: step query error: \(error)")
|
||||
return
|
||||
}
|
||||
let steps = Int(stats?.sumQuantity()?.doubleValue(for: .count()) ?? 0)
|
||||
defaults.set(now, forKey: anchorKey)
|
||||
deliver(steps: min(steps, maxStepsPerSync),
|
||||
from: anchor, to: now, dir: dir)
|
||||
}
|
||||
store.execute(query)
|
||||
}
|
||||
|
||||
// MARK: - Pending file
|
||||
|
||||
private static func deliver(steps: Int, from: Date, to: Date, dir: String) {
|
||||
let url = URL(fileURLWithPath: dir).appendingPathComponent(pendingName)
|
||||
|
||||
// Merge with an unconsumed earlier delivery so steps are never lost.
|
||||
var total = steps
|
||||
var fromDate = from
|
||||
if let data = try? Data(contentsOf: url),
|
||||
let old = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any] {
|
||||
total += (old["steps"] as? Int) ?? 0
|
||||
if let oldFrom = old["from"] as? String,
|
||||
let parsed = isoFormatter.date(from: oldFrom), parsed < fromDate {
|
||||
fromDate = parsed
|
||||
}
|
||||
}
|
||||
guard total > 0 else { return }
|
||||
|
||||
let payload: [String: Any] = [
|
||||
"steps": total,
|
||||
"from": isoFormatter.string(from: fromDate),
|
||||
"to": isoFormatter.string(from: to),
|
||||
]
|
||||
do {
|
||||
try FileManager.default.createDirectory(
|
||||
at: URL(fileURLWithPath: dir, isDirectory: true),
|
||||
withIntermediateDirectories: true)
|
||||
let data = try JSONSerialization.data(withJSONObject: payload)
|
||||
try data.write(to: url, options: .atomic)
|
||||
NSLog("GRHealthBridge: %d steps pending", total)
|
||||
} catch {
|
||||
NSLog("GRHealthBridge: could not write pending steps: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
private static let isoFormatter: ISO8601DateFormatter = {
|
||||
let f = ISO8601DateFormatter()
|
||||
f.formatOptions = [.withInternetDateTime]
|
||||
return f
|
||||
}()
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
// gen1recomp iOS native bridge: document picker + Files-app inbox sweep.
|
||||
//
|
||||
// liblove's wrap_System.cpp calls into this class through the Objective-C
|
||||
// runtime (objc_getClass / objc_msgSend), so nothing here may be renamed
|
||||
// without updating that patch (see mobile/ios/patch_love_src.py).
|
||||
//
|
||||
// Contract (mirrors love-android's GameActivity.showFilePicker):
|
||||
// love.system.pickFile("rom"|"mod"|"sav") -> copies the user's pick into
|
||||
// the LÖVE save directory as picked_rom.gb / picked_mod.zip /
|
||||
// picked_save.sav; RomImporter's pending-file scan consumes it.
|
||||
// love.system.createFile(name) -> exports save dir's pending_export.sav
|
||||
// through the system picker, then writes export_done.flag.
|
||||
|
||||
import UIKit
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
@objc(GRPickerBridge)
|
||||
public final class GRPickerBridge: NSObject {
|
||||
|
||||
// Every live picker keeps its own delegate here
|
||||
// (UIDocumentPickerViewController holds its delegate weakly). A single
|
||||
// "current delegate" slot would break under double activation: the
|
||||
// engine's touch handling can fire a button twice (touch + synthesized
|
||||
// mouse), the second present would replace the first picker's delegate,
|
||||
// and the sheet the user actually sees would then pick into nil —
|
||||
// silently doing nothing.
|
||||
private static var liveDelegates: [PickerDelegate] = []
|
||||
|
||||
// conf.lua t.identity — where LÖVE puts the fused save directory on iOS
|
||||
// (<sandbox>/Library/Application Support/<identity>).
|
||||
private static let loveIdentity = "pokemon-love2d"
|
||||
|
||||
// MARK: - Entry points called from liblove (C strings on purpose)
|
||||
|
||||
@objc(presentPickerWithKind:saveDir:)
|
||||
public static func presentPicker(kind: UnsafePointer<CChar>?,
|
||||
saveDir: UnsafePointer<CChar>?) -> Bool {
|
||||
let kindStr = kind.map { String(cString: $0) } ?? "rom"
|
||||
guard let dir = resolvedSaveDir(saveDir) else { return false }
|
||||
|
||||
let destName: String
|
||||
var types: [UTType] = []
|
||||
switch kindStr {
|
||||
case "mod":
|
||||
destName = "picked_mod.zip"
|
||||
types = [.zip]
|
||||
case "sav":
|
||||
destName = "picked_save.sav"
|
||||
default:
|
||||
destName = "picked_rom.gb"
|
||||
for ext in ["gb", "gbc"] {
|
||||
if let t = UTType(filenameExtension: ext) { types.append(t) }
|
||||
}
|
||||
}
|
||||
// .gb/.gbc/.sav resolve to dynamic UTTypes on most devices; offering
|
||||
// .data as well keeps every real file selectable. The importer
|
||||
// validates by size + SHA-1, so a wrong pick is rejected safely.
|
||||
types.append(.data)
|
||||
if !types.contains(.item) { types.append(.item) }
|
||||
|
||||
let picker = UIDocumentPickerViewController(forOpeningContentTypes: types,
|
||||
asCopy: true)
|
||||
picker.allowsMultipleSelection = false
|
||||
let delegate = PickerDelegate { urls in
|
||||
guard let src = urls.first else { return }
|
||||
copyItem(at: src, into: dir, named: destName)
|
||||
}
|
||||
return present(picker, with: delegate)
|
||||
}
|
||||
|
||||
@objc(presentExportWithName:saveDir:)
|
||||
public static func presentExport(name: UnsafePointer<CChar>?,
|
||||
saveDir: UnsafePointer<CChar>?) -> Bool {
|
||||
let suggested = name.map { String(cString: $0) } ?? "export.sav"
|
||||
guard let dir = resolvedSaveDir(saveDir) else { return false }
|
||||
let staged = dir.appendingPathComponent("pending_export.sav")
|
||||
guard FileManager.default.fileExists(atPath: staged.path) else { return false }
|
||||
|
||||
// Stage under the suggested name so the picker's filename field is
|
||||
// prefilled; forExporting moves/copies it to the user's destination.
|
||||
let tmp = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent(suggested)
|
||||
try? FileManager.default.removeItem(at: tmp)
|
||||
do {
|
||||
try FileManager.default.copyItem(at: staged, to: tmp)
|
||||
} catch {
|
||||
NSLog("GRPickerBridge: staging export failed: \(error)")
|
||||
return false
|
||||
}
|
||||
|
||||
let picker = UIDocumentPickerViewController(forExporting: [tmp], asCopy: true)
|
||||
let delegate = PickerDelegate { urls in
|
||||
guard !urls.isEmpty else { return }
|
||||
// Same completion signal love-android's GameActivity writes;
|
||||
// RomImporter:focus consumes it and clears pending_export.sav.
|
||||
let flag = dir.appendingPathComponent("export_done.flag")
|
||||
try? "ok".data(using: .utf8)?.write(to: flag)
|
||||
}
|
||||
return present(picker, with: delegate)
|
||||
}
|
||||
|
||||
// Moves ROM/mod/save files the user dropped into the app's Documents
|
||||
// folder (Files app / Finder file sharing) into the LÖVE save directory,
|
||||
// where the importer's pending-file scan looks. Called on every
|
||||
// UIApplicationDidBecomeActive (see GRBootstrap.m).
|
||||
@objc public static func sweepInbox() {
|
||||
let fm = FileManager.default
|
||||
guard let docs = fm.urls(for: .documentDirectory, in: .userDomainMask).first,
|
||||
let appSupport = fm.urls(for: .applicationSupportDirectory,
|
||||
in: .userDomainMask).first else { return }
|
||||
let saveDir = appSupport.appendingPathComponent(loveIdentity, isDirectory: true)
|
||||
let wanted: Set<String> = ["gb", "gbc", "zip", "sav"]
|
||||
guard let items = try? fm.contentsOfDirectory(at: docs,
|
||||
includingPropertiesForKeys: nil) else { return }
|
||||
for url in items where wanted.contains(url.pathExtension.lowercased()) {
|
||||
ensureDirectory(saveDir)
|
||||
let dest = saveDir.appendingPathComponent(url.lastPathComponent)
|
||||
try? fm.removeItem(at: dest)
|
||||
do {
|
||||
try fm.moveItem(at: url, to: dest)
|
||||
NSLog("GRPickerBridge: swept %@ into save dir", url.lastPathComponent)
|
||||
} catch {
|
||||
NSLog("GRPickerBridge: sweep failed for \(url.lastPathComponent): \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private static func resolvedSaveDir(_ cstr: UnsafePointer<CChar>?) -> URL? {
|
||||
var dir = cstr.map { String(cString: $0) } ?? ""
|
||||
if dir.isEmpty {
|
||||
guard let appSupport = FileManager.default
|
||||
.urls(for: .applicationSupportDirectory, in: .userDomainMask).first
|
||||
else { return nil }
|
||||
dir = appSupport.appendingPathComponent(loveIdentity).path
|
||||
}
|
||||
let url = URL(fileURLWithPath: dir, isDirectory: true)
|
||||
ensureDirectory(url)
|
||||
return url
|
||||
}
|
||||
|
||||
private static func ensureDirectory(_ url: URL) {
|
||||
try? FileManager.default.createDirectory(at: url,
|
||||
withIntermediateDirectories: true)
|
||||
}
|
||||
|
||||
private static func copyItem(at src: URL, into dir: URL, named name: String) {
|
||||
let scoped = src.startAccessingSecurityScopedResource()
|
||||
defer { if scoped { src.stopAccessingSecurityScopedResource() } }
|
||||
ensureDirectory(dir)
|
||||
let dest = dir.appendingPathComponent(name)
|
||||
try? FileManager.default.removeItem(at: dest)
|
||||
do {
|
||||
try FileManager.default.copyItem(at: src, to: dest)
|
||||
NSLog("GRPickerBridge: delivered %@", name)
|
||||
} catch {
|
||||
NSLog("GRPickerBridge: copy failed: \(error)")
|
||||
// Surface the failure in-game: the Lua pick poll turns this
|
||||
// into an on-screen notice instead of a silent no-op.
|
||||
let report = "Could not copy \(src.lastPathComponent): " +
|
||||
error.localizedDescription
|
||||
try? report.data(using: .utf8)?
|
||||
.write(to: dir.appendingPathComponent("pick_error.txt"))
|
||||
}
|
||||
}
|
||||
|
||||
private static func present(_ picker: UIDocumentPickerViewController,
|
||||
with delegate: PickerDelegate) -> Bool {
|
||||
let doPresent = { () -> Bool in
|
||||
// Double activation (touch + synthesized mouse) arrives within
|
||||
// one frame — before UIKit even exposes the first sheet via
|
||||
// presentedViewController — so gate on our own delegate list.
|
||||
// Without this the stacked present makes the picker auto-dismiss
|
||||
// with zero documents (observed as didPickDocumentsAt 0 urls)
|
||||
// and the user's pick silently does nothing.
|
||||
guard liveDelegates.isEmpty else {
|
||||
NSLog("GRPickerBridge: picker already active; ignoring re-present")
|
||||
return true
|
||||
}
|
||||
guard var top = UIApplication.shared.windows
|
||||
.first(where: { $0.isKeyWindow })?.rootViewController
|
||||
else { return false }
|
||||
while let presented = top.presentedViewController { top = presented }
|
||||
picker.delegate = delegate
|
||||
liveDelegates.append(delegate)
|
||||
delegate.onFinish = { [weak delegate] in
|
||||
liveDelegates.removeAll { $0 === delegate }
|
||||
}
|
||||
top.present(picker, animated: true)
|
||||
return true
|
||||
}
|
||||
if Thread.isMainThread { return doPresent() }
|
||||
var ok = false
|
||||
DispatchQueue.main.sync { ok = doPresent() }
|
||||
return ok
|
||||
}
|
||||
}
|
||||
|
||||
private final class PickerDelegate: NSObject, UIDocumentPickerDelegate {
|
||||
private let onPick: ([URL]) -> Void
|
||||
var onFinish: (() -> Void)?
|
||||
init(onPick: @escaping ([URL]) -> Void) { self.onPick = onPick }
|
||||
|
||||
func documentPicker(_ controller: UIDocumentPickerViewController,
|
||||
didPickDocumentsAt urls: [URL]) {
|
||||
NSLog("GRPickerBridge: didPickDocumentsAt %d url(s)", urls.count)
|
||||
onPick(urls)
|
||||
onFinish?()
|
||||
}
|
||||
|
||||
func documentPickerWasCancelled(_ controller: UIDocumentPickerViewController) {
|
||||
// No file was written; the Lua side's pending-file poll simply
|
||||
// never finds anything.
|
||||
NSLog("GRPickerBridge: picker cancelled")
|
||||
onFinish?()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<!-- gen1recomp iOS: read-only step counts for the Pokéwalker mod
|
||||
(GRHealthBridge.swift / mods/pokewalker). -->
|
||||
<key>com.apple.developer.healthkit</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -40,7 +40,7 @@
|
||||
<key>CFBundleSignature</key>
|
||||
<string>????</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1.0</string>
|
||||
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
<true/>
|
||||
<key>LSSupportsOpeningDocumentsInPlace</key>
|
||||
@@ -100,5 +100,16 @@
|
||||
</dict>
|
||||
</dict>
|
||||
</array>
|
||||
<!-- gen1recomp iOS: expose the app's Documents folder in the Files app /
|
||||
Finder so ROMs, mod .zips, and .sav files can be dropped in without
|
||||
the picker; GRBootstrap sweeps them into the LÖVE save dir. -->
|
||||
<key>UIFileSharingEnabled</key>
|
||||
<true/>
|
||||
<key>LSSupportsOpeningDocumentsInPlace</key>
|
||||
<true/>
|
||||
<!-- gen1recomp iOS: Pokéwalker mod reads step counts (opt-in, in the
|
||||
in-game mod manager) and converts them to Pokémon EXP. -->
|
||||
<key>NSHealthShareUsageDescription</key>
|
||||
<string>The Pokéwalker mod reads your step count to give your Pokémon party experience points for real-world walking.</string>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Applies gen1recomp's iOS native-bridge patches to the fetched LÖVE 11.5
|
||||
source tree (mobile/ios/love-src/). Idempotent AND re-appliable: the first
|
||||
run stashes a pristine `.orig` copy of every file it rewrites, and later
|
||||
runs always start over from that copy — so editing the patch content here
|
||||
just works on the next build, no manual restore needed.
|
||||
|
||||
What it does:
|
||||
1. Copies mobile/ios/native/ (GRPickerBridge.swift, GRHealthBridge.swift,
|
||||
GRBootstrap.m) and the HealthKit entitlements into the LÖVE tree.
|
||||
2. Patches liblove's wrap_System.cpp to expose love.system.pickFile,
|
||||
love.system.createFile, and love.system.syncHealthSteps on iOS (each
|
||||
calls a GR*Bridge Swift class through the Objective-C runtime, so
|
||||
liblove never links against Swift directly).
|
||||
3. Patches love.xcodeproj so the love-ios app target compiles the native
|
||||
files (Swift 5, iOS 14 deployment for UTType/forExporting APIs).
|
||||
"""
|
||||
|
||||
import re
|
||||
import shutil
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
IOS_DIR = Path(__file__).resolve().parent
|
||||
LOVE_SRC = IOS_DIR / "love-src"
|
||||
NATIVE_SRC = IOS_DIR / "native"
|
||||
NATIVE_DST = LOVE_SRC / "platform" / "xcode" / "ios" / "native"
|
||||
WRAP_SYSTEM = LOVE_SRC / "src" / "modules" / "system" / "wrap_System.cpp"
|
||||
PBXPROJ = LOVE_SRC / "platform" / "xcode" / "love.xcodeproj" / "project.pbxproj"
|
||||
ENTITLEMENTS_SRC = IOS_DIR / "overlays" / "love-ios.entitlements"
|
||||
|
||||
NATIVE_FILES = ("GRPickerBridge.swift", "GRHealthBridge.swift", "GRBootstrap.m")
|
||||
|
||||
MARKER = "gen1recomp iOS picker bridge"
|
||||
|
||||
# Headers must land outside `namespace love { namespace system {`.
|
||||
WRAP_INCLUDES = """
|
||||
// %s: headers for the native-bridge functions below.
|
||||
#ifdef LOVE_IOS
|
||||
#include <objc/runtime.h>
|
||||
#include <objc/message.h>
|
||||
#include "filesystem/Filesystem.h"
|
||||
#endif
|
||||
""" % MARKER
|
||||
|
||||
WRAP_FUNCS = """
|
||||
// --- %s -------------------------------------------------
|
||||
// love.system.pickFile / createFile / syncHealthSteps for iOS. pickFile and
|
||||
// createFile mirror the love-android extension this project's importer
|
||||
// already targets; syncHealthSteps feeds the Pokéwalker mod. Implemented in
|
||||
// Swift (GR*Bridge classes, love-ios app target); reached via the ObjC
|
||||
// runtime so liblove itself needs no Swift interop.
|
||||
#ifdef LOVE_IOS
|
||||
static const char *gr_saveDirectory()
|
||||
{
|
||||
auto fs = Module::getInstance<love::filesystem::Filesystem>(Module::M_FILESYSTEM);
|
||||
return fs != nullptr ? fs->getSaveDirectory() : "";
|
||||
}
|
||||
|
||||
static int gr_callBridge(lua_State *L, const char *className,
|
||||
const char *selector, const char *arg)
|
||||
{
|
||||
Class cls = objc_getClass(className);
|
||||
if (cls == nullptr)
|
||||
{
|
||||
lua_pushboolean(L, 0);
|
||||
return 1;
|
||||
}
|
||||
typedef signed char (*GRMsg)(Class, SEL, const char *, const char *);
|
||||
signed char ok = ((GRMsg)objc_msgSend)(cls, sel_registerName(selector),
|
||||
arg, gr_saveDirectory());
|
||||
lua_pushboolean(L, ok != 0);
|
||||
return 1;
|
||||
}
|
||||
|
||||
int w_pickFile(lua_State *L)
|
||||
{
|
||||
const char *kind = luaL_optstring(L, 1, "rom");
|
||||
return gr_callBridge(L, "GRPickerBridge", "presentPickerWithKind:saveDir:", kind);
|
||||
}
|
||||
|
||||
int w_createFile(lua_State *L)
|
||||
{
|
||||
const char *name = luaL_optstring(L, 1, "export.sav");
|
||||
return gr_callBridge(L, "GRPickerBridge", "presentExportWithName:saveDir:", name);
|
||||
}
|
||||
|
||||
int w_syncHealthSteps(lua_State *L)
|
||||
{
|
||||
return gr_callBridge(L, "GRHealthBridge", "syncStepsWithCommand:saveDir:", "sync");
|
||||
}
|
||||
#endif // LOVE_IOS
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
""" % MARKER
|
||||
|
||||
WRAP_REGISTRATION = """#ifdef LOVE_IOS
|
||||
{ "pickFile", w_pickFile },
|
||||
{ "createFile", w_createFile },
|
||||
{ "syncHealthSteps", w_syncHealthSteps },
|
||||
#endif
|
||||
"""
|
||||
|
||||
# Deterministic 24-hex-digit object IDs, chosen not to collide with the
|
||||
# upstream project (grep-verified against love-11.5's pbxproj).
|
||||
ID_FILE_PICKER = "6E1AC0DE0001000000000001"
|
||||
ID_FILE_OBJC = "6E1AC0DE0001000000000002"
|
||||
ID_FILE_HEALTH = "6E1AC0DE0001000000000003"
|
||||
ID_BUILD_PICKER = "6E1AC0DE0002000000000001"
|
||||
ID_BUILD_OBJC = "6E1AC0DE0002000000000002"
|
||||
ID_BUILD_HEALTH = "6E1AC0DE0002000000000003"
|
||||
SOURCES_PHASE_ID = "FA0B7F021A95AAF3000E1D17" # love-ios Sources phase
|
||||
IOS_APP_CONFIG_IDS = (
|
||||
"FA0B7F261A95AAF4000E1D17", # Debug
|
||||
"FA0B7F271A95AAF4000E1D17", # Release
|
||||
"FA0B7F281A95AAF4000E1D17", # Distribution
|
||||
)
|
||||
|
||||
PBX_SOURCES = (
|
||||
("GRPickerBridge.swift", ID_FILE_PICKER, ID_BUILD_PICKER, "sourcecode.swift"),
|
||||
("GRHealthBridge.swift", ID_FILE_HEALTH, ID_BUILD_HEALTH, "sourcecode.swift"),
|
||||
("GRBootstrap.m", ID_FILE_OBJC, ID_BUILD_OBJC, "sourcecode.c.objc"),
|
||||
)
|
||||
|
||||
|
||||
def fail(msg):
|
||||
print(f"patch_love_src: error: {msg}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def pristine(path: Path) -> str:
|
||||
"""Text of `path` before any of our patching: backed by a `.orig` stash.
|
||||
|
||||
The stash is only trusted if it is itself unpatched; that protects
|
||||
against a stash accidentally taken after an earlier patch run.
|
||||
"""
|
||||
orig = path.with_suffix(path.suffix + ".orig")
|
||||
if orig.is_file():
|
||||
text = orig.read_text()
|
||||
if MARKER not in text and ID_FILE_PICKER not in text:
|
||||
return text
|
||||
text = path.read_text()
|
||||
if MARKER in text or ID_FILE_PICKER in text:
|
||||
fail(f"{path} is already patched and no pristine .orig stash exists;\n"
|
||||
f" delete {LOVE_SRC} and re-run scripts/build_ios.sh --fetch")
|
||||
orig.write_text(text)
|
||||
return text
|
||||
|
||||
|
||||
def copy_native_files():
|
||||
NATIVE_DST.mkdir(parents=True, exist_ok=True)
|
||||
for name in NATIVE_FILES:
|
||||
src = NATIVE_SRC / name
|
||||
if not src.is_file():
|
||||
fail(f"missing {src}")
|
||||
shutil.copy2(src, NATIVE_DST / name)
|
||||
if not ENTITLEMENTS_SRC.is_file():
|
||||
fail(f"missing {ENTITLEMENTS_SRC}")
|
||||
shutil.copy2(ENTITLEMENTS_SRC, NATIVE_DST / "love-ios.entitlements")
|
||||
print(f"patch_love_src: native files -> {NATIVE_DST}")
|
||||
|
||||
|
||||
def patch_wrap_system():
|
||||
text = pristine(WRAP_SYSTEM)
|
||||
include_anchor = '#include "sdl/System.h"\n'
|
||||
if include_anchor not in text:
|
||||
fail(f"include anchor not found in {WRAP_SYSTEM}")
|
||||
text = text.replace(include_anchor, include_anchor + WRAP_INCLUDES, 1)
|
||||
anchor = "static const luaL_Reg functions[] ="
|
||||
if anchor not in text:
|
||||
fail(f"anchor not found in {WRAP_SYSTEM}")
|
||||
text = text.replace(anchor, WRAP_FUNCS + anchor, 1)
|
||||
reg_anchor = '\t{ "vibrate", w_vibrate },\n'
|
||||
if reg_anchor not in text:
|
||||
fail(f"registration anchor not found in {WRAP_SYSTEM}")
|
||||
text = text.replace(reg_anchor, reg_anchor + WRAP_REGISTRATION, 1)
|
||||
WRAP_SYSTEM.write_text(text)
|
||||
print("patch_love_src: wrap_System.cpp patched "
|
||||
"(pickFile/createFile/syncHealthSteps)")
|
||||
|
||||
|
||||
def patch_pbxproj():
|
||||
text = pristine(PBXPROJ)
|
||||
|
||||
build_files = "".join(
|
||||
f"\t\t{build_id} /* {name} in Sources */ = "
|
||||
f"{{isa = PBXBuildFile; fileRef = {file_id} /* {name} */; }};\n"
|
||||
for name, file_id, build_id, _ in PBX_SOURCES
|
||||
)
|
||||
anchor = "/* Begin PBXBuildFile section */\n"
|
||||
if anchor not in text:
|
||||
fail("PBXBuildFile section not found")
|
||||
text = text.replace(anchor, anchor + build_files, 1)
|
||||
|
||||
file_refs = "".join(
|
||||
f"\t\t{file_id} /* {name} */ = "
|
||||
f"{{isa = PBXFileReference; lastKnownFileType = {ftype}; "
|
||||
f"name = {name}; path = ios/native/{name}; "
|
||||
f"sourceTree = SOURCE_ROOT; }};\n"
|
||||
for name, file_id, _, ftype in PBX_SOURCES
|
||||
)
|
||||
anchor = "/* Begin PBXFileReference section */\n"
|
||||
if anchor not in text:
|
||||
fail("PBXFileReference section not found")
|
||||
text = text.replace(anchor, anchor + file_refs, 1)
|
||||
|
||||
# Add the files to the love-ios Sources phase.
|
||||
phase_re = re.compile(
|
||||
re.escape(SOURCES_PHASE_ID)
|
||||
+ r" /\* Sources \*/ = \{.*?files = \(\n", re.S)
|
||||
m = phase_re.search(text)
|
||||
if not m:
|
||||
fail("love-ios Sources phase not found")
|
||||
insertion = "".join(
|
||||
f"\t\t\t\t{build_id} /* {name} in Sources */,\n"
|
||||
for name, _, build_id, _ in PBX_SOURCES
|
||||
)
|
||||
text = text[: m.end()] + insertion + text[m.end():]
|
||||
|
||||
# Swift + modern deployment target + HealthKit entitlements on the
|
||||
# love-ios app target only (UTType and forExporting need iOS 14;
|
||||
# liblove stays as upstream).
|
||||
for config_id in IOS_APP_CONFIG_IDS:
|
||||
cfg_re = re.compile(
|
||||
re.escape(config_id) + r" /\* \w+ \*/ = \{.*?buildSettings = \{\n",
|
||||
re.S)
|
||||
m = cfg_re.search(text)
|
||||
if not m:
|
||||
fail(f"build configuration {config_id} not found")
|
||||
settings = (
|
||||
"\t\t\t\tSWIFT_VERSION = 5.0;\n"
|
||||
"\t\t\t\tIPHONEOS_DEPLOYMENT_TARGET = 14.0;\n"
|
||||
'\t\t\t\tCODE_SIGN_ENTITLEMENTS = "ios/native/love-ios.entitlements";\n'
|
||||
)
|
||||
text = text[: m.end()] + settings + text[m.end():]
|
||||
|
||||
PBXPROJ.write_text(text)
|
||||
print("patch_love_src: love.xcodeproj patched (native sources + Swift + entitlements)")
|
||||
|
||||
|
||||
def main():
|
||||
if not LOVE_SRC.is_dir():
|
||||
fail("love-src/ missing; run scripts/build_ios.sh --fetch first")
|
||||
copy_native_files()
|
||||
patch_wrap_system()
|
||||
patch_pbxproj()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -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: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
|
||||
args+=(--release)
|
||||
fi
|
||||
if [ "$VERSION_EXPLICIT" = true ]; then
|
||||
args+=(--version "$VERSION")
|
||||
fi
|
||||
"$ROOT/scripts/build_ios.sh" ${args[@]+"${args[@]}"}
|
||||
}
|
||||
|
||||
|
||||
@@ -26,6 +26,11 @@ APP_NAME="gen1recomp"
|
||||
APPLICATION_ID="com.theboisclub.pokemonred"
|
||||
LOVE_ANDROID_VERSION="11.5a"
|
||||
NDK_VERSION="25.2.9519653"
|
||||
YELLOW_MANIFEST_RELATIVE="tools/rom_manifest_yellow.json"
|
||||
# A fresh source checkout normally supplies this through Git. This URL is
|
||||
# deliberately only a last resort for incomplete source exports: the manifest
|
||||
# contains extraction metadata, never a ROM or extracted game data.
|
||||
YELLOW_MANIFEST_URL="${YELLOW_MANIFEST_URL:-https://raw.githubusercontent.com/bryanthaboi/gen1recomp/main/tools/rom_manifest_yellow.json}"
|
||||
|
||||
VERSION=""
|
||||
PACKAGE_ONLY=false
|
||||
@@ -72,6 +77,59 @@ if [ ! -d "$ANDROID_DIR/love/src/jni/love/src" ]; then
|
||||
Re-clone or 'git checkout -- mobile/android'. See mobile/ANDROID.md."
|
||||
fi
|
||||
|
||||
# ------------------------------------------------------- Yellow import metadata
|
||||
# Android packages game.love itself rather than reusing scripts/build.sh's
|
||||
# archive. Keep a partial source export from silently shipping an APK that can
|
||||
# list Yellow but cannot import it. Prefer the exact manifest from this
|
||||
# checkout's Git object database; only then fall back to the public repository.
|
||||
yellow_manifest_is_valid() {
|
||||
local path="$1"
|
||||
python3 - "$path" <<'PY'
|
||||
import json, pathlib, sys
|
||||
|
||||
try:
|
||||
manifest = json.loads(pathlib.Path(sys.argv[1]).read_text())
|
||||
except (OSError, ValueError):
|
||||
raise SystemExit(1)
|
||||
|
||||
raise SystemExit(0 if manifest.get("romSha1") ==
|
||||
"cc7d03262ebfaf2f06772c1a480c7d9d5f4a38e1" else 1)
|
||||
PY
|
||||
}
|
||||
|
||||
ensure_yellow_manifest() {
|
||||
local manifest="$ROOT/$YELLOW_MANIFEST_RELATIVE"
|
||||
local staged
|
||||
staged="$(mktemp)"
|
||||
|
||||
if yellow_manifest_is_valid "$manifest"; then
|
||||
rm -f "$staged"
|
||||
return
|
||||
fi
|
||||
|
||||
warn "Yellow import manifest is missing or invalid; recovering it before packaging"
|
||||
if git -C "$ROOT" show "HEAD:$YELLOW_MANIFEST_RELATIVE" > "$staged" 2>/dev/null \
|
||||
&& yellow_manifest_is_valid "$staged"; then
|
||||
mkdir -p "$(dirname "$manifest")"
|
||||
mv "$staged" "$manifest"
|
||||
say "restored Yellow import manifest from this checkout's Git data"
|
||||
return
|
||||
fi
|
||||
|
||||
if command -v curl >/dev/null 2>&1 \
|
||||
&& curl --fail --location --retry 2 --connect-timeout 15 \
|
||||
--output "$staged" "$YELLOW_MANIFEST_URL" \
|
||||
&& yellow_manifest_is_valid "$staged"; then
|
||||
mkdir -p "$(dirname "$manifest")"
|
||||
mv "$staged" "$manifest"
|
||||
say "downloaded Yellow import manifest from the project repository"
|
||||
return
|
||||
fi
|
||||
|
||||
rm -f "$staged"
|
||||
fail "Yellow import manifest is unavailable. Git recovery failed and could not download $YELLOW_MANIFEST_URL"
|
||||
}
|
||||
|
||||
# --------------------------------------------------------------- branding
|
||||
# love-android 11.5+ reads app id / name / orientation from gradle.properties.
|
||||
# Manifest still gets permission trims. Re-applied every build so refreshing
|
||||
@@ -135,6 +193,7 @@ PY
|
||||
# --------------------------------------------------------------- game.love
|
||||
pack_game_love() {
|
||||
say "packing game.love for love-android embed flavor"
|
||||
ensure_yellow_manifest
|
||||
mkdir -p "$EMBED_ASSETS"
|
||||
rm -f "$LOVE_FILE"
|
||||
# tools/save-editor ships with the app: the launcher's Edit button on a save
|
||||
@@ -142,14 +201,21 @@ pack_game_love() {
|
||||
(cd "$ROOT" && zip -q -9 -r "$LOVE_FILE" \
|
||||
main.lua conf.lua src data assets tools/save-editor \
|
||||
tools/rom_manifest.json tools/rom_manifest_blue.json \
|
||||
tools/rom_manifest_yellow.json \
|
||||
-x '*.DS_Store' -x '*/.git/*' -x '*/.DS_Store' \
|
||||
-x 'data/generated/*' -x 'assets/generated/*')
|
||||
if unzip -Z1 "$LOVE_FILE" \
|
||||
| grep -Eq '^(data|assets)/generated/[^/]+|^(data|assets)/generated/.+/'; then
|
||||
fail "game.love unexpectedly contains generated ROM data"
|
||||
fi
|
||||
unzip -Z1 "$LOVE_FILE" | grep -qx 'tools/save-editor/App.lua' \
|
||||
# Do not pipe unzip straight into grep here: on a large archive grep can
|
||||
# finish early and make unzip report SIGPIPE under `set -o pipefail`.
|
||||
local archive_entries
|
||||
archive_entries="$(unzip -Z1 "$LOVE_FILE")"
|
||||
grep -qx 'tools/save-editor/App.lua' <<< "$archive_entries" \
|
||||
|| fail "game.love is missing the save editor (Edit on a save row would crash)"
|
||||
grep -qx "$YELLOW_MANIFEST_RELATIVE" <<< "$archive_entries" \
|
||||
|| fail "game.love is missing the Yellow ROM import manifest"
|
||||
say "game.love: $(du -h "$LOVE_FILE" | cut -f1) -> $LOVE_FILE"
|
||||
|
||||
# This script packs its own game.love (it does not reuse build.sh's), so it
|
||||
|
||||
+216
-17
@@ -2,11 +2,16 @@
|
||||
# Packages the LÖVE2D Pokémon Red port into an iOS app via LÖVE 11.5's
|
||||
# official iOS Xcode project (love-11.5-ios-source.zip).
|
||||
#
|
||||
# Usage: scripts/build_ios.sh [--fetch] [--device] [--release] [--package-only]
|
||||
# Usage: scripts/build_ios.sh [--fetch] [--device] [--release] [--install]
|
||||
# [--version X.Y.Z] [--package-only]
|
||||
#
|
||||
# (default) Simulator Debug (CODE_SIGNING_ALLOWED=NO)
|
||||
# --device iphoneos SDK (needs signing / DEVELOPMENT_TEAM)
|
||||
# (default) Simulator Debug (ad-hoc signed)
|
||||
# --device iphoneos SDK; signing team auto-detected from the
|
||||
# keychain when DEVELOPMENT_TEAM is not set
|
||||
# --install after a --device build, install the app onto the
|
||||
# first connected iPhone/iPad (unlock it first)
|
||||
# --release Release configuration
|
||||
# --version X.Y.Z stamp MARKETING_VERSION / CURRENT_PROJECT_VERSION
|
||||
# --fetch Download love-11.5-ios-source.zip into mobile/ios/love-src/
|
||||
# --package-only Zip game.love + apply plist overlay; skip xcodebuild
|
||||
#
|
||||
@@ -16,6 +21,7 @@
|
||||
# - prebuilt iOS libraries under love-src/platform/xcode/ios/libraries/
|
||||
#
|
||||
# Output: dist/ios/<Config>-<sdk>/gen1recomp.app (convenience copy)
|
||||
# dist/ios/gen1recomp.ipa (device builds only)
|
||||
# mobile/ios/build/Build/Products/<Config>-<sdk>/gen1recomp.app
|
||||
|
||||
set -euo pipefail
|
||||
@@ -35,7 +41,19 @@ LIBS_DIR="$XCODE_DIR/ios/libraries"
|
||||
|
||||
APP_NAME="gen1recomp"
|
||||
DISPLAY_NAME="gen1recomp"
|
||||
BUNDLE_ID="com.theboisclub.pokemonred"
|
||||
# Bundle ID resolution, most specific wins:
|
||||
# 1. GEN1_BUNDLE_ID env var
|
||||
# 2. mobile/ios/bundle_id.local (one line, gitignored — pins YOUR install
|
||||
# so rebuilds keep updating the same app on your phone)
|
||||
# 3. device builds: com.gen1recomp.t<your team id> — explicit App IDs are
|
||||
# globally unique across ALL Apple accounts (and required once
|
||||
# capabilities like HealthKit are involved), so a per-team default
|
||||
# lets anyone build without colliding with someone else's app
|
||||
# 4. simulator: the project default (no App ID registration involved)
|
||||
BUNDLE_ID="${GEN1_BUNDLE_ID:-}"
|
||||
if [ -z "$BUNDLE_ID" ] && [ -f "$IOS_DIR/bundle_id.local" ]; then
|
||||
BUNDLE_ID="$(tr -d '[:space:]' < "$IOS_DIR/bundle_id.local")"
|
||||
fi
|
||||
LOVE_VERSION="$(tr -d '[:space:]' < "$IOS_DIR/LOVE_VERSION" 2>/dev/null || echo 11.5)"
|
||||
IOS_SOURCE_ZIP="love-${LOVE_VERSION}-ios-source.zip"
|
||||
APPLE_LIBS_ZIP="love-${LOVE_VERSION}-apple-libraries.zip"
|
||||
@@ -46,6 +64,8 @@ FETCH=false
|
||||
DEVICE=false
|
||||
RELEASE=false
|
||||
PACKAGE_ONLY=false
|
||||
INSTALL=false
|
||||
VERSION=""
|
||||
|
||||
say() { printf '\033[1;32m==>\033[0m %s\n' "$*"; }
|
||||
warn() { printf '\033[1;33mwarn:\033[0m %s\n' "$*" >&2; }
|
||||
@@ -57,15 +77,64 @@ while [ $# -gt 0 ]; do
|
||||
--device) DEVICE=true ;;
|
||||
--release) RELEASE=true ;;
|
||||
--package-only) PACKAGE_ONLY=true ;;
|
||||
--install) INSTALL=true ;;
|
||||
--version) VERSION="$2"; shift ;;
|
||||
-h|--help)
|
||||
sed -n '2,22p' "$0"
|
||||
sed -n '2,24p' "$0"
|
||||
exit 0
|
||||
;;
|
||||
*) fail "unknown argument: $1 (try --fetch, --device, --release, or --package-only)" ;;
|
||||
*) fail "unknown argument: $1 (try --fetch, --device, --release, --version, --install, or --package-only)" ;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
|
||||
VERSION_CODE=""
|
||||
if [ -n "$VERSION" ]; then
|
||||
if ! printf '%s' "$VERSION" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$'; then
|
||||
fail "invalid --version '$VERSION' (expected X.Y.Z)"
|
||||
fi
|
||||
major="${VERSION%%.*}"
|
||||
rest="${VERSION#*.}"
|
||||
minor="${rest%%.*}"
|
||||
patch="${rest##*.}"
|
||||
VERSION_CODE=$((major * 10000 + minor * 100 + patch))
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------- signing identity
|
||||
# Auto-detect the Apple Development team when the caller didn't set one.
|
||||
# Prefer a *valid* identity from `find-identity` (the parenthetical there is
|
||||
# the cert id, not the team), then read that cert's OU. Scanning every
|
||||
# "Apple Development" certificate picks expired personal/work certs first.
|
||||
detect_team() {
|
||||
local cn
|
||||
cn="$(security find-identity -v -p codesigning 2>/dev/null \
|
||||
| sed -n -E 's/.*"Apple Development: ([^"]+)".*/\1/p' \
|
||||
| head -1)"
|
||||
[ -n "$cn" ] || return 1
|
||||
security find-certificate -c "Apple Development: $cn" -p 2>/dev/null \
|
||||
| openssl x509 -noout -subject 2>/dev/null \
|
||||
| sed -n 's/.*OU *= *\([A-Z0-9]*\).*/\1/p' \
|
||||
| head -1
|
||||
}
|
||||
if $DEVICE && [ -z "${DEVELOPMENT_TEAM:-}" ]; then
|
||||
DEVELOPMENT_TEAM="$(detect_team || true)"
|
||||
if [ -n "$DEVELOPMENT_TEAM" ]; then
|
||||
say "signing team auto-detected from keychain: $DEVELOPMENT_TEAM"
|
||||
else
|
||||
fail "no Apple signing identity found.
|
||||
Open Xcode -> Settings -> Accounts, press +, and sign in with your
|
||||
Apple ID (a free account works). That creates the certificate this
|
||||
script signs with. Then re-run this command."
|
||||
fi
|
||||
fi
|
||||
if [ -z "$BUNDLE_ID" ]; then
|
||||
if $DEVICE; then
|
||||
BUNDLE_ID="com.gen1recomp.t$(printf '%s' "$DEVELOPMENT_TEAM" | tr '[:upper:]' '[:lower:]')"
|
||||
else
|
||||
BUNDLE_ID="com.theboisclub.pokemonred"
|
||||
fi
|
||||
fi
|
||||
|
||||
# --------------------------------------------------------------- host checks
|
||||
if [ "$(uname -s)" != "Darwin" ]; then
|
||||
fail "iOS builds require macOS (Darwin). This host is $(uname -s).
|
||||
@@ -166,17 +235,37 @@ pack_game_love() {
|
||||
rm -f "$LOVE_FILE"
|
||||
# Same payload as scripts/build.sh / build_android.sh: game sources plus
|
||||
# tools/save-editor, which the launcher's Edit button opens in-process.
|
||||
# Deliberately NO fused mods: a mod inside game.love sits in the
|
||||
# read-only app bundle, so the mod manager's Delete can't remove it and
|
||||
# it reappears every launch. Mods install as .zips at runtime instead
|
||||
# (launcher -> MODS -> Import mod .zip), the same lifecycle as every
|
||||
# other platform.
|
||||
(cd "$ROOT" && zip -q -9 -r "$LOVE_FILE" \
|
||||
main.lua conf.lua src data assets tools/save-editor \
|
||||
tools/rom_manifest.json tools/rom_manifest_blue.json \
|
||||
tools/rom_manifest_yellow.json \
|
||||
-x '*.DS_Store' -x '*/.git/*' -x '*/.DS_Store' \
|
||||
-x 'data/generated/*' -x 'assets/generated/*')
|
||||
# NOTE: grep -q here would race pipefail — it exits on first match, unzip
|
||||
# dies of SIGPIPE (141), and the pipeline "fails" nondeterministically.
|
||||
# >/dev/null keeps grep reading the whole stream instead.
|
||||
if unzip -Z1 "$LOVE_FILE" \
|
||||
| grep -Eq '^(data|assets)/generated/[^/]+|^(data|assets)/generated/.+/'; then
|
||||
| grep -E '^(data|assets)/generated/[^/]+|^(data|assets)/generated/.+/' >/dev/null; then
|
||||
fail "game.love unexpectedly contains generated ROM data"
|
||||
fi
|
||||
unzip -Z1 "$LOVE_FILE" | grep -qx 'tools/save-editor/App.lua' \
|
||||
|| fail "game.love is missing the save editor (Edit on a save row would crash)"
|
||||
# Same required-file gate as scripts/build.sh and scripts/build_android.sh.
|
||||
# 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")"
|
||||
for required in tools/save-editor/App.lua tools/save-editor/Kit.lua \
|
||||
tools/save-editor/panels/Party.lua \
|
||||
tools/rom_manifest.json tools/rom_manifest_blue.json \
|
||||
tools/rom_manifest_yellow.json; 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"
|
||||
}
|
||||
|
||||
@@ -271,6 +360,43 @@ PY
|
||||
}
|
||||
|
||||
# --------------------------------------------------------------- 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() {
|
||||
local config sdk destination
|
||||
if $RELEASE; then
|
||||
@@ -291,6 +417,15 @@ run_xcodebuild() {
|
||||
|
||||
# Prefer -target + SYMROOT over -derivedDataPath: modern Xcode requires
|
||||
# -scheme whenever -derivedDataPath is set, and love-ios ships no shared schemes.
|
||||
# Always stamp both: the overlay plist expands $(MARKETING_VERSION) /
|
||||
# $(CURRENT_PROJECT_VERSION), and love-ios has no project-level defaults.
|
||||
local marketing_version="$LOVE_VERSION"
|
||||
local project_version="1"
|
||||
if [ -n "$VERSION" ]; then
|
||||
marketing_version="$VERSION"
|
||||
project_version="$VERSION_CODE"
|
||||
fi
|
||||
|
||||
local args=(
|
||||
-project "$PROJECT"
|
||||
-target love-ios
|
||||
@@ -300,18 +435,26 @@ run_xcodebuild() {
|
||||
SYMROOT="$BUILD_DIR/Build/Products"
|
||||
OBJROOT="$BUILD_DIR/Build/Intermediates"
|
||||
PRODUCT_BUNDLE_IDENTIFIER="$BUNDLE_ID"
|
||||
PRODUCT_NAME="$APP_NAME"
|
||||
MARKETING_VERSION="$LOVE_VERSION"
|
||||
MARKETING_VERSION="$marketing_version"
|
||||
CURRENT_PROJECT_VERSION="$project_version"
|
||||
ONLY_ACTIVE_ARCH=NO
|
||||
)
|
||||
|
||||
if ! $DEVICE; then
|
||||
# Simulator: no signing required
|
||||
args+=(CODE_SIGNING_ALLOWED=NO CODE_SIGNING_REQUIRED=NO CODE_SIGN_IDENTITY=)
|
||||
# Simulator: ad-hoc signing (no certificate needed). A plain unsigned
|
||||
# build would drop the entitlements file, and HealthKit refuses to run
|
||||
# without the com.apple.developer.healthkit entitlement even in the
|
||||
# simulator.
|
||||
args+=(CODE_SIGNING_ALLOWED=YES CODE_SIGNING_REQUIRED=NO CODE_SIGN_IDENTITY=-)
|
||||
else
|
||||
warn "device build: configure signing in Xcode or set DEVELOPMENT_TEAM / CODE_SIGN_IDENTITY"
|
||||
if [ -n "${DEVELOPMENT_TEAM:-}" ]; then
|
||||
args+=(DEVELOPMENT_TEAM="$DEVELOPMENT_TEAM")
|
||||
# Automatic signing + provisioning updates lets xcodebuild register the
|
||||
# bundle ID / create a development profile from the CLI, so a device
|
||||
# build works without ever opening the project in Xcode.
|
||||
args+=(DEVELOPMENT_TEAM="$DEVELOPMENT_TEAM"
|
||||
CODE_SIGN_STYLE=Automatic
|
||||
-allowProvisioningUpdates)
|
||||
fi
|
||||
if [ -n "${CODE_SIGN_IDENTITY:-}" ]; then
|
||||
args+=(CODE_SIGN_IDENTITY="$CODE_SIGN_IDENTITY")
|
||||
@@ -361,23 +504,79 @@ run_xcodebuild() {
|
||||
cp "$LOVE_FILE" "$app/game.love"
|
||||
fi
|
||||
|
||||
verify_native_bridge "$app"
|
||||
|
||||
local dist_dir="$DIST/${config}-${sdk}"
|
||||
rm -rf "$dist_dir"
|
||||
mkdir -p "$dist_dir"
|
||||
cp -R "$app" "$dist_dir/"
|
||||
say "copied to $dist_dir/$(basename "$app")"
|
||||
cp -R "$app" "$dist_dir/$APP_NAME.app"
|
||||
say "copied to $dist_dir/$APP_NAME.app"
|
||||
|
||||
if $DEVICE; then
|
||||
package_ipa "$dist_dir/$APP_NAME.app"
|
||||
fi
|
||||
|
||||
say "iOS app: $app"
|
||||
say "bundle id: $BUNDLE_ID display: $DISPLAY_NAME"
|
||||
if $DEVICE; then
|
||||
warn "signing/provisioning is manual, see mobile/ios/README.md"
|
||||
if $INSTALL; then
|
||||
install_to_device "$app"
|
||||
else
|
||||
say "install with: scripts/build_ios.sh --device --install (iPhone plugged in + unlocked)"
|
||||
fi
|
||||
else
|
||||
say "simulator tip: xcrun simctl install booted \"$app\""
|
||||
fi
|
||||
}
|
||||
|
||||
# Pack Payload/<app>.app into dist/ios/gen1recomp.ipa for release / sideload tools.
|
||||
package_ipa() {
|
||||
local app="$1"
|
||||
local ipa="$DIST/$APP_NAME.ipa"
|
||||
local tmp
|
||||
tmp="$(mktemp -d "$DIST/ipa.XXXXXX")"
|
||||
mkdir -p "$tmp/Payload"
|
||||
cp -R "$app" "$tmp/Payload/$(basename "$app")"
|
||||
rm -f "$ipa"
|
||||
(cd "$tmp" && zip -q -r "$ipa" Payload)
|
||||
rm -rf "$tmp"
|
||||
say "ipa: $ipa ($(du -h "$ipa" | cut -f1))"
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------ device install
|
||||
# Installs the freshly built .app onto the first connected iPhone/iPad via
|
||||
# devicectl. The phone must be paired (plugged in at least once + "Trust
|
||||
# This Computer") and UNLOCKED during the install.
|
||||
install_to_device() {
|
||||
local app="$1"
|
||||
local line udid
|
||||
line="$(xcrun devicectl list devices 2>/dev/null \
|
||||
| grep -E 'iPhone|iPad' | grep -v 'Watch' | head -1 || true)"
|
||||
udid="$(printf '%s' "$line" \
|
||||
| grep -Eo '[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}' \
|
||||
| head -1 || true)"
|
||||
if [ -z "$udid" ]; then
|
||||
fail "no iPhone/iPad found.
|
||||
Plug the phone in with a cable, unlock it, tap 'Trust This Computer'
|
||||
if asked, then re-run: scripts/build_ios.sh --device --install"
|
||||
fi
|
||||
say "installing onto: $(printf '%s' "$line" | sed 's/ .*//') ($udid)"
|
||||
if xcrun devicectl device install app --device "$udid" "$app"; then
|
||||
say "installed. On the phone: tap the new app on your Home Screen."
|
||||
say "first launch may ask you to enable Developer Mode (Settings ->"
|
||||
say "Privacy & Security -> Developer Mode) and to trust the developer"
|
||||
say "(Settings -> General -> VPN & Device Management)."
|
||||
else
|
||||
fail "install failed. Most common cause: the phone was locked.
|
||||
Unlock it, keep it plugged in, and re-run:
|
||||
scripts/build_ios.sh --device --install"
|
||||
fi
|
||||
}
|
||||
|
||||
# --------------------------------------------------------------- main
|
||||
apply_ios_branding
|
||||
say "applying iOS native bridge patches (picker/Files support)"
|
||||
python3 "$IOS_DIR/patch_love_src.py" || fail "patch_love_src.py failed"
|
||||
pack_game_love
|
||||
ensure_game_love_in_xcode
|
||||
|
||||
|
||||
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}"
|
||||
+235
-64
@@ -4,7 +4,7 @@
|
||||
-- Flow: intro -> menu (FIGHT/PKMN/ITEM/RUN) -> move select -> turn
|
||||
-- resolution (a queue of messages/actions/UI pushes) -> back to menu,
|
||||
-- until one side is out, then finish. Pops itself and calls
|
||||
-- onFinish("win"|"lose"|"run"|"caught"|"skipped").
|
||||
-- onFinish("win"|"lose"|"run"|"caught").
|
||||
--
|
||||
-- The Gen 1 move-effect pipeline (multi-hit, charge, trapping, thrash,
|
||||
-- bide, recharge, confusion, screens, substitute, transform, ...) is
|
||||
@@ -38,15 +38,16 @@ BattleState.letterboxWhite = true
|
||||
|
||||
-- BATTLE LAYOUT: the classic 160x144 arrangement, or the widescreen one on
|
||||
-- a 304x144 surface (src/battle/WideBattle.lua). Only the composition
|
||||
-- differs; every battler, queue and animation below is shared. The wide
|
||||
-- layout is live only while this battle is the state being drawn on top --
|
||||
-- a party menu or bag pushed over it is a 160x144 screen, so the surface
|
||||
-- goes back with it and the battle underneath is not drawn at all.
|
||||
function BattleState:wideLayout()
|
||||
-- differs; every battler, queue and animation below is shared. Menus and
|
||||
-- prompts pushed during a wide battle keep its wide canvas, while drawing
|
||||
-- their classic 160px UI centred within it (Game:draw).
|
||||
function BattleState:isWideBattleLayout()
|
||||
local options = self.game and self.game.save and self.game.save.options
|
||||
if not options or options.battleLayout ~= "wide" then return false end
|
||||
local stack = self.game.stack
|
||||
return (stack and stack.top and stack:top()) == self
|
||||
return options and options.battleLayout == "wide" or false
|
||||
end
|
||||
|
||||
function BattleState:wideLayout()
|
||||
return self:isWideBattleLayout()
|
||||
end
|
||||
|
||||
-- Renderer:setUISize asks the top state for its surface before anything draws
|
||||
@@ -187,6 +188,36 @@ local function namedPalette(data, name)
|
||||
return { name = key, colors = colors }
|
||||
end
|
||||
|
||||
-- Custom trainer portraits can opt into the same Advanced OBJ palette source
|
||||
-- as their overworld walker. Vanilla trainers preserve the hardware-faithful
|
||||
-- MEWMON fallback used during the battle introduction.
|
||||
function BattleState.trainerPalette(data, trainer)
|
||||
local source = trainer and trainer.paletteSource
|
||||
if source then
|
||||
local PaletteFX = require("src.render.PaletteFX")
|
||||
local colors, group = PaletteFX.spriteObp({ paletteSource = source }, trainer.id)
|
||||
if colors then
|
||||
return { name = "trainer:" .. source .. ":" .. tostring(group), colors = colors }
|
||||
end
|
||||
end
|
||||
return namedPalette(data, "MEWMON")
|
||||
end
|
||||
|
||||
-- Yellow only: ROCKET with wTrainerNo >= $2a is Jessie & James, who share
|
||||
-- the class and the name "ROCKET" but battle behind their own pic
|
||||
-- (home/trainers2.asm IsFightingJessieJames). picJessieJames exists only
|
||||
-- in a Yellow cache extracted after #439, so an older cache keeps the
|
||||
-- grunt pic until it is re-imported.
|
||||
function BattleState.trainerPicPath(data, trainer, oppClass, partyIndex)
|
||||
if oppClass == "OPP_ROCKET" and (partyIndex or 1) >= 42
|
||||
and trainer and trainer.picJessieJames then
|
||||
return trainer.picJessieJames
|
||||
end
|
||||
if trainer and trainer.pic then return trainer.pic end
|
||||
local base = trainer and trainer.basePic and data.trainers[trainer.basePic]
|
||||
return base and base.pic or nil
|
||||
end
|
||||
|
||||
-- The battle-BGP fade variant of a pic (AnimationFlashScreen and the
|
||||
-- SetAnimationBGPalette effects remap the four BG shades; on the SGB
|
||||
-- the colorizer then colors the REMAPPED shade, so a faded pic shows
|
||||
@@ -607,7 +638,9 @@ function BattleState.newTrainer(game, oppClass, partyIndex)
|
||||
-- MonsterPalettes[0] = PAL_MEWMON -- InitBattleCommon zeroes
|
||||
-- wEnemyMonSpecies2 before the intro's SET_PAL_BATTLE
|
||||
-- (engine/battle/core.asm:6682, engine/gfx/palettes.asm SetPal_Battle)
|
||||
self.trainerPic = getImage(self.trainer.pic, namedPalette(game.data, "MEWMON"))
|
||||
self.trainerPic = getImage(
|
||||
BattleState.trainerPicPath(game.data, self.trainer, oppClass, partyIndex),
|
||||
BattleState.trainerPalette(game.data, self.trainer))
|
||||
self.introText = Strings("%s wants\nto fight!", self.trainer.name)
|
||||
return self
|
||||
end
|
||||
@@ -739,10 +772,17 @@ function BattleState:buildScreen(id, ...)
|
||||
end
|
||||
|
||||
-- insert a wait for the HP bars to finish draining (UpdateHPBar):
|
||||
-- the queue holds until every battler's displayed HP catches up
|
||||
function BattleState:drainNext()
|
||||
-- the queue holds until every battler's displayed HP catches up.
|
||||
-- `stopAt` pins how far that battler's bar may drain on this row. A
|
||||
-- multi-hit move takes every strike off the model while the turn is still
|
||||
-- being queued, so an unpinned row would drain straight to the
|
||||
-- post-last-hit HP and the later strikes would animate nothing (#394);
|
||||
-- ApplyDamageToEnemyPokemon runs UpdateHPBar2 once per strike inside the
|
||||
-- wNumAttacksLeft loop (engine/battle/core.asm:4727).
|
||||
function BattleState:drainNext(battler, stopAt)
|
||||
self.nextInsert = (self.nextInsert or 0) + 1
|
||||
table.insert(self.queue, self.nextInsert, { drain = true })
|
||||
table.insert(self.queue, self.nextInsert,
|
||||
{ drain = true, battler = battler, stopAt = stopAt })
|
||||
end
|
||||
|
||||
-- One frame of the HP-bar drain (engine/gfx/hp_bar.asm UpdateHPBar):
|
||||
@@ -751,14 +791,22 @@ end
|
||||
function BattleState:stepHPDrain()
|
||||
local busy = false
|
||||
for _, b in ipairs({ self.player, self.enemy }) do
|
||||
if b and b.shownHP and b.shownHP ~= b.mon.hp then
|
||||
local step = math.max(1, b.mon.stats.hp) / 96
|
||||
if b.shownHP > b.mon.hp then
|
||||
b.shownHP = math.max(b.mon.hp, b.shownHP - step)
|
||||
else
|
||||
b.shownHP = math.min(b.mon.hp, b.shownHP + step)
|
||||
if b and b.shownHP then
|
||||
-- drainFloor is the stop the running row carries (see drainNext)
|
||||
local goal = b.mon.hp
|
||||
if b.drainFloor and b.drainFloor > goal
|
||||
and b.shownHP >= b.drainFloor then
|
||||
goal = b.drainFloor
|
||||
end
|
||||
if b.shownHP ~= goal then
|
||||
local step = math.max(1, b.mon.stats.hp) / 96
|
||||
if b.shownHP > goal then
|
||||
b.shownHP = math.max(goal, b.shownHP - step)
|
||||
else
|
||||
b.shownHP = math.min(goal, b.shownHP + step)
|
||||
end
|
||||
busy = busy or b.shownHP ~= goal
|
||||
end
|
||||
busy = busy or b.shownHP ~= b.mon.hp
|
||||
end
|
||||
end
|
||||
return busy
|
||||
@@ -836,6 +884,8 @@ function BattleState:updateQueue()
|
||||
if self.draining then
|
||||
if self:stepHPDrain() then return true end
|
||||
self.draining = nil
|
||||
if self.player then self.player.drainFloor = nil end
|
||||
if self.enemy then self.enemy.drainFloor = nil end
|
||||
end
|
||||
-- a move animation holds the queue until it finishes; its screen
|
||||
-- effects (SE_*) and per-row sounds route into the fx layer as they
|
||||
@@ -874,6 +924,7 @@ function BattleState:updateQueue()
|
||||
end
|
||||
if item.drain then
|
||||
self.draining = true
|
||||
if item.battler then item.battler.drainFloor = item.stopAt end
|
||||
return true
|
||||
end
|
||||
if item.wait then
|
||||
@@ -1096,10 +1147,34 @@ function BattleState:battleKind()
|
||||
end
|
||||
|
||||
function BattleState:enter()
|
||||
if self.dead then
|
||||
-- Out of useable POKéMON before the battle even starts. pokered does not
|
||||
-- skip the battle: .checkAnyPartyAlive (engine/battle/core.asm:158-162)
|
||||
-- runs right after the intro and jumps to HandlePlayerBlackOut, so the
|
||||
-- player blacks out and afterBattle's "lose" path revives the party at the
|
||||
-- last heal point. Handing the map back with a 0 HP party instead bricked
|
||||
-- the save: every later encounter aborted here too and sighted trainers
|
||||
-- re-engaged forever (#425). self.dead alone is not the test --
|
||||
-- makeOldManDemo and LinkBattle install a player battler after the
|
||||
-- constructor flagged the battle dead.
|
||||
if self.dead and not self.player then
|
||||
local name = self.game.save.player.name
|
||||
self.result = "lose"
|
||||
self.game.stack:pop()
|
||||
Runtime.emit("battle.ended", { battle = self, result = "skipped" })
|
||||
if self.onFinish then self.onFinish("skipped") end
|
||||
require("src.core.Music").restoreMap(self.data)
|
||||
Runtime.emit("battle.ended", { battle = self, result = "lose", skipped = true })
|
||||
local onFinish = self.onFinish
|
||||
local function blackedOut()
|
||||
if onFinish then onFinish("lose") end
|
||||
end
|
||||
-- the Oak's Lab starter rival returns above PlayerBlackedOutText2 and
|
||||
-- afterBattle keeps the player in the lab, so print nothing there
|
||||
if BattleState.isOaksLabStarterRival(self) then return blackedOut() end
|
||||
-- _PlayerBlackedOutText2 (data/text/text_2.asm:896): the two paragraphs
|
||||
-- playerMonFainted queues on the battle screen; there is no battle
|
||||
-- screen to queue them on here, so they print over the map.
|
||||
self.game.stack:push(require("src.render.TextBox").new(self.game,
|
||||
Strings("%s is out of\nuseable POKéMON!", name) .. "\f"
|
||||
.. Strings("%s blacked\nout!", name), blackedOut))
|
||||
return
|
||||
end
|
||||
local Music = require("src.core.Music")
|
||||
@@ -1379,6 +1454,7 @@ function BattleState:update(dt)
|
||||
for _, b in ipairs({ self.player, self.enemy }) do
|
||||
if b then
|
||||
if b.shownHP then b.shownHP = b.mon.hp end
|
||||
b.drainFloor = nil
|
||||
b.shownStatus = b.mon.status
|
||||
end
|
||||
end
|
||||
@@ -1431,10 +1507,14 @@ function BattleState:update(dt)
|
||||
end
|
||||
local col = (self.menuIndex - 1) % 2
|
||||
local row = math.floor((self.menuIndex - 1) / 2)
|
||||
if input:wasPressed("left") or input:wasPressed("right") then
|
||||
col = 1 - col
|
||||
elseif input:wasPressed("up") or input:wasPressed("down") then
|
||||
row = 1 - row
|
||||
if input:wasPressed("left") then
|
||||
col = math.max(0, col - 1)
|
||||
elseif input:wasPressed("right") then
|
||||
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
|
||||
self.menuIndex = row * 2 + col + 1
|
||||
if input:wasPressed("a") then
|
||||
@@ -1463,10 +1543,14 @@ function BattleState:update(dt)
|
||||
end
|
||||
local col = (self.menuIndex - 1) % 2
|
||||
local row = math.floor((self.menuIndex - 1) / 2)
|
||||
if input:wasPressed("left") or input:wasPressed("right") then
|
||||
col = 1 - col
|
||||
elseif input:wasPressed("up") or input:wasPressed("down") then
|
||||
row = 1 - row
|
||||
if input:wasPressed("left") then
|
||||
col = math.max(0, col - 1)
|
||||
elseif input:wasPressed("right") then
|
||||
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
|
||||
self.menuIndex = row * 2 + col + 1
|
||||
if input:wasPressed("a") then
|
||||
@@ -1936,6 +2020,15 @@ function BattleState:resolveSwitch(newMon)
|
||||
end
|
||||
|
||||
function BattleState:endOfTurn()
|
||||
-- the same ret: a decided battle never reaches HandlePoisonBurnLeechSeed
|
||||
-- or CheckNumAttacksLeft (core.asm:417-421, 456-460), so the residual
|
||||
-- sweep and the trapping-counter release are skipped on the turn a
|
||||
-- Teleport escape (or a win/loss/capture) settles it (#441). The
|
||||
-- turn_ended hook still fires: mods count turns, not residuals.
|
||||
if self.result then
|
||||
Runtime.emit("battle.turn_ended", { battle = self, turn = self.turnCount or 0 })
|
||||
return
|
||||
end
|
||||
-- sideToxic mirrors w*ToxicCounter: it advances only while the
|
||||
-- battler's badly-poisoned flag (toxicCounter) is set, an item/AI
|
||||
-- cure clears the flag but NOT the side counter, and a fresh Toxic
|
||||
@@ -2153,6 +2246,31 @@ local function startPicKind(pf, kind)
|
||||
pf.hidden = nil
|
||||
end
|
||||
|
||||
-- PredefShakeScreenHorizontally (engine/gfx/screen_effects.asm): the window
|
||||
-- jumps right by b for 5 frames then home for 4, b counting down to 1.
|
||||
-- b = 8 for SE_SHAKE_SCREEN and the heavy applying-attack shake, b = 2 for
|
||||
-- the light one.
|
||||
local function fastShakeProg(b)
|
||||
local prog = {}
|
||||
for i = b, 1, -1 do
|
||||
prog[#prog + 1] = { dx = i, frames = 5 }
|
||||
prog[#prog + 1] = { dx = 0, frames = 4 }
|
||||
end
|
||||
return prog
|
||||
end
|
||||
|
||||
-- AnimationShakeScreenHorizontallySlow (engine/battle/animations.asm:526):
|
||||
-- rWX creeps 1px right every 2 frames b times, then back down to 0, c times
|
||||
-- over. Silent -- this is the non-damaging move's feedback.
|
||||
local function slowShakeProg(b, c)
|
||||
local prog = {}
|
||||
for _ = 1, c do
|
||||
for i = 1, b do prog[#prog + 1] = { dx = i, frames = 2 } end
|
||||
for i = b - 1, 0, -1 do prog[#prog + 1] = { dx = i, frames = 2 } end
|
||||
end
|
||||
return prog
|
||||
end
|
||||
|
||||
-- Route one AnimPlayer event into the fx layer. Frame counts and
|
||||
-- amplitudes are the routines' own (engine/battle/animations.asm;
|
||||
-- shakes: engine/gfx/screen_effects.asm).
|
||||
@@ -2194,14 +2312,7 @@ function BattleState:applyAnimEffect(ev)
|
||||
|
||||
-- ---------------------------------------------- screen shakes
|
||||
elseif e == "SE_SHAKE_SCREEN" then
|
||||
-- PredefShakeScreenHorizontally b=8: the window jumps right by b
|
||||
-- for 5 frames then home for 4, b counting down 8..1
|
||||
local prog = {}
|
||||
for b = 8, 1, -1 do
|
||||
prog[#prog + 1] = { dx = b, frames = 5 }
|
||||
prog[#prog + 1] = { dx = 0, frames = 4 }
|
||||
end
|
||||
fx.shakeProg = prog
|
||||
fx.shakeProg = fastShakeProg(8)
|
||||
elseif e == "SE_ROCK_SLIDE_SHAKE" then
|
||||
-- DoRockSlideSpecialEffects: 1px horizontal then vertical rumble
|
||||
fx.shakeProg = { { dx = 1, frames = 5 }, { dx = 0, frames = 4 },
|
||||
@@ -2298,34 +2409,77 @@ function BattleState:applyAnimEffect(ev)
|
||||
-- battler.substituteHP is set (MoveEffects raises it with the move)
|
||||
end
|
||||
|
||||
-- The target's post-animation hit feedback (PlayApplyingAttackAnimation,
|
||||
-- engine/battle/animations.asm:475): the player's damaging moves blink
|
||||
-- the ENEMY pic; the enemy's damaging moves shake the screen vertically
|
||||
-- (ShakeScreenVertically -> PredefShakeScreenVertically b=8: the window
|
||||
-- drops by b for 3 frames then home for 3, b counting down) -- the
|
||||
-- player's pic never blinks. Damage sound with either. A hold keeps
|
||||
-- The post-animation applying-attack feedback (PlayApplyingAttackAnimation
|
||||
-- -> AnimationTypePointerTable, engine/battle/animations.asm:475-524).
|
||||
-- hit.animType is wAnimationType, 1..6:
|
||||
-- 1 enemy damaging, no added effect ShakeScreenVertically (b=8)
|
||||
-- 2 enemy damaging, added effect fast horizontal shake, b=8
|
||||
-- 3 enemy non-damaging slow horizontal shake, b=6, c=2
|
||||
-- 4 player damaging, no added effect BlinkEnemyMonSprite
|
||||
-- 5 player damaging, added effect fast horizontal shake, b=2
|
||||
-- 6 player non-damaging slow horizontal shake, b=3, c=2
|
||||
-- Types 3 and 6 are silent; the rest open with PlayApplyingAttackSound,
|
||||
-- which is the damage sound hit.sfx already carries. Only 1 and 4 were
|
||||
-- implemented, so every move with an added effect blinked (or shook
|
||||
-- vertically) instead of shaking sideways and every status move showed
|
||||
-- nothing at all -- Bubblebeam, Confusion, Hypnosis (#354). A hold keeps
|
||||
-- the queue still until the effect finishes.
|
||||
function BattleState:applyHitFx(hit)
|
||||
if hit.blink then
|
||||
self.fx = self.fx or {}
|
||||
if hit.blink.isPlayer then
|
||||
local prog = {}
|
||||
for b = 8, 1, -1 do
|
||||
prog[#prog + 1] = { dy = b, frames = 3 }
|
||||
prog[#prog + 1] = { dy = 0, frames = 3 }
|
||||
end
|
||||
self.fx.shakeProg = prog
|
||||
self.waitFrames = 48 -- the predef blocks until the shake settles
|
||||
else
|
||||
self.fx.blink = { target = hit.blink, frames = 20 }
|
||||
self.waitFrames = 20
|
||||
end
|
||||
end
|
||||
self.fx = self.fx or {}
|
||||
-- rows queued before animType existed carry only the blink target
|
||||
local t = hit.animType
|
||||
if not t and hit.blink then t = hit.blink.isPlayer and 1 or 4 end
|
||||
if hit.sfx then
|
||||
require("src.core.Sound").play(self.data, hit.sfx)
|
||||
end
|
||||
if not t or not self:animationsOn() then return end
|
||||
if t == 1 then
|
||||
-- PredefShakeScreenVertically b=8: the window drops by b for 3 frames
|
||||
-- then home for 3, b counting down
|
||||
local prog = {}
|
||||
for b = 8, 1, -1 do
|
||||
prog[#prog + 1] = { dy = b, frames = 3 }
|
||||
prog[#prog + 1] = { dy = 0, frames = 3 }
|
||||
end
|
||||
self.fx.shakeProg = prog
|
||||
self.waitFrames = 48 -- the predef blocks until the shake settles
|
||||
elseif t == 2 then
|
||||
self.fx.shakeProg = fastShakeProg(8)
|
||||
self.waitFrames = 72
|
||||
elseif t == 3 then
|
||||
self.fx.shakeProg = slowShakeProg(6, 2)
|
||||
self.waitFrames = 48
|
||||
elseif t == 4 then
|
||||
if hit.blink then
|
||||
self.fx.blink = { target = hit.blink, frames = 20 }
|
||||
self.waitFrames = 20
|
||||
end
|
||||
elseif t == 5 then
|
||||
self.fx.shakeProg = fastShakeProg(2)
|
||||
self.waitFrames = 18
|
||||
elseif t == 6 then
|
||||
self.fx.shakeProg = slowShakeProg(3, 2)
|
||||
self.waitFrames = 24
|
||||
end
|
||||
end
|
||||
|
||||
-- Primary status effects whose pokered handler ends in
|
||||
-- PlayCurrentMoveAnimation2 (engine/battle/effects.asm:1448), which sets
|
||||
-- wAnimationType 6 on the player's turn and 3 on the enemy's: sleep,
|
||||
-- poison, confuse, disable and the primary stat-down effects. Every other
|
||||
-- primary effect goes through PlayCurrentMoveAnimation and leaves the type
|
||||
-- at 0 (no applying animation): paralysis (FreezeBurnParalyzeEffect),
|
||||
-- leech seed, the stat-UP effects, Splash. Side-effect stat drops are
|
||||
-- skipped too -- UpdateLoweredStatDone bails out for them because the
|
||||
-- damaging move's own type 2/5 shake already played.
|
||||
local SLOW_SHAKE_EFFECTS = {
|
||||
SLEEP_EFFECT = true, POISON_EFFECT = true, CONFUSION_EFFECT = true,
|
||||
DISABLE_EFFECT = true,
|
||||
ATTACK_DOWN1_EFFECT = true, DEFENSE_DOWN1_EFFECT = true,
|
||||
DEFENSE_DOWN2_EFFECT = true, SPEED_DOWN1_EFFECT = true,
|
||||
ACCURACY_DOWN1_EFFECT = true,
|
||||
}
|
||||
|
||||
-- AnimateSendingOutMon (core.asm:6801-6838): the mon grows out of the
|
||||
-- ball -- a 3-frame ball beat, 4 frames of the pic at 3/7 scale (a 3x3
|
||||
-- block of its 7x7 tiles), 5 frames at 5/7 (5x5), then full size.
|
||||
@@ -2545,6 +2699,12 @@ function BattleState:syncShownStatus()
|
||||
end
|
||||
|
||||
function BattleState:executeAction(user, target, action)
|
||||
-- MainInBattleLoop reads wEscapedFromBattle right after Execute*Move and
|
||||
-- rets (core.asm:417-421, 456-460): a Teleport/Roar/Whirlwind escape ends
|
||||
-- the turn where it lands and the second mover never moves. self.result
|
||||
-- is only ever set once the battle is over (run/win/lose/caught), and the
|
||||
-- faint cases are already covered by the HP guard below (#441)
|
||||
if self.result then return end
|
||||
if user.mon.hp <= 0 or target.mon.hp <= 0 then return end
|
||||
if not action then return end
|
||||
|
||||
@@ -2909,6 +3069,8 @@ function BattleState:performMove(user, target, moveInst, isCalled)
|
||||
-- (AlreadyAsleep / NothingHappened / ButItFailed print with no anim)
|
||||
if primaryEffectFailed(msgs) then
|
||||
self:cancelMoveAnim()
|
||||
elseif SLOW_SHAKE_EFFECTS[move.effect] and self.moveAnimRow then
|
||||
self.moveAnimRow.hit = { animType = user.isPlayer and 6 or 3 }
|
||||
end
|
||||
for _, m in ipairs(msgs) do
|
||||
self:sayNext(m)
|
||||
@@ -2962,6 +3124,10 @@ function BattleState:continueBide(user, target)
|
||||
self:sayNext(Strings("But, it failed!"))
|
||||
return
|
||||
end
|
||||
-- .UnleashEnergy (core.asm:3501-3529) re-points wPlayerMoveNum at BIDE
|
||||
-- and rejoins HandleIfPlayerMoveMissed, so BIDE's own animation plays
|
||||
-- here, after UnleashedEnergyText and before the damage (#375)
|
||||
self:animNext("BIDE", user.isPlayer)
|
||||
self:applyDamage(target, dmg)
|
||||
if target.mon.hp <= 0 then self:onFaint(target) end
|
||||
end
|
||||
@@ -2986,7 +3152,7 @@ function BattleState:applyDamage(target, dmg)
|
||||
end
|
||||
local dealt = math.min(dmg, target.mon.hp)
|
||||
target.mon.hp = target.mon.hp - dealt
|
||||
if dealt > 0 then self:drainNext() end -- animate the bar down
|
||||
if dealt > 0 then self:drainNext(target, target.mon.hp) end -- animate the bar down
|
||||
if target.bideTurns then
|
||||
target.bideDamage = (target.bideDamage or 0) + dealt
|
||||
end
|
||||
@@ -3833,12 +3999,15 @@ function BattleState:throwBall(ball)
|
||||
self:act(function() self:endOfTurn() end)
|
||||
return
|
||||
end
|
||||
if self.ghost then
|
||||
if self.ghost or self.noCatch then
|
||||
-- ItemUseBall's can't-be-caught path (item_effects.asm:149-153):
|
||||
-- the ball is thrown (TossBallAnimation still picks the arc from
|
||||
-- wCurItem, so a Master/Ultra toss keeps its flicker), dodged
|
||||
-- ($10 anim data, no wobbles), and the turn is spent like any
|
||||
-- failed throw
|
||||
-- failed throw. battle.noCatch is the .notOldManBattle half of the
|
||||
-- same check (item_effects.asm:166-175): the POKEMON_TOWER_6F
|
||||
-- RESTLESS SOUL dodges balls even once the scope has revealed it,
|
||||
-- so it is not a ghost battle any more (#444)
|
||||
self:animNext(self:tossAnimFor(ball), true, nil, ball)
|
||||
self:sayNext(Strings("It dodged the\nthrown BALL!"))
|
||||
self:sayNext(Strings("This POKéMON\ncan't be caught!"))
|
||||
@@ -4322,7 +4491,9 @@ function BattleState:sgbBattlePals()
|
||||
-- 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
|
||||
-- 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]
|
||||
for i = 0, 3 do
|
||||
local c = out[i]
|
||||
|
||||
@@ -93,6 +93,12 @@ function EffectRegistry.runDamaging(battle, ctx, record)
|
||||
-- Explosion/Selfdestruct still animate on a miss (HandleIfPlayerMoveMissed)
|
||||
if not (record and record.explode) then battle:cancelMoveAnim() end
|
||||
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
|
||||
end
|
||||
|
||||
@@ -186,7 +192,15 @@ function EffectRegistry.runDamaging(battle, ctx, record)
|
||||
-- hitRow carries the blink instead.
|
||||
local hitSfx = info.typeMult > 10 and "Super_Effective"
|
||||
or info.typeMult < 10 and "Not_Very_Effective" or "Damage"
|
||||
-- GetPlayerAnimationType / GetEnemyAnimationType (engine/battle/core.asm
|
||||
-- :3159 / :5555): wAnimationType is 4 (blink the enemy pic) or 1 (shake
|
||||
-- the screen vertically) for a damaging move with no added effect, and
|
||||
-- 5 / 2 (a horizontal shake) as soon as the move HAS one -- which is why
|
||||
-- Bubblebeam and Confusion shake instead of blinking (#354)
|
||||
local added = move.effect ~= nil and move.effect ~= "NO_ADDITIONAL_EFFECT"
|
||||
local hitFx = { sfx = hitSfx,
|
||||
animType = user.isPlayer and (added and 5 or 4)
|
||||
or (added and 2 or 1),
|
||||
blink = battle:animationsOn() and target or nil }
|
||||
|
||||
local totalDealt = 0
|
||||
|
||||
@@ -609,10 +609,16 @@ MoveEffects.full = {
|
||||
},
|
||||
|
||||
BIDE_EFFECT = {
|
||||
-- BideEffect (effects.asm:764-789) is a ResidualEffects2 entry: the
|
||||
-- storing turn plays XSTATITEM_ANIM (XSTATITEM_DUPLICATE_ANIM on the
|
||||
-- enemy side) and never BIDE's own animation, which belongs to the
|
||||
-- release turn in .UnleashEnergy (#375)
|
||||
perform = function(ctx)
|
||||
local user = ctx.user
|
||||
user.bideTurns = ctx.rng(2, 3)
|
||||
user.bideDamage = 0
|
||||
ctx.battle:cancelMoveAnim()
|
||||
ctx.anim(user.isPlayer and "XSTATITEM_ANIM" or "XSTATITEM_DUPLICATE_ANIM")
|
||||
ctx.say(Strings("%s\nis storing energy!", displayName(user)))
|
||||
end,
|
||||
},
|
||||
|
||||
+118
-4
@@ -25,6 +25,32 @@ local SAMPLE_RATE = ChipSynth.SAMPLE_RATE
|
||||
local MUSIC_BUFFER_SAMPLES = ChipSynth.MUSIC_BUFFER_SAMPLES
|
||||
local MUSIC_BUFFER_COUNT = ChipSynth.MUSIC_BUFFER_COUNT
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Per-channel mix (edit these)
|
||||
-- Applied on load and whenever this file hot-reloads.
|
||||
-- Runtime: ChipAudio.setChannelVolume / setChannelPitch.
|
||||
-- [1] pulse 1 [2] pulse 2 [3] wave [4] noise / drums
|
||||
-- Volume: 1 = authentic, 0 = mute, >1 boosts
|
||||
-- Pitch: 1 = authentic, 2 = +1 octave, 0.5 = -1 octave
|
||||
-- The shipped values stay at 1: 0.25 / 0.5 on the wave channel buried the Ch3
|
||||
-- countermelodies an octave low (#429), and ChipSynth already applies the
|
||||
-- wave channel's own hardware octave (frequency * 0.5).
|
||||
-- ---------------------------------------------------------------------------
|
||||
local CHANNEL_VOLUME = {
|
||||
[1] = 1, -- pulse 1
|
||||
[2] = 1, -- pulse 2
|
||||
[3] = 1, -- wave
|
||||
[4] = 1, -- noise / drums
|
||||
}
|
||||
local CHANNEL_PITCH = {
|
||||
[1] = 1, -- pulse 1
|
||||
[2] = 1, -- pulse 2
|
||||
[3] = 1, -- wave
|
||||
[4] = 1, -- noise / drums
|
||||
}
|
||||
ChipSynth.setChannelVolumes(CHANNEL_VOLUME)
|
||||
ChipSynth.setChannelPitches(CHANNEL_PITCH)
|
||||
|
||||
-- currentMusic: { source, gen, threaded, started, finished, engine }
|
||||
-- threaded songs stream from the worker (engine is nil here);
|
||||
-- the fallback path owns a local engine and fills the source itself.
|
||||
@@ -32,6 +58,14 @@ local currentMusic
|
||||
local pendingBuf -- a current-gen buffer popped from the worker but not yet
|
||||
-- queued because the Source was momentarily full
|
||||
|
||||
-- Music holds playback while a fanfare owns the music channels (#398).
|
||||
-- Pausing the Source is not enough on its own: this module is what starts a
|
||||
-- chip song (immediately on the sync path, on the first worker buffer on the
|
||||
-- threaded one), so a song that begins during a jingle would come up
|
||||
-- underneath it. Music.duckForFanfare sets the hold, Music releases it when
|
||||
-- the jingle ends.
|
||||
local musicHeld = false
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- worker management
|
||||
-- ---------------------------------------------------------------------------
|
||||
@@ -125,7 +159,7 @@ local function playMusicSync(data, header, allowLoops)
|
||||
currentMusic = { source = source, engine = engine, threaded = false,
|
||||
started = true, finished = false }
|
||||
fillSync(MUSIC_FILL_INITIAL)
|
||||
source:play()
|
||||
if not musicHeld then source:play() end
|
||||
return source
|
||||
end
|
||||
|
||||
@@ -152,13 +186,23 @@ function ChipAudio.playMusic(data, header, allowLoops)
|
||||
musicGen = musicGen + 1
|
||||
local gen = musicGen
|
||||
cmdCh:push({ cmd = "play", gen = gen, header = header,
|
||||
allowLoops = allowLoops, audio = slimAudio(data) })
|
||||
allowLoops = allowLoops, audio = slimAudio(data),
|
||||
channelVolumes = ChipSynth.getChannelVolumes(),
|
||||
channelPitches = ChipSynth.getChannelPitches() })
|
||||
currentMusic = { source = source, gen = gen, threaded = true,
|
||||
started = false, finished = false }
|
||||
-- playback starts in update() once the first buffer arrives (~1 frame)
|
||||
return source
|
||||
end
|
||||
|
||||
local function pushChannelMix()
|
||||
if workerReady and cmdCh then
|
||||
cmdCh:push({ cmd = "channelMix",
|
||||
volumes = ChipSynth.getChannelVolumes(),
|
||||
pitches = ChipSynth.getChannelPitches() })
|
||||
end
|
||||
end
|
||||
|
||||
-- move finished buffers from the worker into the Source; start playback once
|
||||
-- the first one lands
|
||||
local function updateThreaded()
|
||||
@@ -189,7 +233,7 @@ local function updateThreaded()
|
||||
end
|
||||
end
|
||||
end
|
||||
if not m.started then
|
||||
if not m.started and not musicHeld then
|
||||
if (MUSIC_BUFFER_COUNT - m.source:getFreeBufferCount()) > 0 then
|
||||
pcall(function() m.source:play() end)
|
||||
m.started = true
|
||||
@@ -212,7 +256,7 @@ end
|
||||
-- pause/resume behavior.
|
||||
function ChipAudio.ensureMusicPlaying()
|
||||
local m = currentMusic
|
||||
if not m or m.finished then return end
|
||||
if not m or m.finished or musicHeld then return end
|
||||
if m.threaded then
|
||||
if not m.started then return end
|
||||
local ok, playing = pcall(function() return m.source:isPlaying() end)
|
||||
@@ -230,6 +274,18 @@ function ChipAudio.ensureMusicPlaying()
|
||||
end
|
||||
end
|
||||
|
||||
-- Silence the song for the length of a fanfare and start whatever was held
|
||||
-- back once it ends. Held state outlives a song change: Music.play may swap
|
||||
-- songs while the jingle is still sounding.
|
||||
function ChipAudio.holdMusic(held)
|
||||
held = not not held
|
||||
if held == musicHeld then return end
|
||||
musicHeld = held
|
||||
if held then return end
|
||||
ChipAudio.update()
|
||||
ChipAudio.ensureMusicPlaying()
|
||||
end
|
||||
|
||||
-- Threaded playMusic returns an empty QueueableSource and only calls
|
||||
-- Source:play once the first worker buffer lands (~1 frame later). Until
|
||||
-- then Source:isPlaying is false -- callers that treat that as "song over"
|
||||
@@ -270,6 +326,64 @@ function ChipAudio.invalidate()
|
||||
if workerReady and cmdCh then cmdCh:push({ cmd = "invalidate" }) end
|
||||
end
|
||||
|
||||
-- End the worker thread. LOVE waits for every live love.thread before the
|
||||
-- process exits and the worker's command loop only returns on "quit", so
|
||||
-- skipping this leaves the process running after the window is gone (#339).
|
||||
function ChipAudio.shutdown()
|
||||
ChipAudio.stopMusic()
|
||||
if workerReady and cmdCh then cmdCh:push({ cmd = "quit" }) end
|
||||
if worker then pcall(function() worker:wait() end) end
|
||||
worker, cmdCh, outCh = nil, nil, nil
|
||||
workerReady = false
|
||||
end
|
||||
|
||||
-- Runtime mix for one hardware channel (1..4). Takes effect on the next
|
||||
-- synthesized buffer (live music) and on any SFX/cry rendered after the call.
|
||||
function ChipAudio.setChannelVolume(hw, scale)
|
||||
ChipSynth.setChannelVolume(hw, scale)
|
||||
pushChannelMix()
|
||||
end
|
||||
|
||||
function ChipAudio.getChannelVolume(hw)
|
||||
return ChipSynth.getChannelVolume(hw)
|
||||
end
|
||||
|
||||
function ChipAudio.setChannelVolumes(volumes)
|
||||
ChipSynth.setChannelVolumes(volumes)
|
||||
pushChannelMix()
|
||||
end
|
||||
|
||||
function ChipAudio.getChannelVolumes()
|
||||
return ChipSynth.getChannelVolumes()
|
||||
end
|
||||
|
||||
function ChipAudio.setChannelPitch(hw, scale)
|
||||
ChipSynth.setChannelPitch(hw, scale)
|
||||
pushChannelMix()
|
||||
end
|
||||
|
||||
function ChipAudio.getChannelPitch(hw)
|
||||
return ChipSynth.getChannelPitch(hw)
|
||||
end
|
||||
|
||||
function ChipAudio.setChannelPitches(pitches)
|
||||
ChipSynth.setChannelPitches(pitches)
|
||||
pushChannelMix()
|
||||
end
|
||||
|
||||
function ChipAudio.getChannelPitches()
|
||||
return ChipSynth.getChannelPitches()
|
||||
end
|
||||
|
||||
-- aliases for channel 4 (noise / drums)
|
||||
function ChipAudio.setNoiseVolume(scale)
|
||||
ChipAudio.setChannelVolume(4, scale)
|
||||
end
|
||||
|
||||
function ChipAudio.getNoiseVolume()
|
||||
return ChipAudio.getChannelVolume(4)
|
||||
end
|
||||
|
||||
-- a stale song must not keep sounding past the flush that replaced its
|
||||
-- program (20 §2 cache contract, chip music row)
|
||||
Assets.register(ChipAudio.invalidate)
|
||||
|
||||
+77
-7
@@ -29,6 +29,71 @@ ChipSynth.SAMPLE_RATE = SAMPLE_RATE
|
||||
ChipSynth.MUSIC_BUFFER_SAMPLES = MUSIC_BUFFER_SAMPLES
|
||||
ChipSynth.MUSIC_BUFFER_COUNT = MUSIC_BUFFER_COUNT
|
||||
|
||||
-- Runtime mix per hardware channel (1 pulse, 2 pulse, 3 wave, 4 noise).
|
||||
-- Volume: 1 = authentic GB, 0 = mute. Pitch: 1 = authentic, 2 = +1 octave,
|
||||
-- 0.5 = -1 octave. Applied at sample time so a live change reaches the next
|
||||
-- buffer on both the sync path and the worker (via ChipAudio).
|
||||
local channelVolume = { 1, 1, 1, 1 }
|
||||
local channelPitch = { 1, 1, 1, 1 }
|
||||
|
||||
local function clampScale(scale)
|
||||
return math.max(0, tonumber(scale) or 0)
|
||||
end
|
||||
|
||||
local function setChannelTable(table, hw, scale)
|
||||
hw = tonumber(hw)
|
||||
if not hw or hw < 1 or hw > 4 then return end
|
||||
table[hw] = clampScale(scale)
|
||||
end
|
||||
|
||||
local function setChannelTables(table, values)
|
||||
if type(values) ~= "table" then return end
|
||||
for hw = 1, 4 do
|
||||
if values[hw] ~= nil then table[hw] = clampScale(values[hw]) end
|
||||
end
|
||||
end
|
||||
|
||||
function ChipSynth.setChannelVolume(hw, scale)
|
||||
setChannelTable(channelVolume, hw, scale)
|
||||
end
|
||||
|
||||
function ChipSynth.getChannelVolume(hw)
|
||||
return channelVolume[tonumber(hw) or 0] or 1
|
||||
end
|
||||
|
||||
function ChipSynth.setChannelVolumes(volumes)
|
||||
setChannelTables(channelVolume, volumes)
|
||||
end
|
||||
|
||||
function ChipSynth.getChannelVolumes()
|
||||
return { channelVolume[1], channelVolume[2], channelVolume[3], channelVolume[4] }
|
||||
end
|
||||
|
||||
function ChipSynth.setChannelPitch(hw, scale)
|
||||
setChannelTable(channelPitch, hw, scale)
|
||||
end
|
||||
|
||||
function ChipSynth.getChannelPitch(hw)
|
||||
return channelPitch[tonumber(hw) or 0] or 1
|
||||
end
|
||||
|
||||
function ChipSynth.setChannelPitches(pitches)
|
||||
setChannelTables(channelPitch, pitches)
|
||||
end
|
||||
|
||||
function ChipSynth.getChannelPitches()
|
||||
return { channelPitch[1], channelPitch[2], channelPitch[3], channelPitch[4] }
|
||||
end
|
||||
|
||||
-- aliases for the noise/drum layer
|
||||
function ChipSynth.setNoiseVolume(scale)
|
||||
ChipSynth.setChannelVolume(4, scale)
|
||||
end
|
||||
|
||||
function ChipSynth.getNoiseVolume()
|
||||
return ChipSynth.getChannelVolume(4)
|
||||
end
|
||||
|
||||
local PITCHES = {
|
||||
0xF82C, 0xF89D, 0xF907, 0xF96B, 0xF9CA, 0xFA23,
|
||||
0xFA77, 0xFAC7, 0xFB12, 0xFB58, 0xFB9B, 0xFBDA,
|
||||
@@ -430,7 +495,8 @@ function Channel:sampleNoise(parameter)
|
||||
local divisor = NOISE_DIVISORS[bit.band(parameter, 7)]
|
||||
local shift = bit.rshift(parameter, 4)
|
||||
if shift < 14 then
|
||||
local cycles = GB_CLOCK / divisor / (2 ^ shift) / SAMPLE_RATE
|
||||
local pitch = channelPitch[self.hardware] or 1
|
||||
local cycles = GB_CLOCK / divisor / (2 ^ shift) / SAMPLE_RATE * pitch
|
||||
local width7 = bit.band(parameter, 8) ~= 0
|
||||
local remaining = cycles
|
||||
while remaining > 0 do
|
||||
@@ -500,11 +566,14 @@ function Channel:sample()
|
||||
event.sample = sampleIndex + 1
|
||||
if event.silence then return 0 end
|
||||
|
||||
if event.drum then return self:sampleDrum(event, sampleIndex) end
|
||||
local gain = channelVolume[self.hardware] or 1
|
||||
if event.drum then
|
||||
return self:sampleDrum(event, sampleIndex) * gain
|
||||
end
|
||||
local volume = envelopeVolume(
|
||||
event.volume or 0, event.fade or 0, event.elapsed)
|
||||
if event.noise then
|
||||
return self:sampleNoise(event.noiseParameter) * volume / 15
|
||||
return self:sampleNoise(event.noiseParameter) * volume / 15 * gain
|
||||
end
|
||||
|
||||
local register = event.register
|
||||
@@ -529,7 +598,8 @@ function Channel:sample()
|
||||
end
|
||||
end
|
||||
end
|
||||
local frequency = 131072 / (2048 - math.min(register, 2047))
|
||||
local pitch = channelPitch[self.hardware] or 1
|
||||
local frequency = 131072 / (2048 - math.min(register, 2047)) * pitch
|
||||
if event.wave then frequency = frequency * 0.5 end
|
||||
local phase = self.phase
|
||||
self.phase = (phase + frequency / SAMPLE_RATE) % 1
|
||||
@@ -539,7 +609,7 @@ function Channel:sample()
|
||||
-- a def-local program may omit its wave table entirely
|
||||
if not wave then return 0 end
|
||||
local index = math.min(32, math.floor(phase * 32) + 1)
|
||||
return wave[index] * event.waveLevel
|
||||
return wave[index] * event.waveLevel * gain
|
||||
end
|
||||
local duty = event.duty
|
||||
if type(duty) == "table" then
|
||||
@@ -548,9 +618,9 @@ function Channel:sample()
|
||||
local pattern = WAVE_PATTERN_TABLES[duty or 2] or WAVE_PATTERN_TABLES[2]
|
||||
local step = math.floor(phase * 8) % 8
|
||||
if pattern[step + 1] == 0 then
|
||||
return -volume / 15
|
||||
return -volume / 15 * gain
|
||||
end
|
||||
return volume / 15
|
||||
return volume / 15 * gain
|
||||
end
|
||||
|
||||
local Engine = {}
|
||||
|
||||
+26
-3
@@ -38,6 +38,22 @@ local CONSTANT_DEFAULTS = {
|
||||
},
|
||||
}
|
||||
|
||||
-- data/events/trades.asm in Pokemon Yellow has its own TradeMons table.
|
||||
-- Old Yellow imports were built from the Red manifest, so correct that
|
||||
-- table after loading as well as in the fixed import manifest below.
|
||||
local YELLOW_TRADES = {
|
||||
{ give = "LICKITUNG", get = "DUGTRIO", dialogset = 1, nickname = "GURIO" },
|
||||
{ give = "CLEFAIRY", get = "MR_MIME", dialogset = 1, nickname = "MILES" },
|
||||
{ give = "BUTTERFREE", get = "BEEDRILL", dialogset = 3, nickname = "STINGER" },
|
||||
{ give = "KANGASKHAN", get = "MUK", dialogset = 1, nickname = "STICKY" },
|
||||
{ give = "MEW", get = "MEW", dialogset = 3, nickname = "BART" },
|
||||
{ give = "TANGELA", get = "PARASECT", dialogset = 1, nickname = "SPIKE" },
|
||||
{ give = "PIDGEOT", get = "PIDGEOT", dialogset = 2, nickname = "MARTY" },
|
||||
{ give = "GOLDUCK", get = "RHYDON", dialogset = 2, nickname = "BUFFY" },
|
||||
{ give = "GROWLITHE", get = "DEWGONG", dialogset = 3, nickname = "CEZANNE" },
|
||||
{ give = "CUBONE", get = "MACHOKE", dialogset = 3, nickname = "RICKY" },
|
||||
}
|
||||
|
||||
-- field.boot is the total-conversion override point for the new game; the
|
||||
-- values match what SaveData.newGame and the Oak speech used to inline.
|
||||
local BOOT_DEFAULTS = {
|
||||
@@ -58,6 +74,12 @@ local function copy(value)
|
||||
return out
|
||||
end
|
||||
|
||||
function Data:applyVersionedFieldData()
|
||||
if require("src.core.GameVersion").isYellow() then
|
||||
self.field.trades = copy(YELLOW_TRADES)
|
||||
end
|
||||
end
|
||||
|
||||
-- Fills only what the cache is missing, so an importer that learns to
|
||||
-- stamp one of these keys silently takes over from the engine.
|
||||
function Data:seedDefaults()
|
||||
@@ -77,6 +99,7 @@ function Data:seedDefaults()
|
||||
if constants.dexDigits == nil then
|
||||
constants.dexDigits = math.max(3, #tostring(constants.dexSize))
|
||||
end
|
||||
self:applyVersionedFieldData()
|
||||
local boot = self.field.boot
|
||||
if boot == nil then
|
||||
boot = {}
|
||||
@@ -121,8 +144,8 @@ end
|
||||
-- no def_trainers row (DisplayTextID routes to his ASM script), so the
|
||||
-- extractor emits headers only for the four blackbelts ([2]..[5]). Seed
|
||||
-- object index [1] so he behaves like the real leader:
|
||||
-- * range 4 (matches the strongest blackbelt) -> CheckFightingMapTrainers
|
||||
-- spots the player in his DOWN line and he challenges on sight (#197),
|
||||
-- * range 0: FightingDojoDefaultScript, not trainer sight, starts his
|
||||
-- battle from the single tile to his left (#495),
|
||||
-- * battle = his pre-battle challenge, won = "Hwa! Arrgh! Beaten!",
|
||||
-- * after = the "Stay and train at Karate with us!" re-talk line.
|
||||
-- Deliberately NO `event`: EVENT_BEAT_KARATE_MASTER is owned by
|
||||
@@ -137,7 +160,7 @@ function Data:seedFightingDojoKarateMaster()
|
||||
headers.FightingDojo = headers.FightingDojo or {}
|
||||
if headers.FightingDojo[1] then return end
|
||||
headers.FightingDojo[1] = {
|
||||
range = 4,
|
||||
range = 0,
|
||||
battle = "_FightingDojoKarateMasterText",
|
||||
won = "_FightingDojoKarateMasterDefeatedText",
|
||||
after = "_FightingDojoKarateMasterStayAndTrainWithUsText",
|
||||
|
||||
+135
-9
@@ -172,6 +172,11 @@ function Game:returnToTitle()
|
||||
end
|
||||
|
||||
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()
|
||||
-- serviced unconditionally: a link battle's ENet transport must not
|
||||
-- stall just because PartyMenu/ChoiceBox/NamingScreen is temporarily
|
||||
@@ -244,35 +249,93 @@ end
|
||||
-- exactly as the owning state computed it
|
||||
local function sameZones(_, zones) return zones end
|
||||
|
||||
-- A wide battle owns the surface until it leaves the stack. The party,
|
||||
-- bag, choice and text states it opens still draw their original 160px UI,
|
||||
-- but the canvas must not snap to 160px between those states.
|
||||
function Game.wideBattleInStack(stack)
|
||||
for i = #(stack and stack.states or {}), 1, -1 do
|
||||
local state = stack.states[i]
|
||||
if state and state.isWideBattleLayout and state:isWideBattleLayout() then
|
||||
return state
|
||||
end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
-- Shift classic SGB zones to the centred UI. A full-width base zone extends
|
||||
-- into both margins, keeping the canvas' paper color continuous; narrower
|
||||
-- sprite and status zones move with the classic UI content.
|
||||
local function centerClassicZones(zones, offset)
|
||||
if not zones or offset == 0 then return zones end
|
||||
local shifted = {}
|
||||
for i, zone in ipairs(zones) do
|
||||
local copy = {}
|
||||
for key, value in pairs(zone) do copy[key] = value end
|
||||
if copy.x == 0 and copy.w == Renderer.WIDTH then
|
||||
copy.w = copy.w + offset * 2
|
||||
else
|
||||
copy.x = (copy.x or 0) + offset
|
||||
end
|
||||
shifted[i] = copy
|
||||
end
|
||||
return shifted
|
||||
end
|
||||
|
||||
function Game:draw()
|
||||
-- the UI canvas clears transparent when the overworld's world pass
|
||||
-- shows through beneath it; opaque full-screen states get the classic
|
||||
-- white clear
|
||||
local base = self.stack:visibleBase()
|
||||
local worldBelow = self.stack.states[base] == self.overworld
|
||||
-- The UI surface is resolved once, before any state draws: the top state
|
||||
-- may want more than the Game Boy's 160x144 (the widescreen battle layout
|
||||
-- asks for 304x144). Anything else keeps the classic surface, so a menu
|
||||
-- pushed over a wide battle brings the screen straight back to 160x144.
|
||||
-- A wide battle holds its 304px surface through every menu or prompt it
|
||||
-- opens. States that do not draw the wide battle composition are centred
|
||||
-- in that surface below, so their classic coordinates and hit testing stay
|
||||
-- unchanged. Outside a battle, including the title screen, the option is
|
||||
-- intentionally inactive because it is a battle-layout setting.
|
||||
local top = self.stack:top()
|
||||
if top and top.uiSize then
|
||||
local wideBattle = Game.wideBattleInStack(self.stack)
|
||||
local classicOffset = 0
|
||||
if wideBattle and wideBattle.uiSize then
|
||||
Renderer:setUISize(wideBattle:uiSize())
|
||||
classicOffset = math.floor((select(1, Renderer:uiSize()) - Renderer.WIDTH) / 2)
|
||||
elseif top and top.uiSize then
|
||||
Renderer:setUISize(top:uiSize())
|
||||
else
|
||||
Renderer:setUISize(Renderer.WIDTH, Renderer.HEIGHT)
|
||||
end
|
||||
Renderer:beginFrame(worldBelow)
|
||||
self.stack:draw()
|
||||
for i = self.stack:visibleBase(), #self.stack.states do
|
||||
local state = self.stack.states[i]
|
||||
local wideState = state and state.isWideBattleLayout
|
||||
and state:isWideBattleLayout()
|
||||
if state and state.draw then
|
||||
if classicOffset ~= 0 and not wideState then
|
||||
love.graphics.push()
|
||||
love.graphics.translate(classicOffset, 0)
|
||||
state:draw()
|
||||
love.graphics.pop()
|
||||
else
|
||||
state:draw()
|
||||
end
|
||||
end
|
||||
end
|
||||
-- SGB colorization: the topmost state that knows its palette owns the
|
||||
-- screen (overlays like text boxes inherit from what's beneath them);
|
||||
-- the overworld's world pass colors each visible map area separately
|
||||
local zones, worldZones
|
||||
local zones, worldZones, zoneOwner
|
||||
for i = #self.stack.states, 1, -1 do
|
||||
local s = self.stack.states[i]
|
||||
if s.sgbPalettes then
|
||||
zones = s:sgbPalettes(self)
|
||||
zoneOwner = s
|
||||
break
|
||||
end
|
||||
end
|
||||
if classicOffset ~= 0 and zoneOwner
|
||||
and not (zoneOwner.isWideBattleLayout
|
||||
and zoneOwner:isWideBattleLayout()) then
|
||||
zones = centerClassicZones(zones, classicOffset)
|
||||
end
|
||||
-- 14's render.zones: weather/lighting overlays and custom colorization
|
||||
-- recolor or add zones before the blit
|
||||
if ModRuntime.wantsHook("render.zones") then
|
||||
@@ -281,7 +344,13 @@ function Game:draw()
|
||||
if worldBelow and self.overworld.sgbWorldZones then
|
||||
worldZones = self.overworld:sgbWorldZones()
|
||||
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
|
||||
TouchControls:draw()
|
||||
end
|
||||
@@ -433,6 +502,40 @@ function Game:gamepadaxis(joystick, axis, value)
|
||||
Input:gamepadaxis(joystick, axis, value)
|
||||
end
|
||||
|
||||
-- conf.lua turns the mobile accelerometer-joystick off (#468), but guard the
|
||||
-- generic joystick path anyway: any sensor-style device that still reaches us
|
||||
-- has gravity pinning an axis past the deadzone, which would hide the touch
|
||||
-- overlay every instant and steer the player by tilt through the axis-1/2
|
||||
-- mapping (#459). Real controllers arrive as SDL gamepads or named sticks,
|
||||
-- never as "* Accelerometer".
|
||||
local function isAccelerometer(joystick)
|
||||
local name = joystick and joystick.getName and joystick:getName()
|
||||
return name ~= nil and name:lower():find("accelerometer", 1, true) ~= nil
|
||||
end
|
||||
|
||||
function Game:joystickpressed(joystick, button)
|
||||
if isAccelerometer(joystick) then return end
|
||||
TouchControls:noteGamepad()
|
||||
Input:joystickpressed(joystick, button)
|
||||
end
|
||||
|
||||
function Game:joystickreleased(joystick, button)
|
||||
if isAccelerometer(joystick) then return end
|
||||
Input:joystickreleased(joystick, button)
|
||||
end
|
||||
|
||||
function Game:joystickaxis(joystick, axis, value)
|
||||
if isAccelerometer(joystick) then return end
|
||||
if math.abs(value) > 0.5 then TouchControls:noteGamepad() end
|
||||
Input:joystickaxis(joystick, axis, value)
|
||||
end
|
||||
|
||||
function Game:joystickhat(joystick, hat, direction)
|
||||
if isAccelerometer(joystick) then return end
|
||||
if direction ~= "c" then TouchControls:noteGamepad() end
|
||||
Input:joystickhat(joystick, hat, direction)
|
||||
end
|
||||
|
||||
-- Window focus/visibility flips: a release due while unfocused/hidden can
|
||||
-- be swallowed by the OS. Reset on both edges -- gaining focus with a
|
||||
-- physically held key won't re-fire keypressed, so trusting leftover
|
||||
@@ -486,6 +589,11 @@ end
|
||||
-- Capture the live world state into the save table and persist it.
|
||||
-- Options are flushed to options.lua as part of SaveData.save.
|
||||
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
|
||||
self.overworld:captureSave(self.save)
|
||||
end
|
||||
@@ -496,7 +604,7 @@ function Game:writeSave()
|
||||
if ModRuntime.wants("save.writing") then
|
||||
ModRuntime.emit("save.writing", { save = self.save, meta = self.save.meta })
|
||||
end
|
||||
SaveData.save(self.save)
|
||||
return SaveData.save(self.save)
|
||||
end
|
||||
|
||||
-- Persist options.lua only (Options menu / hotkeys 2-5). Keeps settings
|
||||
@@ -526,7 +634,25 @@ function Game:applyOptions(opts)
|
||||
-- normalizes a nil/garbage cap to the 60 default, so old saves with no
|
||||
-- fpsCap key pace at the standard rate (issue #88)
|
||||
require("src.core.FrameCap").applyOptions(opts)
|
||||
-- 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)
|
||||
TouchControls:applyOptions(opts)
|
||||
-- heal soft-bricked APK installs that already saved gbcfx > 0 (#136)
|
||||
if gbcCleared then self:writeOptions() end
|
||||
end
|
||||
|
||||
@@ -39,6 +39,20 @@ local DEFAULT_GAMEPAD_BINDINGS = {
|
||||
local STICK_ON = 0.5
|
||||
local STICK_OFF = 0.3
|
||||
|
||||
-- Generic SDL joysticks expose the left stick as the first two numbered
|
||||
-- axes and the D-pad as a hat. This is common on Linux handhelds whose
|
||||
-- controller has no game-controller database entry.
|
||||
local RAW_BUTTON_BINDINGS = {
|
||||
[1] = "a", [2] = "b",
|
||||
[7] = "select", [8] = "start", [9] = "select", [10] = "start",
|
||||
}
|
||||
|
||||
local HAT_DIRECTIONS = {
|
||||
u = { "up" }, d = { "down" }, l = { "left" }, r = { "right" },
|
||||
lu = { "left", "up" }, ru = { "right", "up" },
|
||||
ld = { "left", "down" }, rd = { "right", "down" },
|
||||
}
|
||||
|
||||
function Input:init()
|
||||
self:applyBindings(nil)
|
||||
self:reset()
|
||||
@@ -79,6 +93,7 @@ function Input:reset()
|
||||
self.sources = {}
|
||||
self.stickAxis = { x = 0, y = 0 }
|
||||
self.stickDir = nil
|
||||
self.hatDirs = {}
|
||||
end
|
||||
|
||||
-- Multiple physical sources (W + Up, d-pad + stick, etc.) can claim the
|
||||
@@ -179,6 +194,16 @@ function Input:gamepadreleased(joystick, button)
|
||||
end
|
||||
end
|
||||
|
||||
function Input:joystickpressed(joystick, button)
|
||||
local btn = RAW_BUTTON_BINDINGS[button]
|
||||
if btn then press(self, btn, "joy:" .. button) end
|
||||
end
|
||||
|
||||
function Input:joystickreleased(joystick, button)
|
||||
local btn = RAW_BUTTON_BINDINGS[button]
|
||||
if btn then release(self, btn, "joy:" .. button) end
|
||||
end
|
||||
|
||||
-- left stick treated as a continuous held direction, same 4-way rule as
|
||||
-- the touch swipe d-pad: whichever axis has the larger magnitude wins.
|
||||
function Input:gamepadaxis(joystick, axis, value)
|
||||
@@ -214,6 +239,26 @@ function Input:gamepadaxis(joystick, axis, value)
|
||||
end
|
||||
end
|
||||
|
||||
function Input:joystickaxis(joystick, axis, value)
|
||||
if axis == 1 then
|
||||
self:gamepadaxis(joystick, "leftx", value)
|
||||
elseif axis == 2 then
|
||||
self:gamepadaxis(joystick, "lefty", value)
|
||||
end
|
||||
end
|
||||
|
||||
function Input:joystickhat(joystick, hat, direction)
|
||||
local source = "hat:" .. hat
|
||||
for _, btn in ipairs(self.hatDirs[hat] or {}) do
|
||||
release(self, btn, source)
|
||||
end
|
||||
local dirs = HAT_DIRECTIONS[direction] or {}
|
||||
for _, btn in ipairs(dirs) do
|
||||
press(self, btn, source)
|
||||
end
|
||||
self.hatDirs[hat] = dirs
|
||||
end
|
||||
|
||||
function Input:isDown(btn)
|
||||
return self.state[btn] or false
|
||||
end
|
||||
|
||||
+12
-1
@@ -23,6 +23,13 @@ local volumeScale = 1
|
||||
local FILTER_HIGHGAIN = { 0.4, 0.16, 0.064 }
|
||||
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)
|
||||
if not src then return end
|
||||
local vol = VOLUME * volumeScale
|
||||
@@ -63,7 +70,7 @@ local function applyFilter(src)
|
||||
end
|
||||
end
|
||||
|
||||
local state = {
|
||||
state = {
|
||||
current = nil, -- song label
|
||||
chip = false, -- the playing song is a synthesized channel program
|
||||
source = nil, -- currently playing source
|
||||
@@ -85,6 +92,7 @@ local function fanfareActive()
|
||||
local ok, playing = pcall(src.isPlaying, src)
|
||||
if ok and playing then return true end
|
||||
state.fanfare = nil
|
||||
require("src.core.ChipAudio").holdMusic(false)
|
||||
return false
|
||||
end
|
||||
|
||||
@@ -94,6 +102,9 @@ end
|
||||
function Music.duckForFanfare(src)
|
||||
if not src then return end
|
||||
state.fanfare = src
|
||||
-- ChipAudio is what starts a chip song, so the pause below cannot hold one
|
||||
-- that has not started yet (nor one Music.play swaps in mid-jingle) (#398)
|
||||
require("src.core.ChipAudio").holdMusic(true)
|
||||
if state.source then
|
||||
local ok, playing = pcall(state.source.isPlaying, state.source)
|
||||
if ok and playing then
|
||||
|
||||
@@ -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
|
||||
@@ -223,6 +223,9 @@ function SaveData.defaultOptions()
|
||||
-- 0-7 like the GB's NR50 master volume
|
||||
musicVol = 7,
|
||||
sfxVol = 7,
|
||||
-- Yellow only: 0-7 trim for Pikachu's PCM voice clips on top of sfxVol
|
||||
-- (the PIKACHU VOL row appears only on Yellow; see Sound.lua)
|
||||
pikaVol = 7,
|
||||
musicFilter = 0,
|
||||
-- logic fast-forward multiplier; audio is unaffected (GameSpeed.lua)
|
||||
speed = 1,
|
||||
@@ -238,6 +241,11 @@ function SaveData.defaultOptions()
|
||||
videoMode = "windowed",
|
||||
-- hard render frame-rate cap; render-only pacing (issue #88, FrameCap.lua)
|
||||
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
|
||||
-- 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
|
||||
@@ -246,6 +254,24 @@ function SaveData.defaultOptions()
|
||||
-- Native mod enablement is an installation option, not save-slot data.
|
||||
-- Missing entries mean enabled so newly installed mods work by default.
|
||||
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
|
||||
|
||||
|
||||
+51
-10
@@ -16,6 +16,30 @@ local cache = {}
|
||||
-- scaling the 0.8 base every source gets
|
||||
local BASE_VOLUME = 0.8
|
||||
local volumeScale = 1
|
||||
-- port addition (Yellow only): 0-7 trim from save.options.pikaVol on top of
|
||||
-- the SFX level, for Pikachu's voice clips alone. Yellow voices every
|
||||
-- Pikachu cry with PCM samples that are far louder and far more frequent
|
||||
-- than the chip cries around them (the follower alone talks on every
|
||||
-- interaction), so this is the one source players want to pull down
|
||||
-- without muting the rest of the SFX bus. 7 = untouched, 0 = silent.
|
||||
local pikaScale = 1
|
||||
|
||||
-- cache keys whose volume the Pikachu trim applies to: the PCM clips, plus
|
||||
-- the chip PIKACHU cry that a Yellow cache without extracted clips falls
|
||||
-- back to (playCry). Red/Blue never reach the second branch, so a shared
|
||||
-- options.lua carrying a low pikaVol cannot quiet their Pikachu.
|
||||
local function isPikaKey(key)
|
||||
if type(key) ~= "string" then return false end
|
||||
if key:sub(1, 8) == "pikacry:" then return true end
|
||||
return key == "cry:PIKACHU"
|
||||
and require("src.core.GameVersion").isYellow()
|
||||
end
|
||||
|
||||
local function volumeFor(key)
|
||||
local scale = volumeScale
|
||||
if isPikaKey(key) then scale = scale * pikaScale end
|
||||
return BASE_VOLUME * scale
|
||||
end
|
||||
|
||||
-- Fanfares occupy the music's tone channels on the Game Boy: their sfx
|
||||
-- headers claim channels 5-7 (= hardware channels 1-3), silencing the
|
||||
@@ -91,7 +115,7 @@ local function playPath(data, key, def, pitch, tempo)
|
||||
reportBadDef("sfx", key, owner(data, "sfx", key), err)
|
||||
return nil
|
||||
end
|
||||
s:setVolume(BASE_VOLUME * volumeScale)
|
||||
s:setVolume(volumeFor(key))
|
||||
cache[key] = s
|
||||
src = s
|
||||
end
|
||||
@@ -111,6 +135,9 @@ local function played(kind, name, species)
|
||||
Runtime.emit("sound.played", { kind = kind, name = name, species = species })
|
||||
end
|
||||
|
||||
-- returns the started source (nil headless, or when the def failed to load)
|
||||
-- so callers that block on a fanfare like the original's
|
||||
-- PlaySoundWaitForCurrent -> WaitForSoundToFinish can poll it
|
||||
function Sound.play(data, name)
|
||||
local sfx = data.audio and data.audio.sfx
|
||||
local def = sfx and sfx[name]
|
||||
@@ -120,6 +147,7 @@ function Sound.play(data, name)
|
||||
require("src.core.Music").duckForFanfare(src)
|
||||
end
|
||||
played("sfx", name)
|
||||
return src
|
||||
end
|
||||
|
||||
-- Play a move's sound with its MoveSoundTable pitch/tempo modifiers
|
||||
@@ -211,7 +239,7 @@ function Sound.playPikaCry(data, n)
|
||||
cache[key] = false
|
||||
return nil
|
||||
end
|
||||
s:setVolume(BASE_VOLUME * volumeScale)
|
||||
s:setVolume(volumeFor(key))
|
||||
cache[key] = s
|
||||
src = s
|
||||
end
|
||||
@@ -245,7 +273,7 @@ function Sound.playCry(data, species)
|
||||
owner(data, "cries", species), err)
|
||||
return nil
|
||||
end
|
||||
s:setVolume(BASE_VOLUME * volumeScale)
|
||||
s:setVolume(volumeFor(key))
|
||||
cache[key] = s
|
||||
src = s
|
||||
end
|
||||
@@ -321,7 +349,7 @@ function Sound.startLoop(data, name)
|
||||
return
|
||||
end
|
||||
s:setLooping(true)
|
||||
s:setVolume(BASE_VOLUME * volumeScale)
|
||||
s:setVolume(volumeFor(name))
|
||||
loopCache[name] = s
|
||||
src = s
|
||||
end
|
||||
@@ -345,14 +373,26 @@ end
|
||||
-- 0-7 SFX volume level (0 mutes); cached sources (menu beeps, cries,
|
||||
-- the low-health alarm loop) update immediately so the change is heard
|
||||
-- on the next play
|
||||
local function reapplyVolumes()
|
||||
for key, src in pairs(cache) do
|
||||
if src then pcall(src.setVolume, src, volumeFor(key)) end
|
||||
end
|
||||
for key, src in pairs(loopCache) do
|
||||
if src then pcall(src.setVolume, src, volumeFor(key)) end
|
||||
end
|
||||
end
|
||||
|
||||
function Sound.setVolumeLevel(level)
|
||||
volumeScale = math.max(0, math.min(7, level or 7)) / 7
|
||||
for _, src in pairs(cache) do
|
||||
if src then pcall(src.setVolume, src, BASE_VOLUME * volumeScale) end
|
||||
end
|
||||
for _, src in pairs(loopCache) do
|
||||
if src then pcall(src.setVolume, src, BASE_VOLUME * volumeScale) end
|
||||
end
|
||||
reapplyVolumes()
|
||||
end
|
||||
|
||||
-- 0-7 Pikachu-voice trim on top of the SFX level (7 = no trim, 0 mutes the
|
||||
-- clips while the rest of the SFX bus keeps its level). Yellow only: on
|
||||
-- Red/Blue no cached key answers isPikaKey, so this is inert there.
|
||||
function Sound.setPikaVolumeLevel(level)
|
||||
pikaScale = math.max(0, math.min(7, level or 7)) / 7
|
||||
reapplyVolumes()
|
||||
end
|
||||
|
||||
-- hot reload / jukebox A-B: drop one key's sources (its pitch-tempo
|
||||
@@ -386,6 +426,7 @@ Assets.register(Sound.invalidate)
|
||||
-- loading a save)
|
||||
function Sound.applyOptions(opts)
|
||||
Sound.setVolumeLevel(opts and opts.sfxVol or 7)
|
||||
Sound.setPikaVolumeLevel(opts and opts.pikaVol or 7)
|
||||
end
|
||||
|
||||
return Sound
|
||||
|
||||
+195
-48
@@ -12,6 +12,11 @@
|
||||
-- (main.lua then drives it with the mouse); POKEPORT_TOUCH=0 forces it
|
||||
-- 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
|
||||
-- own input source, not a keyboard alias -- so a held overlay direction
|
||||
-- merges cleanly with a keyboard key or stick holding the same button,
|
||||
@@ -39,6 +44,7 @@ local DPAD_DEAD = 0.16
|
||||
local SLOP = { a = 1.3, b = 1.3, start = 1.4, select = 1.4 }
|
||||
|
||||
local BUTTONS = { "a", "b", "start", "select" }
|
||||
local CONTROLS = { "dpad", "a", "b", "start", "select" }
|
||||
|
||||
local IMAGES = {
|
||||
dpad = "assets/touch/dpad.png",
|
||||
@@ -52,6 +58,12 @@ local IMAGES = {
|
||||
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 env = os.getenv("POKEPORT_TOUCH")
|
||||
if env == "1" then return true end
|
||||
@@ -60,8 +72,59 @@ local function wantsOverlay()
|
||||
return osName == "Android" or osName == "iOS"
|
||||
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 given window size. Shared by
|
||||
-- layout() and the editor's Reset path so defaults stay in one place.
|
||||
function TouchControls.defaultLayout(ww, wh)
|
||||
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 = margin + dpadW / 2, cy = wh - margin - dpadW / 2, w = dpadW },
|
||||
a = { cx = ww - margin - abW * 0.55, cy = wh - margin - abW * 1.75, w = abW },
|
||||
b = { cx = ww - margin - abW * 1.60, cy = wh - margin - abW * 0.55, w = abW },
|
||||
start = { cx = ww / 2 + ssW * 0.60, cy = wh - margin - ssW * 0.95, w = ssW },
|
||||
select = { cx = ww / 2 - ssW * 0.60, cy = 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()
|
||||
self.active = wantsOverlay()
|
||||
self.enabled = true
|
||||
self.positions = nil
|
||||
self.preview = false
|
||||
self.controllerHidden = false
|
||||
self.touches = {}
|
||||
-- per-GB-button owner count: two fingers on A must not double-press it,
|
||||
@@ -70,51 +133,86 @@ function TouchControls:init()
|
||||
self.dpadTouch = nil
|
||||
self.layoutW, self.layoutH = nil, nil
|
||||
self.img = nil
|
||||
if not self.active then return end
|
||||
-- soft-fail: a missing/corrupt PNG must never block boot; the overlay
|
||||
-- stays off and keyboard/controller play still works
|
||||
local img = {}
|
||||
for name, path in pairs(IMAGES) do
|
||||
local ok, im = pcall(love.graphics.newImage, path)
|
||||
if not ok then
|
||||
img = nil
|
||||
break
|
||||
end
|
||||
-- smooth UI icons; the global default filter is nearest for GB pixels
|
||||
im:setFilter("linear", "linear")
|
||||
img[name] = im
|
||||
-- Images load whenever the platform wants the overlay OR the launcher
|
||||
-- editor forces a preview (desktop testing of the editor).
|
||||
if self.active then
|
||||
self.img = loadImages()
|
||||
end
|
||||
end
|
||||
|
||||
-- Ensure art is loaded for the launcher editor even when wantsOverlay()
|
||||
-- is false (desktop without POKEPORT_TOUCH).
|
||||
function TouchControls:ensureImages()
|
||||
if self.img then return true end
|
||||
self.img = loadImages()
|
||||
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
|
||||
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
|
||||
self.img = img
|
||||
end
|
||||
|
||||
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
|
||||
|
||||
local function clampZone(zone, ww, wh)
|
||||
local half = zone.w * 0.5
|
||||
zone.cx = math.max(half, math.min(ww - half, zone.cx))
|
||||
zone.cy = math.max(half, math.min(wh - half, zone.cy))
|
||||
end
|
||||
|
||||
-- Layout in LOVE units (density-independent on mobile), recomputed when
|
||||
-- the window size changes (rotation, resize). D-pad bottom-left, B/A
|
||||
-- bottom-right with A above B (the Game Boy diagonal), START/SELECT
|
||||
-- flanking the bottom center.
|
||||
-- the window size changes (rotation, resize). Default: d-pad bottom-left,
|
||||
-- B/A bottom-right with A above B (the Game Boy diagonal), START/SELECT
|
||||
-- flanking the bottom center. Custom positions (normalized 0..1) override
|
||||
-- centers while sizes stay derived from the short edge.
|
||||
function TouchControls:layout()
|
||||
local ww, wh = love.graphics.getDimensions()
|
||||
if self.layoutW == ww and self.layoutH == wh then return self.L end
|
||||
if self.layoutW == ww and self.layoutH == wh and self.L then return self.L end
|
||||
self.layoutW, self.layoutH = ww, wh
|
||||
local short = math.min(ww, wh)
|
||||
-- ~a third of the short edge, capped so tablets don't get a dinner plate
|
||||
local dpadW = math.min(180, short * 0.34)
|
||||
local abW = dpadW * 0.46
|
||||
local ssW = dpadW * 0.30
|
||||
local margin = dpadW * 0.12
|
||||
-- START/SELECT hug the bottom center: on a narrow portrait phone the
|
||||
-- d-pad and B leave little room, so a tight pair is what keeps them off
|
||||
-- the neighboring controls
|
||||
self.L = {
|
||||
dpad = { cx = margin + dpadW / 2, cy = wh - margin - dpadW / 2, w = dpadW },
|
||||
a = { cx = ww - margin - abW * 0.55, cy = wh - margin - abW * 1.75, w = abW },
|
||||
b = { cx = ww - margin - abW * 1.60, cy = wh - margin - abW * 0.55, w = abW },
|
||||
start = { cx = ww / 2 + ssW * 0.60, cy = wh - margin - ssW * 0.95, w = ssW },
|
||||
select = { cx = ww / 2 - ssW * 0.60, cy = wh - margin - ssW * 0.95, w = ssW },
|
||||
}
|
||||
self.L = TouchControls.defaultLayout(ww, wh)
|
||||
if self.positions then
|
||||
for _, name in ipairs(CONTROLS) do
|
||||
local p = self.positions[name]
|
||||
local zone = self.L[name]
|
||||
if p and zone then
|
||||
zone.cx = p.x * ww
|
||||
zone.cy = p.y * wh
|
||||
clampZone(zone, ww, wh)
|
||||
end
|
||||
end
|
||||
end
|
||||
local ssW = self.L.start.w
|
||||
local fontSize = math.max(8, math.floor(ssW * 0.26))
|
||||
if not self.labelFont or self.fontSize ~= fontSize then
|
||||
self.fontSize = fontSize
|
||||
@@ -123,12 +221,45 @@ function TouchControls:layout()
|
||||
return self.L
|
||||
end
|
||||
|
||||
-- Move one control to a screen-space point and persist its normalized
|
||||
-- position. Used by the layout editor while dragging.
|
||||
function TouchControls:setControlCenter(name, cx, cy)
|
||||
local ww, wh = love.graphics.getDimensions()
|
||||
local L = self:layout()
|
||||
local zone = L[name]
|
||||
if not zone then return end
|
||||
zone.cx, zone.cy = cx, cy
|
||||
clampZone(zone, ww, wh)
|
||||
self.positions = self.positions or {}
|
||||
self.positions[name] = { x = zone.cx / ww, y = zone.cy / wh }
|
||||
end
|
||||
|
||||
function TouchControls:clearPositions()
|
||||
self.positions = nil
|
||||
self.layoutW, self.layoutH = nil, nil
|
||||
end
|
||||
|
||||
local function inCircle(zone, x, y, slop)
|
||||
local r = zone.w * 0.5 * slop
|
||||
local dx, dy = x - zone.cx, y - zone.cy
|
||||
return dx * dx + dy * dy <= r * r
|
||||
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 dx, dy = x - zone.cx, y - zone.cy
|
||||
local dead = zone.w * DPAD_DEAD
|
||||
@@ -165,7 +296,9 @@ local function setDpad(self, touch, dir)
|
||||
end
|
||||
|
||||
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
|
||||
if self.controllerHidden then
|
||||
self.controllerHidden = false
|
||||
@@ -192,6 +325,7 @@ function TouchControls:touchpressed(id, x, y)
|
||||
end
|
||||
|
||||
function TouchControls:touchmoved(id, x, y)
|
||||
if self.preview then return end
|
||||
local touch = self.touches[id]
|
||||
-- only the d-pad tracks movement (slide between directions without
|
||||
-- lifting); buttons hold until release wherever the finger wanders
|
||||
@@ -200,6 +334,7 @@ function TouchControls:touchmoved(id, x, y)
|
||||
end
|
||||
|
||||
function TouchControls:touchreleased(id, x, y)
|
||||
if self.preview then return end
|
||||
local touch = self.touches[id]
|
||||
if not touch then return end
|
||||
self.touches[id] = nil
|
||||
@@ -216,7 +351,7 @@ end
|
||||
-- touchreleased and would strand its button held forever. Called from
|
||||
-- Game alongside Input:reset() on focus/visibility loss.
|
||||
function TouchControls:reset()
|
||||
for btn in pairs(self.held) do
|
||||
for btn in pairs(self.held or {}) do
|
||||
Input:overlayReleased(btn)
|
||||
end
|
||||
self.held = {}
|
||||
@@ -225,9 +360,13 @@ function TouchControls:reset()
|
||||
end
|
||||
|
||||
-- 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()
|
||||
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:reset()
|
||||
end
|
||||
@@ -242,40 +381,46 @@ function TouchControls:joystickremoved()
|
||||
end
|
||||
end
|
||||
|
||||
local function drawIcon(img, zone, pressed)
|
||||
love.graphics.setColor(1, 1, 1, pressed and BACK_PRESSED or BACK)
|
||||
local function drawIcon(img, zone, pressed, alphaMul)
|
||||
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)
|
||||
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,
|
||||
zone.cy - img:getHeight() * scale / 2, 0, scale, scale)
|
||||
end
|
||||
|
||||
-- Screen-space, called by Game:draw after Renderer:endFrame so the
|
||||
-- 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()
|
||||
if not self:visible() then return end
|
||||
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.origin()
|
||||
|
||||
local dpadTouch = self.dpadTouch and self.touches[self.dpadTouch]
|
||||
local dir = dpadTouch and dpadTouch.dir
|
||||
drawIcon(dir and self.img["dpad_" .. dir] or self.img.dpad, L.dpad,
|
||||
dir ~= nil)
|
||||
dir ~= nil, alphaMul)
|
||||
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
|
||||
|
||||
-- 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)
|
||||
local ly = L.start.cy + L.start.w * 0.66
|
||||
local function label(text, zone)
|
||||
local ly = zone.cy + zone.w * 0.66
|
||||
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.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)
|
||||
end
|
||||
label("START", L.start)
|
||||
@@ -284,4 +429,6 @@ function TouchControls:draw()
|
||||
love.graphics.pop()
|
||||
end
|
||||
|
||||
TouchControls.CONTROLS = CONTROLS
|
||||
|
||||
return TouchControls
|
||||
|
||||
@@ -5,8 +5,10 @@
|
||||
--
|
||||
-- Protocol -- main thread pushes command tables onto the "chipaudio_cmd"
|
||||
-- channel and drains produced buffers off "chipaudio_out":
|
||||
-- cmd = "play" { gen, header, allowLoops, audio } start a song
|
||||
-- cmd = "play" { gen, header, allowLoops, audio,
|
||||
-- channelVolumes?, channelPitches? }
|
||||
-- cmd = "stop" halt production
|
||||
-- cmd = "channelMix" { volumes, pitches } per-hw volume/pitch
|
||||
-- cmd = "invalidate" drop the bank cache
|
||||
-- cmd = "quit" end the thread
|
||||
-- out buffers are tagged with the play's `gen` so the main thread can
|
||||
@@ -45,6 +47,12 @@ local function handle(cmd)
|
||||
engine = nil
|
||||
outCh:clear() -- drop any buffers left from the previous song
|
||||
data = { audio = cmd.audio }
|
||||
if cmd.channelVolumes ~= nil then
|
||||
ChipSynth.setChannelVolumes(cmd.channelVolumes)
|
||||
end
|
||||
if cmd.channelPitches ~= nil then
|
||||
ChipSynth.setChannelPitches(cmd.channelPitches)
|
||||
end
|
||||
local ok, eng = pcall(ChipSynth.newEngine, data, cmd.header,
|
||||
{ allowLoops = cmd.allowLoops })
|
||||
if ok then
|
||||
@@ -58,6 +66,9 @@ local function handle(cmd)
|
||||
engine = nil
|
||||
finished = false
|
||||
outCh:clear()
|
||||
elseif cmd.cmd == "channelMix" then
|
||||
if cmd.volumes ~= nil then ChipSynth.setChannelVolumes(cmd.volumes) end
|
||||
if cmd.pitches ~= nil then ChipSynth.setChannelPitches(cmd.pitches) end
|
||||
elseif cmd.cmd == "invalidate" then
|
||||
ChipSynth.invalidateBanks()
|
||||
elseif cmd.cmd == "quit" then
|
||||
|
||||
+42
-3
@@ -179,6 +179,38 @@ local function resolveUnmount()
|
||||
return physfsUnmountFn
|
||||
end
|
||||
|
||||
-- PHYSFS_getMountPoint, resolved the same way: a non-NULL return means `dir`
|
||||
-- is already somewhere in the search path. withMounted needs it because
|
||||
-- PHYSFS_mount reports success for an already-mounted directory without
|
||||
-- adding a second entry, so its unmount would drop a mount it did not make
|
||||
-- (#413).
|
||||
local physfsMountPointFn = nil
|
||||
local function resolveMountPoint()
|
||||
if physfsMountPointFn ~= nil then return physfsMountPointFn end
|
||||
physfsMountPointFn = false
|
||||
local ok, ffi = pcall(require, "ffi")
|
||||
if not ok then return physfsMountPointFn end
|
||||
pcall(ffi.cdef, "const char *PHYSFS_getMountPoint(const char *dir);")
|
||||
local libs = {
|
||||
function() return ffi.C end,
|
||||
function() return ffi.load("love") end,
|
||||
}
|
||||
for _, getlib in ipairs(libs) do
|
||||
local okl, lib = pcall(getlib)
|
||||
if okl and lib then
|
||||
local oks, fn = pcall(function() return lib.PHYSFS_getMountPoint end)
|
||||
if oks and fn then
|
||||
physfsMountPointFn = function(d)
|
||||
local okr, ret = pcall(fn, d)
|
||||
return okr and ret ~= nil and ret ~= ffi.NULL
|
||||
end
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
return physfsMountPointFn
|
||||
end
|
||||
|
||||
-- The portable game folder when the cache should live there, else nil.
|
||||
-- Resolved (and, for a fused build, mounted) once and cached. Requires a
|
||||
-- desktop portable install (SaveData) and a working windowless mkdir.
|
||||
@@ -398,13 +430,20 @@ end
|
||||
-- resolves -- which is what makes it safe to point at a folder whose contents
|
||||
-- nobody has validated.
|
||||
--
|
||||
-- Returns nil when the mount is unavailable (no ffi, no PHYSFS symbol, or the
|
||||
-- mount was refused), which callers must treat as "could not look", not as
|
||||
-- "nothing there". An error inside fn still unmounts before it propagates.
|
||||
-- Returns nil when the mount is unavailable (no ffi, no PHYSFS symbol, the
|
||||
-- mount was refused, or `dir` is already on the read path), which callers must
|
||||
-- treat as "could not look", not as "nothing there". An error inside fn still
|
||||
-- unmounts before it propagates.
|
||||
function CacheFs.withMounted(dir, mountPoint, fn)
|
||||
if not dir or dir == "" then return nil end
|
||||
local mount, unmount = resolveMount(), resolveUnmount()
|
||||
if not (mount and unmount) then return nil end
|
||||
-- A directory already in the search path cannot be borrowed: PHYSFS_mount
|
||||
-- returns success without adding an entry, and the unmount below would then
|
||||
-- remove the mount somebody else is relying on -- for the portable game
|
||||
-- folder, the one that makes its cache and mods readable at all (#413)
|
||||
local mountedAt = resolveMountPoint()
|
||||
if mountedAt and mountedAt(dir) then return nil end
|
||||
if not mount(dir, mountPoint, true) then return nil end
|
||||
local ok, res = pcall(fn)
|
||||
unmount(dir)
|
||||
|
||||
@@ -1307,6 +1307,16 @@ function RomExtractor:extractTrainers()
|
||||
}
|
||||
self:tick("Trainers", index, #order)
|
||||
end
|
||||
-- Yellow's Jessie & James fight as OPP_ROCKET but carry their own pic
|
||||
-- (home/trainers2.asm IsFightingJessieJames); only Yellow's manifest has
|
||||
-- the symbol, so Red and Blue skip this (#439).
|
||||
if self.symbols.JessieJamesPic then
|
||||
local relative = "battle/trainers/jessie_james.png"
|
||||
self:writeCompressedPic("JessieJamesPic", relative)
|
||||
if out.OPP_ROCKET then
|
||||
out.OPP_ROCKET.picJessieJames = "assets/generated/" .. relative
|
||||
end
|
||||
end
|
||||
self:write("trainers", out)
|
||||
return out
|
||||
end
|
||||
|
||||
+1932
-126
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.")
|
||||
:format(SAVE_SIZE, #bytes)
|
||||
end
|
||||
local save, convertErr = SaveConvert.importSav(bytes, version)
|
||||
-- 3rd arg: the crosswalk has to come from THIS game's ROM cache. The
|
||||
-- launcher imports before the cache is mounted on the un-prefixed paths, so
|
||||
-- SaveConvert cannot find the generated tables by itself here (#420).
|
||||
local save, convertErr = SaveConvert.importSav(bytes, version, version)
|
||||
if not save then return false, convertErr end
|
||||
-- Tag the game version and normalize the meta stamp: SaveConvert leaves
|
||||
-- meta.format = "gen1_import", but SaveData.load's migration pass compares
|
||||
@@ -103,7 +106,7 @@ function SaveFileIO.exportActiveSlot(version)
|
||||
version = version or GameVersion.get()
|
||||
local save = SaveData.load(version)
|
||||
if not save then return false, "this game has no save to export yet" end
|
||||
local bytes, exportErr = SaveConvert.exportSav(save)
|
||||
local bytes, exportErr = SaveConvert.exportSav(save, version)
|
||||
if not bytes then return false, exportErr end
|
||||
local slotId = SaveData.activeSlot(version) or "save"
|
||||
local fs = love and love.filesystem
|
||||
|
||||
@@ -10,7 +10,6 @@
|
||||
-- "ball" caller must throw it (battle only)
|
||||
-- "learn", moveId caller must run the learn-move flow
|
||||
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
local Flags = require("src.script.Flags")
|
||||
local Strings = require("src.core.Strings")
|
||||
|
||||
@@ -433,6 +432,13 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
|
||||
if battle then
|
||||
return "failed", { Strings("OAK: %s!\nThis isn't the\ntime to use that!", save.player.name) }
|
||||
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
|
||||
end
|
||||
|
||||
|
||||
@@ -128,7 +128,14 @@ end
|
||||
|
||||
-- ------- 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" }
|
||||
local MOVE_FIELDS = { "power", "type", "accuracy", "pp", "effect", "category",
|
||||
"priority", "highCrit", "fixedDamage", "multiHit",
|
||||
|
||||
+9
-12
@@ -178,19 +178,16 @@ end
|
||||
|
||||
-- ------- subset negotiation
|
||||
|
||||
-- the species and moves this party actually references, so the exchange
|
||||
-- stays small (six mons) instead of shipping the whole catalog
|
||||
-- every record hash, not just this party's slice: the receiver filters
|
||||
-- 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)
|
||||
local species = Fingerprint.records(data, "pokemon")
|
||||
local moves = Fingerprint.records(data, "moves")
|
||||
local outSpecies, outMoves = {}, {}
|
||||
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 }
|
||||
return { type = "records",
|
||||
pokemon = Fingerprint.records(data, "pokemon"),
|
||||
moves = Fingerprint.records(data, "moves") }
|
||||
end
|
||||
|
||||
-- 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
|
||||
self.pendingBattleOpts.seed = msg.seed
|
||||
end
|
||||
local battle, why = self.isHost
|
||||
and LinkBattle.newHost(self.game, self.net, self.pendingBattleOpts)
|
||||
or LinkBattle.newGuest(self.game, self.net, self.pendingBattleOpts)
|
||||
-- Split rather than `cond and newHost() or newGuest()`: the and/or
|
||||
-- idiom truncates a call to its first result, so the second return
|
||||
-- (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
|
||||
self:exitWith(why or Strings("Link battle\ncan't start."))
|
||||
return
|
||||
|
||||
+124
-16
@@ -95,8 +95,15 @@ function LauncherMods.deriveList(manifests, options)
|
||||
local byId, enabledSet = {}, {}
|
||||
for _, m in ipairs(ordered) do
|
||||
byId[m.id] = m
|
||||
-- missing entry means enabled, matching the loader's default
|
||||
if mods[m.id] ~= false then enabledSet[m.id] = true end
|
||||
-- missing entry means enabled, matching the loader -- except experimental
|
||||
-- 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
|
||||
|
||||
local out = {}
|
||||
@@ -104,16 +111,19 @@ function LauncherMods.deriveList(manifests, options)
|
||||
local enabled = enabledSet[m.id] == true
|
||||
local status, detail = statusFor(byId, m.id, enabledSet, enabled)
|
||||
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] = {
|
||||
id = m.id,
|
||||
name = m.name or m.id,
|
||||
version = m.version,
|
||||
-- category, then profile, then a generic fallback -- uppercased
|
||||
badge = tostring(raw.category or m.profile or "MOD"):upper(),
|
||||
badge = badge,
|
||||
description = m.description or "",
|
||||
enabled = enabled,
|
||||
status = status,
|
||||
statusDetail = detail,
|
||||
github = m.github,
|
||||
experimental = m.experimental == true,
|
||||
}
|
||||
end
|
||||
return out
|
||||
@@ -168,6 +178,21 @@ function LauncherMods.pickStrays(found, installed)
|
||||
return out
|
||||
end
|
||||
|
||||
-- isReadableRoot(folder, source, cacheRoot) -> is folder already on the
|
||||
-- physfs read path, pure. source is love.filesystem.getSource(), cacheRoot
|
||||
-- the mounted portable game folder (CacheFs.root()); a fused build has both,
|
||||
-- and they are different paths (the archive inside the executable vs the
|
||||
-- folder beside it). Either one's mods/ is readable already, so nothing in
|
||||
-- it is a stray -- and the portable folder must additionally never be handed
|
||||
-- to CacheFs.withMounted: PHYSFS_mount reports success for a directory
|
||||
-- already in the search path WITHOUT adding a second entry, so the paired
|
||||
-- unmount tears down the one real mount and the panel loses every mod in the
|
||||
-- game folder (#413).
|
||||
function LauncherMods.isReadableRoot(folder, source, cacheRoot)
|
||||
if not folder or folder == "" then return false end
|
||||
return folder == source or folder == cacheRoot
|
||||
end
|
||||
|
||||
-- ------- discovery (love.filesystem)
|
||||
|
||||
local function decodeManifest(raw, path)
|
||||
@@ -198,7 +223,9 @@ local function discover()
|
||||
for _, name in ipairs(fs.getDirectoryItems("mods")) do
|
||||
local path = "mods/" .. name
|
||||
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")
|
||||
if raw then
|
||||
local manifest = decodeManifest(raw, path)
|
||||
@@ -216,8 +243,15 @@ end
|
||||
-- options.mods enable-state the loader persists, so a toggle here is what the
|
||||
-- game sees on its next boot.
|
||||
function LauncherMods.list()
|
||||
local options = SaveData.loadOptions()
|
||||
return LauncherMods.deriveList(discover(), options)
|
||||
local ok, result = pcall(function()
|
||||
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
|
||||
|
||||
-- setEnabled(id, enabled): persist options.mods[id] in the exact shape
|
||||
@@ -349,17 +383,20 @@ end
|
||||
local STRAY_MOUNT = "stray_scan"
|
||||
|
||||
-- Run fn(mountedModsRoot) for each game folder that has a readable mods/
|
||||
-- directory, one mount at a time. Folders that are already the physfs source
|
||||
-- are skipped: their mods/ is discoverable by definition, so anything there is
|
||||
-- installed already and not a stray (this is every `love <gamedir>` dev run).
|
||||
-- directory, one mount at a time. Folders already on the read path are
|
||||
-- skipped (isReadableRoot): the physfs source, which is every `love <gamedir>`
|
||||
-- dev run, and the portable game folder CacheFs mounted, where re-mounting is
|
||||
-- what used to drop the mount (#413).
|
||||
local function eachStrayRoot(fn)
|
||||
local SaveData_ = require("src.core.SaveData")
|
||||
local fs = love and love.filesystem
|
||||
if not fs then return end
|
||||
local source = fs.getSource and fs.getSource()
|
||||
local cacheRoot = CacheFs.root()
|
||||
local seen = {}
|
||||
for _, folder in ipairs(SaveData_.gameFolders() or {}) do
|
||||
if not seen[folder] and folder ~= source then
|
||||
if not seen[folder]
|
||||
and not LauncherMods.isReadableRoot(folder, source, cacheRoot) then
|
||||
seen[folder] = true
|
||||
CacheFs.withMounted(folder, STRAY_MOUNT, function()
|
||||
local root = STRAY_MOUNT .. "/mods"
|
||||
@@ -436,13 +473,22 @@ function LauncherMods.strays() return scanStrays(false) end
|
||||
-- on the player's behalf is not this function's call to make.
|
||||
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
|
||||
-- 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
|
||||
-- the save dir is staged into a save-dir temp first, because
|
||||
-- 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
|
||||
return nil, "mod install needs LOVE"
|
||||
end
|
||||
@@ -483,12 +529,24 @@ function LauncherMods.installZip(source)
|
||||
cleanup()
|
||||
return nil, "invalid mod manifest: " .. tostring(manifestErr)
|
||||
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
|
||||
if fs.getInfo(dest) then
|
||||
cleanup()
|
||||
return nil, "a mod named '" .. manifest.id .. "' is already installed"
|
||||
if not opts.replace then
|
||||
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
|
||||
|
||||
-- CacheFs.prefix steers ROM-cache writes into a version subtree (blue/...);
|
||||
@@ -511,6 +569,56 @@ function LauncherMods.installZip(source)
|
||||
return true, manifest.id
|
||||
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
|
||||
-- Removes mods/<id>/ from wherever it was installed (the portable game folder
|
||||
-- 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
|
||||
local path = root .. "/" .. name
|
||||
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)
|
||||
if manifest then
|
||||
if self.mods[manifest.id] then
|
||||
@@ -834,6 +838,18 @@ function Loader:load(data)
|
||||
require("src.mods.Builtins").install(self.content, data)
|
||||
self:_loadState()
|
||||
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
|
||||
mod.enabled = not self.disabled[id]
|
||||
mod.state = mod.enabled and "pending" or "disabled"
|
||||
|
||||
@@ -324,6 +324,12 @@ function ManagerState:detailRows(m)
|
||||
rows[#rows + 1] = { label = Strings("PERMISSIONS.."),
|
||||
action = function() self:goTo("permissions") 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
|
||||
rows[#rows + 1] = { label = Strings("VIEW ERROR.."),
|
||||
action = function() self:goTo("errors") end }
|
||||
@@ -607,13 +613,26 @@ function ManagerState:beginToggle(m)
|
||||
r = ManagerState.resolveToggle(self:manifestMap(), m.id, want,
|
||||
self:enabledSet())
|
||||
end
|
||||
if #r.missing > 0 or #r.conflicts > 0 or #r.badVersion > 0 then
|
||||
self:openBlocked(r)
|
||||
elseif #r.alsoEnable > 0 or #r.alsoDisable > 0 then
|
||||
self:openCascade(r, m, want)
|
||||
else
|
||||
self:commitToggle(r.apply)
|
||||
local function proceed()
|
||||
if #r.missing > 0 or #r.conflicts > 0 or #r.badVersion > 0 then
|
||||
self:openBlocked(r)
|
||||
elseif #r.alsoEnable > 0 or #r.alsoDisable > 0 then
|
||||
self:openCascade(r, m, want)
|
||||
else
|
||||
self:commitToggle(r.apply)
|
||||
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
|
||||
|
||||
function ManagerState:commitToggle(apply)
|
||||
|
||||
+49
-2
@@ -50,6 +50,42 @@ local function parseSpecs(list, field)
|
||||
return specs
|
||||
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)
|
||||
assert(type(raw) == "table", "manifest must be an object")
|
||||
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")
|
||||
: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
|
||||
-- fingerprint unless the manifest says otherwise; content packs are not
|
||||
local affectsLink = profile ~= "content"
|
||||
@@ -97,6 +139,8 @@ function Manifest.validate(raw, path)
|
||||
return value
|
||||
end
|
||||
|
||||
local conflicts = mergeConflictLists(raw.conflicts, raw.incompatible)
|
||||
|
||||
return {
|
||||
id = raw.id,
|
||||
name = raw.name,
|
||||
@@ -106,13 +150,16 @@ function Manifest.validate(raw, path)
|
||||
priority = tonumber(raw.priority) or 0,
|
||||
dependencies = array(raw.dependencies),
|
||||
optional_dependencies = array(raw.optional_dependencies),
|
||||
conflicts = array(raw.conflicts),
|
||||
conflicts = conflicts,
|
||||
incompatible = array(raw.incompatible),
|
||||
dependencySpecs = parseSpecs(array(raw.dependencies), "dependencies"),
|
||||
optionalSpecs = parseSpecs(array(raw.optional_dependencies), "optional_dependencies"),
|
||||
conflictSpecs = parseSpecs(array(raw.conflicts), "conflicts"),
|
||||
conflictSpecs = parseSpecs(conflicts, "conflicts"),
|
||||
category = raw.category or "OTHER",
|
||||
game_version = raw.game_version,
|
||||
description = raw.description or "",
|
||||
github = github,
|
||||
experimental = experimental,
|
||||
profile = profile,
|
||||
affects_link = affectsLink,
|
||||
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),
|
||||
dexEntry = f.opt(f.rec{ kind = f.str, heightFt = f.int(0),
|
||||
heightIn = f.int(0, 11), weight = f.num,
|
||||
heightM = f.opt(f.num), weightKg = f.opt(f.num),
|
||||
text = f.str }),
|
||||
icon = f.opt(f.union{ f.str, f.rec{ image = f.path,
|
||||
frames = f.opt(f.int(1)) } }),
|
||||
@@ -561,6 +562,11 @@ R.trainers = {
|
||||
index = f.opt(f.int(0, 255)),
|
||||
-- unused vanilla classes ship without a pic, so it cannot be required
|
||||
pic = f.opt(f.path),
|
||||
-- Optional Advanced-mode OBJ palette source for a custom trainer portrait.
|
||||
-- It follows the same ROM crosswalk form as sprites.paletteSource.
|
||||
paletteSource = f.opt(f.str),
|
||||
-- Reuse a base trainer class's portrait without redistributing its asset.
|
||||
basePic = f.opt(f.id("trainers")),
|
||||
baseMoney = f.opt(f.int(0)),
|
||||
parties = f.list(f.list(f.rec{ level = f.int(1),
|
||||
species = f.id("pokemon") })),
|
||||
@@ -580,6 +586,10 @@ R.sprites = {
|
||||
frames = f.int(1),
|
||||
walker = f.opt(f.bool),
|
||||
trueColor = f.opt(f.bool),
|
||||
-- Mod art can opt into an existing ROM sprite's Advanced-mode OBJ
|
||||
-- palette assignment without claiming that the image itself came from
|
||||
-- the ROM (which is what `source` documents on imported records).
|
||||
paletteSource = f.opt(f.str),
|
||||
},
|
||||
example = 'mod.content.sprites:register("SPRITE_HERO", { image = "...", frames = 6 })',
|
||||
}
|
||||
|
||||
@@ -18,7 +18,8 @@ local function samePath(path) return path end
|
||||
-- side: "front" | "back"
|
||||
-- opts.mon: the live mon when available (per-instance skins)
|
||||
-- opts.kind: "battle" | "summary" | "dex" | "evolution" | "hof" | "trade"
|
||||
-- | "title" | "oak" | "credits" (informational for wrappers)
|
||||
-- | "title" | "oak" | "credits" | "overworld" (informational
|
||||
-- for wrappers)
|
||||
-- Returns path, trueColor.
|
||||
function Sprites.path(data, species, side, opts)
|
||||
opts = opts or {}
|
||||
|
||||
+149
-45
@@ -5,11 +5,13 @@
|
||||
-- then drawn once per zone through a shader that remaps the four DMG
|
||||
-- shades to that zone's palette.
|
||||
--
|
||||
-- Port display option: COLORS (OG RED / SGB / ADVANCED / OG / OG INV /
|
||||
-- SGB INV / CLASSIC) transforms every zone's palette at send time via
|
||||
-- effectiveColors. ADVANCED (the `redpp` id below) swaps the named-palette
|
||||
-- pack for pokered-gbc SuperPalettes (data/palettes_gbc.lua), including
|
||||
-- per-species mon colors.
|
||||
-- Port display option: COLORS (OG RED / OG BLUE / OG YELLOW / SGB /
|
||||
-- ADVANCED / OG / OG INV / SGB INV / CLASSIC) transforms every zone's
|
||||
-- palette at send time via effectiveColors. ADVANCED (the `redpp` id
|
||||
-- below) swaps the named-palette pack for pokered-gbc SuperPalettes
|
||||
-- (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")
|
||||
|
||||
@@ -17,10 +19,11 @@ local PaletteFX = {}
|
||||
|
||||
local shader -- false = unavailable (headless / no shader support)
|
||||
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
|
||||
-- come first (OG RED = GBC hardware, SGB = per-map Super Game Boy, ADVANCED =
|
||||
-- pokered-gbc per-tile), then the DMG-shade novelty modes.
|
||||
-- come first (OG RED/BLUE/YELLOW = GBC hardware, SGB = per-map Super Game Boy,
|
||||
-- ADVANCED = pokered-gbc per-tile), then the DMG-shade novelty modes.
|
||||
PaletteFX.MODES = { "ogred", "gbc", "redpp", "og", "og_inv", "gbc_inv", "classic" }
|
||||
-- `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
|
||||
@@ -35,6 +38,48 @@ PaletteFX.MODE_LABELS = {
|
||||
}
|
||||
PaletteFX.mode = "gbc"
|
||||
|
||||
-- ------- dark-cave state (wMapPalOffset)
|
||||
--
|
||||
-- Unlike the per-frame shadeMap further down, this outlives a frame: ADVANCED
|
||||
-- resolves real colour per tile and BAKES it (tileset atlas, sprite sheets),
|
||||
-- so FadePal2's shift has to reach those bakes and their cache keys rather
|
||||
-- than a shader (#383). OverworldState owns it: armed before the map's atlas
|
||||
-- is built, cleared by FLASH.
|
||||
local darkWorld = false
|
||||
|
||||
-- returns true when the flag actually changed, so the caller can rebuild
|
||||
function PaletteFX.setDarkWorld(on)
|
||||
on = on and true or false
|
||||
if darkWorld == on then return false end
|
||||
darkWorld = on
|
||||
return true
|
||||
end
|
||||
|
||||
function PaletteFX.darkWorld() return darkWorld end
|
||||
|
||||
-- cache-key suffix for anything baked under the dark shift
|
||||
function PaletteFX.darkKey() return darkWorld and "#dark" or "" end
|
||||
|
||||
-- FadePal2 sets rOBP0 = `dc 3,3,3,2` as well as rBGP, so EVERY OBJ colour a
|
||||
-- sprite can carry lands on shade 3: the player, trainers and item balls are
|
||||
-- black silhouettes until FLASH (#383). Applied to whatever 4-colour OBP the
|
||||
-- active mode resolved, with a distinct cache group so the lit and dark bakes
|
||||
-- of one sheet never collide in SpriteRenderer's obpCache.
|
||||
function PaletteFX.darkObp(colors, group)
|
||||
if not (colors and darkWorld) then return colors, group end
|
||||
return PaletteFX.permute(colors, PaletteFX.DARK_BGP), tostring(group) .. "dark"
|
||||
end
|
||||
|
||||
-- the same shift folded into a baked 8-group world palette array (ADVANCED)
|
||||
local function darkGroups(groups)
|
||||
if not (groups and darkWorld) then return groups end
|
||||
local out = {}
|
||||
for i = 1, #groups do
|
||||
out[i] = PaletteFX.permute(groups[i], PaletteFX.DARK_BGP)
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
-- Classic DMG pea-soup greens (#9BBC0F / #8BAC0F / #306230 / #0F380F)
|
||||
PaletteFX.CLASSIC = {
|
||||
{ 155, 188, 15 }, { 139, 172, 15 }, { 48, 98, 48 }, { 15, 56, 15 },
|
||||
@@ -75,14 +120,19 @@ PaletteFX.GBC_OBJ_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
|
||||
-- identical across versions, so callers that only touch the endpoints
|
||||
-- (e.g. BattleState's zone white/black snap) need no version branch.
|
||||
-- Yellow is CGB-enhanced (pokeyellow CGBBasePalettes) and has no extracted
|
||||
-- boot-ROM auto-palette here; keep the Red ramp -- never Blue's GBC_BG_BLUE.
|
||||
-- playthrough, red for Red. White (index 1) and black (index 4) are
|
||||
-- identical across Red/Blue, so callers that only touch the endpoints
|
||||
-- (e.g. BattleState's zone white/black snap) need no version branch there.
|
||||
-- Yellow is CGB-enhanced (pokeyellow CGBBasePalettes): named zones go through
|
||||
-- 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()
|
||||
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
|
||||
end
|
||||
|
||||
@@ -91,11 +141,13 @@ end
|
||||
-- version-distinct cache-group string, because SpriteRenderer.getObpImage keys
|
||||
-- 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.
|
||||
-- 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()
|
||||
if GameVersion.isBlue() then return PaletteFX.GBC_OBJ_BLUE, "gbcobj_blue" end
|
||||
if GameVersion.isYellow() then return PaletteFX.GBC_OBJ, "gbcobj" end
|
||||
return PaletteFX.GBC_OBJ, "gbcobj"
|
||||
if GameVersion.isBlue() then
|
||||
return PaletteFX.darkObp(PaletteFX.GBC_OBJ_BLUE, "gbcobj_blue")
|
||||
end
|
||||
return PaletteFX.darkObp(PaletteFX.GBC_OBJ, "gbcobj")
|
||||
end
|
||||
|
||||
-- The DMG object ramp every mode except OG RED bakes onto overworld sprites,
|
||||
@@ -114,7 +166,7 @@ PaletteFX.OBP0_SHADES = {
|
||||
}
|
||||
|
||||
function PaletteFX.dmgObj()
|
||||
return PaletteFX.OBP0_SHADES, "obp0"
|
||||
return PaletteFX.darkObp(PaletteFX.OBP0_SHADES, "obp0")
|
||||
end
|
||||
|
||||
local INV_MAP = { [0] = 3, [1] = 2, [2] = 1, [3] = 0 }
|
||||
@@ -225,13 +277,31 @@ function PaletteFX.gbcPack()
|
||||
return gbcPack or nil
|
||||
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)
|
||||
mode = mode or PaletteFX.mode
|
||||
return mode == "redpp"
|
||||
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
|
||||
-- (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 --
|
||||
-- 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
|
||||
@@ -242,6 +312,10 @@ end
|
||||
-- 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.
|
||||
--
|
||||
-- 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
|
||||
-- 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
|
||||
@@ -260,7 +334,7 @@ end
|
||||
-- usesGbcPack() path in SpriteRenderer instead.
|
||||
function PaletteFX.usesSpriteObp(mode)
|
||||
mode = mode or PaletteFX.mode
|
||||
return mode == "ogred"
|
||||
return mode == "ogred" and not GameVersion.isYellow()
|
||||
end
|
||||
|
||||
-- ------- post-zone sprite redraw (OG RED)
|
||||
@@ -317,8 +391,8 @@ end
|
||||
-- read these from the ROM-imported table or the title ribbon stays red
|
||||
-- and the Game Corner reels keep Red's pink (issue #128).
|
||||
-- Yellow is intentionally NOT in BLUE_VERSIONED: skip Blue LOGO1/SLOTS*
|
||||
-- recolors. When CGBBasePalettes were imported (palettes.cgbBase), Yellow
|
||||
-- prefers those over SGB SuperPalettes for named zones.
|
||||
-- recolors. OG YELLOW (usesYellowCgb) reads CGBBasePalettes; SGB mode on
|
||||
-- Yellow keeps SuperPalettes.
|
||||
local BLUE_VERSIONED = {
|
||||
LOGO1 = true, SLOTS2 = true, SLOTS3 = true, SLOTS4 = true,
|
||||
}
|
||||
@@ -330,31 +404,42 @@ end
|
||||
|
||||
local function yellowCgbNamedPal(data, name)
|
||||
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
|
||||
|
||||
-- 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).
|
||||
-- OG RED short-circuits EVERY name to the one global GBC boot-ROM BG palette
|
||||
-- (the hardware had a single BGP for the whole game), so terrain zones,
|
||||
-- battle HP bars / text, and menu boxes all come out red -- everything a
|
||||
-- background tile drew. Objects do not come through here (they bake
|
||||
-- GBC_OBJ green), so this stays a BG-only hook.
|
||||
-- OG RED / OG BLUE short-circuit EVERY name to the one global GBC boot-ROM
|
||||
-- BG palette (the hardware had a single BGP for the whole game), so terrain
|
||||
-- zones, battle HP bars / text, and menu boxes all come out red/blue --
|
||||
-- everything a background tile drew. Objects do not come through here
|
||||
-- (they bake GBC_OBJ), so this stays a BG-only hook.
|
||||
-- OG YELLOW instead resolves each name through CGBBasePalettes.
|
||||
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
|
||||
-- Red keep the active pack / Red-like path -- do not apply Blue recolors.
|
||||
if GameVersion.isBlue() and BLUE_VERSIONED[name] then
|
||||
local fromRom = romNamedPal(data, name)
|
||||
if fromRom then return fromRom end
|
||||
end
|
||||
if GameVersion.isYellow() then
|
||||
if PaletteFX.usesYellowCgb() then
|
||||
local fromCgb = yellowCgbNamedPal(data, name)
|
||||
if fromCgb then return fromCgb end
|
||||
end
|
||||
local p = PaletteFX.pack(data)
|
||||
local c = p and p.palettes[name]
|
||||
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
|
||||
return romNamedPal(data, name)
|
||||
end
|
||||
@@ -367,14 +452,20 @@ end
|
||||
-- Transformed mon's pic is tinted gray, not the copied species' own
|
||||
-- SGB color). RED++ uses per-species pals from mon_palettes.asm.
|
||||
function PaletteFX.monPal(data, species, transformed)
|
||||
-- OG RED: a battle mon pic is a BG tile on the Game Boy Color (drawn into
|
||||
-- the tilemap, colored by BGP), so it wears the global red BG palette, not
|
||||
-- a per-species one -- matching the hardware capture where both mons are
|
||||
-- red/pink on the white field.
|
||||
if PaletteFX.mode == "ogred" then return PaletteFX.ogBg() end
|
||||
-- OG RED / OG BLUE: a battle mon pic is a BG tile on the Game Boy Color
|
||||
-- (drawn into the tilemap, colored by BGP), so it wears the global boot-ROM
|
||||
-- BG palette, not a per-species one -- matching the hardware capture where
|
||||
-- both mons are red/pink (or blue/pink) on the white field.
|
||||
-- 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)
|
||||
if not p then return nil end
|
||||
if transformed then
|
||||
if PaletteFX.usesYellowCgb() then
|
||||
return PaletteFX.pal(data, "GRAYMON")
|
||||
end
|
||||
return p.palettes.GRAYMON
|
||||
or (data and data.palettes and data.palettes.palettes.GRAYMON)
|
||||
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.
|
||||
local def = data and data.pokemon and data.pokemon[species]
|
||||
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]
|
||||
or (data and data.palettes and data.palettes.palettes[def.palette])
|
||||
if pal then return pal end
|
||||
end
|
||||
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]
|
||||
if c then return c end
|
||||
if PaletteFX.usesGbcPack() and data and data.palettes then
|
||||
name = data.palettes.pokemon[species] or "MEWMON"
|
||||
return data.palettes.palettes[name]
|
||||
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
|
||||
end
|
||||
|
||||
@@ -503,7 +609,7 @@ function PaletteFX.worldGroupColors(data, tileset, mapId, playerCellY)
|
||||
local w = pack and pack.world
|
||||
local base = w and w.groupColors[tileset]
|
||||
if not base then return nil end
|
||||
if not w.roofGroup[tileset] then return base end
|
||||
if not w.roofGroup[tileset] then return darkGroups(base) end
|
||||
local roofMapId = mapId
|
||||
if mapId == ROUTE_6_SAFFRON.mapId and playerCellY
|
||||
and playerCellY < ROUTE_6_SAFFRON.cellYBelow then
|
||||
@@ -511,7 +617,7 @@ function PaletteFX.worldGroupColors(data, tileset, mapId, playerCellY)
|
||||
end
|
||||
local roofMap = data and data.maps and data.maps[roofMapId]
|
||||
local roof = roofMap and w.roofByMapIndex[roofMap.index]
|
||||
if not roof then return base end
|
||||
if not roof then return darkGroups(base) end
|
||||
local out = {}
|
||||
for i = 1, 8 do out[i] = base[i] end
|
||||
-- LoadTownPalette only overwrites W2_BgPaletteData + $32, i.e. colors 1
|
||||
@@ -521,7 +627,7 @@ function PaletteFX.worldGroupColors(data, tileset, mapId, playerCellY)
|
||||
-- material's 2 middle shades are town-specific
|
||||
local base4 = base[ROOF_GROUP + 1]
|
||||
out[ROOF_GROUP + 1] = { base4[1], roof[1], roof[2], base4[4] }
|
||||
return out
|
||||
return darkGroups(out)
|
||||
end
|
||||
|
||||
-- an overworld sprite's resolved 4-color OBJ palette (ColorOverworldSprite),
|
||||
@@ -537,7 +643,7 @@ end
|
||||
function PaletteFX.spriteObp(spriteDef, seed)
|
||||
local pack = PaletteFX.gbcPack()
|
||||
local w = pack and pack.world
|
||||
local src = spriteDef and spriteDef.source
|
||||
local src = spriteDef and (spriteDef.paletteSource or spriteDef.source)
|
||||
if not (w and src) then return nil end
|
||||
local idx = tonumber(src:match("%[(%d+)%]"))
|
||||
-- RedBikeSprite loads outside SpriteSheetPointerTable
|
||||
@@ -552,7 +658,7 @@ function PaletteFX.spriteObp(spriteDef, seed)
|
||||
for i = 1, #seed do h = (h * 31 + seed:byte(i)) % 4294967296 end
|
||||
group = h % 4
|
||||
end
|
||||
return w.spritePalettes[group], group
|
||||
return PaletteFX.darkObp(w.spritePalettes[group], group)
|
||||
end
|
||||
|
||||
-- GetHealthBarColor (home/palettes.asm) on the standard 48px bar
|
||||
@@ -661,12 +767,10 @@ end
|
||||
|
||||
function PaletteFX.modeLabel(mode)
|
||||
mode = mode or PaletteFX.mode
|
||||
-- The GBC boot-ROM mode wears the running game's name: it is red for Red and
|
||||
-- blue for Blue (see ogBg), so a Blue playthrough shows "OG BLUE".
|
||||
-- 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.
|
||||
-- The GBC hardware mode wears the running game's name: OG RED / OG BLUE
|
||||
-- (boot-ROM auto-palette) or OG YELLOW (pokeyellow CGBBasePalettes).
|
||||
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"
|
||||
end
|
||||
|
||||
|
||||
+25
-4
@@ -437,12 +437,26 @@ function Renderer:drawTiltedWorld(zoneList, sx, sy, wox, woy, target)
|
||||
return true
|
||||
end
|
||||
|
||||
-- clamp a scissor rect to the viewport box
|
||||
local function scissorClamped(x, y, w, h, ox, oy, vpw, vph)
|
||||
-- Clamp a scissor rect to the viewport box, then round it outward to whole
|
||||
-- framebuffer pixels. love.graphics.setScissor truncates x, y, w and h to
|
||||
-- pixels independently, so a rect with fractional unit edges (Android's
|
||||
-- non-integer DPI puts fitScale/dpi in Sx/Sy) loses up to a pixel per side
|
||||
-- and two adjacent SGB zones stop sharing an edge: the letterbox clear shows
|
||||
-- through as a horizontal seam at every zone boundary (#373). Rounding
|
||||
-- outward makes neighbours overlap by at most one row instead -- the overlap
|
||||
-- redraws the same canvas pixels one palette later, and past the canvas edge
|
||||
-- there is nothing to draw. The half pixel keeps LOVE's truncation on the
|
||||
-- snapped edge rather than one short of it.
|
||||
local function scissorClamped(x, y, w, h, ox, oy, vpw, vph, dpiX, dpiY)
|
||||
local x2, y2 = math.min(x + w, ox + vpw), math.min(y + h, oy + vph)
|
||||
x, y = math.max(x, ox), math.max(y, oy)
|
||||
if x2 <= x or y2 <= y then return false end
|
||||
love.graphics.setScissor(x, y, x2 - x, y2 - y)
|
||||
dpiX, dpiY = dpiX or 1, dpiY or 1
|
||||
local px1, py1 = math.floor(x * dpiX), math.floor(y * dpiY)
|
||||
local px2, py2 = math.ceil(x2 * dpiX), math.ceil(y2 * dpiY)
|
||||
love.graphics.setScissor((px1 + 0.5) / dpiX, (py1 + 0.5) / dpiY,
|
||||
(px2 - px1 + 0.5) / dpiX,
|
||||
(py2 - py1 + 0.5) / dpiY)
|
||||
return true
|
||||
end
|
||||
|
||||
@@ -572,7 +586,7 @@ function Renderer:endFrame(zones, worldZones)
|
||||
if not plain then PaletteFX.sendColors(shader, z.colors) end
|
||||
if scissorClamped(bx + z.x * zoneSx, by + z.y * zoneSy,
|
||||
z.w * zoneSx, z.h * zoneSy,
|
||||
boxX, boxY, boxW, boxH) then
|
||||
boxX, boxY, boxW, boxH, dpiX, dpiY) then
|
||||
love.graphics.draw(canvas, bx, by, 0, sx, sy)
|
||||
end
|
||||
end
|
||||
@@ -706,6 +720,13 @@ function Renderer:endFrame(zones, worldZones)
|
||||
self.uprightActive = false
|
||||
self.worldOverride = nil
|
||||
PaletteFX.setPass(nil)
|
||||
return {
|
||||
width = ww, height = wh,
|
||||
gameX = ox, gameY = oy,
|
||||
gameWidth = vpw, gameHeight = vph,
|
||||
scale = Sp,
|
||||
dpiX = dpiX, dpiY = dpiY,
|
||||
}
|
||||
end
|
||||
|
||||
return Renderer
|
||||
|
||||
@@ -135,7 +135,10 @@ local function blitFrame(image, quad, x, y, flip, redraw)
|
||||
end
|
||||
end
|
||||
|
||||
function SpriteRenderer:draw(px, py, camX, camY, facing, walkPhase, stepFlip)
|
||||
-- topHalf blits only the upper 8 rows of the frame: FishingAnim overwrites the
|
||||
-- bottom tile row of the standing frames with the fishing pose art, which the
|
||||
-- caller then draws itself through :drawTile (Player:draw, #384)
|
||||
function SpriteRenderer:draw(px, py, camX, camY, facing, walkPhase, stepFlip, topHalf)
|
||||
local x = math.floor(px - camX)
|
||||
local y = math.floor(py - camY) - 4
|
||||
local image = self.image
|
||||
@@ -190,7 +193,39 @@ function SpriteRenderer:draw(px, py, camX, camY, facing, walkPhase, stepFlip)
|
||||
flip = true
|
||||
end
|
||||
local quad = self.frames[frame] or self.frames[0]
|
||||
if topHalf then
|
||||
self.halfFrames = self.halfFrames or {}
|
||||
if not self.halfFrames[frame] then
|
||||
local iw, ih = self.image:getDimensions()
|
||||
self.halfFrames[frame] = love.graphics.newQuad(0, frame * 16, 16, 8, iw, ih)
|
||||
end
|
||||
quad = self.halfFrames[frame]
|
||||
end
|
||||
blitFrame(image, quad, x, y, flip, redraw)
|
||||
end
|
||||
|
||||
-- Blit a loose 16-wide fx tile at screen (x, y) wearing THIS sprite's OBJ
|
||||
-- palette, mirroring the mode branches in :draw above. The fishing pose row
|
||||
-- overwrites the sheet's own tiles in VRAM in the original, so it has to be
|
||||
-- recolored and OG-RED-redrawn exactly like the sheet rather than blitted as
|
||||
-- raw DMG shades (#384).
|
||||
function SpriteRenderer:drawTile(path, x, y, flip)
|
||||
local image, redraw = getImage(path), false
|
||||
if self.def.trueColor then
|
||||
PaletteFX.markTrueColor(x, y, 16, 8)
|
||||
elseif PaletteFX.usesGbcPack() then
|
||||
local colors, group = PaletteFX.spriteObp(self.def, self.seed)
|
||||
if colors then image = getObpImage(path, colors, group) end
|
||||
elseif PaletteFX.usesSpriteObp() and PaletteFX.spriteRedrawPassActive() then
|
||||
image, redraw = getObpImage(path, PaletteFX.ogObj()), true
|
||||
else
|
||||
image = getObpImage(path, PaletteFX.dmgObj())
|
||||
end
|
||||
local iw, ih = image:getDimensions()
|
||||
self.tileQuads = self.tileQuads or {}
|
||||
self.tileQuads[path] = self.tileQuads[path]
|
||||
or love.graphics.newQuad(0, 0, iw, ih, iw, ih)
|
||||
blitFrame(image, self.tileQuads[path], x, y, flip, redraw)
|
||||
end
|
||||
|
||||
return SpriteRenderer
|
||||
|
||||
@@ -402,8 +402,15 @@ end
|
||||
-- the route's own default roof (Vermilion's) throughout.
|
||||
local gbcAtlasCache = {}
|
||||
|
||||
-- Cache suffix for a map's RED++ bake. A dark cave folds FadePal2 into the
|
||||
-- palette worldGroupColors hands the bake (#383), so the lit and dark bakes of
|
||||
-- one map are different images and must not share a key.
|
||||
local function gbcKeyFor(mapId)
|
||||
return "#gbc:" .. mapId .. PaletteFX.darkKey()
|
||||
end
|
||||
|
||||
local function getGbcAtlas(imagePath, tilesetId, mapId, perRow, data)
|
||||
local key = imagePath .. "#gbc:" .. mapId
|
||||
local key = imagePath .. gbcKeyFor(mapId)
|
||||
if gbcAtlasCache[key] ~= nil then return gbcAtlasCache[key] or nil end
|
||||
local img = false
|
||||
if love.image and love.image.newImageData then
|
||||
@@ -473,7 +480,7 @@ function TileRenderer.new(map, data)
|
||||
self.gbcAtlas = true
|
||||
-- also recolors the animated water/flower entries below, so they
|
||||
-- match the atlas's static tiles instead of showing raw grayscale
|
||||
gbcCtx = { tilesetId = map.tileset.id, mapId = map.id, key = "#gbc:" .. map.id,
|
||||
gbcCtx = { tilesetId = map.tileset.id, mapId = map.id, key = gbcKeyFor(map.id),
|
||||
groupColors = PaletteFX.worldGroupColors(data, map.tileset.id, map.id, nil) }
|
||||
-- ...and feeds the color-0-keyed single tiles the feet overdraw needs
|
||||
-- (see getKeyedTile): same source image and palette groups, so keep the
|
||||
@@ -481,6 +488,7 @@ function TileRenderer.new(map, data)
|
||||
gbcCtx.imagePath = map.tileset.image
|
||||
gbcCtx.perRow = map.tileset.tilesPerRow
|
||||
self.gbcCtx = gbcCtx
|
||||
self.gbcAtlasKey = map.tileset.image .. gbcCtx.key
|
||||
self.gbcKeyed = {}
|
||||
end
|
||||
end
|
||||
@@ -582,7 +590,7 @@ local function ensureWaterBorderFill(self)
|
||||
local groupColors = PaletteFX.worldGroupColors(
|
||||
self.data, map.tileset.id, map.id, nil)
|
||||
colors = group and groupColors and groupColors[group + 1] or nil
|
||||
gbcKey = "#gbc:" .. map.id
|
||||
gbcKey = gbcKeyFor(map.id)
|
||||
end
|
||||
local textures = getShiftVariants(map.tileset.image, perRow, WATER_TILE,
|
||||
colors, gbcKey)
|
||||
@@ -913,8 +921,9 @@ function TileRenderer:release()
|
||||
self.gbcCtx = nil
|
||||
end
|
||||
if self.gbcAtlas and self.image then
|
||||
local key = self.map.tileset.image .. "#gbc:" .. self.map.id
|
||||
local key = self.gbcAtlasKey or (self.map.tileset.image .. gbcKeyFor(self.map.id))
|
||||
if gbcAtlasCache[key] == self.image then gbcAtlasCache[key] = nil end
|
||||
self.gbcAtlasKey = nil
|
||||
safeRelease(self.image)
|
||||
self.image = nil
|
||||
self.gbcAtlas = nil
|
||||
|
||||
@@ -10,6 +10,13 @@ local Zoom = {}
|
||||
|
||||
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
|
||||
-- through 2× fit). zoom.range may widen or shrink the window.
|
||||
function Zoom.offsetRange(S)
|
||||
@@ -21,6 +28,9 @@ function Zoom.offsetRange(S)
|
||||
hi = math.floor(tonumber(hi) or S)
|
||||
if lo > hi then lo, hi = hi, lo 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
|
||||
end
|
||||
|
||||
|
||||
@@ -84,6 +84,15 @@ O.coins = O.mainData + 685 -- 2B BCD
|
||||
-- (wWalkBikeSurfState) + 10 = 359, so 685 + 359 = 1044 as well.
|
||||
O.townVisited = O.mainData + 1044 -- 2B (flag_array NUM_CITY_MAPS)
|
||||
O.eventFlags = O.mainData + 1104 -- 320B (flag_array NUM_EVENTS = 2560 bits)
|
||||
-- Progress bits vanilla keeps OUTSIDE wEventFlags that this port still spells
|
||||
-- as save.flags entries (#396). Offsets walk forward from wTownVisitedFlag
|
||||
-- over the same ram/wram.asm declaration run the 60-byte gap above sums:
|
||||
-- +29 wStatusFlags1, +35 wStatusFlags4, +41 wElite4Flags,
|
||||
-- +44 wCompletedInGameTradeFlags.
|
||||
O.statusFlags1 = O.townVisited + 29 -- 1B
|
||||
O.statusFlags4 = O.townVisited + 35 -- 1B
|
||||
O.elite4Flags = O.townVisited + 41 -- 1B
|
||||
O.tradeFlags = O.townVisited + 44 -- 2B (flag_array NUM_NPC_TRADES)
|
||||
-- Play time (wPlayTimeHours/Maxed/Minutes/Seconds/Frames) lives INSIDE the
|
||||
-- sMainData window (wMainDataStart..wMainDataEnd is copied verbatim into
|
||||
-- SRAM), 1866 bytes past wMainDataStart -- reached from the checksum-verified
|
||||
@@ -314,6 +323,34 @@ local BADGE_BY_BIT = {
|
||||
local BADGE_BY_BIT_SET = {}
|
||||
for _, name in pairs(BADGE_BY_BIT) do BADGE_BY_BIT_SET[name] = true end
|
||||
|
||||
-- save.flags names whose vanilla home is NOT wEventFlags (#396: exporting a
|
||||
-- save and importing it back made the Saffron gate guards thirsty again,
|
||||
-- because BIT_GAVE_SAFFRON_GUARDS_DRINK is a wStatusFlags1 bit and nothing
|
||||
-- carried it). Bit numbers are constants/ram_constants.asm; the trade bits
|
||||
-- are wWhichTrade, which engine/events/in_game_trades.asm uses to index
|
||||
-- wCompletedInGameTradeFlags, i.e. the data/events/trades.asm row order the
|
||||
-- port's `trade` command takes 1-based.
|
||||
local EXTRA_FLAG_BITS = {
|
||||
EVENT_GOT_OLD_ROD = { O.statusFlags1, 3 },
|
||||
EVENT_GOT_GOOD_ROD = { O.statusFlags1, 4 },
|
||||
EVENT_GOT_SUPER_ROD = { O.statusFlags1, 5 },
|
||||
EVENT_GAVE_GUARDS_DRINK = { O.statusFlags1, 6 },
|
||||
EVENT_GOT_LAPRAS = { O.statusFlags4, 0 },
|
||||
EVENT_STARTED_ELITE_4 = { O.elite4Flags, 1 },
|
||||
EVENT_TRADED_NIDORINO_FOR_NIDORINA = { O.tradeFlags, 0 },
|
||||
EVENT_TRADED_ABRA_FOR_MR_MIME = { O.tradeFlags, 1 },
|
||||
EVENT_TRADED_PONYTA_FOR_SEEL = { O.tradeFlags, 3 },
|
||||
EVENT_TRADED_SPEAROW_FOR_FARFETCHD = { O.tradeFlags, 4 },
|
||||
EVENT_TRADED_SLOWBRO_FOR_LICKITUNG = { O.tradeFlags, 5 },
|
||||
EVENT_TRADED_POLIWHIRL_FOR_JYNX = { O.tradeFlags, 6 },
|
||||
EVENT_TRADED_RAICHU_FOR_ELECTRODE = { O.tradeFlags, 7 },
|
||||
EVENT_TRADED_VENONAT_FOR_TANGELA = { O.tradeFlags, 8 },
|
||||
EVENT_TRADED_NIDORAN_M_FOR_NIDORAN_F = { O.tradeFlags, 9 },
|
||||
}
|
||||
|
||||
-- port-local name -> the wEventFlags name it means (#396)
|
||||
local FLAG_ALIAS = { EVENT_RECEIVED_BIKE_VOUCHER = "EVENT_GOT_BIKE_VOUCHER" }
|
||||
|
||||
-- STATUS_* bits (constants/battle_constants.asm): 0-2 sleep-turns-left,
|
||||
-- 3 PSN, 4 BRN, 5 FRZ, 6 PAR
|
||||
local STATUS_BIT = { PSN = 3, BRN = 4, FRZ = 5, PAR = 6 }
|
||||
@@ -671,6 +708,14 @@ function GenSave.decode(bytes, data, opts)
|
||||
end
|
||||
end
|
||||
|
||||
-- the same progress under names that are not wEventFlags bits (#396)
|
||||
for name, spec in pairs(EXTRA_FLAG_BITS) do
|
||||
if bitGet(bytes, spec[1], spec[2]) then save.flags[name] = true end
|
||||
end
|
||||
for portName, vanillaName in pairs(FLAG_ALIAS) do
|
||||
if save.flags[vanillaName] then save.flags[portName] = true end
|
||||
end
|
||||
|
||||
-- FLY destinations. wTownVisitedFlag's bit index IS the town's map index:
|
||||
-- engine/items/town_map.asm BuildFlyLocationsList loads the 16-bit value
|
||||
-- into de and rotates it right one bit per iteration with b counting up
|
||||
@@ -794,6 +839,19 @@ function GenSave.encode(save, data, template)
|
||||
local bitIdx = events.byName[name]
|
||||
if bitIdx then bitSet(buf, O.eventFlags, bitIdx, true) end
|
||||
end
|
||||
for portName, vanillaName in pairs(FLAG_ALIAS) do
|
||||
local bitIdx = events.byName[vanillaName]
|
||||
if bitIdx and save.flags[portName] then bitSet(buf, O.eventFlags, bitIdx, true) end
|
||||
end
|
||||
end
|
||||
|
||||
-- Non-wEventFlags progress, written both ways: this port's save is the only
|
||||
-- authority for these names, so a flag it does not hold must clear the
|
||||
-- template's bit rather than survive in the export (#396).
|
||||
if save.flags then
|
||||
for name, spec in pairs(EXTRA_FLAG_BITS) do
|
||||
bitSet(buf, spec[1], spec[2], save.flags[name] and true or false)
|
||||
end
|
||||
end
|
||||
|
||||
-- FLY destinations back into wTownVisitedFlag (see the decode note), so a
|
||||
|
||||
@@ -11,9 +11,14 @@
|
||||
-- tables load through `require`, exactly how src/core/Data.lua pulls the
|
||||
-- generated modules -- which resolves under both plain luajit (package.path
|
||||
-- "./?.lua") for the headless CLI/tests and love.filesystem for a fused
|
||||
-- build, with an OS-path fallback for odd working directories. The only
|
||||
-- place `love` is referenced is inside a guarded fallback, so running under
|
||||
-- stock Lua never touches it.
|
||||
-- build, with an OS-path fallback for odd working directories. `love` is only
|
||||
-- ever referenced inside guarded fallbacks, so running under stock Lua never
|
||||
-- touches it.
|
||||
--
|
||||
-- When a caller names the game a save belongs to, the generated tables come
|
||||
-- out of that version's ROM cache through CacheFs instead: the launcher does
|
||||
-- its importing before the cache is mounted onto the un-prefixed paths, so
|
||||
-- require alone cannot see them there (#420).
|
||||
|
||||
local GenSave = require("src.save_convert.GenSave")
|
||||
|
||||
@@ -63,20 +68,59 @@ local function loadTable(requirePath, filePath)
|
||||
:format(requirePath, requirePath, filePath)
|
||||
end
|
||||
|
||||
local crosswalk -- { pokemon=, moves=, items=, maps=, eventFlags= }
|
||||
-- The four generated tables live in one game's ROM cache, and the launcher
|
||||
-- reaches this code before that cache is on the un-prefixed read path:
|
||||
-- CacheFs.mountVersion only runs from main.lua's bootGame (after Play), and
|
||||
-- Blue/Yellow keep their cache under GameVersion.cachePrefix. So a bare
|
||||
-- require sees Red's copy at best, and nothing at all in a fused portable
|
||||
-- build (the game folder is only readable through CacheFs's PhysFS mount) --
|
||||
-- read the tables out of the cache whenever the caller names the game the
|
||||
-- save belongs to, and let the require path above cover everything else
|
||||
-- (#420).
|
||||
local function loadCacheTable(gameVersion, filePath)
|
||||
if not (gameVersion and love and love.filesystem) then return nil end
|
||||
if not filePath:match("^data/generated/") then return nil end
|
||||
local okc, CacheFs = pcall(require, "src.import.CacheFs")
|
||||
local okg, GameVersion = pcall(require, "src.core.GameVersion")
|
||||
if not (okc and okg and type(CacheFs) == "table") then return nil end
|
||||
local info = GameVersion.VERSIONS[gameVersion]
|
||||
if not info then return nil end
|
||||
-- CacheFs.prefix is launcher-owned global state (it points at whatever
|
||||
-- import last ran), so borrow it for this read and put it back.
|
||||
local saved = CacheFs.prefix
|
||||
CacheFs.prefix = info.cachePrefix
|
||||
local okr, bytes = pcall(CacheFs.read, filePath)
|
||||
CacheFs.prefix = saved
|
||||
if not (okr and type(bytes) == "string") then return nil end
|
||||
local chunk = loadstring(bytes, "@" .. info.cachePrefix .. filePath)
|
||||
if not chunk then return nil end
|
||||
local okx, mod = pcall(chunk)
|
||||
if okx and type(mod) == "table" then return mod end
|
||||
return nil
|
||||
end
|
||||
|
||||
-- Crosswalk sets keyed by the game whose cache they came from ("*" for the
|
||||
-- require-resolved set): Yellow's tables are not Red's, so one import must
|
||||
-- never be handed the previous import's data (#420).
|
||||
local crosswalks = {} -- [key] = { pokemon=, moves=, items=, maps=, eventFlags= }
|
||||
local charmapReady
|
||||
|
||||
local function ensureData()
|
||||
if not crosswalk then
|
||||
local function ensureData(gameVersion)
|
||||
local key = gameVersion or "*"
|
||||
if not crosswalks[key] then
|
||||
local data = {}
|
||||
for key, spec in pairs(DATA_MODULES) do
|
||||
if key ~= "charmap" then
|
||||
local mod, e = loadTable(spec[1], spec[2])
|
||||
if not mod then return nil, e end
|
||||
data[key] = mod
|
||||
for name, spec in pairs(DATA_MODULES) do
|
||||
if name ~= "charmap" then
|
||||
local mod = loadCacheTable(gameVersion, spec[2])
|
||||
if not mod then
|
||||
local e
|
||||
mod, e = loadTable(spec[1], spec[2])
|
||||
if not mod then return nil, e end
|
||||
end
|
||||
data[name] = mod
|
||||
end
|
||||
end
|
||||
crosswalk = data
|
||||
crosswalks[key] = data
|
||||
end
|
||||
if not charmapReady then
|
||||
local cm, err = loadTable(DATA_MODULES.charmap[1], DATA_MODULES.charmap[2])
|
||||
@@ -84,13 +128,14 @@ local function ensureData()
|
||||
GenSave.setCharmap(cm)
|
||||
charmapReady = true
|
||||
end
|
||||
return crosswalk
|
||||
return crosswalks[key]
|
||||
end
|
||||
|
||||
-- Exposed for the CLI/tests so they can share the exact data set the codec
|
||||
-- uses (and so a caller can pre-warm the cache). Returns data, err.
|
||||
function SaveConvert.loadData()
|
||||
return ensureData()
|
||||
-- uses (and so a caller can pre-warm the cache). gameVersion picks whose ROM
|
||||
-- cache the generated tables come from. Returns data, err.
|
||||
function SaveConvert.loadData(gameVersion)
|
||||
return ensureData(gameVersion)
|
||||
end
|
||||
|
||||
-- ------------------------------------------------------------------
|
||||
@@ -108,7 +153,8 @@ local function defaultsSave()
|
||||
options = {
|
||||
textSpeed = 3, animations = true, battleStyle = "shift",
|
||||
battleLayout = "og",
|
||||
ruleset = "gen1_faithful", musicVol = 7, sfxVol = 7, musicFilter = 0,
|
||||
ruleset = "gen1_faithful", musicVol = 7, sfxVol = 7, pikaVol = 7,
|
||||
musicFilter = 0,
|
||||
speed = 1, colors = "gbc", tilt = 0, gbcfx = 0,
|
||||
videoMode = "windowed", mods = {},
|
||||
},
|
||||
@@ -116,13 +162,12 @@ local function defaultsSave()
|
||||
end
|
||||
|
||||
-- Merge a GenSave.decode() result over the new-game defaults, exactly the
|
||||
-- way convert.lua did, then stamp the requested version. The 32768-byte
|
||||
-- import template GenSave stashes as `rawImport` and the decode `warnings`
|
||||
-- are dropped here: neither belongs in a serialized slot file (a fresh
|
||||
-- export always starts zero-filled -- see GenSave.lua's header).
|
||||
-- way convert.lua did, then stamp the requested version. Keep the imported
|
||||
-- SRAM image with the slot: Pokémon Red restores its saved current-map cache
|
||||
-- before Continue, and an export needs that unmodeled data to remain bootable.
|
||||
-- Decode warnings are only import diagnostics and do not belong in the slot.
|
||||
local function mergeDefaults(decoded, version)
|
||||
decoded.warnings = nil
|
||||
decoded.rawImport = nil
|
||||
local save = defaultsSave()
|
||||
for k, v in pairs(decoded) do save[k] = v end
|
||||
save.lastHeal = { map = save.player.map, x = save.player.x, y = save.player.y }
|
||||
@@ -139,20 +184,22 @@ SaveConvert.mergeDefaults = mergeDefaults
|
||||
-- Public API
|
||||
-- ------------------------------------------------------------------
|
||||
|
||||
-- importSav(bytes, version) -> saveTable, err
|
||||
-- importSav(bytes, version, gameVersion) -> saveTable, err
|
||||
-- bytes: the raw 32768-byte SRAM string. Validates size and the main-data
|
||||
-- checksum, decodes through GenSave, and returns a save table fully merged
|
||||
-- over the new-game defaults and tagged with `version`, ready to hand to
|
||||
-- SaveSerializer.encode for a slot file. On any failure returns nil + a
|
||||
-- message (never raises).
|
||||
function SaveConvert.importSav(bytes, version)
|
||||
-- SaveSerializer.encode for a slot file. gameVersion ("red"/"blue"/"yellow")
|
||||
-- names the game the save is being imported for, which is what selects the
|
||||
-- crosswalk tables; omit it to take whatever `require` resolves. On any
|
||||
-- failure returns nil + a message (never raises).
|
||||
function SaveConvert.importSav(bytes, version, gameVersion)
|
||||
if type(bytes) ~= "string" then
|
||||
return nil, "expected raw save bytes as a string"
|
||||
end
|
||||
if #bytes ~= GenSave.SAVE_SIZE then
|
||||
return nil, ("save must be %d bytes, got %d"):format(GenSave.SAVE_SIZE, #bytes)
|
||||
end
|
||||
local data, derr = ensureData()
|
||||
local data, derr = ensureData(gameVersion)
|
||||
if not data then return nil, derr end
|
||||
|
||||
local ok, decoded = pcall(GenSave.decode, bytes, data)
|
||||
@@ -171,16 +218,17 @@ function SaveConvert.importSav(bytes, version)
|
||||
return mergeDefaults(decoded, version)
|
||||
end
|
||||
|
||||
-- exportSav(saveTable) -> bytes, err
|
||||
-- exportSav(saveTable, gameVersion) -> bytes, err
|
||||
-- Encodes a save table back to a raw 32768-byte SRAM image. Template-aware:
|
||||
-- if the table still carries the stashed import template (saveTable.rawImport)
|
||||
-- GenSave reproduces every unmodeled region from it; otherwise those regions
|
||||
-- are zero-filled. On failure returns nil + a message (never raises).
|
||||
function SaveConvert.exportSav(saveTable)
|
||||
-- are zero-filled. gameVersion selects the crosswalk tables exactly as in
|
||||
-- importSav. On failure returns nil + a message (never raises).
|
||||
function SaveConvert.exportSav(saveTable, gameVersion)
|
||||
if type(saveTable) ~= "table" then
|
||||
return nil, "expected a save table"
|
||||
end
|
||||
local data, derr = ensureData()
|
||||
local data, derr = ensureData(gameVersion)
|
||||
if not data then return nil, derr end
|
||||
local ok, bytes = pcall(GenSave.encode, saveTable, data, nil)
|
||||
if not ok then return nil, "encode failed: " .. tostring(bytes) end
|
||||
|
||||
+32
-5
@@ -225,11 +225,23 @@ function Commands.give_item(ctx, itemId, count, gotText)
|
||||
ctx.game.stringBuffer = def and def.name or itemId
|
||||
-- the jingle rides the box -- Sound.play routes fanfares through
|
||||
-- Music.duckForFanfare, like PlaySoundWaitForCurrent
|
||||
require("src.core.Sound").play(ctx.game.data,
|
||||
(def and def.keyItem) and "Get_Key_Item" or "Get_Item1")
|
||||
local Sound = require("src.core.Sound")
|
||||
local jingle = (def and def.keyItem) and "Get_Key_Item" or "Get_Item1"
|
||||
if gotText ~= false then
|
||||
-- the gift texts carry the jingle as a trailing text command
|
||||
-- (sound_get_item_1 / sound_get_key_item -> home/text.asm
|
||||
-- TextCommand_SOUND), so it only fires once the last page has typed
|
||||
-- out, blocks on WaitForSoundToFinish, and AfterDisplayingTextID's
|
||||
-- button wait runs after it (#374)
|
||||
ctx.textOpts = ctx.textOpts or {}
|
||||
ctx.textOpts.auto = {
|
||||
sound = function() return Sound.play(ctx.game.data, jingle) end,
|
||||
wait = true,
|
||||
}
|
||||
Commands.show_text(ctx, gotText
|
||||
or Strings("{PLAYER} got\n%s!", ctx.game.stringBuffer))
|
||||
else
|
||||
Sound.play(ctx.game.data, jingle)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -253,7 +265,18 @@ function Commands.start_battle(ctx, kind, a, b)
|
||||
ctx.lastBattleResult = result
|
||||
ctx.lastCheck = result == "win"
|
||||
if ctx.overworld then
|
||||
ctx.overworld:afterBattle(result, battle)
|
||||
-- A map script often follows a trainer battle with its own text.
|
||||
-- Keep a level evolution behind that text: otherwise afterBattle
|
||||
-- pushes the evolution screen, then this runner resumes and pushes
|
||||
-- the trainer's text on top of it.
|
||||
if result == "win" then
|
||||
ctx.afterScript = ctx.afterScript or {}
|
||||
table.insert(ctx.afterScript, function()
|
||||
ctx.overworld:afterBattle(result, battle)
|
||||
end)
|
||||
else
|
||||
ctx.overworld:afterBattle(result, battle)
|
||||
end
|
||||
end
|
||||
runner:resume()
|
||||
end
|
||||
@@ -711,11 +734,15 @@ function Commands.record_hall_of_fame(ctx)
|
||||
if game.overworld then
|
||||
game.overworld.lastOutdoor = ctx.save.lastOutdoor
|
||||
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;
|
||||
-- re-apply home and persist so CONTINUE resumes in the bedroom.
|
||||
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)
|
||||
runner:yield()
|
||||
|
||||
@@ -125,6 +125,10 @@ function ScriptRunner:run(script, extra)
|
||||
end
|
||||
self.co = coroutine.create(function()
|
||||
self:exec(script, ctx)
|
||||
-- Commands can defer a game action until the script's own dialogue is
|
||||
-- done. start_battle uses this for win-path evolutions, which must not
|
||||
-- be covered by post-battle trainer text.
|
||||
for _, callback in ipairs(ctx.afterScript or {}) do callback() end
|
||||
if ctx.onDone then ctx.onDone() end
|
||||
if Runtime.wants("script.ended") then
|
||||
Runtime.emit("script.ended", { ctx = ctx, completed = true })
|
||||
|
||||
+16
-3
@@ -93,6 +93,17 @@ local function useOn(game, battle, id, target, list, moveIndex, picker)
|
||||
end
|
||||
|
||||
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()
|
||||
local ow = game.overworld
|
||||
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
|
||||
-- and close, the order item_effects.asm .doneHealing runs in
|
||||
-- (SFX_HEAL_HP -> UpdateHPBar2 -> RedrawPartyMenu prints the message).
|
||||
-- picker is nil for every other item and for in-battle use, which keeps
|
||||
-- the pop-then-print path below. #252
|
||||
if picker and extra and extra.healedFrom and target then
|
||||
-- Only a keepOpen picker is still on the stack to animate: every other
|
||||
-- item, and every in-battle use, popped it in PartyMenu before onSwitch,
|
||||
-- and takes the pop-then-print path below -- which is the path that
|
||||
-- spends the battle turn. #252, #379
|
||||
if picker and picker.keepOpen and extra and extra.healedFrom and target then
|
||||
picker:animateTo(target, extra.healedFrom, function()
|
||||
showMessages(game, payload, closePicker)
|
||||
end)
|
||||
|
||||
+22
-2
@@ -64,9 +64,24 @@ function BindingsMenu.new(game)
|
||||
local self = setmetatable(ListMenu.new(game, "CONTROLS", items, {}),
|
||||
BindingsMenu)
|
||||
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
|
||||
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
|
||||
-- routing only ever sees this screen while a capture is armed
|
||||
function BindingsMenu:beginCapture(item)
|
||||
@@ -75,7 +90,12 @@ function BindingsMenu:beginCapture(item)
|
||||
self.onGamepadPressed = BindingsMenu.capturePad
|
||||
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)
|
||||
if key == "escape" then return self:storeBinding("key", nil) end
|
||||
self:storeBinding("key", key)
|
||||
end
|
||||
|
||||
@@ -100,7 +120,6 @@ function BindingsMenu:storeBinding(slot, value)
|
||||
b[slot] = value
|
||||
opts.bindings[item.button.id] = b
|
||||
item.right = boundRight(opts.bindings, item.button)
|
||||
Input:applyBindings(opts.bindings)
|
||||
if game.writeOptions then game:writeOptions() end
|
||||
end
|
||||
|
||||
@@ -112,9 +131,10 @@ end
|
||||
function BindingsMenu:draw()
|
||||
ListMenu.draw(self)
|
||||
if self.capture then
|
||||
Font.drawBox(1, 6, 18, 4)
|
||||
Font.drawBox(1, 6, 18, 5)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
Font.draw(Strings("PRESS A BUTTON"), 24, 60)
|
||||
Font.draw(Strings("ESC TO CANCEL"), 24, 72)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
end
|
||||
end
|
||||
|
||||
+22
-7
@@ -35,14 +35,15 @@ function DexEntryMenu.new(game, speciesOrOpts)
|
||||
local species, forceOwned = resolveArgs(speciesOrOpts)
|
||||
local self = setmetatable({ game = game, forceOwned = forceOwned }, DexEntryMenu)
|
||||
self.def = game.data.pokemon[species]
|
||||
local path = require("src.pokemon.Sprites").path(game.data, species, "front",
|
||||
{ kind = "dex" })
|
||||
local path, trueColor = require("src.pokemon.Sprites").path(
|
||||
game.data, species, "front", { kind = "dex" })
|
||||
-- `path and pcall(...)` truncates to one value, so img was always nil and
|
||||
-- every dex page drew without its pic (#307); the guard has to be a
|
||||
-- statement for pcall's second return to survive.
|
||||
local ok, img = false, nil
|
||||
if path then ok, img = pcall(love.graphics.newImage, path) end
|
||||
self.sprite = ok and img or nil
|
||||
self.spriteTrueColor = self.sprite and trueColor or false
|
||||
require("src.core.Sound").playCry(game.data, species)
|
||||
return self
|
||||
end
|
||||
@@ -55,17 +56,26 @@ function DexEntryMenu:update(dt)
|
||||
end
|
||||
|
||||
function DexEntryMenu:draw()
|
||||
DexEntryMenu.render(self.game, self.def, self.sprite, self.forceOwned)
|
||||
DexEntryMenu.render(self.game, self.def, self.sprite, self.forceOwned,
|
||||
self.spriteTrueColor)
|
||||
end
|
||||
|
||||
-- Static entry-page renderer, shared with the printer stand-in
|
||||
-- (src/core/Printer.lua renders the same page into a PNG the way
|
||||
-- PrintPokedexEntry rendered it to the Game Boy Printer).
|
||||
function DexEntryMenu.render(game, def, sprite, forceOwned)
|
||||
function DexEntryMenu.render(game, def, sprite, forceOwned, trueColor)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
love.graphics.rectangle("fill", 0, 0, 160, 144)
|
||||
if sprite then
|
||||
love.graphics.draw(sprite, 8, math.max(0, 60 - sprite:getHeight()))
|
||||
local y = math.max(0, 60 - sprite:getHeight())
|
||||
love.graphics.draw(sprite, 8, y)
|
||||
-- a full-color pic has to sit out the SGB recolor, so mark its bounds
|
||||
-- for the unshaded pass (#350). The printer path leaves trueColor nil:
|
||||
-- it renders to its own PNG canvas, and a mark left behind there would
|
||||
-- bleed into the next real frame.
|
||||
if trueColor then
|
||||
require("src.render.PaletteFX").markTrueColor(8, y, sprite:getDimensions())
|
||||
end
|
||||
end
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
Font.draw(def.name, 72, 8)
|
||||
@@ -87,8 +97,13 @@ function DexEntryMenu.render(game, def, sprite, forceOwned)
|
||||
-- feet/inches use the dex screen's ′/″ glyphs ("HT ?′??″" in
|
||||
-- pokedex.asm; the tiles come from gfx/pokedex/pokedex.png via
|
||||
-- engine/gfx/load_pokedex_tiles.asm)
|
||||
Font.draw(Strings("HT %d′%02d″", e.heightFt, e.heightIn or 0), 72, 44)
|
||||
Font.draw(Strings("WT %.1flb", (e.weight or 0) / 10), 72, 54)
|
||||
if e.heightM then
|
||||
Font.draw((("GR. %.1fm"):format(e.heightM):gsub("(%d)%.(%d)", "%1,%2")), 64, 44)
|
||||
Font.draw((("GEW. %.1fkg"):format(e.weightKg or 0):gsub("(%d)%.(%d)", "%1,%2")), 64, 54)
|
||||
else
|
||||
Font.draw(Strings("HT %d′%02d″", e.heightFt, e.heightIn or 0), 72, 44)
|
||||
Font.draw(Strings("WT %.1flb", (e.weight or 0) / 10), 72, 54)
|
||||
end
|
||||
end
|
||||
local text = owned and e.text and game.data.text[e.text] or nil
|
||||
local y = 72
|
||||
|
||||
+23
-11
@@ -48,11 +48,11 @@ end
|
||||
local FLASH_FRAMES = 220
|
||||
|
||||
local function frontSprite(game, species, mon)
|
||||
local path = require("src.pokemon.Sprites").path(game.data, species, "front",
|
||||
{ mon = mon, kind = "evolution" })
|
||||
if not path then return nil end
|
||||
local path, trueColor = require("src.pokemon.Sprites").path(
|
||||
game.data, species, "front", { mon = mon, kind = "evolution" })
|
||||
if not path then return nil, false end
|
||||
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
|
||||
|
||||
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).
|
||||
self.cancelable = (via ~= "TRADE" and via ~= "ITEM")
|
||||
self.oldName = mon.nickname or game.data.pokemon[mon.species].name
|
||||
self.oldSprite = frontSprite(game, mon.species, mon)
|
||||
self.newSprite = frontSprite(game, newSpecies, mon)
|
||||
self.oldSprite, self.oldSpriteTrueColor = frontSprite(game, mon.species, mon)
|
||||
self.newSprite, self.newSpriteTrueColor = frontSprite(game, newSpecies, mon)
|
||||
self.t = 0
|
||||
self.done = false
|
||||
self.canceled = false
|
||||
@@ -127,18 +127,30 @@ function EvolutionState:draw()
|
||||
love.graphics.rectangle("fill", 0, 0, 160, 144)
|
||||
|
||||
-- accelerating flash between the two forms
|
||||
local sprite
|
||||
local sprite, spriteTrueColor
|
||||
if self.done then
|
||||
-- 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
|
||||
local period = math.max(4, 28 - math.floor(self.t / 40) * 6)
|
||||
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
|
||||
if sprite then
|
||||
love.graphics.draw(sprite, math.floor((160 - sprite:getWidth()) / 2),
|
||||
math.max(8, 64 - sprite:getHeight()))
|
||||
local x = math.floor((160 - sprite:getWidth()) / 2)
|
||||
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
|
||||
|
||||
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.rectangle("fill", 0, 0, 160, 144)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
Font.draw(self.title, 8, 4)
|
||||
Font.draw(Strings(self.title), 8, 4)
|
||||
if #self.items == 0 then
|
||||
Font.draw(Strings("Nothing here."), 16, 64)
|
||||
end
|
||||
|
||||
@@ -19,6 +19,20 @@ function Menu.new(game, items, opts)
|
||||
self.tx = opts.tx or 10
|
||||
self.ty = opts.ty or 0
|
||||
self.tw = opts.tw or 10
|
||||
-- grow the box to the widest label so longer (e.g. localized) labels don't
|
||||
-- overflow the frame; nudge tx left to keep the box on-screen (20 tiles).
|
||||
do
|
||||
local widest = 0
|
||||
for _, it in ipairs(items) do
|
||||
if it.label then
|
||||
local n = #Font.split(it.label)
|
||||
if n > widest then widest = n end
|
||||
end
|
||||
end
|
||||
local needed = widest + 3
|
||||
if needed > self.tw then self.tw = needed end
|
||||
if self.tx + self.tw > 20 then self.tx = math.max(0, 20 - self.tw) end
|
||||
end
|
||||
self.rowStep = opts.rowStep or 2
|
||||
-- maxVisible: cap the box to this many rows and scroll the rest instead
|
||||
-- of growing past it (e.g. the start menu, whose row count varies with
|
||||
|
||||
@@ -183,7 +183,7 @@ function NamingScreen:draw()
|
||||
end
|
||||
for r, row in ipairs(self:grid()) do
|
||||
for c, cell in ipairs(row) do
|
||||
Font.draw(cell, c * 16, 32 + r * 16)
|
||||
Font.draw(Strings(cell), c * 16, 32 + r * 16)
|
||||
end
|
||||
end
|
||||
Font.drawCode(Theme.cursor, self.col * 16 - 8, 32 + self.row * 16)
|
||||
|
||||
+38
-20
@@ -61,7 +61,7 @@ local function tryImage(path)
|
||||
return ok and img or nil
|
||||
end
|
||||
|
||||
-- Resolve a pic descriptor to (image, flip).
|
||||
-- Resolve a pic descriptor to (image, flip, trueColor).
|
||||
-- Descriptors:
|
||||
-- "oak" | "rival" | "player" shorthand
|
||||
-- { type = "trainer", id = "OPP_PROF_OAK" }
|
||||
@@ -70,7 +70,7 @@ end
|
||||
-- { type = "image", path = "..." }
|
||||
-- { type = "sprite", id = "SPRITE_RED" }
|
||||
function OakSpeech.resolvePic(game, desc, speech)
|
||||
if desc == nil then return nil, false end
|
||||
if desc == nil then return nil, false, false end
|
||||
if type(desc) == "string" then
|
||||
if desc == "oak" then
|
||||
desc = { type = "trainer", id = "OPP_PROF_OAK" }
|
||||
@@ -86,35 +86,37 @@ function OakSpeech.resolvePic(game, desc, speech)
|
||||
local t = desc.type
|
||||
if t == "trainer" then
|
||||
if speech and desc.id == "OPP_PROF_OAK" and speech.oakPic then
|
||||
return speech.oakPic, false
|
||||
return speech.oakPic, false, false
|
||||
end
|
||||
if speech and desc.id == "OPP_RIVAL1" and speech.rivalPic then
|
||||
return speech.rivalPic, false
|
||||
return speech.rivalPic, false, false
|
||||
end
|
||||
local trainers = game.data.trainers or {}
|
||||
local tr = trainers[desc.id]
|
||||
return tryImage(tr and tr.pic), false
|
||||
return tryImage(tr and tr.pic), false, false
|
||||
elseif t == "pokemon" then
|
||||
if speech and desc.id == speech.demoSpecies and speech.demoPic then
|
||||
return speech.demoPic, desc.flip and true or false
|
||||
return speech.demoPic, desc.flip and true or false, speech.demoTrueColor
|
||||
end
|
||||
local path = require("src.pokemon.Sprites").path(
|
||||
local path, trueColor = require("src.pokemon.Sprites").path(
|
||||
game.data, desc.id, "front", { kind = "oak" })
|
||||
return tryImage(path), desc.flip and true or false
|
||||
return tryImage(path), desc.flip and true or false, trueColor
|
||||
elseif t == "player" then
|
||||
if speech and speech.playerPic and not desc.path then
|
||||
return speech.playerPic, false
|
||||
return speech.playerPic, false, speech.playerTrueColor
|
||||
end
|
||||
if desc.path then return tryImage(desc.path), false end
|
||||
return tryImage(require("src.pokemon.Sprites").playerPath(
|
||||
game.data, "front", { kind = "intro" })), false
|
||||
if desc.path then return tryImage(desc.path), false, false end
|
||||
local path, trueColor = require("src.pokemon.Sprites").playerPath(
|
||||
game.data, "front", { kind = "intro" })
|
||||
return tryImage(path), false, trueColor
|
||||
elseif t == "image" then
|
||||
return tryImage(desc.path), desc.flip and true or false
|
||||
return tryImage(desc.path), desc.flip and true or false, false
|
||||
elseif t == "sprite" then
|
||||
local sp = game.data.sprites and game.data.sprites[desc.id]
|
||||
return tryImage(sp and sp.image), desc.flip and true or false
|
||||
return tryImage(sp and sp.image), desc.flip and true or false,
|
||||
sp and sp.trueColor or false
|
||||
end
|
||||
return nil, false
|
||||
return nil, false, false
|
||||
end
|
||||
|
||||
-- Vanilla step list. Ids are the stable anchors mods insert around.
|
||||
@@ -219,15 +221,18 @@ function OakSpeech.new(game, onDone)
|
||||
-- the show-off mon and the name length cap come from data; the vanilla
|
||||
-- literals stay as the fallbacks
|
||||
self.demoSpecies = oakGfx.demoSpecies or "NIDORINO"
|
||||
local demoPath = require("src.pokemon.Sprites").path(
|
||||
local demoPath, demoTrueColor = require("src.pokemon.Sprites").path(
|
||||
game.data, self.demoSpecies, "front", { kind = "oak" })
|
||||
self.demoPic = tryImage(demoPath)
|
||||
self.demoTrueColor = self.demoPic and demoTrueColor or false
|
||||
local constants = game.data.constants or {}
|
||||
self.nameLen = constants.playerNameLength or 7
|
||||
-- RedPicFront (gfx/player/red.png, shared with the trainer card) and
|
||||
-- the ShrinkPic1/ShrinkPic2 frames (gfx/player/shrink{1,2}.png)
|
||||
self.playerPic = tryImage(require("src.pokemon.Sprites").playerPath(
|
||||
game.data, "front", { kind = "intro" }))
|
||||
local playerPath, playerTrueColor = require("src.pokemon.Sprites").playerPath(
|
||||
game.data, "front", { kind = "intro" })
|
||||
self.playerPic = tryImage(playerPath)
|
||||
self.playerTrueColor = self.playerPic and playerTrueColor or false
|
||||
self.shrinkPic1 = tryImage(oakGfx.shrink1
|
||||
or "assets/generated/intro/shrink1.png")
|
||||
self.shrinkPic2 = tryImage(oakGfx.shrink2
|
||||
@@ -278,14 +283,17 @@ end
|
||||
|
||||
function OakSpeech:applyPic(step)
|
||||
if step.pic == nil then return end
|
||||
local img, flip = OakSpeech.resolvePic(self.game, step.pic, self)
|
||||
local img, flip, trueColor = OakSpeech.resolvePic(self.game, step.pic, self)
|
||||
if img then
|
||||
self.pic = img
|
||||
self.picFlip = flip or false
|
||||
self.picTrueColor = trueColor or false
|
||||
elseif step.pic == "player" or (type(step.pic) == "table" and step.pic.type == "player") then
|
||||
-- mirror the old fallback: player pic missing → oak
|
||||
self.pic = self.playerPic or self.oakPic
|
||||
self.picFlip = false
|
||||
self.picTrueColor = self.pic == self.playerPic and self.playerTrueColor
|
||||
or false
|
||||
end
|
||||
end
|
||||
|
||||
@@ -342,6 +350,7 @@ function OakSpeech:runStep(step)
|
||||
-- NIDORINO show-off: mirrored front sprite + wipe + cry + text 2A
|
||||
self.pic = self.demoPic
|
||||
self.picFlip = true
|
||||
self.picTrueColor = self.demoTrueColor
|
||||
self:revealPic("wipe", function()
|
||||
Sound.playCry(self.game.data, self.demoSpecies)
|
||||
self:say(Strings("_OakSpeechText2A"), function() self:advance() end)
|
||||
@@ -477,7 +486,10 @@ end
|
||||
|
||||
function OakSpeech:advance()
|
||||
self.step = self.step + 1
|
||||
self.picFlip = false
|
||||
-- picFlip belongs to the pic, not to the step: OakSpeechText2 prints 2A
|
||||
-- and 2B over one flipped NIDORINO with no redraw between them
|
||||
-- (oak_speech.asm:80-83), so a pic-less step must not un-mirror what is
|
||||
-- still on screen; only applyPic and the demo step may change it (#397)
|
||||
local steps = self.steps
|
||||
if not steps then
|
||||
-- enter() builds steps; keep a path for callers that advance early
|
||||
@@ -536,8 +548,10 @@ function OakSpeech:update(dt)
|
||||
s.frame = s.frame + 1
|
||||
if s.frame == 5 then
|
||||
self.pic = self.shrinkPic1 or self.pic
|
||||
self.picTrueColor = false
|
||||
elseif s.frame == 9 then
|
||||
self.pic = self.shrinkPic2 or self.pic
|
||||
self.picTrueColor = false
|
||||
-- wAudioFadeOutControl = 10: the music ramps to silence over ~70
|
||||
-- frames (7 levels x 10), reaching 0 just as the fade-to-white
|
||||
-- begins at frame 79, instead of a hard cut (oak_speech.asm:145-149,
|
||||
@@ -545,6 +559,7 @@ function OakSpeech:update(dt)
|
||||
Music.fadeOut(10)
|
||||
elseif s.frame == 29 then
|
||||
self.pic = nil
|
||||
self.picTrueColor = false
|
||||
self.walkVisible = true
|
||||
elseif s.frame >= 79 and s.frame <= 102 then
|
||||
self.fadeLevel = math.floor((s.frame - 79) / 8) + 1
|
||||
@@ -584,6 +599,9 @@ function OakSpeech:draw()
|
||||
else
|
||||
love.graphics.draw(self.pic, x + off, y)
|
||||
end
|
||||
if self.picTrueColor then
|
||||
require("src.render.PaletteFX").markTrueColor(x + off, y, w, h)
|
||||
end
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
end
|
||||
if self.walkVisible and self.walkSheet then
|
||||
|
||||
+81
-6
@@ -2,7 +2,8 @@
|
||||
-- (engine/menus/main_menu.asm DisplayOptionMenu), the battle ruleset
|
||||
-- (cycles the merged rulesets registry; gen1_faithful keeps the original
|
||||
-- quirks), plus the port's audio rows and display rows: music/SFX
|
||||
-- volume (0-7), music low-pass filter (OFF/1X/2X/3X), COLORS / TILT /
|
||||
-- volume (0-7), PIKACHU VOL (0-7, Yellow only: trims the PCM voice clips
|
||||
-- under SFX VOL), music low-pass filter (OFF/1X/2X/3X), COLORS / TILT /
|
||||
-- GBC FX / ZOOM / VOID FILL / VIDEO MODE, and the MODS row that opens
|
||||
-- the mod manager.
|
||||
-- Rows are descriptors fed through the ui.options.rows hook, so mods can
|
||||
@@ -16,8 +17,10 @@ local GBCFX = require("src.render.GBCFX")
|
||||
local Zoom = require("src.render.Zoom")
|
||||
local TileRenderer = require("src.render.TileRenderer")
|
||||
local GameSpeed = require("src.core.GameSpeed")
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
local VideoMode = require("src.core.VideoMode")
|
||||
local FrameCap = require("src.core.FrameCap")
|
||||
local Performance = require("src.core.Performance")
|
||||
local Logger = require("src.core.Logger")
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
local OptionRows = require("src.ui.OptionRows")
|
||||
@@ -82,7 +85,7 @@ local function rulesetName(game)
|
||||
local ids = rulesetIds(game)
|
||||
local id = ids[rulesetIndex(game, ids)] or game.save.options.ruleset
|
||||
local record = id and rulesets[id]
|
||||
return record and record.name or id or "----"
|
||||
return Strings(record and record.name or id or "----")
|
||||
end
|
||||
|
||||
-- 0-7 volume level display (0 = OFF)
|
||||
@@ -118,7 +121,7 @@ local function sameRows(_, rows) return rows end
|
||||
local function buildRows(game)
|
||||
local rows = {
|
||||
{ id = "textSpeed", label = Strings("TEXT SPEED"),
|
||||
value = function(g) return SPEEDS[speedIndex(g)][2] end,
|
||||
value = function(g) return Strings(SPEEDS[speedIndex(g)][2]) end,
|
||||
step = function(g)
|
||||
local i = speedIndex(g) % #SPEEDS + 1
|
||||
g.save.options.textSpeed = SPEEDS[i][1]
|
||||
@@ -126,7 +129,7 @@ local function buildRows(game)
|
||||
end },
|
||||
{ id = "animations", label = Strings("BATTLE ANIMATION"),
|
||||
value = function(g)
|
||||
return g.save.options.animations == false and "OFF" or "ON"
|
||||
return g.save.options.animations == false and Strings("OFF") or Strings("ON")
|
||||
end,
|
||||
step = function(g)
|
||||
local o = g.save.options
|
||||
@@ -135,7 +138,7 @@ local function buildRows(game)
|
||||
end },
|
||||
{ id = "battleStyle", label = Strings("BATTLE STYLE"),
|
||||
value = function(g)
|
||||
return g.save.options.battleStyle == "set" and "SET" or "SHIFT"
|
||||
return g.save.options.battleStyle == "set" and Strings("SET") or Strings("SHIFT")
|
||||
end,
|
||||
step = function(g)
|
||||
local o = g.save.options
|
||||
@@ -146,7 +149,7 @@ local function buildRows(game)
|
||||
-- widescreen composition (src/battle/WideBattle.lua)
|
||||
{ id = "battleLayout", label = Strings("BATTLE LAYOUT"),
|
||||
value = function(g)
|
||||
return g.save.options.battleLayout == "wide" and "WIDE" or "OG"
|
||||
return g.save.options.battleLayout == "wide" and Strings("WIDE") or Strings("OG")
|
||||
end,
|
||||
step = function(g)
|
||||
local o = g.save.options
|
||||
@@ -178,6 +181,18 @@ local function buildRows(game)
|
||||
require("src.core.Sound").setVolumeLevel(o.sfxVol)
|
||||
return true
|
||||
end },
|
||||
-- Yellow only (filtered out below on Red/Blue): trims Pikachu's voice
|
||||
-- clips under the SFX level, since Yellow plays them constantly (every
|
||||
-- follower interaction, the title screen, the dex) and they are mixed
|
||||
-- much hotter than the chip cries.
|
||||
{ id = "pikaVol", label = Strings("PIKACHU VOL"),
|
||||
value = function(g) return volLabel(g.save.options.pikaVol) end,
|
||||
step = function(g, dir)
|
||||
local o = g.save.options
|
||||
o.pikaVol = stepVolume(o.pikaVol, dir)
|
||||
require("src.core.Sound").setPikaVolumeLevel(o.pikaVol)
|
||||
return true
|
||||
end },
|
||||
{ id = "musicFilter", label = Strings("MUSIC FILTER"),
|
||||
value = function(g)
|
||||
return FILTERS[(g.save.options.musicFilter or 0) + 1]
|
||||
@@ -188,6 +203,21 @@ local function buildRows(game)
|
||||
require("src.core.Music").setFilterLevel(o.musicFilter)
|
||||
return true
|
||||
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"),
|
||||
value = function(g)
|
||||
return PaletteFX.modeLabel(g.save.options.colors or "gbc")
|
||||
@@ -308,6 +338,25 @@ local function buildRows(game)
|
||||
activate = function(g)
|
||||
require("src.ui.Screens").push(g, "BindingsMenu")
|
||||
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
|
||||
if not GBCFX.isSupported() then
|
||||
@@ -317,6 +366,32 @@ local function buildRows(game)
|
||||
end
|
||||
rows = filtered
|
||||
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
|
||||
-- with it rather than at the end of the list where a mod's own
|
||||
-- ui.options.rows additions land. Nothing registered means nothing
|
||||
|
||||
+24
-15
@@ -351,18 +351,23 @@ function PartyMenu:update(dt)
|
||||
end })
|
||||
return
|
||||
elseif action == "flash" then -- FLASH lights dark tunnels
|
||||
-- start_sub_menus.asm .flash: PrintText _FlashLightsAreaText, then
|
||||
-- GBPalWhiteOutWithDelay3 + jp .goBackToMap
|
||||
-- start_sub_menus.asm .flash: PrintText _FlashLightsAreaText runs
|
||||
-- with the party menu still on screen, and only then
|
||||
-- GBPalWhiteOutWithDelay3 + jp .goBackToMap. So the message reads
|
||||
-- over the menu, and the cave is lit when the blink hands the
|
||||
-- screen back, never under the text (#385).
|
||||
local ow = self.game.overworld
|
||||
local TextBox = require("src.render.TextBox")
|
||||
local Transition = require("src.render.Transition")
|
||||
self.game.stack:pop()
|
||||
ow.dark = false
|
||||
self.game.save.flashLit = true
|
||||
self.game.stack:push(TextBox.new(self.game,
|
||||
self.game.data.text._FlashLightsAreaText
|
||||
or Strings("A blinding FLASH\nlights the area!"), function()
|
||||
self.game.stack:push(Transition.whiteFlash(self.game))
|
||||
self:close()
|
||||
-- setDark, not a bare field write: ADVANCED carries the darkness
|
||||
-- in a baked atlas, so lighting the cave rebuilds it (#383)
|
||||
self.game.stack:push(Transition.whiteFlash(self.game, nil,
|
||||
function() ow:setDark(false) end))
|
||||
end))
|
||||
return
|
||||
elseif action == "surf" then
|
||||
@@ -377,9 +382,11 @@ function PartyMenu:update(dt)
|
||||
local reason = ow:useSurfFieldMove()
|
||||
local Transition = require("src.render.Transition")
|
||||
if reason == "ok" then
|
||||
self.game.stack:pop() -- close the party menu (jp .goBackToMap)
|
||||
-- UseItem prints _SurfingGotOnText with the party menu still up;
|
||||
-- GBPalWhiteOutWithDelay3 + jp .goBackToMap only follow it, so
|
||||
-- trySurf closes this menu when its text does (#385)
|
||||
local fx, fy = ow.player:facingCell()
|
||||
ow:trySurf(fx, fy)
|
||||
ow:trySurf(fx, fy, function() self:close() end)
|
||||
return
|
||||
end
|
||||
if reason == "dismount" then
|
||||
@@ -410,9 +417,10 @@ function PartyMenu:update(dt)
|
||||
-- .cannotStopSurfing prints _SurfingNoPlaceToGetOffText but
|
||||
-- never zeroes wActionResultOrTookBattleTurn, so unlike the
|
||||
-- other refusals the menu still closes afterwards
|
||||
-- (GBPalWhiteOutWithDelay3 + .goBackToMap)
|
||||
self.game.stack:pop()
|
||||
-- (GBPalWhiteOutWithDelay3 + .goBackToMap), and the text prints
|
||||
-- over the still-open menu like every other .loop refusal (#385)
|
||||
self.game.stack:push(TextBox.new(self.game, txt, function()
|
||||
self:close()
|
||||
self.game.stack:push(Transition.whiteFlash(self.game))
|
||||
end))
|
||||
return
|
||||
@@ -454,18 +462,19 @@ function PartyMenu:update(dt)
|
||||
local Transition = require("src.render.Transition")
|
||||
local def = self.game.data.pokemon[mon.species]
|
||||
local name = mon.nickname or def.name
|
||||
self.game.stack:pop() -- close the party menu (jp .goBackToMap)
|
||||
ow.strengthActive = true
|
||||
local t1 = (self.game.data.text._UsedStrengthText
|
||||
or Strings("{RAM:wNameBuffer} used\nSTRENGTH.")):gsub("{RAM:wNameBuffer}", name)
|
||||
local t2 = (self.game.data.text._CanMoveBouldersText
|
||||
or Strings("{RAM:wNameBuffer} can\nmove boulders.")):gsub("{RAM:wNameBuffer}", name)
|
||||
-- like surf (#320): the blink belongs UNDER the texts, not as a
|
||||
-- flashbang on the empty map after them; the stack only updates
|
||||
-- the top state, so the flash holds until the texts close
|
||||
self.game.stack:push(Transition.whiteFlash(self.game))
|
||||
-- like surf (#320, #385): both texts print with the party menu
|
||||
-- still on screen, and the blink IS the menu closing afterwards,
|
||||
-- not a flashbang on the empty map
|
||||
self.game.stack:push(TextBox.new(self.game, t1, function()
|
||||
self.game.stack:push(TextBox.new(self.game, t2))
|
||||
self.game.stack:push(TextBox.new(self.game, t2, function()
|
||||
self:close()
|
||||
self.game.stack:push(Transition.whiteFlash(self.game))
|
||||
end))
|
||||
end, { auto = { sound = function()
|
||||
return require("src.core.Sound").playCry(self.game.data, mon.species)
|
||||
end } }))
|
||||
|
||||
+11
-4
@@ -43,12 +43,13 @@ function SummaryMenu.new(game, mon)
|
||||
Stats.ensure(game.data.pokemon[mon.species], mon)
|
||||
local self = setmetatable({ game = game, mon = mon, page = 1 }, SummaryMenu)
|
||||
local Sprites = require("src.pokemon.Sprites")
|
||||
local path = Sprites.path(game.data, mon.species, "front",
|
||||
local path, trueColor = Sprites.path(game.data, mon.species, "front",
|
||||
{ mon = mon, kind = "summary" })
|
||||
if path then
|
||||
local ok, img = pcall(love.graphics.newImage, path)
|
||||
self.sprite = ok and img or nil
|
||||
end
|
||||
self.spriteTrueColor = self.sprite and trueColor or false
|
||||
require("src.core.Sound").playCry(game.data, mon.species)
|
||||
return self
|
||||
end
|
||||
@@ -111,8 +112,14 @@ function SummaryMenu:draw()
|
||||
-- the same routine the intro's NIDORINO show-off uses (OakSpeech picFlip:
|
||||
-- negative x scale anchored at the pic's right edge). #280
|
||||
if self.sprite then
|
||||
love.graphics.draw(self.sprite, 8 + self.sprite:getWidth(),
|
||||
math.max(0, 56 - self.sprite:getHeight()), 0, -1, 1)
|
||||
local pw, ph = self.sprite:getDimensions()
|
||||
local py = math.max(0, 56 - ph)
|
||||
love.graphics.draw(self.sprite, 8 + pw, py, 0, -1, 1)
|
||||
-- a full-color pic has to sit out the SGB monPal recolor, so mark the
|
||||
-- rect the mirrored draw covers for the unshaded pass (#430)
|
||||
if self.spriteTrueColor then
|
||||
require("src.render.PaletteFX").markTrueColor(8, py, pw, ph)
|
||||
end
|
||||
end
|
||||
local HudTiles = require("src.render.HudTiles")
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
@@ -146,7 +153,7 @@ function SummaryMenu:draw()
|
||||
}
|
||||
for i, s in ipairs(stats) do
|
||||
local y = 72 + (i - 1) * 16
|
||||
Font.draw(s[1], 8, y)
|
||||
Font.draw(Strings(s[1]), 8, y)
|
||||
Font.draw(("%3d"):format(s[2]), 48, y + 8)
|
||||
end
|
||||
|
||||
|
||||
+72
-17
@@ -7,6 +7,8 @@ local Font = require("src.render.Font")
|
||||
local Music = require("src.core.Music")
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
local Strings = require("src.core.Strings")
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
local Logger = require("src.core.Logger")
|
||||
|
||||
local TitleState = {}
|
||||
TitleState.__index = TitleState
|
||||
@@ -22,13 +24,17 @@ TitleState.isOpaque = true
|
||||
-- LOGO2 blue / LOGO1 red (issue #133). A trailing trueColor zone leaves the
|
||||
-- overlay's DMG black unshaded while the logo and title mon keep title pals.
|
||||
--
|
||||
-- ROM SuperPal whites are often {255,239,255}. Under RED++, LOGO2/MEWMON
|
||||
-- come from the GBC pack (pure white) while Blue's LOGO1 stays on the ROM
|
||||
-- pack (#128), so the version-ribbon row reads as a pink band. Force that
|
||||
-- slot to pure white; ink colors (Blue/Red "Version" text) stay intact.
|
||||
local function withPureWhite(pal)
|
||||
-- Every title SuperPal shares color 0 on hardware (sgb_palettes.asm: LOGO1,
|
||||
-- LOGO2 and MEWMON all start RGB 31,29,31), so the ribbon band's white has to
|
||||
-- be the neighbouring zones' white. Under RED++, LOGO2/MEWMON come from the
|
||||
-- GBC pack (pure white) while Blue's LOGO1 stays on the ROM pack (#128) and
|
||||
-- the row reads as a pink band; taking LOGO2's white fixes that without
|
||||
-- forcing pure white in SGB, where a brighter band drew two faint lines
|
||||
-- across the title (#373). Ink colors (Blue/Red "Version" text) stay intact.
|
||||
local function withWhiteOf(pal, ref)
|
||||
if not pal then return nil end
|
||||
return { { 255, 255, 255 }, pal[2], pal[3], pal[4] }
|
||||
if not (ref and ref[1]) then return pal end
|
||||
return { ref[1], pal[2], pal[3], pal[4] }
|
||||
end
|
||||
|
||||
function TitleState:sgbPalettes(game)
|
||||
@@ -46,9 +52,10 @@ function TitleState:sgbPalettes(game)
|
||||
P.zone(logoPal, 9, 8, 10, 8),
|
||||
}
|
||||
else
|
||||
local logoPal = P.pal(game.data, "LOGO2")
|
||||
z = {
|
||||
P.zone(P.pal(game.data, "LOGO2"), 0, 0, 19, 7),
|
||||
P.zone(withPureWhite(P.pal(game.data, "LOGO1")), 0, 8, 19, 9),
|
||||
P.zone(logoPal, 0, 0, 19, 7),
|
||||
P.zone(withWhiteOf(P.pal(game.data, "LOGO1"), logoPal), 0, 8, 19, 9),
|
||||
P.zone(P.pal(game.data, "MEWMON"), 0, 10, 19, 17),
|
||||
}
|
||||
end
|
||||
@@ -98,6 +105,30 @@ local function imagePath(entry)
|
||||
return entry
|
||||
end
|
||||
|
||||
-- PaletteFX redraws a true-color rectangle after the palette pass. Red's
|
||||
-- title art is drawn on top of the title mon, so leave its bounds out of the
|
||||
-- rectangle rather than redrawing that art without its title palette.
|
||||
local function markVisibleTrueColor(x, y, w, h, cover)
|
||||
local P = require("src.render.PaletteFX")
|
||||
if not cover then
|
||||
P.markTrueColor(x, y, w, h)
|
||||
return
|
||||
end
|
||||
local cx, cy, cw, ch = cover[1], cover[2], cover[3], cover[4]
|
||||
local right, bottom = x + w, y + h
|
||||
local cright, cbottom = cx + cw, cy + ch
|
||||
local ix1, iy1 = math.max(x, cx), math.max(y, cy)
|
||||
local ix2, iy2 = math.min(right, cright), math.min(bottom, cbottom)
|
||||
if ix1 >= ix2 or iy1 >= iy2 then
|
||||
P.markTrueColor(x, y, w, h)
|
||||
return
|
||||
end
|
||||
if y < iy1 then P.markTrueColor(x, y, w, iy1 - y) end
|
||||
if iy2 < bottom then P.markTrueColor(x, iy2, w, bottom - iy2) end
|
||||
if x < ix1 then P.markTrueColor(x, iy1, ix1 - x, iy2 - iy1) end
|
||||
if ix2 < right then P.markTrueColor(ix2, iy1, right - ix2, iy2 - iy1) end
|
||||
end
|
||||
|
||||
function TitleState.new(game, opts)
|
||||
opts = opts or {}
|
||||
local self = setmetatable({}, TitleState)
|
||||
@@ -148,7 +179,7 @@ function TitleState.new(game, opts)
|
||||
self.cycleSpecies = (type(title.cycleSpecies) == "table"
|
||||
and #title.cycleSpecies > 0)
|
||||
and title.cycleSpecies or defaultCycle
|
||||
self.sprites = {} -- species -> image or false (load failed)
|
||||
self.sprites = {} -- species -> { image, trueColor } or false (load failed)
|
||||
self.cycleIndex = 1
|
||||
self.timer = 0
|
||||
self.blink = 0
|
||||
@@ -252,12 +283,13 @@ function TitleState:currentSprite()
|
||||
local species = self.cycleSpecies[self.cycleIndex]
|
||||
local cached = self.sprites[species]
|
||||
if cached == nil then
|
||||
local path = require("src.pokemon.Sprites").path(
|
||||
local path, trueColor = require("src.pokemon.Sprites").path(
|
||||
self.game.data, species, "front", { kind = "title" })
|
||||
cached = tryImage(path) or false
|
||||
local image = tryImage(path)
|
||||
cached = image and { image = image, trueColor = trueColor } or false
|
||||
self.sprites[species] = cached
|
||||
end
|
||||
return cached or nil
|
||||
return cached and cached.image or nil, cached and cached.trueColor or false
|
||||
end
|
||||
|
||||
local function hasSave()
|
||||
@@ -270,6 +302,8 @@ local function hasSave()
|
||||
return ok and info ~= nil
|
||||
end
|
||||
|
||||
local function sameItems(_, items) return items end
|
||||
|
||||
-- The CONTINUE info window (main_menu.asm DisplayContinueGameInfo):
|
||||
-- PLAYER / BADGES / POKéDEX / TIME over the title, shown after choosing
|
||||
-- CONTINUE. A confirms and loads the game, B returns to the main menu.
|
||||
@@ -345,6 +379,13 @@ function TitleState:openMenu()
|
||||
love.event.quit()
|
||||
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 menu = Menu.new(game, items, { tx = 0, ty = 0, tw = 13, th = th })
|
||||
-- full-width title LOGO zones would recolor this box; see sgbPalettes
|
||||
@@ -402,7 +443,7 @@ end
|
||||
-- the version ribbon at (7,8), Red's title art as OAM at px (82,80),
|
||||
-- the title mon in the 7x7 box at tile (5,10), copyright on row 17.
|
||||
-- Yellow (title_yellow.asm): logo (2,1), speech bubble (6,4), Pikachu
|
||||
-- (4,8) 12x9 — no version ribbon, no cycling mon, no Red OAM.
|
||||
-- (4,8) 12x9 -- no version ribbon, no cycling mon, no Red OAM.
|
||||
function TitleState:draw()
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
love.graphics.rectangle("fill", 0, 0, 160, 144)
|
||||
@@ -446,13 +487,27 @@ function TitleState:draw()
|
||||
love.graphics.newQuad(40, 0, 40, 8, iw, ih), 80, 64)
|
||||
end
|
||||
end
|
||||
local sprite = self:currentSprite()
|
||||
local sprite, spriteTrueColor = self:currentSprite()
|
||||
if sprite then
|
||||
local w, h = sprite:getDimensions()
|
||||
local slide = (self.slideIn or 0) * 8
|
||||
love.graphics.draw(sprite, 40 + math.floor((56 - w) / 2) + slide,
|
||||
136 - h)
|
||||
local slide = (self.slideIn or 0) * 8 -- scroll in from the right
|
||||
-- bottom-aligned and centered in the (5,10)-(11,16) tile box
|
||||
local x = 40 + math.floor((56 - w) / 2) + slide
|
||||
local y = 136 - h
|
||||
love.graphics.draw(sprite, x, y)
|
||||
-- a full-color mon keeps its own palette through the SGB pass, minus
|
||||
-- the strip Red's OAM covers (#350). Yellow never reaches here: its
|
||||
-- layout has no cycling mon and no Red art (title_yellow.asm).
|
||||
if spriteTrueColor then
|
||||
local cover
|
||||
if self.player then
|
||||
local pw, ph = self.player:getDimensions()
|
||||
cover = { 82, 80, pw, ph }
|
||||
end
|
||||
markVisibleTrueColor(x, y, w, h, cover)
|
||||
end
|
||||
end
|
||||
-- Red is OAM in the original: he draws over the mon's box edge
|
||||
if self.player then
|
||||
love.graphics.draw(self.player, 82, 80)
|
||||
end
|
||||
|
||||
@@ -0,0 +1,275 @@
|
||||
-- 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 ww, wh = love.graphics.getDimensions()
|
||||
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, ww, wh)
|
||||
col(PAL.bgTop, 0.85)
|
||||
love.graphics.circle("fill", ww * 0.5, 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
|
||||
col(PAL.card, 0.92)
|
||||
love.graphics.rectangle("fill", 0, 0, ww, barH + pad)
|
||||
col(PAL.stroke, 0.35)
|
||||
love.graphics.setLineWidth(1)
|
||||
love.graphics.line(0, barH + pad, ww, barH + pad)
|
||||
|
||||
love.graphics.setFont(Editor.fonts.title)
|
||||
col(PAL.white)
|
||||
love.graphics.print("Touch Controls", pad, pad + 4 * s)
|
||||
|
||||
-- Done / Reset
|
||||
local done = { x = ww - pad - btnW, y = 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 = barH + pad + 14 * s
|
||||
local cardH = 64 * s
|
||||
local cardX, cardW = 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, 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
|
||||
|
||||
local function spriteOf(game, mon)
|
||||
local path = require("src.pokemon.Sprites").path(game.data, mon.species, "front",
|
||||
{ mon = mon, kind = "trade" })
|
||||
return tryImage(path)
|
||||
local path, trueColor = require("src.pokemon.Sprites").path(
|
||||
game.data, mon.species, "front", { mon = mon, kind = "trade" })
|
||||
local image = tryImage(path)
|
||||
return image, image and trueColor or false
|
||||
end
|
||||
|
||||
local function expand(game, key, subs)
|
||||
@@ -110,8 +111,8 @@ function TradeAnim.new(game, opts)
|
||||
cableBallAlt = tryImage(art.cableBallAlt or DEFAULT_ART.cableBallAlt),
|
||||
bubble = tryImage(art.bubble or DEFAULT_ART.bubble),
|
||||
}
|
||||
self.sentSprite = spriteOf(game, self.sent)
|
||||
self.recvSprite = spriteOf(game, self.received)
|
||||
self.sentSprite, self.sentSpriteTrueColor = spriteOf(game, self.sent)
|
||||
self.recvSprite, self.recvSpriteTrueColor = spriteOf(game, self.received)
|
||||
|
||||
self.seq = 1
|
||||
self.phase = SEQ[1]
|
||||
@@ -349,8 +350,8 @@ function TradeAnim:drawMonInfo(mon, ot, otId, boxTy)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
end
|
||||
|
||||
function TradeAnim:drawIconInBubble(mon, x, y)
|
||||
local spr = (mon.species == self.received.species) and self.recvSprite or self.sentSprite
|
||||
function TradeAnim:drawIconInBubble(sprite, x, y)
|
||||
local spr = sprite
|
||||
if spr then
|
||||
local sw, sh = spr:getDimensions()
|
||||
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
|
||||
if self.monVisible and self.sentSprite then
|
||||
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
|
||||
self:drawMonInfo(self.sent, self.playerOt, self.playerOtId, 10)
|
||||
love.graphics.pop()
|
||||
@@ -466,8 +471,13 @@ function TradeAnim:draw()
|
||||
love.graphics.translate(160, 0)
|
||||
self:drawRightGB()
|
||||
love.graphics.pop()
|
||||
local mon = (p == "transfer_lr") and self.sent or self.received
|
||||
self:drawIconInBubble(mon, self.monX, self.monY)
|
||||
local sprite
|
||||
if p == "transfer_lr" then
|
||||
sprite = self.sentSprite
|
||||
else
|
||||
sprite = self.recvSprite
|
||||
end
|
||||
self:drawIconInBubble(sprite, self.monX, self.monY)
|
||||
if self.cableFlash then
|
||||
love.graphics.setColor(1, 1, 1, 0.15)
|
||||
love.graphics.rectangle("fill", 0, 32, 160, 8)
|
||||
@@ -477,6 +487,10 @@ function TradeAnim:draw()
|
||||
elseif p == "show_enemy" then
|
||||
if self.monVisible and self.recvSprite then
|
||||
love.graphics.draw(self.recvSprite, 56, 16)
|
||||
if self.recvSpriteTrueColor then
|
||||
require("src.render.PaletteFX").markTrueColor(
|
||||
56, 16, self.recvSprite:getDimensions())
|
||||
end
|
||||
end
|
||||
self:drawMonInfo(self.received, self.enemyName, self.enemyOtId, 10)
|
||||
end
|
||||
|
||||
+104
-13
@@ -148,6 +148,37 @@ local SPEED_BARS = {
|
||||
{ 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
|
||||
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 }
|
||||
@@ -208,6 +239,8 @@ function YellowIntro.new(game, onDone)
|
||||
self.palName = "MEWMON" -- PalPacket_Generic
|
||||
self.objects = {}
|
||||
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.atlas2 = tryImage("assets/generated/intro/yellow_intro_2.png")
|
||||
@@ -559,6 +592,24 @@ function YellowIntro:startScene(scene)
|
||||
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()
|
||||
if self.pre then return end -- pre-roll first; beginScenes takes over
|
||||
self:beginScenes()
|
||||
@@ -571,27 +622,63 @@ function YellowIntro:beginScenes()
|
||||
local song = songs and (songs.Music_YellowIntro and "Music_YellowIntro"
|
||||
or songs.Music_IntroBattle and "Music_IntroBattle")
|
||||
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
|
||||
|
||||
-- The movie's exit never stops the song (intro_yellow.asm PlayIntroScene
|
||||
-- .go_to_title_screen, where the A/B/START skip lands too): Music_YellowIntro
|
||||
-- outlasts the scenes and plays on into the title setup until title.asm's
|
||||
-- StopAllMusic before MUSIC_TITLE_SCREEN, which TitleState:startMusic stands
|
||||
-- in for (#436)
|
||||
function YellowIntro:finish()
|
||||
if self.finished then return end
|
||||
self.finished = true
|
||||
pcall(Music.stop)
|
||||
self.game.stack:pop()
|
||||
if self.onDone then self.onDone() end
|
||||
end
|
||||
|
||||
-- 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)
|
||||
if self.finished then return end
|
||||
if self.pre then
|
||||
self.pre:update(dt)
|
||||
return
|
||||
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
|
||||
if input:wasPressed("a") or input:wasPressed("b")
|
||||
or input:wasPressed("start") then
|
||||
self:finish()
|
||||
self:exitToTitle()
|
||||
return
|
||||
end
|
||||
|
||||
@@ -601,7 +688,7 @@ function YellowIntro:update(dt)
|
||||
self.timer = self.timer - 1
|
||||
else
|
||||
self:clearObjects()
|
||||
self:startScene(scene + 1)
|
||||
self:enterSetupScene(scene + 1) -- BlankPalsDelay2AndDisableLCD (#523)
|
||||
end
|
||||
elseif scene == 3 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
|
||||
else
|
||||
self:clearObjects()
|
||||
self:startScene(4)
|
||||
self:enterSetupScene(4) -- BlankPalsDelay2AndDisableLCD (#523)
|
||||
end
|
||||
elseif scene == 7 then
|
||||
if self.timer > 0 then
|
||||
@@ -621,7 +708,7 @@ function YellowIntro:update(dt)
|
||||
self.wave[255] = first
|
||||
else
|
||||
self:clearObjects()
|
||||
self:startScene(8)
|
||||
self:enterSetupScene(8) -- BlankPalsDelay2AndDisableLCD (#523)
|
||||
end
|
||||
elseif scene == 11 then
|
||||
if self.timer > 0 then
|
||||
@@ -636,7 +723,7 @@ function YellowIntro:update(dt)
|
||||
self.timer = self.timer - 1
|
||||
else
|
||||
self:clearObjects()
|
||||
self:startScene(12)
|
||||
self:enterSetupScene(12) -- BlankPalsDelay2AndDisableLCD (#523)
|
||||
end
|
||||
elseif scene == 13 then
|
||||
if self.timer > 0 then
|
||||
@@ -652,13 +739,17 @@ function YellowIntro:update(dt)
|
||||
if v then
|
||||
self.bgp = v
|
||||
else
|
||||
-- .expired: everything despawns, letterbox returns, logo/face
|
||||
-- object $7 appears for the strobe scene
|
||||
-- .expired: everything despawns and the letterbox returns, then the
|
||||
-- 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:bgLetterbox()
|
||||
self.bgp = 0xE4
|
||||
self:spawn(7, 0x58, 0x58)
|
||||
self:startScene(15)
|
||||
self:enterDelay(3, function()
|
||||
self.bgp = 0xE4
|
||||
self:spawn(7, 0x58, 0x58)
|
||||
self:startScene(15)
|
||||
end)
|
||||
end
|
||||
elseif scene == 15 then
|
||||
if self.timer > 0 then
|
||||
@@ -683,7 +774,7 @@ function YellowIntro:update(dt)
|
||||
if self.timer > 0 then
|
||||
self.timer = self.timer - 1
|
||||
else
|
||||
self:finish()
|
||||
self:exitToTitle()
|
||||
return
|
||||
end
|
||||
end
|
||||
|
||||
+3
-1
@@ -50,7 +50,9 @@ local CALLBACK_NAMES = {
|
||||
"keypressed", "keyreleased", "textinput",
|
||||
"mousepressed", "mousereleased", "mousemoved", "wheelmoved",
|
||||
"touchpressed", "touchmoved", "touchreleased",
|
||||
"gamepadpressed", "gamepadreleased", "gamepadaxis", "joystickremoved",
|
||||
"gamepadpressed", "gamepadreleased", "gamepadaxis",
|
||||
"joystickpressed", "joystickreleased", "joystickaxis", "joystickhat",
|
||||
"joystickremoved",
|
||||
"focus", "visible", "resize", "filedropped", "directorydropped",
|
||||
"errorhandler", "threaderror", "lowmemory",
|
||||
}
|
||||
|
||||
@@ -174,4 +174,14 @@ function Check.download()
|
||||
cmdCh:push({ cmd = "download" })
|
||||
end
|
||||
|
||||
-- End the worker thread. Its command loop sits in Channel:demand(), which
|
||||
-- never returns on its own, and LOVE waits for every live love.thread before
|
||||
-- the process exits (#339).
|
||||
function Check.shutdown()
|
||||
if cmdCh then cmdCh:push({ cmd = "quit" }) end
|
||||
if worker then pcall(function() worker:wait() end) end
|
||||
worker, cmdCh, stateCh = nil, nil, nil
|
||||
workerReady = false
|
||||
end
|
||||
|
||||
return Check
|
||||
|
||||
@@ -128,6 +128,23 @@ FieldDefaults.FIELD = {
|
||||
-- the one-shot flag the gate's pass text is gated on; a gate a mod adds
|
||||
-- gets "PASSED_<mapId>" instead of this pre-v2 spelling
|
||||
badgeGates = { ROUTE_22_GATE = { passedFlag = "PASSED_ROUTE22_GATE" } },
|
||||
-- the Rocket Hideout lift gates the floor callbacks stamp shut
|
||||
-- (scripts/RocketHideoutB1F.asm / RocketHideoutB4F.asm
|
||||
-- ...DoorCallbackScript); seeds caches imported before the manifest
|
||||
-- carried them, which left both gates standing open (#372)
|
||||
cardKeyDoors = {
|
||||
closedDoors = {
|
||||
ROCKET_HIDEOUT_B1F = {
|
||||
{ block = 0x54, bx = 12, by = 8, open = 0x0e,
|
||||
event = "EVENT_BEAT_ROCKET_HIDEOUT_1_TRAINER_4" },
|
||||
},
|
||||
ROCKET_HIDEOUT_B4F = {
|
||||
{ block = 0x2d, bx = 12, by = 5, open = 0x0e,
|
||||
events = { "EVENT_BEAT_ROCKET_HIDEOUT_4_TRAINER_0",
|
||||
"EVENT_BEAT_ROCKET_HIDEOUT_4_TRAINER_1" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
-- VermilionGymSetDoorTile opens the motorized door once both locks are hit
|
||||
hiddenExtras = {
|
||||
-- PrintTrashText bins (#188); seeds stale caches missing the key
|
||||
|
||||
+20
-4
@@ -52,13 +52,24 @@ function NPC:facePlayer(player)
|
||||
end
|
||||
|
||||
function NPC:update(map, entities)
|
||||
-- self.stepFrames overrides the shared 16-frame walk for an object whose
|
||||
-- step has to stay in phase with something else: Yellow's follower
|
||||
-- Pikachu takes the player's own step length, halved while it is more
|
||||
-- than a cell behind (FastPikachuFollow, engine/pikachu/
|
||||
-- pikachu_follow.asm). self.hopStep is the same file's $5-$8 hop
|
||||
-- command: two cells of travel inside one step's frames
|
||||
-- (DoubleAddPikachuStepVectorToScreenPixelCoords), which is why the
|
||||
-- pixel span doubles while the frame count does not. Nothing else sets
|
||||
-- either field, so every other object keeps the constant (#410, #409).
|
||||
local stepLen = self.stepFrames or STEP_FRAMES
|
||||
local span = self.hopStep and 2 or 1
|
||||
if self.moving then
|
||||
self.progress = self.progress + 1
|
||||
-- NPC_CHANGE_FACING: animate the walk cycle in place, no translation
|
||||
-- (movement.asm ChangeFacingDirection zeroes the delta); px/py stay
|
||||
-- pinned to the current cell while walkPhase() cycles.
|
||||
if self.marching then
|
||||
if self.progress >= STEP_FRAMES then
|
||||
if self.progress >= stepLen then
|
||||
self.progress = 0
|
||||
self.moving = false
|
||||
self.marching = false
|
||||
@@ -67,13 +78,18 @@ function NPC:update(map, entities)
|
||||
return
|
||||
end
|
||||
local d = Collision.DELTA[self.facing]
|
||||
self.px = self.cellX * 16 + d[1] * self.progress
|
||||
self.py = self.cellY * 16 + d[2] * self.progress
|
||||
if self.progress >= STEP_FRAMES then
|
||||
-- 1px per frame at the default length; a shortened step scales instead,
|
||||
-- so the cell still lands on a 16px boundary (Player:update does the
|
||||
-- same for the bicycle)
|
||||
local moved = math.floor(self.progress * 16 * span / stepLen)
|
||||
self.px = self.cellX * 16 + d[1] * moved
|
||||
self.py = self.cellY * 16 + d[2] * moved
|
||||
if self.progress >= stepLen then
|
||||
self.cellX, self.cellY = self.targetX, self.targetY
|
||||
self.targetX, self.targetY = nil, nil
|
||||
self.px, self.py = self.cellX * 16, self.cellY * 16
|
||||
self.moving = false
|
||||
self.hopStep = nil
|
||||
self.stepFlip = not self.stepFlip
|
||||
end
|
||||
return
|
||||
|
||||
+381
-194
@@ -3,6 +3,7 @@
|
||||
-- surfing, Cut trees, trainer sight lines, and dispatches interactions to
|
||||
-- map scripts (data/scripts/), marts, nurses or extracted text.
|
||||
|
||||
local Assets = require("src.render.Assets")
|
||||
local Camera = require("src.render.Camera")
|
||||
local Collision = require("src.world.Collision")
|
||||
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
|
||||
}
|
||||
|
||||
-- field.darkMaps (home/overworld.asm's dark-map check): the floors that run
|
||||
-- with wMapPalOffset = 6 until FLASH
|
||||
local function isDarkMap(mapId)
|
||||
local darkDef = Game.data.field.darkMaps
|
||||
for _, m in ipairs(darkDef and darkDef.maps or {}) do
|
||||
if m == mapId then return true end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
-- object_event spawn filter (toggleable_objects, items taken, beaten
|
||||
-- static encounters), shared by the current map's real NPCs and the
|
||||
-- visual-only ghosts on connected neighbor maps
|
||||
@@ -188,8 +199,50 @@ function OverworldState:enter(mapId, x, y, facing)
|
||||
-- survives save/load: a loaded game may start inside a building whose
|
||||
-- exit mat is a LAST_MAP warp
|
||||
self.lastOutdoor = Game.save.lastOutdoor
|
||||
self.justWarped = false
|
||||
self:setMap(mapId, x, y, facing, { via = "boot" })
|
||||
-- boot/load: derive the flag from the tile the save left us standing on,
|
||||
-- like MapEntryAfterBattle's IsPlayerStandingOnWarp, so a game saved on a
|
||||
-- door mat can still walk straight back out (issue #378)
|
||||
self:refreshStandingOnWarp()
|
||||
end
|
||||
|
||||
-- Silph Co card key doors + Rocket Hideout elevator gates: the .blk
|
||||
-- layouts ship with the doorways open; each floor's map script stamps
|
||||
-- the closed door block on load until its unlock event is set
|
||||
-- (scripts/SilphCo2F.asm SilphCo2FGateCallbackScript et al., closed
|
||||
-- blocks $54/$5f/$20; scripts/RocketHideoutB1F.asm +
|
||||
-- RocketHideoutB4F.asm ...DoorCallbackScript, closed blocks $54/$2d over
|
||||
-- the lift doorway). A door opens on its single `event`, or on `events`
|
||||
-- when every listed flag must be set (Rocket Hideout B4F's lift gate
|
||||
-- needs both guard trainers beaten -- CheckBothEventsSet). The callbacks
|
||||
-- run whenever BIT_CUR_MAP_LOADED_1 is set, which is map load AND the end
|
||||
-- of a battle on that map (home/trainers.asm EndTrainerBattle), so the
|
||||
-- gate opens with SFX_GO_INSIDE the moment the last guard falls (#372).
|
||||
function OverworldState:stampClosedDoors()
|
||||
local closedDoors = FieldDefaults.fieldValue(Game.data, "cardKeyDoors",
|
||||
"closedDoors")
|
||||
local floorDoors = self.map and closedDoors and closedDoors[self.map.id]
|
||||
if not floorDoors then return end
|
||||
local stamped, unlocked = false, false
|
||||
for _, door in ipairs(floorDoors) do
|
||||
local open
|
||||
if door.events then
|
||||
open = true
|
||||
for _, ev in ipairs(door.events) do
|
||||
if not Game.save.flags[ev] then open = false break end
|
||||
end
|
||||
else
|
||||
open = Game.save.flags[door.event]
|
||||
end
|
||||
local want = open and door.open or door.block
|
||||
if self.map:blockAt(door.bx, door.by) ~= want then
|
||||
self.map:setBlock(door.bx, door.by, want)
|
||||
stamped = true
|
||||
if open then unlocked = true end
|
||||
end
|
||||
end
|
||||
if stamped then self.map.renderer:rebuild() end
|
||||
if unlocked then require("src.core.Sound").play(Game.data, "Go_Inside") end
|
||||
end
|
||||
|
||||
function OverworldState:setMap(mapId, x, y, facing, opts)
|
||||
@@ -222,6 +275,13 @@ function OverworldState:setMap(mapId, x, y, facing, opts)
|
||||
self.tileAnimOverride.tileset.animation = self.tileAnimOverride.animation
|
||||
self.tileAnimOverride = nil
|
||||
end
|
||||
-- ADVANCED bakes colour into the tileset atlas, so the dark-cave shift has
|
||||
-- to be armed before that atlas is built for this map (#383); self.dark is
|
||||
-- settled below, once the map record is in hand.
|
||||
if PaletteFX.setDarkWorld(isDarkMap(mapId) and not Game.save.flashLit)
|
||||
and PaletteFX.usesGbcPack() then
|
||||
MapLoader.invalidateAll()
|
||||
end
|
||||
self.map = MapLoader.load(Game.data, mapId)
|
||||
-- STRENGTH deactivates on every real map load (home/overworld.asm
|
||||
-- EnterMap -> ResetUsingStrengthOutOfBattleBit clears BIT_STRENGTH_ACTIVE
|
||||
@@ -241,38 +301,7 @@ function OverworldState:setMap(mapId, x, y, facing, opts)
|
||||
self.map.renderer:rebuild()
|
||||
self.cutBlocks[mapId] = nil
|
||||
end
|
||||
-- Silph Co card key doors + Rocket Hideout elevator gates: the .blk
|
||||
-- layouts ship with the doorways open; each floor's map script stamps
|
||||
-- the closed door block on load until its unlock event is set
|
||||
-- (scripts/SilphCo2F.asm SilphCo2FGateCallbackScript et al., closed
|
||||
-- blocks $54/$5f/$20; scripts/RocketHideoutB1F.asm +
|
||||
-- RocketHideoutB4F.asm ...DoorCallbackScript, closed blocks $54/$2d over
|
||||
-- the lift doorway). A door opens on its single `event`, or on `events`
|
||||
-- when every listed flag must be set (Rocket Hideout B4F's lift gate
|
||||
-- needs both guard trainers beaten -- CheckBothEventsSet).
|
||||
local closedDoors = FieldDefaults.fieldValue(Game.data, "cardKeyDoors",
|
||||
"closedDoors")
|
||||
local floorDoors = closedDoors and closedDoors[mapId]
|
||||
if floorDoors then
|
||||
local stamped = false
|
||||
for _, door in ipairs(floorDoors) do
|
||||
local open
|
||||
if door.events then
|
||||
open = true
|
||||
for _, ev in ipairs(door.events) do
|
||||
if not Game.save.flags[ev] then open = false break end
|
||||
end
|
||||
else
|
||||
open = Game.save.flags[door.event]
|
||||
end
|
||||
local want = open and door.open or door.block
|
||||
if self.map:blockAt(door.bx, door.by) ~= want then
|
||||
self.map:setBlock(door.bx, door.by, want)
|
||||
stamped = true
|
||||
end
|
||||
end
|
||||
if stamped then self.map.renderer:rebuild() end
|
||||
end
|
||||
self:stampClosedDoors()
|
||||
-- forced dismount only where riding is disallowed (IsBikeRidingAllowed,
|
||||
-- home/overworld.asm: bike_riding_tilesets.asm tilesets plus the
|
||||
-- ROUTE_23/INDIGO_PLATEAU map exceptions)
|
||||
@@ -294,18 +323,11 @@ function OverworldState:setMap(mapId, x, y, facing, opts)
|
||||
-- Rock Tunnel darkness (wMapPalOffset, home/overworld.asm): dark
|
||||
-- until FLASH is used; the light persists between the tunnel floors
|
||||
-- and resets once outside
|
||||
local darkDef = Game.data.field.darkMaps
|
||||
self.dark = false
|
||||
if darkDef then
|
||||
local isDark = false
|
||||
for _, m in ipairs(darkDef.maps) do
|
||||
if m == mapId then isDark = true break end
|
||||
end
|
||||
if isDark then
|
||||
self.dark = not Game.save.flashLit
|
||||
else
|
||||
Game.save.flashLit = nil
|
||||
end
|
||||
if isDarkMap(mapId) then
|
||||
self:setDark(not Game.save.flashLit)
|
||||
else
|
||||
Game.save.flashLit = nil
|
||||
self:setDark(false)
|
||||
end
|
||||
if Game.data.field.flyWarps[mapId] then
|
||||
Game.save.visited = Game.save.visited or {}
|
||||
@@ -338,6 +360,36 @@ function OverworldState:setMap(mapId, x, y, facing, opts)
|
||||
else
|
||||
self.player = Player.new(Game.data, x, y, facing)
|
||||
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
|
||||
-- cannot leave a stale deferred PlayMapMusic pending
|
||||
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
|
||||
-- Yellow's companion Pikachu trails the player (never in
|
||||
-- self.entities: it does not block movement, pikachu_follow.asm)
|
||||
require("src.world.PikachuFollower").onMapEntered(Game, self)
|
||||
require("src.world.PikachuFollower").onMapEntered(Game, self, opts)
|
||||
|
||||
-- opts.keepMusic: the Oak-escort warp keeps MUSIC_MEET_PROF_OAK
|
||||
-- playing into the lab (BIT_NO_MAP_MUSIC in wStatusFlags7);
|
||||
@@ -529,9 +581,26 @@ end
|
||||
-- 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,
|
||||
-- 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()
|
||||
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
|
||||
|
||||
-- World-pass palette zones in world-canvas pixels: each visible map
|
||||
@@ -576,17 +645,19 @@ function OverworldState:sgbWorldZones()
|
||||
return zones
|
||||
end
|
||||
|
||||
-- Whether the dark-map shade shift (PaletteFX.DARK_BGP, armed in drawWorld)
|
||||
-- can actually reach this frame's world pass. It cannot in RED++: that mode
|
||||
-- bakes real per-tile colour into the tileset atlas and sgbWorldZones returns
|
||||
-- an EMPTY zone list above, so the world blits with no shade-remap shader at
|
||||
-- all and there is no palette left to permute. Only then does fxDark
|
||||
-- composite the darkness by hand (#322).
|
||||
function OverworldState:darkNeedsOverlay()
|
||||
if not self.dark then return false end
|
||||
local renderer = self.map and self.map.renderer
|
||||
return PaletteFX.usesGbcPack() and renderer ~= nil
|
||||
and renderer.gbcAtlas ~= nil
|
||||
-- wMapPalOffset, the one piece of state both halves of the darkness read:
|
||||
-- drawWorld arms PaletteFX.DARK_BGP off self.dark for the shade-remapped
|
||||
-- modes, and PaletteFX.setDarkWorld feeds the bakes ADVANCED does instead of
|
||||
-- shading (tileset atlas, sprite sheets) plus their cache keys. A bake cannot
|
||||
-- be re-shaded in place, so a change there rebuilds every resident map --
|
||||
-- every dark floor, not just this one, since FLASH lights them all (#383).
|
||||
function OverworldState:setDark(on)
|
||||
on = on and true or false
|
||||
self.dark = on
|
||||
if PaletteFX.setDarkWorld(on) and PaletteFX.usesGbcPack() and self.map then
|
||||
MapLoader.invalidateAll()
|
||||
self:reloadMap(self.map.id, "dark")
|
||||
end
|
||||
end
|
||||
|
||||
function OverworldState:npcByIndex(index)
|
||||
@@ -795,6 +866,21 @@ function OverworldState:update(dt)
|
||||
if ca.onDone then ca.onDone() end
|
||||
end
|
||||
end
|
||||
-- fishing pose tail: the rod is already gone, the pose holds for the
|
||||
-- frames the original spends unwinding the item menu (#384)
|
||||
if self.fishPose then
|
||||
self.fishPose = self.fishPose - 1
|
||||
if self.fishPose <= 0 then
|
||||
self.fishPose = nil
|
||||
self.player.fishing = nil
|
||||
end
|
||||
end
|
||||
-- Yellow's companion hopping up onto the Poke Center counter owns the
|
||||
-- world for its arc, the same way the heal machine below does (#417)
|
||||
if self.pikaHop then
|
||||
require("src.world.PikachuFollower").updateHop(self)
|
||||
return
|
||||
end
|
||||
if self.healAnim then
|
||||
local ha = self.healAnim
|
||||
local ev = OverworldState.stepHealAnim(ha)
|
||||
@@ -861,7 +947,12 @@ function OverworldState:update(dt)
|
||||
-- the emotion-bubble pause holds the world for a beat
|
||||
if self.emote then
|
||||
self.emote.frames = self.emote.frames - 1
|
||||
if self.emote.frames <= 0 then
|
||||
-- PikaPicAnimTimerAndJoypad (engine/pikachu/pikachu_pic_animation.asm)
|
||||
-- cuts a pikapic beat short on A or B; the "!" bubble hold has no such
|
||||
-- check, so only the pikapic marks itself skippable (#424)
|
||||
local cut = self.emote.skippable
|
||||
and (Game.input:wasPressed("a") or Game.input:wasPressed("b"))
|
||||
if cut or self.emote.frames <= 0 then
|
||||
local done = self.emote.onDone
|
||||
self.emote = nil
|
||||
if done then done() end
|
||||
@@ -955,19 +1046,33 @@ function OverworldState:dirHeld()
|
||||
or input:isDown("left") or input:isDown("right")
|
||||
end
|
||||
|
||||
-- The warp cell the player WARPED IN on is inert until they physically step
|
||||
-- off it: standing on it, or bonking a wall/edge from it, must not re-fire a
|
||||
-- warp (CheckWarpsNoCollision's arrival-disable; see setMap where
|
||||
-- warpEntryCell/justWarped are set and onStepComplete where they clear).
|
||||
-- Both stand-still warp triggers -- the map-edge exit (checkEdgeExit) and the
|
||||
-- blocked-step collision warp (handleInput) -- must consult this, or a corner
|
||||
-- staircase whose warp tile sits on the map edge (Red's-house (7,1)) bounces
|
||||
-- floors every input frame (issue #230).
|
||||
function OverworldState:onWarpArrivalCell()
|
||||
if self.justWarped then return true end
|
||||
local entry = self.warpEntryCell
|
||||
return entry ~= nil and self.player.cellX == entry.x
|
||||
and self.player.cellY == entry.y
|
||||
-- BIT_STANDING_ON_WARP (wMovementFlags): the warp under the player's feet may
|
||||
-- only fire from a collision -- the blocked-step warp (handleInput) and the
|
||||
-- map-edge exit (checkEdgeExit) -- while this flag is set. pokered clears it
|
||||
-- on every completed step, sets it again when that step lands on a warp
|
||||
-- square, then clears it once more when the square is a warp-activating tile
|
||||
-- that is not also a door tile (CheckWarpsNoCollisionLoop ->
|
||||
-- IsPlayerStandingOnDoorTileOrWarpTile, engine/overworld/player_state.asm);
|
||||
-- onStepComplete maintains it. ClearVariablesOnEnterMap does not clear
|
||||
-- wMovementFlags, so the flag rides through the warp itself: a house door
|
||||
-- tile ($1B) leaves it set, so you land on the interior mat still able to
|
||||
-- walk back out on that same tile (issue #378), while a staircase tile
|
||||
-- ($1A/$1C) clears it and cannot bounce you between floors (issue #230).
|
||||
function OverworldState:canCollisionWarp()
|
||||
return self.standingOnWarp == true
|
||||
end
|
||||
|
||||
-- Re-derive the flag from the tile under the player, the way a completed step
|
||||
-- does (and the way MapEntryAfterBattle's IsPlayerStandingOnWarp does after a
|
||||
-- battle): a door tile keeps it, a stair/ladder warp tile clears it.
|
||||
function OverworldState:refreshStandingOnWarp()
|
||||
local p = self.player
|
||||
self.standingOnWarp = false
|
||||
if self.map:warpAtCell(p.cellX, p.cellY)
|
||||
and not (self.map:isWarpTileCell(p.cellX, p.cellY)
|
||||
and not self.map:isDoorTileCell(p.cellX, p.cellY)) then
|
||||
self.standingOnWarp = true
|
||||
end
|
||||
end
|
||||
|
||||
function OverworldState:handleInput()
|
||||
@@ -983,19 +1088,38 @@ function OverworldState:handleInput()
|
||||
-- OverworldLoop (home/overworld.asm) gates ALL of JoypadOverworld on
|
||||
-- wWalkCounter == 0 ("if the player sprite has not yet completed the
|
||||
-- walking animation" it jumps straight to .moveAhead): A, START and
|
||||
-- direction initiation are only ever looked at while the player stands
|
||||
-- on a tile, and a button pressed mid-step is simply never seen.
|
||||
-- Without this gate a mid-step A/START pushed its TextBox/StartMenu
|
||||
-- right there and froze Red between tiles, mid-animation (#286). Held
|
||||
-- directions need no buffering -- isDown below picks them up on the
|
||||
-- landing frame.
|
||||
if self.player.moving then return end
|
||||
-- direction initiation are only ever ACTED ON while the player stands on
|
||||
-- a tile. Without this gate a mid-step A/START pushed its TextBox/
|
||||
-- StartMenu right there and froze Red between tiles, mid-animation
|
||||
-- (#286). Held directions need no buffering -- isDown below picks them
|
||||
-- up on the landing frame.
|
||||
--
|
||||
-- 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()
|
||||
return
|
||||
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")
|
||||
Screens.push(Game, "StartMenu")
|
||||
return
|
||||
@@ -1011,9 +1135,9 @@ function OverworldState:handleInput()
|
||||
local result, why = self.player:tryMove(dir, self.map, self.entities)
|
||||
-- a collision while standing on a warp square fires the warp when the
|
||||
-- extra check passes (CheckWarpsCollision: route-gate doorways, dock
|
||||
-- entrances, ...) -- but never on the inert cell we just warped in on
|
||||
-- (issue #230), which the completed-step path guards the same way.
|
||||
if result == "blocked" and not self:onWarpArrivalCell() then
|
||||
-- entrances, ...), and only while BIT_STANDING_ON_WARP is set (issue
|
||||
-- #230), which the map-edge path guards the same way.
|
||||
if result == "blocked" and self:canCollisionWarp() then
|
||||
local w = Warp.onCollision(self.map, Game.data.field.warpCarpets,
|
||||
self.player.cellX, self.player.cellY, dir)
|
||||
if w then
|
||||
@@ -1169,11 +1293,11 @@ function OverworldState:checkEdgeExit(dir)
|
||||
|
||||
local w = Warp.onEdge(self.map, p.cellX, p.cellY, dir)
|
||||
if w then
|
||||
-- ...but not while still standing on the warp cell we just arrived on
|
||||
-- (issue #230): fall through so pushing into the edge bonks (SFX +
|
||||
-- walk-in-place) instead of instantly re-warping. A real step onto an
|
||||
-- exit-carpet edge cleared warpEntryCell first, so those still fire.
|
||||
if self:onWarpArrivalCell() then return false end
|
||||
-- ...but only with BIT_STANDING_ON_WARP set: a staircase tile clears it,
|
||||
-- so pushing into the edge beside one bonks (SFX + walk-in-place) instead
|
||||
-- of bouncing floors (issue #230), while the door mat you warped in on
|
||||
-- keeps it and exits on that same tile (issue #378).
|
||||
if not self:canCollisionWarp() then return false end
|
||||
self:takeWarp(w.def)
|
||||
return true
|
||||
end
|
||||
@@ -1239,7 +1363,14 @@ function OverworldState:crossConnection(dir, conn)
|
||||
-- still protects the visible step when neighbor rebuild or the sync
|
||||
-- fallback stalls, and avoids the rare one-frame volume spike from a
|
||||
-- song swap mid-step.
|
||||
self:setMap(conn.map, x, y, p.facing, { seamless = true, keepMusic = true })
|
||||
-- Yellow's follower crosses the seam as one continuous walk, so hand the
|
||||
-- live instance through setMap (which rebuilds self.npcs) instead of
|
||||
-- letting it respawn behind the player (#427)
|
||||
local PikachuFollower = require("src.world.PikachuFollower")
|
||||
local pika = PikachuFollower.current(self)
|
||||
local fromX, fromY = p.cellX, p.cellY
|
||||
self:setMap(conn.map, x, y, p.facing,
|
||||
{ seamless = true, keepMusic = true, keepPikachu = pika })
|
||||
self.pendingSeamMusic = conn.map
|
||||
-- place the player one cell before the seam (their old world spot,
|
||||
-- which the neighbor strip renders identically) and start the step
|
||||
@@ -1249,6 +1380,8 @@ function OverworldState:crossConnection(dir, conn)
|
||||
local d = DIRVEC[dir]
|
||||
p.cellX, p.cellY = x - d[1], y - d[2]
|
||||
p.px, p.py = p.cellX * 16, p.cellY * 16
|
||||
-- same translation for the follower and the cell it is chasing
|
||||
PikachuFollower.rebase(self, p.cellX - fromX, p.cellY - fromY)
|
||||
self.camera:follow(p.px, p.py)
|
||||
p.facing = dir
|
||||
p.targetX, p.targetY = x, y
|
||||
@@ -1399,6 +1532,7 @@ function OverworldState:goFishing(rod)
|
||||
-- the bobber waits a beat before the verdict (the original's
|
||||
-- FishingInit dot animation); the rod pose draws in the meantime
|
||||
self.fishing = { facing = self.player.facing }
|
||||
self.player.fishing = true
|
||||
Game.stack:push(TextBox.new(Game, ". . .", function()
|
||||
-- FishingAnim (engine/overworld/player_animations.asm) holds
|
||||
-- BIT_LEDGE_OR_FISHING -- the rod OAM and the fishing pose -- through
|
||||
@@ -1406,12 +1540,20 @@ function OverworldState:goFishing(rod)
|
||||
-- must NOT vanish with the dots box (#321).
|
||||
if not enc then
|
||||
Game.stack:push(TextBox.new(Game, Strings("Not even a nibble!"), function()
|
||||
-- the rod OAM goes out with the verdict box (res BIT_LEDGE_OR_FISHING
|
||||
-- straight after PrintText) but the player keeps the patched tiles
|
||||
-- until the overworld reloads them a few frames later
|
||||
-- (RestoreScreenTilesAndReloadTilePatterns, home/palettes.asm ->
|
||||
-- ReloadMapSpriteTilePatterns, home/reload_sprites.asm) -- #384
|
||||
self.fishing = nil
|
||||
self.fishPose = 10
|
||||
end))
|
||||
return
|
||||
end
|
||||
Game.stack:push(TextBox.new(Game, Strings("Oh!\nIt's a bite!"), function()
|
||||
-- the bite goes straight into battle, which reloads the sprite tiles
|
||||
self.fishing = nil
|
||||
self.player.fishing = nil
|
||||
local BattleState = require("src.battle.BattleState")
|
||||
local battle = BattleState.newWild(Game, enc.species, enc.level, { hooked = true })
|
||||
if Game.save.safari and Map.inRegion(self.map.def, "SAFARI", "SAFARI_ZONE") then
|
||||
@@ -1497,13 +1639,18 @@ function OverworldState:interact()
|
||||
npc = self:npcAtCell(fx2, fy2)
|
||||
end
|
||||
if npc then
|
||||
if not npc.moving then
|
||||
if npc.pikachuFollower then
|
||||
-- the companion answers directly (TalkToPikachu), no map text id
|
||||
require("src.world.PikachuFollower").talk(Game, self, npc)
|
||||
else
|
||||
self:talkTo(npc)
|
||||
end
|
||||
if npc.pikachuFollower then
|
||||
-- the companion answers directly (TalkToPikachu), no map text id --
|
||||
-- and it answers mid-step too. pikachu_follow.asm walks the follower
|
||||
-- on the player's own step clock, so the original never has it
|
||||
-- mid-tile while the player stands; this port's follow is a frame
|
||||
-- late (the npc loop runs before Player:update lands the step), so
|
||||
-- the not-moving gate used to eat the A press in the frames right
|
||||
-- after landing -- exactly when you turn round to face it (#407).
|
||||
-- talk() lands the follower on its cell first.
|
||||
require("src.world.PikachuFollower").talk(Game, self, npc)
|
||||
elseif not npc.moving then
|
||||
self:talkTo(npc)
|
||||
end
|
||||
interacted(self, fx, fy, "npc", npc)
|
||||
return
|
||||
@@ -2013,6 +2160,7 @@ function OverworldState:billsHouseBillExits()
|
||||
local Commands = require("src.script.Commands")
|
||||
local ctx = { game = Game, save = Game.save, overworld = self }
|
||||
Commands.show_object(ctx, "BILLS_HOUSE", "BILLSHOUSE_BILL1")
|
||||
require("src.world.PikachuFollower").onBillExitedMachine(Game, self)
|
||||
local function done()
|
||||
Game.save.flags.EVENT_MET_BILL = true
|
||||
Game.save.flags.EVENT_MET_BILL_2 = true
|
||||
@@ -2087,25 +2235,26 @@ end
|
||||
-- Gen 1 has no confirmation prompt: using SURF gets straight on
|
||||
-- (_SurfingGotOnText, item_effects.asm .surf). Called from the party
|
||||
-- menu's SURF action (via useSurfFieldMove) once the facing tile has been
|
||||
-- confirmed to be water -- there is no overworld A-press hook.
|
||||
function OverworldState:trySurf(fx, fy)
|
||||
-- confirmed to be water -- there is no overworld A-press hook. onClose is
|
||||
-- that menu's own close, called when the got-on text ends (see below).
|
||||
function OverworldState:trySurf(fx, fy, onClose)
|
||||
local mon = self:partyKnows("SURF")
|
||||
if not mon then return end
|
||||
local name = mon.nickname or Game.data.pokemon[mon.species].name
|
||||
local p = self.player
|
||||
local text = (Game.data.text._SurfingGotOnText or Strings("{PLAYER} got on\n{RAM:wNameBuffer}!"))
|
||||
:gsub("{RAM:wNameBuffer}", name)
|
||||
-- GBPalWhiteOutWithDelay3 runs while the got-on text is still up
|
||||
-- (start_sub_menus.asm .surf), so the blink reads as a text flash
|
||||
-- instead of a flashbang on the empty map (#320). The flash sits
|
||||
-- under the textbox on the stack: only the top state updates, so it
|
||||
-- holds its frames until the text closes. surfing (and the sprite
|
||||
-- swap) only applies when the step happens -- no paddling on land.
|
||||
Game.stack:push(require("src.render.Transition").whiteFlash(Game))
|
||||
-- UseItem prints the got-on text with the party menu still on screen and
|
||||
-- GBPalWhiteOutWithDelay3 + .goBackToMap only run after it
|
||||
-- (start_sub_menus.asm .surf), so the text reads over the menu and the
|
||||
-- blink is the menu closing, not a flashbang on the empty map (#320,
|
||||
-- #385). The mount rides the blink, so nothing paddles on land.
|
||||
Game.stack:push(TextBox.new(Game, text, function()
|
||||
if onClose then onClose() end
|
||||
p.surfing = true
|
||||
require("src.core.Music").setSurfing(Game.data, true)
|
||||
self:stepForwardOrCrossEdge(p.facing)
|
||||
Game.stack:push(require("src.render.Transition").whiteFlash(Game, nil,
|
||||
function() self:stepForwardOrCrossEdge(p.facing) end))
|
||||
end))
|
||||
end
|
||||
|
||||
@@ -2509,41 +2658,56 @@ function OverworldState:nurseHeal(onDone, npc)
|
||||
hello = hello .. "\f"
|
||||
.. (t._ShallWeHealYourPokemonText or Strings("Shall we heal your\nPOKéMON?"))
|
||||
end
|
||||
-- Yellow's companion has its own beat threaded through this sequence
|
||||
local Follower = require("src.world.PikachuFollower")
|
||||
Game.stack:push(TextBox.new(Game, hello, nil, { choice = function(yes)
|
||||
if not yes then
|
||||
Game.stack:push(TextBox.new(Game, bye, onDone))
|
||||
return
|
||||
end
|
||||
local need = t._NeedYourPokemonText or Strings("OK. We'll need\nyour POKéMON.")
|
||||
Game.stack:push(TextBox.new(Game, need, function()
|
||||
-- the nurse turns to the machine, the map music stops, and the
|
||||
-- party heals before the machine runs (predef HealParty)
|
||||
if npc then npc.facing = "left" end
|
||||
require("src.core.Music").stop()
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
for _, mon in ipairs(Game.save.party) do
|
||||
Pokemon.heal(mon)
|
||||
end
|
||||
Game.save.lastHeal = { -- SetLastBlackoutMap
|
||||
map = self.map.id, x = self.player.cellX, y = self.player.cellY,
|
||||
-- the town door of this interior, for LAST_MAP exits after a
|
||||
-- blackout/ESCAPE ROPE warp here
|
||||
outdoor = self.lastOutdoor
|
||||
and { id = self.lastOutdoor.id, x = self.lastOutdoor.x, y = self.lastOutdoor.y }
|
||||
or nil,
|
||||
}
|
||||
self.healAnim = { balls = #Game.save.party, lit = 0, timer = 0,
|
||||
visible = true,
|
||||
-- map anchor: the player's cell when healing
|
||||
-- began (the GB's fixed screen coords assume it
|
||||
-- BG-aligned at (64,64))
|
||||
px = self.player.cellX * 16,
|
||||
py = self.player.cellY * 16 }
|
||||
self.healAnim.onDone = function()
|
||||
if npc then npc:facePlayer(self.player) end
|
||||
self:finishNurseHeal(bye, onDone)
|
||||
end
|
||||
end))
|
||||
-- accepting the heal sends the companion up onto the counter to Nurse
|
||||
-- Joy first: pokecenter.asm runs `callfar PikachuWalksToNurseJoy`
|
||||
-- between SetLastBlackoutMap and NeedYourPokemonText, and the hop has
|
||||
-- to finish before the text box goes up because only the top state
|
||||
-- updates. No follower (or not Yellow) calls straight through (#417).
|
||||
Follower.hopToCounter(self, function()
|
||||
Game.stack:push(TextBox.new(Game, need, function()
|
||||
-- the nurse turns to the machine, the map music stops, and the
|
||||
-- party heals before the machine runs (predef HealParty)
|
||||
if npc then npc.facing = "left" end
|
||||
-- DisablePikachuOverworldSpriteDrawing: Pikachu goes behind the
|
||||
-- counter with the party for the machine animation
|
||||
Follower.setVisible(self, false)
|
||||
require("src.core.Music").stop()
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
for _, mon in ipairs(Game.save.party) do
|
||||
Pokemon.heal(mon)
|
||||
end
|
||||
Game.save.lastHeal = { -- SetLastBlackoutMap
|
||||
map = self.map.id, x = self.player.cellX, y = self.player.cellY,
|
||||
-- the town door of this interior, for LAST_MAP exits after a
|
||||
-- blackout/ESCAPE ROPE warp here
|
||||
outdoor = self.lastOutdoor
|
||||
and { id = self.lastOutdoor.id, x = self.lastOutdoor.x, y = self.lastOutdoor.y }
|
||||
or nil,
|
||||
}
|
||||
self.healAnim = { balls = #Game.save.party, lit = 0, timer = 0,
|
||||
visible = true,
|
||||
-- map anchor: the player's cell when healing
|
||||
-- began (the GB's fixed screen coords assume it
|
||||
-- BG-aligned at (64,64))
|
||||
px = self.player.cellX * 16,
|
||||
py = self.player.cellY * 16 }
|
||||
self.healAnim.onDone = function()
|
||||
-- EnablePikachuOverworldSpriteDrawing, before the fighting-fit
|
||||
-- line: it comes back on the counter facing the player
|
||||
Follower.setVisible(self, true)
|
||||
if npc then npc:facePlayer(self.player) end
|
||||
self:finishNurseHeal(bye, onDone)
|
||||
end
|
||||
end))
|
||||
end)
|
||||
end }))
|
||||
end
|
||||
|
||||
@@ -3024,18 +3188,16 @@ function OverworldState:onStepComplete()
|
||||
entry = nil
|
||||
end
|
||||
-- The arrival disable is POSITIONAL: warpEntryCell above is the whole
|
||||
-- test. justWarped only records that an arrival happened (it still
|
||||
-- backs onWarpArrivalCell's bonk guard for issue #230), so consuming a
|
||||
-- completed step with it swallowed the warp under the player's feet,
|
||||
-- which is why a second ladder one cell from the first did nothing
|
||||
-- (Seafoam B3F has warp tiles on (25,3) and (25,4)) -- issue #265.
|
||||
-- pokered has no such counter: every completed step runs
|
||||
-- CheckWarpsNoCollision (home/overworld.asm), and BIT_STANDING_ON_WARP,
|
||||
-- the flag the bonk path needs, is only set by
|
||||
-- CheckWarpsNoCollisionLoop itself or by IsPlayerStandingOnWarp from
|
||||
-- MapEntryAfterBattle, never on a plain warp arrival -- which is
|
||||
-- exactly what warpEntryCell reproduces.
|
||||
self.justWarped = false
|
||||
-- test. Consuming a completed step with a one-shot "just warped" counter
|
||||
-- instead swallowed the warp under the player's feet, which is why a
|
||||
-- second ladder one cell from the first did nothing (Seafoam B3F has warp
|
||||
-- tiles on (25,3) and (25,4)) -- issue #265. pokered has no such counter:
|
||||
-- every completed step runs CheckWarpsNoCollision (home/overworld.asm).
|
||||
-- That same step is where BIT_STANDING_ON_WARP is maintained: cleared
|
||||
-- before the check (home/overworld.asm:324), set again while standing on a
|
||||
-- warp square, then cleared once more when the square is a warp-activating
|
||||
-- tile that is not a door tile (IsPlayerStandingOnDoorTileOrWarpTile).
|
||||
self:refreshStandingOnWarp()
|
||||
if entry then
|
||||
-- still standing on the warp we arrived through; do not re-trigger it
|
||||
else
|
||||
@@ -3473,6 +3635,10 @@ function OverworldState:afterBattle(result, battle)
|
||||
{ save = Game.save, healTarget = self:healPoint() })
|
||||
self:warpToHealPoint(evolutions)
|
||||
else
|
||||
-- EndTrainerBattle sets BIT_CUR_MAP_LOADED_1 (home/trainers.asm), which
|
||||
-- re-runs the floor's door callback: beating the last Rocket Hideout guard
|
||||
-- opens the lift gate without leaving the map (#372)
|
||||
self:stampClosedDoors()
|
||||
-- throwing the last SAFARI BALL ends the game
|
||||
if Game.save.safari and Game.save.safari.balls <= 0 then
|
||||
self:safariGameOver(Strings("PA: You're out of\nSAFARI BALLs!"))
|
||||
@@ -3606,12 +3772,12 @@ function OverworldState:startWarpTo(mapId, x, y, facing, onDone, opts)
|
||||
self.arriveWarp = nil
|
||||
Game.stack:push(Transition.new(Game, function()
|
||||
self:setMap(mapId, x, y, facing or "down", opts)
|
||||
self.justWarped = true
|
||||
-- The warp we land ON stays inert until we physically step off it, so a
|
||||
-- warp whose destination cell is itself a warp cannot bounce us straight
|
||||
-- back (elevator cars, stacked stair/door mats). This generalizes the
|
||||
-- one-step justWarped guard, which only skipped the very next frame's
|
||||
-- check and so let a mon walked back onto the pad re-trigger it.
|
||||
-- The warp we land ON stays inert for the completed-step check until we
|
||||
-- physically step off it, so a warp whose destination cell is itself a
|
||||
-- warp cannot bounce us straight back (elevator cars, stacked stair/door
|
||||
-- mats). BIT_STANDING_ON_WARP is deliberately NOT touched here:
|
||||
-- ClearVariablesOnEnterMap leaves wMovementFlags alone, so the flag the
|
||||
-- departing tile set rides through the warp (issue #378).
|
||||
self.warpEntryCell = { x = x, y = y }
|
||||
-- Fly/Teleport/Dig/Escape-Rope landings poof the player back in
|
||||
-- (player_animations.asm EnterMapAnim). Blackouts and ordinary
|
||||
@@ -3637,8 +3803,8 @@ function OverworldState:startWarpTo(mapId, x, y, facing, onDone, opts)
|
||||
-- PlayerStepOutFromDoor (engine/overworld/auto_movement.asm): any
|
||||
-- warp that lands on a door tile auto-steps south once, indoor or
|
||||
-- outdoor. Auto-walk leaves the mat, so the arrival disable
|
||||
-- (warpEntryCell / justWarped) is unnecessary -- and would let you
|
||||
-- stand on the door without re-entering if you hold back into it.
|
||||
-- (warpEntryCell) is unnecessary -- and would let you stand on the
|
||||
-- door without re-entering if you hold back into it.
|
||||
-- The walk-out is a simulated d-pad press (wSimulatedJoypadStates),
|
||||
-- not a forced move, so it obeys collision: on a landing with a
|
||||
-- solid cell south of the door (the mansion stair landings back
|
||||
@@ -3647,7 +3813,6 @@ function OverworldState:startWarpTo(mapId, x, y, facing, onDone, opts)
|
||||
if self.map:isDoorTileCell(self.player.cellX, self.player.cellY) then
|
||||
if Collision.canMove(self.map, self.entities, self.player, "down") then
|
||||
self.warpEntryCell = nil
|
||||
self.justWarped = false
|
||||
self:scriptMove(self.player, "down", 1)
|
||||
else
|
||||
self.player.facing = "down"
|
||||
@@ -3817,6 +3982,31 @@ function OverworldState:draw()
|
||||
self:drawUI()
|
||||
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.
|
||||
-- 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
|
||||
@@ -4056,7 +4246,7 @@ function OverworldState:drawWorld()
|
||||
local drawn = false
|
||||
if bubble and bubble.path then
|
||||
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
|
||||
end)
|
||||
-- EXCLAMATION_BUBBLE is index 0 -> first crop; the emote command
|
||||
@@ -4090,23 +4280,6 @@ function OverworldState:drawWorld()
|
||||
end
|
||||
end
|
||||
|
||||
-- Rock Tunnel darkness. The original never cuts a window of light around
|
||||
-- the player: it shifts the BG palette for the WHOLE screen (wMapPalOffset
|
||||
-- = 6 -> home/fade.asm LoadGBPal -> FadePal2 `dc 3,3,3,2`) and FLASH shifts
|
||||
-- it back (#322). PaletteFX.DARK_BGP does that for every shade-remapped
|
||||
-- mode, armed at the top of drawWorld. RED++ is the one mode with no
|
||||
-- palette left to shift -- TileRenderer bakes true colour into the tileset
|
||||
-- atlas and sgbWorldZones hands the blit an EMPTY zone list, so no shader
|
||||
-- runs over the world at all -- so there the darkness is composited instead:
|
||||
-- a flat veil over the whole world view (surveying still cannot peek past
|
||||
-- it) at the 85/255 brightness FadePal2 leaves DMG white on.
|
||||
local function fxDark()
|
||||
if not self:darkNeedsOverlay() then return end
|
||||
love.graphics.setColor(0, 0, 0, 1 - 85 / 255)
|
||||
love.graphics.rectangle("fill", 0, 0, vw, vh)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
end
|
||||
|
||||
-- the FLY bird sweeping off with the player
|
||||
local function fxBird()
|
||||
if not self.flyAnim then return end
|
||||
@@ -4184,7 +4357,7 @@ function OverworldState:drawWorld()
|
||||
return PaletteFX.pal(Game.data, self:paletteNameFor(map or self.map))
|
||||
end,
|
||||
fx = { heal = fxHeal, dust = fxDust, cutTree = fxCutTree,
|
||||
emote = fxEmote, dark = fxDark, bird = fxBird, rod = fxRod },
|
||||
emote = fxEmote, bird = fxBird, rod = fxRod },
|
||||
}
|
||||
-- Draw every active field FX into the finished scene. `project(wx, wy)`
|
||||
-- maps a world point to canvas pixels (nil when it is behind the
|
||||
@@ -4235,17 +4408,6 @@ function OverworldState:drawWorld()
|
||||
if self.fishing then
|
||||
at(fxRod, self.player.px + 8, self.player.py + 16)
|
||||
end
|
||||
-- Rock Tunnel darkness is a screen-space veil, not a ground object:
|
||||
-- draw it flat over the finished scene like the tilt path. It fills
|
||||
-- the view in world-pixel units, so it only needs the scale, and it is
|
||||
-- only needed at all in the mode whose palette cannot carry the
|
||||
-- darkening itself (see fxDark).
|
||||
if self:darkNeedsOverlay() then
|
||||
love.graphics.push()
|
||||
love.graphics.scale(scale, scale)
|
||||
fxDark()
|
||||
love.graphics.pop()
|
||||
end
|
||||
end
|
||||
override = Pipelines.drawWorld(pipelineId, ctx)
|
||||
-- world post-processes (a miniature-diorama blur, a colour grade) fold
|
||||
@@ -4308,7 +4470,6 @@ function OverworldState:drawWorld()
|
||||
fxDust()
|
||||
fxCutTree()
|
||||
fxEmote()
|
||||
fxDark()
|
||||
fxBird()
|
||||
fxRod()
|
||||
else
|
||||
@@ -4397,12 +4558,6 @@ function OverworldState:drawWorld()
|
||||
self:billboard(fx, fy, vw, vh, zoneColorsAt(zones, fx, fy), false, fxRod)
|
||||
end
|
||||
|
||||
-- Rock Tunnel darkness is a screen-space veil, not a ground object --
|
||||
-- draw it flat into the upright canvas so it darkens the final
|
||||
-- composited scene uniformly. It no-ops unless this mode needs the
|
||||
-- composited fallback (see fxDark).
|
||||
fxDark()
|
||||
|
||||
Game.renderer:endUprightPass()
|
||||
end
|
||||
|
||||
@@ -4410,6 +4565,33 @@ end
|
||||
|
||||
-- screen-space overlays: drawn to the UI canvas at normal scale
|
||||
function OverworldState:drawUI()
|
||||
-- TalkToPikachu's picture box (engine/pikachu/pikachu_pic_animation.asm
|
||||
-- PlacePikapicTextBoxBorder: TextBoxBorder at (6,5) with b,c = 5,5, so a
|
||||
-- 7x7 box holding the 5x5 pic at (7,6) -- PikaAnimTilemap_1). The
|
||||
-- per-emotion frame gfx (gfx/pikachu/unknown_*) are not extracted, so the
|
||||
-- front pic stands in for every frame of the script; PikachuFollower
|
||||
-- .picLift lifts it on the runs that draw the alternate pose, and the
|
||||
-- script's own duration times the beat (#407, #424). Palette zone
|
||||
-- PAL_PIKACHU_PORTRAIT covers (7,6)-(11,10) via sgbPalettes above.
|
||||
if self.emote and self.emote.pikaPic then
|
||||
require("src.render.Font").drawBox(6, 5, 7, 7)
|
||||
-- one image per path, cached: this draws every frame of the hold, and
|
||||
-- a mod skin can move the path between talks
|
||||
if self.pikaPicPath ~= self.emote.pikaPic then
|
||||
local ok, loaded = pcall(love.graphics.newImage, self.emote.pikaPic)
|
||||
self.pikaPicImg = ok and loaded or nil
|
||||
self.pikaPicPath = self.emote.pikaPic
|
||||
end
|
||||
local img = self.pikaPicImg
|
||||
if img then
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
local w, h = img:getDimensions()
|
||||
local lift = require("src.world.PikachuFollower").picLift(self.emote)
|
||||
love.graphics.draw(img, math.floor(56 + (40 - w) / 2),
|
||||
math.floor(48 + (40 - h) / 2) - lift)
|
||||
end
|
||||
end
|
||||
|
||||
-- poison step flicker (ChangeBGPalColor0_4Frames: dark for two
|
||||
-- 4-frame pulses)
|
||||
if self.poisonFlash and self.poisonFlash > 0 then
|
||||
@@ -4428,6 +4610,11 @@ function OverworldState:captureSave(save)
|
||||
save.player.x = self.player.cellX
|
||||
save.player.y = self.player.cellY
|
||||
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
|
||||
|
||||
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