mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-14 23:31:27 +02:00
Compare commits
30 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0ca1defb66 | |||
| 370ddea0b8 | |||
| a03f69926e | |||
| 04ece522d7 | |||
| 5a48a61f2e | |||
| fd66a04686 | |||
| b69f8c27c9 | |||
| 66f0326492 | |||
| dbab4a2116 | |||
| 3e8da9b232 | |||
| 223202f230 | |||
| 3078c20e1d | |||
| 617a87a47b | |||
| 8539a6b268 | |||
| f9f38d161f | |||
| 8b85cda84c | |||
| 1deba07106 | |||
| c24c7cbcb7 | |||
| c2334f2f91 | |||
| bfac5fe327 | |||
| 3b1032cc63 | |||
| 72f83760b7 | |||
| 63a179b77a | |||
| 37ba5c3879 | |||
| 00f86ca192 | |||
| b0868e7571 | |||
| 9ec3ff26d8 | |||
| 726e1d4ec5 | |||
| 5b3c5f5da8 | |||
| c45f6be8f2 |
@@ -11,7 +11,7 @@ body:
|
||||
If what you want is a gameplay, cosmetic, audio, or QoL change that a Lua mod
|
||||
could ship — running shoes, alternate sprites, day/night, shiny indicators,
|
||||
Gen 2-like battle toggles, soundtrack packs — open a
|
||||
**[Mod request](https://github.com/bryanthaboi/pokemon-gen1-recomp-project/issues/new?template=mod_request.yml)**
|
||||
**[Mod request](https://github.com/bryanthaboi/gen1recomp/issues/new?template=mod_request.yml)**
|
||||
instead.
|
||||
|
||||
"Can we add X" on its own is hard to act on. Say what you want, why you want it,
|
||||
|
||||
@@ -10,7 +10,7 @@ body:
|
||||
soundtrack packs, Gen 2-like battle toggles, map cosmetics, bag QoL, etc.
|
||||
|
||||
The engine already exposes a lot of this through registries and hooks
|
||||
([modding wiki](https://github.com/bryanthaboi/pokemon-gen1-recomp-project/wiki)).
|
||||
([modding wiki](https://github.com/bryanthaboi/gen1recomp/wiki)).
|
||||
Use a **Feature request** instead for launcher / ports / video options /
|
||||
networking / save tooling / new API seams.
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
name: Release
|
||||
|
||||
# Builds the macOS, Windows, and Linux desktop apps plus an Android APK
|
||||
# on the self-hosted Mac runner, and publishes them as a GitHub 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.
|
||||
#
|
||||
# Versioning:
|
||||
# - First ever release is 0.1.0.
|
||||
@@ -169,6 +170,13 @@ jobs:
|
||||
set -euo pipefail
|
||||
scripts/build_android.sh --version "${{ steps.ver.outputs.version }}"
|
||||
|
||||
- name: Build Anbernic RG34XXSP port
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Self-contained aarch64 PortMaster-style pack; pulls the LÖVE 11.5
|
||||
# runtime from PortMaster-GUI, so it needs no signing/notarization.
|
||||
./build-rg34xxsp.sh --version "${{ steps.ver.outputs.version }}"
|
||||
|
||||
- name: Notarize & staple macOS app
|
||||
run: |
|
||||
set -euo pipefail
|
||||
@@ -218,6 +226,12 @@ jobs:
|
||||
[ -n "$apk" ] || { echo "::error::no Android APK found under dist/android/debug"; exit 1; }
|
||||
cp "$apk" "$outdir/gen1recomp-${v}-android.apk"
|
||||
|
||||
# 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"
|
||||
[ -f "$rg34" ] || { echo "::error::$rg34 not found (expected from ./build-rg34xxsp.sh)"; exit 1; }
|
||||
cp "$rg34" "$outdir/gen1recomp-${v}-rg34xxsp-stockos64-mod.zip"
|
||||
|
||||
# Platform-independent update payload, built alongside the desktop
|
||||
# apps above (same game.love that gets fused into each of them).
|
||||
love_file=".bazinga/work/game.love"
|
||||
@@ -283,6 +297,7 @@ jobs:
|
||||
"dist/release/gen1recomp-${v}-windows.zip" \
|
||||
"dist/release/gen1recomp-${v}-linux.zip" \
|
||||
"dist/release/gen1recomp-${v}-android.apk" \
|
||||
"dist/release/gen1recomp-${v}-rg34xxsp-stockos64-mod.zip" \
|
||||
"dist/release/gen1recomp-${v}.love" \
|
||||
"dist/release/sha256sums.txt"
|
||||
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
# Pokemon Gen 1 Recompilation Project
|
||||
# Gen1Recomp
|
||||
|
||||
A native LÖVE2D recreation of Pokemon Red and Blue. The engine and map
|
||||
A native LÖVE2D recreation of Poke Red and Blue. 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)
|
||||
|
||||
This project does not include a ROM, emulate the Game Boy, transpile assembly,
|
||||
or download a disassembly. A canonical US Pokemon Red or Blue ROM is the only
|
||||
or download a disassembly. A canonical US Poke Red or Blue ROM is the only
|
||||
game content input.
|
||||
|
||||
The ROM is verified, used during import, and then released from memory. It is
|
||||
@@ -33,46 +33,82 @@ audio channel programs copied out of the verified ROM.
|
||||
|
||||
## Controls
|
||||
|
||||
| Action | Keyboard | Controller |
|
||||
|--------|----------|------------|
|
||||
| Move | Arrow keys / WASD | D-pad / left stick |
|
||||
| A | Z / Enter / Space | A |
|
||||
| B | X / Backspace | B |
|
||||
| Start | Escape | Start |
|
||||
| Select | Tab / Shift | Back / Select |
|
||||
|
||||
| Action | Keyboard | Controller |
|
||||
| ------ | ----------------- | ------------------ |
|
||||
| Move | Arrow keys / WASD | D-pad / left stick |
|
||||
| A | Z / Enter / Space | A |
|
||||
| B | X / Backspace | B |
|
||||
| Start | Escape | Start |
|
||||
| Select | Tab / Shift | Back / Select |
|
||||
|
||||
|
||||
Rebind any of these in-game under **OPTIONS → CONTROLS**. Controllers are
|
||||
supported out of the box.
|
||||
|
||||
### Hotkeys
|
||||
|
||||
| Key | What it does |
|
||||
|-----|----------------|
|
||||
| `-` / `=` | Zoom out / in (overworld; also mouse wheel) |
|
||||
| `2` | Cycle COLORS |
|
||||
| `3` | Cycle TILT (free-roam overworld) |
|
||||
| `4` | Cycle ZOOM through every level (free-roam overworld) |
|
||||
| `5` | Cycle GBC FX |
|
||||
| `F1` | Save |
|
||||
| `F2` | Load |
|
||||
| `F10` | Open / close the mod manager |
|
||||
|
||||
| Key | What it does |
|
||||
| --------- | ---------------------------------------------------- |
|
||||
| `-` / `=` | Zoom out / in (overworld; also mouse wheel) |
|
||||
| `2` | Cycle COLORS |
|
||||
| `3` | Cycle TILT (free-roam overworld) |
|
||||
| `4` | Cycle ZOOM through every level (free-roam overworld) |
|
||||
| `5` | Cycle GBC FX |
|
||||
| `F1` | Save |
|
||||
| `F2` | Load |
|
||||
| `F10` | Open / close the mod manager |
|
||||
|
||||
|
||||
COLORS, TILT, ZOOM, GBC FX, and VOID FILL are also in the Options menu
|
||||
and persist in `options.lua`.
|
||||
|
||||
### Rulesets
|
||||
|
||||
**OPTIONS → RULESET** picks which set of Gen 1 battle behaviors to run.
|
||||
Both rulesets share the same damage formulas; they differ only in whether
|
||||
the original's quirks are kept. The setting persists in `options.lua`, and
|
||||
mods can register their own.
|
||||
|
||||
`gen1_faithful` is the default and reproduces the original cartridge,
|
||||
famous bugs included:
|
||||
|
||||
| Rule | Behavior |
|
||||
| --------------------------- | ----------------------------------------------------- |
|
||||
| `oneIn256Miss` | A 100%-accurate move still misses on a roll of 255 |
|
||||
| `critUsesBaseSpeed` | Crit rate reads base speed, not the current stat |
|
||||
| `critIgnoresStages` | Crit rate ignores stat stages |
|
||||
| `focusEnergyBug` | FOCUS ENERGY quarters the crit rate instead of x4 |
|
||||
| `enemyUnlimitedPP` | Enemies never spend PP, so they never Struggle |
|
||||
| `hyperBeamSkipRechargeOnKO` | HYPER BEAM skips its recharge when the target faints |
|
||||
| `randMin` / `randMax` | Damage random factor 217-255 |
|
||||
|
||||
`modern_clean` keeps the formulas but removes the notorious quirks:
|
||||
|
||||
| Rule | Behavior |
|
||||
| --------------------------- | ----------------------------------------------------- |
|
||||
| `oneIn256Miss` | Off: a 100%-accurate move always hits |
|
||||
| `critUsesBaseSpeed` | Unchanged: crit rate still reads base speed |
|
||||
| `critIgnoresStages` | Off: stat stages count toward the crit rate |
|
||||
| `focusEnergyBug` | Off: FOCUS ENERGY raises the crit rate as intended |
|
||||
| `enemyUnlimitedPP` | Off: enemies deplete PP and Struggle when empty |
|
||||
| `hyperBeamSkipRechargeOnKO` | Off: HYPER BEAM always recharges, like Gen 2+ |
|
||||
| `randMin` / `randMax` | Damage random factor 217-255, same as faithful |
|
||||
|
||||
## Running From Source
|
||||
|
||||
Requires LÖVE 11.x. Place a Red or Blue ROM in the project folder and
|
||||
double-click `Play-Mac.command` or `Play-Windows.bat`, or run:
|
||||
|
||||
```sh
|
||||
scripts/setup.sh --rom "/path/to/Pokemon Red.gb" # or Pokemon Blue.gb
|
||||
scripts/setup.sh --rom "/path/to/Poke Red.gb" # or Poke Blue.gb
|
||||
scripts/run.sh
|
||||
```
|
||||
|
||||
then `love .` for later launches. Windows PowerShell scripts, the optional
|
||||
developer data build, test suites, and cache management are covered in
|
||||
[Developer Setup](https://github.com/bryanthaboi/pokemon-gen1-recomp-project/wiki/Guide-Developer-Setup).
|
||||
[Developer Setup](https://github.com/bryanthaboi/gen1recomp/wiki/Guide-Developer-Setup).
|
||||
|
||||
## Portable Mode
|
||||
|
||||
@@ -80,7 +116,7 @@ By default the game keeps your save, options, and the private ROM-derived
|
||||
data cache in your OS's normal per-user app data folder. To keep everything
|
||||
next to the game instead (handy for a USB stick or portable drive you carry
|
||||
between computers), drop an empty file named `portable.txt` next to the app
|
||||
(next to `PokemonRed.app`/`.exe`, or next to `main.lua`/`conf.lua` when
|
||||
(next to `gen1recomp.app`/`.exe`, or next to `main.lua`/`conf.lua` when
|
||||
running from source), then launch the game. Portable mode is desktop-only
|
||||
(Windows, Linux, macOS); it has no effect on Android or iOS, where the app
|
||||
runs from a read-only package.
|
||||
@@ -88,14 +124,21 @@ runs from a read-only package.
|
||||
With `portable.txt` present:
|
||||
|
||||
- `save.lua`, `save.lua.bak`, and `options.lua` are read from and written to
|
||||
that same folder instead of the OS save directory.
|
||||
that same folder instead of the OS save directory.
|
||||
- A ROM import writes the generated `data/generated` and `assets/generated`
|
||||
cache straight into that folder too (nothing is left in the OS save
|
||||
directory), so a later launch reuses it without asking for the ROM again
|
||||
even on a different computer, as long as the same folder comes along.
|
||||
cache straight into that folder too (nothing is left in the OS save
|
||||
directory), so a later launch reuses it without asking for the ROM again
|
||||
even on a different computer, as long as the same folder comes along.
|
||||
- Deleting `portable.txt` switches back to the normal OS save directory; nothing
|
||||
already written to either location is touched automatically, so copy files
|
||||
over yourself if you want to carry existing progress across the switch.
|
||||
already written to either location is touched automatically, so copy files
|
||||
over yourself if you want to carry existing progress across the switch.
|
||||
|
||||
## Handhelds
|
||||
|
||||
A PortMaster-style port for the **Anbernic RG34XXSP** on Stock OS 64-bit MOD
|
||||
ships with every release as `gen1recomp-*-rg34xxsp-stockos64-mod.zip`.
|
||||
Install steps, controls, and troubleshooting live in
|
||||
[docs/anbernic-rg34xxsp.md](docs/anbernic-rg34xxsp.md).
|
||||
|
||||
## Modding
|
||||
|
||||
@@ -103,33 +146,35 @@ The game ships a native mod platform: content registries, events and hooks,
|
||||
per-mod saves and options, and an in-game manager. The full modding book —
|
||||
getting started, a twelve-rung tutorial ladder, a cookbook, and the generated
|
||||
reference — lives on the
|
||||
[project wiki](https://github.com/bryanthaboi/pokemon-gen1-recomp-project/wiki).
|
||||
[project wiki](https://github.com/bryanthaboi/gen1recomp/wiki).
|
||||
|
||||
Shipped example mods, one per kind of author, live in [`mods/`](mods/).
|
||||
Shipped example mods, one per kind of author, live in `[mods/](mods/)`.
|
||||
|
||||
## Bugs and Ideas
|
||||
|
||||
Found a bug? A warp dropping you somewhere it shouldn't, a battle doing math
|
||||
that looks wrong, text in the wrong box, anything that does not match the
|
||||
original game.
|
||||
[Open a bug report](https://github.com/bryanthaboi/pokemon-gen1-recomp-project/issues/new?template=bug_report.yml).
|
||||
[Open a bug report](https://github.com/bryanthaboi/gen1recomp/issues/new?template=bug_report.yml).
|
||||
Attach a screenshot if you can. It saves a lot of back and forth, and if you
|
||||
can't get one, the form asks you to describe what you saw instead.
|
||||
|
||||
Thought of a feature that could be good, or a way to improve one that already
|
||||
exists?
|
||||
[Open a feature request](https://github.com/bryanthaboi/pokemon-gen1-recomp-project/issues/new?template=feature_request.yml).
|
||||
[Open a feature request](https://github.com/bryanthaboi/gen1recomp/issues/new?template=feature_request.yml).
|
||||
Say what you want, why it is worth doing, and how you picture it working. A
|
||||
request with real detail is one that can actually get built.
|
||||
|
||||
## More
|
||||
|
||||
- [Link play](https://github.com/bryanthaboi/pokemon-gen1-recomp-project/wiki/Guide-Link-Play)
|
||||
— START > LINK connects two copies directly over UDP.
|
||||
- [Save editor](https://github.com/bryanthaboi/pokemon-gen1-recomp-project/wiki/Guide-Save-Editor)
|
||||
— edit party, boxes, items, events, and Pokédex flags outside the game.
|
||||
- [Link play](https://github.com/bryanthaboi/gen1recomp/wiki/Guide-Link-Play)
|
||||
— START > LINK connects two copies directly over UDP.
|
||||
- [Save editor](https://github.com/bryanthaboi/gen1recomp/wiki/Guide-Save-Editor)
|
||||
— edit party, boxes, items, events, and Pokédex flags outside the game.
|
||||
- `docs/architecture.md` — runtime details;
|
||||
`docs/behavior-porting-notes.md` — formula provenance.
|
||||
`docs/behavior-porting-notes.md` — formula provenance.
|
||||
|
||||
|
||||
|
||||
## Special Thanks
|
||||
|
||||
|
||||
Executable
+333
@@ -0,0 +1,333 @@
|
||||
#!/usr/bin/env bash
|
||||
# Build a PortMaster-style aarch64 port of gen1recomp for Anbernic RG34XXSP
|
||||
# on Stock OS 64-bit MOD (H700; cbepx-me StockOS MOD / official V1.0.6 base
|
||||
# + PortMaster).
|
||||
#
|
||||
# The stock Anbernic firmware resolves ports relative to the launch script
|
||||
# (roms/PORTS/...), not PortMaster's /$directory/ports/... path. This pack
|
||||
# uses SHDIR-relative paths and bundles the LÖVE 11.5 aarch64 runtime so the
|
||||
# device does not need a separate love_11.5 runtime download on first launch.
|
||||
#
|
||||
# Usage:
|
||||
# ./build-rg34xxsp.sh [--version X.Y.Z]
|
||||
#
|
||||
# Output:
|
||||
# dist/rg34xxsp/gen1recomp-rg34xxsp-stockos64-mod.zip
|
||||
#
|
||||
# Install on device:
|
||||
# 1. Flash / run Stock OS 64-bit MOD with PortMaster installed.
|
||||
# 2. Unzip into the SD card's roms/PORTS/ folder so you have:
|
||||
# roms/PORTS/Gen1recomp.sh
|
||||
# roms/PORTS/gen1recomp/...
|
||||
# 3. Copy a legal US Red or Blue .gb into roms/PORTS/gen1recomp/lovegame/
|
||||
# 4. Launch "Gen1recomp" from the Ports list; press Choose ROM (scans that
|
||||
# folder when zenity is missing).
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")" && pwd)"
|
||||
HERE="$ROOT/.bazinga"
|
||||
CACHE="$HERE/cache/rg34xxsp"
|
||||
WORK="$HERE/work/rg34xxsp"
|
||||
DIST="$ROOT/dist/rg34xxsp"
|
||||
|
||||
APP_NAME="gen1recomp"
|
||||
# Artifact suffix matches the CFW this port targets (Anbernic H700 Stock OS
|
||||
# 64-bit MOD for RG34XXSP). Release uploads stage it as
|
||||
# gen1recomp-<ver>-rg34xxsp-stockos64-mod.zip.
|
||||
ARTIFACT_SUFFIX="rg34xxsp-stockos64-mod"
|
||||
PORT_DIR_NAME="gen1recomp"
|
||||
LAUNCHER_NAME="Gen1recomp.sh"
|
||||
LOVE_VERSION="11.5"
|
||||
VERSION="$(git -C "$ROOT" rev-parse --short HEAD 2>/dev/null || echo dev)"
|
||||
|
||||
# Official PortMaster LÖVE 11.5 aarch64 runtime (small love stub + liblove).
|
||||
PM_RUNTIME_BASE="https://raw.githubusercontent.com/PortsMaster/PortMaster-GUI/main/PortMaster/runtimes/love_${LOVE_VERSION}"
|
||||
|
||||
say() { printf '\033[1;32m==>\033[0m %s\n' "$*"; }
|
||||
warn() { printf '\033[1;33mwarn:\033[0m %s\n' "$*" >&2; }
|
||||
fail() { printf '\033[1;31merror:\033[0m %s\n' "$*" >&2; exit 1; }
|
||||
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--version) VERSION="$2"; shift ;;
|
||||
-h|--help)
|
||||
sed -n '2,24p' "$0"
|
||||
exit 0
|
||||
;;
|
||||
*) fail "unknown argument: $1" ;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
|
||||
command -v curl >/dev/null || fail "curl is required"
|
||||
command -v zip >/dev/null || fail "zip is required"
|
||||
command -v unzip >/dev/null || fail "unzip is required"
|
||||
|
||||
mkdir -p "$CACHE" "$WORK" "$DIST"
|
||||
|
||||
download() {
|
||||
local url="$1" dest="$2"
|
||||
if [ -f "$dest" ] && [ -s "$dest" ]; then
|
||||
return 0
|
||||
fi
|
||||
say "downloading $(basename "$dest")"
|
||||
curl -fL --progress-bar "$url" -o "$dest.tmp" \
|
||||
|| fail "download failed: $url"
|
||||
mv "$dest.tmp" "$dest"
|
||||
}
|
||||
|
||||
# --------------------------------------------------------------- game tree
|
||||
# Unpacked directory (not a .love zip) so the player can drop a .gb next to
|
||||
# main.lua and RomImporter can scan it without zenity/kdialog.
|
||||
say "staging lovegame/"
|
||||
GAME_SRC="$WORK/lovegame"
|
||||
rm -rf "$GAME_SRC"
|
||||
mkdir -p "$GAME_SRC"
|
||||
|
||||
# Same payload as scripts/build.sh's game.love — never ship ROM-derived cache.
|
||||
# tools/save-editor is part of that payload: the launcher's Edit button on a
|
||||
# save row opens it in-process (main.lua).
|
||||
(cd "$ROOT" && zip -q -9 -r "$WORK/game-payload.zip" \
|
||||
main.lua conf.lua src data assets tools/save-editor \
|
||||
tools/rom_manifest.json tools/rom_manifest_blue.json \
|
||||
-x '*.DS_Store' 'data/generated/*' 'assets/generated/*')
|
||||
if unzip -Z1 "$WORK/game-payload.zip" \
|
||||
| grep -Eq '^(data|assets)/generated/[^/]+|^(data|assets)/generated/.+/'; then
|
||||
fail "payload unexpectedly contains generated ROM data"
|
||||
fi
|
||||
unzip -q "$WORK/game-payload.zip" -d "$GAME_SRC"
|
||||
rm -f "$WORK/game-payload.zip"
|
||||
|
||||
# Stamp release version into the staged tree only (never the working tree).
|
||||
if printf '%s' "$VERSION" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$'; then
|
||||
say "stamping engine version $VERSION"
|
||||
sed -E "s/(engine[[:space:]]*=[[:space:]]*\")[^\"]*(\")/\1$VERSION\2/" \
|
||||
"$ROOT/src/core/Version.lua" > "$GAME_SRC/src/core/Version.lua"
|
||||
version_re="$(printf '%s' "$VERSION" | sed 's/\./\\./g')"
|
||||
grep -Eq "engine[[:space:]]*=[[:space:]]*\"$version_re\"" \
|
||||
"$GAME_SRC/src/core/Version.lua" \
|
||||
|| fail "version stamp failed"
|
||||
else
|
||||
say "version '$VERSION' is not X.Y.Z — shipping default engine (no stamp)"
|
||||
fi
|
||||
|
||||
# Portable marker: saves + ROM cache live next to the game on the SD card.
|
||||
: > "$GAME_SRC/portable.txt"
|
||||
|
||||
# --------------------------------------------------------------- love runtime
|
||||
say "fetching LÖVE $LOVE_VERSION aarch64 runtime"
|
||||
LOVE_BIN="$CACHE/love.aarch64"
|
||||
LOVE_LIB="$CACHE/liblove-11.5.so"
|
||||
LUAJIT_LIB="$CACHE/libluajit-5.1.so.2"
|
||||
MODPLUG_LIB="$CACHE/libmodplug.so.1"
|
||||
OGG_LIB="$CACHE/libogg.so.0"
|
||||
|
||||
download "$PM_RUNTIME_BASE/love.aarch64" "$LOVE_BIN"
|
||||
download "$PM_RUNTIME_BASE/libs.aarch64/liblove-11.5.so" "$LOVE_LIB"
|
||||
download "$PM_RUNTIME_BASE/libs.aarch64/libluajit-5.1.so.2" "$LUAJIT_LIB"
|
||||
download "$PM_RUNTIME_BASE/libs.aarch64/libmodplug.so.1" "$MODPLUG_LIB"
|
||||
download "$PM_RUNTIME_BASE/libs.aarch64/libogg.so.0" "$OGG_LIB"
|
||||
|
||||
# Sanity: love stub must be an aarch64 ELF.
|
||||
file "$LOVE_BIN" | grep -qi 'aarch64\|ARM aarch64' \
|
||||
|| fail "love.aarch64 does not look like an aarch64 ELF (got: $(file "$LOVE_BIN"))"
|
||||
|
||||
# --------------------------------------------------------------- port tree
|
||||
say "assembling port package"
|
||||
PORT_ROOT="$WORK/port"
|
||||
rm -rf "$PORT_ROOT"
|
||||
mkdir -p "$PORT_ROOT/$PORT_DIR_NAME/bin" \
|
||||
"$PORT_ROOT/$PORT_DIR_NAME/libs.aarch64" \
|
||||
"$PORT_ROOT/$PORT_DIR_NAME/licenses" \
|
||||
"$PORT_ROOT/$PORT_DIR_NAME/conf"
|
||||
|
||||
cp -R "$GAME_SRC" "$PORT_ROOT/$PORT_DIR_NAME/lovegame"
|
||||
cp "$LOVE_BIN" "$PORT_ROOT/$PORT_DIR_NAME/bin/love.aarch64"
|
||||
chmod +x "$PORT_ROOT/$PORT_DIR_NAME/bin/love.aarch64"
|
||||
cp "$LOVE_LIB" "$LUAJIT_LIB" "$MODPLUG_LIB" "$OGG_LIB" \
|
||||
"$PORT_ROOT/$PORT_DIR_NAME/libs.aarch64/"
|
||||
|
||||
# Drop a short license pointer for the bundled LÖVE bits.
|
||||
cat > "$PORT_ROOT/$PORT_DIR_NAME/licenses/LICENSE.love2d.txt" <<'EOF'
|
||||
This port bundles the LÖVE 11.5 aarch64 runtime from PortMaster
|
||||
(https://github.com/PortsMaster/PortMaster-GUI). LÖVE is zlib-licensed;
|
||||
see https://love2d.org/ for full terms.
|
||||
EOF
|
||||
|
||||
# --------------------------------------------------------------- launcher
|
||||
# Stock OS fix: resolve GAMEDIR from this script's directory (SHDIR), not
|
||||
# PortMaster's $directory variable — Anbernic uses roms/PORTS with different
|
||||
# casing and mount points (mmc / sdcard / TF1 / TF2).
|
||||
cat > "$PORT_ROOT/$LAUNCHER_NAME" <<'EOF'
|
||||
#!/bin/bash
|
||||
# gen1recomp — Anbernic RG34XXSP stock OS / PortMaster launcher
|
||||
# Uses SHDIR-relative paths so stock firmware finds the game folder.
|
||||
|
||||
export HOME="${HOME:-/root}"
|
||||
XDG_DATA_HOME="${XDG_DATA_HOME:-$HOME/.local/share}"
|
||||
|
||||
if [ -d "/opt/system/Tools/PortMaster/" ]; then
|
||||
controlfolder="/opt/system/Tools/PortMaster"
|
||||
elif [ -d "/opt/tools/PortMaster/" ]; then
|
||||
controlfolder="/opt/tools/PortMaster"
|
||||
elif [ -d "$XDG_DATA_HOME/PortMaster/" ]; then
|
||||
controlfolder="$XDG_DATA_HOME/PortMaster"
|
||||
elif [ -d "/mnt/mmc/Roms/PORTS/PortMaster" ]; then
|
||||
controlfolder="/mnt/mmc/Roms/PORTS/PortMaster"
|
||||
elif [ -d "/mnt/sdcard/Roms/PORTS/PortMaster" ]; then
|
||||
controlfolder="/mnt/sdcard/Roms/PORTS/PortMaster"
|
||||
elif [ -d "/roms/ports/PortMaster" ]; then
|
||||
controlfolder="/roms/ports/PortMaster"
|
||||
else
|
||||
controlfolder="/roms/PORTS/PortMaster"
|
||||
fi
|
||||
|
||||
SHDIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
# shellcheck disable=SC1090
|
||||
source "$controlfolder/control.txt"
|
||||
get_controls
|
||||
[ -f "${controlfolder}/mod_${CFW_NAME}.txt" ] && source "${controlfolder}/mod_${CFW_NAME}.txt"
|
||||
|
||||
GAMEDIR="$SHDIR/gen1recomp"
|
||||
CONFDIR="$GAMEDIR/conf"
|
||||
mkdir -p "$CONFDIR"
|
||||
|
||||
cd "$GAMEDIR" || exit 1
|
||||
> "$GAMEDIR/log.txt" && exec > >(tee "$GAMEDIR/log.txt") 2>&1
|
||||
|
||||
export XDG_DATA_HOME="$CONFDIR"
|
||||
export XDG_CONFIG_HOME="$CONFDIR"
|
||||
export LD_LIBRARY_PATH="$GAMEDIR/libs.aarch64:${LD_LIBRARY_PATH:-}"
|
||||
export SDL_GAMECONTROLLERCONFIG="${sdl_controllerconfig:-}"
|
||||
# Mali / H700: prefer GLES where available
|
||||
export LOVE_GRAPHICS_USE_OPENGLES="${LOVE_GRAPHICS_USE_OPENGLES:-1}"
|
||||
# Same GPU class as a phone, but getOS() here says "Linux", so the Android
|
||||
# gate (issue #136) would not fire on its own: refuse GBC FX explicitly.
|
||||
# Hides the OPTIONS row, pins the level to OFF, and heals a level already
|
||||
# persisted in options.lua.
|
||||
export POKEPORT_GBCFX="${POKEPORT_GBCFX:-0}"
|
||||
|
||||
$ESUDO chmod a+x ./bin/love.aarch64 2>/dev/null || chmod a+x ./bin/love.aarch64
|
||||
$ESUDO chmod 666 /dev/uinput 2>/dev/null || true
|
||||
|
||||
if [ -n "${GPTOKEYB:-}" ]; then
|
||||
$GPTOKEYB "love.aarch64" &
|
||||
fi
|
||||
if type pm_platform_helper >/dev/null 2>&1; then
|
||||
pm_platform_helper "$GAMEDIR/bin/love.aarch64"
|
||||
fi
|
||||
|
||||
./bin/love.aarch64 "$GAMEDIR/lovegame"
|
||||
|
||||
if type pm_finish >/dev/null 2>&1; then
|
||||
pm_finish
|
||||
else
|
||||
if [ -n "${ESUDO:-}" ]; then
|
||||
$ESUDO kill -9 $(pidof gptokeyb) 2>/dev/null || true
|
||||
else
|
||||
kill -9 $(pidof gptokeyb) 2>/dev/null || true
|
||||
fi
|
||||
fi
|
||||
EOF
|
||||
chmod +x "$PORT_ROOT/$LAUNCHER_NAME"
|
||||
|
||||
# --------------------------------------------------------------- metadata
|
||||
cat > "$PORT_ROOT/port.json" <<EOF
|
||||
{
|
||||
"version": 2,
|
||||
"name": "gen1recomp.zip",
|
||||
"items": [
|
||||
"$LAUNCHER_NAME",
|
||||
"$PORT_DIR_NAME"
|
||||
],
|
||||
"items_opt": null,
|
||||
"attr": {
|
||||
"title": "gen1recomp",
|
||||
"desc": "Native LÖVE2D recreation of Pokemon Red and Blue. Supply your own legal US Red or Blue ROM.",
|
||||
"inst": "Requires Anbernic RG34XXSP Stock OS 64-bit MOD with PortMaster. Copy a canonical US Red or Blue .gb into gen1recomp/lovegame/, then launch and press Choose ROM.",
|
||||
"genres": ["adventure", "rpg"],
|
||||
"porter": ["gen1recomp"],
|
||||
"image": {},
|
||||
"rtr": true,
|
||||
"runtime": null,
|
||||
"reqs": [],
|
||||
"arch": ["aarch64"]
|
||||
}
|
||||
}
|
||||
EOF
|
||||
|
||||
cat > "$PORT_ROOT/gameinfo.xml" <<EOF
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<gameList>
|
||||
<game>
|
||||
<path>./$LAUNCHER_NAME</path>
|
||||
<name>gen1recomp</name>
|
||||
<desc>Native LÖVE2D recreation of Pokemon Red and Blue. Requires your own legal US Red or Blue ROM.</desc>
|
||||
<releasedate>20250101T000000</releasedate>
|
||||
<developer>the bois club</developer>
|
||||
<publisher>the bois club</publisher>
|
||||
<genre>RPG</genre>
|
||||
</game>
|
||||
</gameList>
|
||||
EOF
|
||||
|
||||
cat > "$PORT_ROOT/README.md" <<'EOF'
|
||||
## gen1recomp (RG34XXSP / Stock OS 64-bit MOD)
|
||||
|
||||
Native LÖVE 11.5 port of gen1recomp for Anbernic RG34XXSP on
|
||||
**Stock OS 64-bit MOD** (H700, PortMaster).
|
||||
|
||||
### Install
|
||||
|
||||
1. Use **Stock OS 64-bit MOD** with PortMaster installed (TF1).
|
||||
2. Unzip so `Gen1recomp.sh` and the `gen1recomp/` folder sit in `roms/PORTS/`.
|
||||
3. Copy a legal US Pokemon Red or Blue `.gb` into `gen1recomp/lovegame/`.
|
||||
4. Refresh Ports / restart EmulationStation and launch **Gen1recomp**.
|
||||
|
||||
### Controls (launcher)
|
||||
|
||||
| Input | Action |
|
||||
|--|--|
|
||||
| D-pad / left stick | Move cursor |
|
||||
| A | Click |
|
||||
| L1 / R1 | Switch tabs |
|
||||
| Right stick | Scroll lists |
|
||||
| Start / Select | Play or Choose ROM |
|
||||
|
||||
In-game controls use the normal PortMaster / SDL pad map (rebind under OPTIONS → CONTROLS).
|
||||
|
||||
### Display options
|
||||
|
||||
GBC FX is disabled on this device (the H700's Mali GPU compiles that present
|
||||
pass and then shows a black frame), so the OPTIONS row is hidden. COLORS,
|
||||
TILT, ZOOM, VOID FILL and MAX FPS all work. To try it anyway, launch with
|
||||
`POKEPORT_GBCFX=1`.
|
||||
|
||||
### First run
|
||||
|
||||
Stock OS has no zenity file picker. Put the `.gb` in `lovegame/`, then press
|
||||
**Choose ROM** — the game scans that folder. After import, the ROM-derived
|
||||
cache and saves stay beside the game (`portable.txt`).
|
||||
|
||||
Canonical SHA-1 (1 MiB US carts only):
|
||||
|
||||
- Red: `ea9bcae617fdf159b045185467ae58b2e4a48b9a`
|
||||
- Blue: `d7037c83e1ae5b39bde3c30787637ba1d4c48ce2`
|
||||
|
||||
### Thanks
|
||||
|
||||
LÖVE runtime binaries from [PortMaster](https://portmaster.games/).
|
||||
Stock OS 64-bit MOD: [cbepx-me](https://github.com/cbepx-me/Anbernic-H700-RG-xx-StockOS-Modification).
|
||||
EOF
|
||||
|
||||
# --------------------------------------------------------------- zip
|
||||
ZIP_OUT="$DIST/$APP_NAME-$ARTIFACT_SUFFIX.zip"
|
||||
rm -f "$ZIP_OUT" "$DIST/$APP_NAME-rg34xxsp.zip"
|
||||
say "packing $ZIP_OUT"
|
||||
(cd "$PORT_ROOT" && zip -q -9 -r "$ZIP_OUT" \
|
||||
"$LAUNCHER_NAME" "$PORT_DIR_NAME" port.json gameinfo.xml README.md)
|
||||
|
||||
say "done."
|
||||
say "artifact: $ZIP_OUT ($(du -h "$ZIP_OUT" | cut -f1))"
|
||||
say "copy onto the RG34XXSP SD card under roms/PORTS/ (Stock OS 64-bit MOD), then drop your .gb into gen1recomp/lovegame/"
|
||||
@@ -13,7 +13,12 @@ function love.conf(t)
|
||||
_G.POKEPORT_DEV_MODE = developer
|
||||
|
||||
if editor then
|
||||
t.identity = os.getenv("POKEPORT_IDENTITY") or "pokemon-love2d-editor"
|
||||
-- Same identity as the game, deliberately: the editor edits the game's
|
||||
-- saves and reads the game's ROM cache, both of which live under this
|
||||
-- folder. A private editor identity would point love.filesystem at an
|
||||
-- empty directory in a packaged build, so `--editor` could not find
|
||||
-- data/generated at all (SaveIO.defaultPath already assumed this name).
|
||||
t.identity = os.getenv("POKEPORT_IDENTITY") or "pokemon-love2d"
|
||||
t.window.title = "Pokemon Save Editor"
|
||||
t.window.width = 1280
|
||||
t.window.height = 800
|
||||
|
||||
@@ -28,7 +28,29 @@ local function badgeGuard(badge, passFlag)
|
||||
}
|
||||
end
|
||||
|
||||
-- Route23SetVictoryRoadBoulders (pokered scripts/Route23.asm:8): every entry
|
||||
-- to Route 23 resets the Victory Road boulder puzzle behind you. The 2F/3F
|
||||
-- switch events clear (so those barriers are closed again on the next floor
|
||||
-- load) and the boulder that fell through 3F's hole goes back upstairs:
|
||||
-- ShowObject TOGGLE_VICTORY_ROAD_3F_BOULDER / HideObject
|
||||
-- TOGGLE_VICTORY_ROAD_2F_BOULDER, which are VICTORYROAD3F_BOULDER4 and
|
||||
-- VICTORYROAD2F_BOULDER3 (data/maps/toggleable_objects.asm). 1F's event is
|
||||
-- reset by the Indigo Plateau lobby and by Victory Road 2F, not here. This
|
||||
-- is what makes the puzzle "completely reset" on a return trip (#258).
|
||||
-- onEnter is the port's BIT_CUR_MAP_LOADED_2 equivalent: setMap runs it on
|
||||
-- every map entry, connection crossings included, like EnterMap.
|
||||
M.ROUTE_23 = {
|
||||
onEnter = function(game, ow)
|
||||
local f = game.save.flags
|
||||
f.EVENT_VICTORY_ROAD_2_BOULDER_ON_SWITCH1 = nil
|
||||
f.EVENT_VICTORY_ROAD_2_BOULDER_ON_SWITCH2 = nil
|
||||
f.EVENT_VICTORY_ROAD_3_BOULDER_ON_SWITCH1 = nil
|
||||
f.EVENT_VICTORY_ROAD_3_BOULDER_ON_SWITCH2 = nil
|
||||
local Commands = require("src.script.Commands")
|
||||
local ctx = { save = game.save, overworld = ow, game = game }
|
||||
Commands.show_object(ctx, "VICTORY_ROAD_3F", "VICTORYROAD3F_BOULDER4")
|
||||
Commands.hide_object(ctx, "VICTORY_ROAD_2F", "VICTORYROAD2F_BOULDER3")
|
||||
end,
|
||||
talk = {
|
||||
-- Route23Guard1Text: EventFlagBit ..., EVENT_PASSED_EARTHBADGE_CHECK
|
||||
-- -> wWhichBadge = EARTHBADGE
|
||||
|
||||
+57
-15
@@ -94,6 +94,27 @@ M.VIRIDIAN_CITY = {
|
||||
{ "show_text", "_ViridianCityOldManTimeIsMoneyText" }, -- 8 (9 = end)
|
||||
},
|
||||
},
|
||||
-- Re-apply the Pokedex old-man swap for a save that already holds the flag
|
||||
-- but was never standing here when it fired: a vanilla .sav imported
|
||||
-- through src/save_convert, whose codec does not model pokered's
|
||||
-- wToggleableObjectFlags array, so save.objectToggles arrives empty and
|
||||
-- both objects fall back to their compiled-in defaults -- the sleeper ON
|
||||
-- (data/maps/toggleable_objects.asm toggleable_objects_for VIRIDIAN_CITY),
|
||||
-- still lying across the north path (#234).
|
||||
-- OaksLabOakGivesPokedexScript (scripts/OaksLab.asm) sets EVENT_GOT_POKEDEX
|
||||
-- and, with no branch in between, runs HideObject TOGGLE_LYING_OLD_MAN /
|
||||
-- ShowObject TOGGLE_OLD_MAN, so that one flag settles both toggles exactly.
|
||||
-- Same shape as M.OAKS_LAB.onEnter in data/scripts/oaks_lab.lua, which
|
||||
-- re-applies the same script's other two HideObjects on entry (#106).
|
||||
onEnter = function(game, ow)
|
||||
if not (game.save.flags and game.save.flags.EVENT_GOT_POKEDEX) then
|
||||
return
|
||||
end
|
||||
local Commands = require("src.script.Commands")
|
||||
local ctx = { save = game.save, game = game, overworld = ow }
|
||||
Commands.hide_object(ctx, "VIRIDIAN_CITY", "VIRIDIANCITY_OLD_MAN_SLEEPY")
|
||||
Commands.show_object(ctx, "VIRIDIAN_CITY", "VIRIDIANCITY_OLD_MAN")
|
||||
end,
|
||||
-- ViridianCityCheckGotPokedexScript: the north corridor is gated on
|
||||
-- EVENT_GOT_POKEDEX, NOT on the sleeper being hidden, and it triggers
|
||||
-- on exactly one cell -- (19,9), the gap east of the sleeper (18,9)
|
||||
@@ -719,11 +740,20 @@ local function boulderAt(ow, x, y)
|
||||
return npc and npc.def.sprite == "SPRITE_BOULDER" and npc or nil
|
||||
end
|
||||
|
||||
-- Each barrier is stamped BOTH ways on entry. pokered only ever stamps the
|
||||
-- OPEN block (ReplaceTileBlock edits the loaded block map, which the next map
|
||||
-- load throws away, so a fresh entry always shows the .blk's closed barrier),
|
||||
-- but OverworldState:replaceBlock writes through Map:setBlock into the SHARED
|
||||
-- map record in Game.data, so the port has to put the closed block back
|
||||
-- itself or a solved barrier stays open for the rest of the session (#258).
|
||||
-- The closed ids are the shipped .blk bytes: VictoryRoad1F.blk (4,6) = $25,
|
||||
-- VictoryRoad2F.blk (3,4) = $37 and (11,7) = $25, VictoryRoad3F.blk
|
||||
-- (3,5) = $25. Same both-ways pattern as the card-key doors in
|
||||
-- OverworldState:setMap.
|
||||
M.VICTORY_ROAD_1F = {
|
||||
onEnter = function(game, ow)
|
||||
if game.save.flags.EVENT_VICTORY_ROAD_1_BOULDER_ON_SWITCH then
|
||||
ow:replaceBlock(4, 6, 0x1D)
|
||||
end
|
||||
ow:replaceBlock(4, 6,
|
||||
game.save.flags.EVENT_VICTORY_ROAD_1_BOULDER_ON_SWITCH and 0x1D or 0x25)
|
||||
end,
|
||||
onBoulderMoved = function(game, ow, npc)
|
||||
if npc.cellX == 17 and npc.cellY == 13
|
||||
@@ -736,12 +766,14 @@ M.VICTORY_ROAD_1F = {
|
||||
|
||||
M.VICTORY_ROAD_2F = {
|
||||
onEnter = function(game, ow)
|
||||
if game.save.flags.EVENT_VICTORY_ROAD_2_BOULDER_ON_SWITCH1 then
|
||||
ow:replaceBlock(3, 4, 0x15)
|
||||
end
|
||||
if game.save.flags.EVENT_VICTORY_ROAD_2_BOULDER_ON_SWITCH2 then
|
||||
ow:replaceBlock(11, 7, 0x1D)
|
||||
end
|
||||
-- VictoryRoad2FResetBoulderEventScript (scripts/VictoryRoad2F.asm:19):
|
||||
-- entering 2F clears the 1F switch event, so 1F's barrier is closed
|
||||
-- again the next time you climb down (#258)
|
||||
game.save.flags.EVENT_VICTORY_ROAD_1_BOULDER_ON_SWITCH = nil
|
||||
ow:replaceBlock(3, 4,
|
||||
game.save.flags.EVENT_VICTORY_ROAD_2_BOULDER_ON_SWITCH1 and 0x15 or 0x37)
|
||||
ow:replaceBlock(11, 7,
|
||||
game.save.flags.EVENT_VICTORY_ROAD_2_BOULDER_ON_SWITCH2 and 0x1D or 0x25)
|
||||
end,
|
||||
onBoulderMoved = function(game, ow, npc)
|
||||
if npc.cellX == 1 and npc.cellY == 16
|
||||
@@ -759,9 +791,8 @@ M.VICTORY_ROAD_2F = {
|
||||
|
||||
M.VICTORY_ROAD_3F = {
|
||||
onEnter = function(game, ow)
|
||||
if game.save.flags.EVENT_VICTORY_ROAD_3_BOULDER_ON_SWITCH1 then
|
||||
ow:replaceBlock(3, 5, 0x1D)
|
||||
end
|
||||
ow:replaceBlock(3, 5,
|
||||
game.save.flags.EVENT_VICTORY_ROAD_3_BOULDER_ON_SWITCH1 and 0x1D or 0x25)
|
||||
end,
|
||||
onBoulderMoved = function(game, ow, npc)
|
||||
if npc.cellX == 3 and npc.cellY == 5
|
||||
@@ -769,12 +800,23 @@ M.VICTORY_ROAD_3F = {
|
||||
game.save.flags.EVENT_VICTORY_ROAD_3_BOULDER_ON_SWITCH1 = true
|
||||
ow:replaceBlock(3, 5, 0x1D)
|
||||
end
|
||||
-- the hole at (23,15): the boulder drops to 2F next to switch 2
|
||||
if npc.cellX == 23 and npc.cellY == 15 then
|
||||
-- the hole at (23,15): the boulder drops to 2F next to switch 2.
|
||||
-- VictoryRoad3FDefaultScript .handle_hole gates the swap on
|
||||
-- CheckAndSetEvent EVENT_VICTORY_ROAD_3_BOULDER_ON_SWITCH2, then
|
||||
-- HideObject TOGGLE_VICTORY_ROAD_3F_BOULDER / ShowObject
|
||||
-- TOGGLE_VICTORY_ROAD_2F_BOULDER. Those two toggles are
|
||||
-- VICTORYROAD3F_BOULDER4 and VICTORYROAD2F_BOULDER3
|
||||
-- (data/maps/toggleable_objects.asm); the old "VICTORYROAD2F_BOULDER"
|
||||
-- matched no object_event name, so the toggle landed under a key
|
||||
-- objectVisible never reads: harmless only while nothing hid that
|
||||
-- boulder, and fatal once Route 23's reset does (#258).
|
||||
if npc.cellX == 23 and npc.cellY == 15
|
||||
and not game.save.flags.EVENT_VICTORY_ROAD_3_BOULDER_ON_SWITCH2 then
|
||||
game.save.flags.EVENT_VICTORY_ROAD_3_BOULDER_ON_SWITCH2 = true
|
||||
local Commands = require("src.script.Commands")
|
||||
local ctx = { save = game.save, overworld = ow, game = game }
|
||||
Commands.hide_object(ctx, "VICTORY_ROAD_3F", npc.def.name)
|
||||
Commands.show_object(ctx, "VICTORY_ROAD_2F", "VICTORYROAD2F_BOULDER")
|
||||
Commands.show_object(ctx, "VICTORY_ROAD_2F", "VICTORYROAD2F_BOULDER3")
|
||||
end
|
||||
end,
|
||||
-- scripts/VictoryRoad3F.asm VictoryRoad3FDefaultScript: the same hole
|
||||
|
||||
@@ -761,7 +761,7 @@ M.CINNABAR_LAB_FOSSIL_ROOM = {
|
||||
-- #118: do not raise mon.level until a paid retrieve (pokered reverts
|
||||
-- wDayCareMonBoxLevel on .leaveMonInDayCare). Fold pending steps into
|
||||
-- mon.exp once and clear them so a second talk cannot re-apply the same
|
||||
-- walk. Fill {RAM:wNameBuffer}/{RAM:wDayCareMonName}/{NUM:...} here —
|
||||
-- walk. Fill {RAM:wNameBuffer}/{RAM:wDayCareMonName}/{NUM:...} here --
|
||||
-- TextBox.TOKENS.RAM only knows wStringBuffer.
|
||||
-- -------------------------------------------------------------------
|
||||
|
||||
|
||||
@@ -300,7 +300,7 @@ local function elevator(elevatorMapId, keyGate, preFrames)
|
||||
-- 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.
|
||||
-- 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,
|
||||
|
||||
+147
-19
@@ -293,7 +293,11 @@ pewterEscort.guySteps = {
|
||||
"right", "right", "right",
|
||||
}
|
||||
|
||||
-- Walk home: reverse of guySteps with opposite facings (gym → spawn).
|
||||
-- Reverse of guySteps with opposite facings, i.e. the gym-to-spawn
|
||||
-- mirror of RLEList_PewterGymGuy. Kept as the documented inverse that
|
||||
-- tests/parity_pewter_escort.lua checks; the youngster does NOT walk it
|
||||
-- home any more, because its first step is LEFT through the player
|
||||
-- parked on (11,18) (#241). See walkHome below.
|
||||
do
|
||||
local opp = { up = "down", down = "up", left = "right", right = "left" }
|
||||
local ret = {}
|
||||
@@ -371,20 +375,44 @@ local function pewterGymEscort(game, ow)
|
||||
local head = plan.guyHeadStart
|
||||
|
||||
-- After the walk: face the player, restore map music, "Go take on
|
||||
-- BROCK", then retrace RLEList_PewterGymGuy back to his spawn (35,16).
|
||||
-- (pokered teleports him via MovementData_PewterGymGuyExit; we walk
|
||||
-- the same route home instead. Brock victory still HideObject's him.)
|
||||
-- BROCK", then MovementData_PewterGymGuyExit -- five steps RIGHT out of
|
||||
-- (12,18), the cell PewterCityYoungsterShowsPlayerGymScript pins him to
|
||||
-- with SetSpritePosition1 (hSpriteMapXCoord 16 / hSpriteMapYCoord 22,
|
||||
-- minus the +4 border offset object_event coords carry per
|
||||
-- macros/scripts/maps.asm) and exactly where the escort leaves him.
|
||||
-- That lands him on (17,18), the last walkable cell before the fence at
|
||||
-- (18,18) and one column past the screen edge with the player parked on
|
||||
-- (11,18). PewterCityHideYoungsterScript then HideObject's him and
|
||||
-- PewterCityResetYoungsterScript's SetSpritePosition2 + ShowObject put
|
||||
-- him back on his object_event spawn (35,16) facing DOWN, which is the
|
||||
-- snap below (the vanish/reappear is off screen, same as the original).
|
||||
--
|
||||
-- The old code retraced guyReturnSteps instead, whose first step is
|
||||
-- LEFT into (11,18) -- the cell the player is standing on -- and
|
||||
-- scriptMove is a pure tween with no entity test, so he walked straight
|
||||
-- through Red (#241). There is no honest route home: (17,18) is a
|
||||
-- dead-end pocket ((18,18) fence, (17,17) wall), which is precisely why
|
||||
-- the original teleports. guyReturnSteps stays as the documented
|
||||
-- mirror of RLEList_PewterGymGuy that parity_pewter_escort asserts.
|
||||
-- Brock victory still HideObject's him.
|
||||
local function walkHome()
|
||||
if not guy then return end
|
||||
local ret = pewterEscort.guyReturnSteps
|
||||
local i = 0
|
||||
local function tick()
|
||||
i = i + 1
|
||||
if not ret[i] then
|
||||
if i > 5 then
|
||||
-- SetSpritePosition2: same field writes as Commands.place_npc,
|
||||
-- including the target clear -- a stale targetX/targetY would
|
||||
-- leave OverworldState:npcAtCell reserving (17,18) forever and
|
||||
-- silently wall the player out of that pocket.
|
||||
guy.cellX, guy.cellY = 35, 16
|
||||
guy.px, guy.py = 35 * 16, 16 * 16
|
||||
guy.moving = false
|
||||
guy.targetX, guy.targetY = nil, nil
|
||||
guy.facing = "down"
|
||||
return
|
||||
end
|
||||
ow:scriptMove(guy, ret[i], 1, tick)
|
||||
ow:scriptMove(guy, "right", 1, tick)
|
||||
end
|
||||
tick()
|
||||
end
|
||||
@@ -468,23 +496,48 @@ end
|
||||
-- Gym) and again in Viridian Gym; we derive the same windows from the
|
||||
-- surrounding story flags so old saves work too.
|
||||
-- Rival1Exit → Viridian (right/down); Rival2Exit → League (left).
|
||||
-- Both exit lists are keyed on wSavedCoordIndex -- WHICH entry of
|
||||
-- Route22DefaultScript.Route22RivalBattleCoords the player matched -- not
|
||||
-- on where the rival ended up. CheckCoords (home/map_objects.asm:107)
|
||||
-- zeroes wCoordIndex and `inc [hl]` BEFORE each compare, so the first
|
||||
-- entry reports 1: index 1 is (29,4), index 2 is (29,5).
|
||||
-- Route22Rival1AfterBattleScript (Route22.asm:175) takes
|
||||
-- ...ExitMovementData1 on index 1 and ...Data2 otherwise. From the top
|
||||
-- tile the rival stands BELOW the player on (29,5) and leaves east along
|
||||
-- row 5; from the bottom tile he stands LEFT of him on (28,5) and has to
|
||||
-- step UP to row 4 to get around him. The port had the two branches
|
||||
-- swapped, so the top tile started the walk with UP out of (28,4) into
|
||||
-- the cliff cell (28,3), which is not walkable (#236).
|
||||
local function route22ExitDirs(n, py)
|
||||
if n == 2 then
|
||||
if py == 5 then return { "left", "left", "left", "left" } end
|
||||
-- Route22Rival2ExitMovementData1 falls through into ...Data2: LEFT x4
|
||||
-- from (29,5), LEFT x3 from (28,5), both back onto the spawn (25,5).
|
||||
if py == 4 then return { "left", "left", "left", "left" } end
|
||||
return { "left", "left", "left" }
|
||||
end
|
||||
if py == 5 then
|
||||
-- Route22Rival1ExitMovementData1: (29,5) -> (31,5) -> (31,10)
|
||||
if py == 4 then
|
||||
return { "right", "right", "down", "down", "down", "down", "down" }
|
||||
end
|
||||
-- Route22Rival1ExitMovementData2: (28,5) -> (28,4) -> (31,4) -> (31,10)
|
||||
return { "up", "right", "right", "right",
|
||||
"down", "down", "down", "down", "down", "down" }
|
||||
end
|
||||
|
||||
local function route22Scene(n, objIndex, objName, oppClass, baseParty, beatFlag, py)
|
||||
-- Route22MoveRivalRightScript (Route22.asm:39) walks him RIGHT along his
|
||||
-- own row from the object_event spawn (25,5): the full four-RIGHT
|
||||
-- Route22RivalMovementData on coord index 1 (player on (29,4)), so he
|
||||
-- stops BELOW the player on (29,5); `inc de` drops one RIGHT on index 2
|
||||
-- (player on (29,5)), so he stops LEFT of him on (28,5). He never
|
||||
-- leaves row 5. Route22Rival{1,2}StartBattleScript (Route22.asm:110)
|
||||
-- then faces him UP on index 1 and RIGHT otherwise (#236).
|
||||
local rx = (py == 4) and 29 or 28
|
||||
local rivalFacing = (py == 4) and "up" or "right"
|
||||
return {
|
||||
{ "show_object", "ROUTE_22", objName }, -- 1
|
||||
{ "move_npc_to", objIndex, 28, py }, -- 2
|
||||
{ "face_object", objIndex, "right" }, -- 3
|
||||
{ "move_npc_to", objIndex, rx, 5 }, -- 2
|
||||
{ "face_object", objIndex, rivalFacing }, -- 3
|
||||
{ "show_text", "_Route22RivalBeforeBattleText" .. n }, -- 4
|
||||
{ "rival_battle", oppClass, baseParty }, -- 5
|
||||
{ "jump_if_false", 11 }, -- 6
|
||||
@@ -496,20 +549,25 @@ local function route22Scene(n, objIndex, objName, oppClass, baseParty, beatFlag,
|
||||
}
|
||||
end
|
||||
|
||||
-- Route22Rival{1,2}StartBattleScript turns the player toward him too:
|
||||
-- PLAYER_DIR_DOWN on coord index 1 (the (29,4) tile, rival below him),
|
||||
-- and the PLAYER_DIR_LEFT Route22DefaultScript already set on index 2
|
||||
-- (the (29,5) tile, rival to his left) (#236).
|
||||
M.ROUTE_22 = {
|
||||
onStep = function(game, ow, x, y)
|
||||
if not inCoords({ { 29, 4 }, { 29, 5 } }, x, y) then return false end
|
||||
local f = game.save.flags
|
||||
local playerFacing = (y == 4) and "down" or "left"
|
||||
if f.EVENT_GOT_POKEDEX and not f.EVENT_BEAT_BROCK
|
||||
and not f.EVENT_BEAT_ROUTE22_RIVAL_1ST_BATTLE then
|
||||
return runAmbush(game, ow,
|
||||
route22Scene(1, 1, "ROUTE22_RIVAL1", "OPP_RIVAL1", 4,
|
||||
"EVENT_BEAT_ROUTE22_RIVAL_1ST_BATTLE", y), "left")
|
||||
"EVENT_BEAT_ROUTE22_RIVAL_1ST_BATTLE", y), playerFacing)
|
||||
end
|
||||
if f.EVENT_BEAT_GIOVANNI and not f.EVENT_BEAT_ROUTE22_RIVAL_2ND_BATTLE then
|
||||
return runAmbush(game, ow,
|
||||
route22Scene(2, 2, "ROUTE22_RIVAL2", "OPP_RIVAL2", 10,
|
||||
"EVENT_BEAT_ROUTE22_RIVAL_2ND_BATTLE", y), "left")
|
||||
"EVENT_BEAT_ROUTE22_RIVAL_2ND_BATTLE", y), playerFacing)
|
||||
end
|
||||
return false
|
||||
end,
|
||||
@@ -545,7 +603,7 @@ end
|
||||
-- scripts/CeruleanCity.asm CeruleanCityRocketText: fight the thief,
|
||||
-- then he returns TM28 (DIG) and hurries off. CeruleanHideRocket
|
||||
-- (CeruleanCity_2.asm) is GBFadeOutToBlack → Show GUARD1 / Hide GUARD2 /
|
||||
-- Hide ROCKET → GBFadeInFromBlack — not a bare hide_object.
|
||||
-- Hide ROCKET → GBFadeInFromBlack -- not a bare hide_object.
|
||||
local rocketRows = {
|
||||
{ "face_player" }, -- 1
|
||||
{ "check_flag", "EVENT_GOT_TM28" }, -- 2
|
||||
@@ -564,7 +622,7 @@ local rocketRows = {
|
||||
{ "fade", "out" }, -- 15 GBFadeOutToBlack
|
||||
-- CeruleanHideRocket while black: GUARD1 (28,12) appears, GUARD2
|
||||
-- (27,12) and the ROCKET go. GUARD2 blocks the trashed-house south
|
||||
-- door neighbour — the swap reconnects the city (Bill's ticket does
|
||||
-- door neighbour -- the swap reconnects the city (Bill's ticket does
|
||||
-- the same in story.lua; either route is enough).
|
||||
{ "show_object", "CERULEAN_CITY", "CERULEANCITY_GUARD1" }, -- 16
|
||||
{ "hide_object", "CERULEAN_CITY", "CERULEANCITY_GUARD2" }, -- 17
|
||||
@@ -628,13 +686,83 @@ M.MUSEUM_1F = {
|
||||
}
|
||||
|
||||
-- The Pewter Center's singing JIGGLYPUFF (scripts/PewterPokecenter.asm
|
||||
-- plays MUSIC_JIGGLYPUFF_SONG, then the map theme resumes)
|
||||
-- PewterPokecenterJigglypuffText). The text_asm sets
|
||||
-- wDoNotWaitForButtonPressAfterDisplayingText before PrintText, so the box
|
||||
-- prints with no A prompt and the script keeps running underneath it:
|
||||
-- SFX_STOP_ALL_MUSIC, DelayFrames 32, PlayMusic MUSIC_JIGGLYPUFF_SONG,
|
||||
-- then .spinMovementLoop writes the next of DOWN -> LEFT -> UP -> RIGHT (a
|
||||
-- clockwise turn) every 24 frames for as long as the song's CHAN1/CHAN2
|
||||
-- still sound, DelayFrames 48, PlayDefaultMusic, TextScriptEnd. Only that
|
||||
-- last step closes the box, so the whole dance plays out unskippably
|
||||
-- (#249). Music.playOnce's pendingRestore stands in for both the channel
|
||||
-- poll and PlayDefaultMusic, so the Center's theme comes back the frame
|
||||
-- the song ends rather than 48 frames later.
|
||||
local JIGGLYPUFF_SPIN = { "down", "left", "up", "right" }
|
||||
local JIGGLYPUFF_SILENCE, JIGGLYPUFF_STEP, JIGGLYPUFF_TAIL = 32, 24, 48
|
||||
|
||||
-- Built as a TextBox `auto` table: auto.sound fires the frame the last
|
||||
-- page has typed out (PrintText returning), and auto.tick then runs once
|
||||
-- per frame while the gate it returns still reads as playing.
|
||||
local function jigglypuffDance(game, npc)
|
||||
local Music = require("src.core.Music")
|
||||
-- .findMatchingFacingDirectionLoop: the rotation picks up at the entry
|
||||
-- matching the sprite's current facing (showMapText has just turned it
|
||||
-- toward the player), and the first write is that same facing, so the
|
||||
-- first visible quarter turn lands 24 frames in
|
||||
local step = 1
|
||||
for i, dir in ipairs(JIGGLYPUFF_SPIN) do
|
||||
if npc and npc.facing == dir then step = i end
|
||||
end
|
||||
local frames, phase = 0, "silence"
|
||||
return {
|
||||
sound = function()
|
||||
Music.stop() -- SFX_STOP_ALL_MUSIC
|
||||
return { isPlaying = function() return phase ~= "done" end }
|
||||
end,
|
||||
tick = function()
|
||||
frames = frames + 1
|
||||
if phase == "silence" then
|
||||
if frames < JIGGLYPUFF_SILENCE then return end
|
||||
frames = 0
|
||||
if Music.playOnce(game.data, "Music_JigglypuffSong") then
|
||||
phase = "spin"
|
||||
else
|
||||
-- no song def (or headless): Music.stop above already took the
|
||||
-- map theme down and nothing armed pendingRestore, so put it
|
||||
-- back by hand and fall through to the tail rather than hold
|
||||
-- the box on a poll that would never clear
|
||||
Music.restoreMap(game.data)
|
||||
phase = "tail"
|
||||
end
|
||||
return
|
||||
end
|
||||
if phase == "spin" then
|
||||
if frames < JIGGLYPUFF_STEP then return end
|
||||
frames = 0
|
||||
-- the loop tests the channels after the delay and before the next
|
||||
-- write, so a song that just ended costs no extra quarter turn
|
||||
if not Music.oneShotPlaying() then
|
||||
phase = "tail"
|
||||
return
|
||||
end
|
||||
step = step % #JIGGLYPUFF_SPIN + 1
|
||||
if npc then npc.facing = JIGGLYPUFF_SPIN[step] end
|
||||
return
|
||||
end
|
||||
if frames >= JIGGLYPUFF_TAIL then phase = "done" end
|
||||
end,
|
||||
}
|
||||
end
|
||||
|
||||
M.PEWTER_POKECENTER = {
|
||||
talk = {
|
||||
TEXT_PEWTERPOKECENTER_JIGGLYPUFF = function(game, ow, npc, done)
|
||||
require("src.core.Music").playOnce(game.data, "Music_JigglypuffSong")
|
||||
push(game, text(game)._PewterPokecenterJigglypuffText
|
||||
or "JIGGLYPUFF: Puu\npupuu!", done)
|
||||
-- not the local push() helper: this box needs opts.auto, which is
|
||||
-- also what suppresses the blinking arrow and the A dismissal
|
||||
local TextBox = require("src.render.TextBox")
|
||||
game.stack:push(TextBox.new(game,
|
||||
text(game)._PewterPokecenterJigglypuffText or "JIGGLYPUFF: Puu\npupuu!",
|
||||
done, { auto = jigglypuffDance(game, npc) }))
|
||||
end,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
-- `dialogue` is the end-battle + post-battle text chain each gym leader
|
||||
-- runs (SaveEndBattleTextPointers then the map's *PostBattle / ReceiveTM
|
||||
-- script). Leaders are not def_trainers entries, so engageTrainer has
|
||||
-- no header.won — checkVictoryRewards shows this chain instead of a
|
||||
-- no header.won -- checkVictoryRewards shows this chain instead of a
|
||||
-- synthetic "received badge/TM" stub.
|
||||
|
||||
local function range(prefix, first, last)
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
# Anbernic RG34XXSP (Stock OS 64-bit MOD)
|
||||
|
||||
Download `gen1recomp-*-rg34xxsp-stockos64-mod.zip` from
|
||||
[Releases](https://github.com/bryanthaboi/gen1recomp/releases). This build
|
||||
targets **Stock OS 64-bit MOD** on the RG34XXSP with PortMaster installed
|
||||
(TF1).
|
||||
|
||||
## Install
|
||||
|
||||
1. Unzip the release on your computer. You get `Gen1recomp.sh` and a
|
||||
`gen1recomp/` folder.
|
||||
2. Copy **both** onto the SD card under **`Roms/PORTS/`** so the layout is:
|
||||
|
||||
```
|
||||
Roms/PORTS/Gen1recomp.sh
|
||||
Roms/PORTS/gen1recomp/
|
||||
```
|
||||
|
||||
On the device that path is `/mnt/mmc/Roms/PORTS/`. Keep the launcher and
|
||||
the `gen1recomp/` folder as siblings — do not nest the `.sh` inside the
|
||||
folder.
|
||||
3. Put your legal US Red and/or Blue `.gb` files inside the game folder:
|
||||
|
||||
```
|
||||
Roms/PORTS/gen1recomp/lovegame/
|
||||
```
|
||||
|
||||
Example: `Roms/PORTS/gen1recomp/lovegame/Pokemon - Red Version.gb`
|
||||
4. Eject the card, boot the handheld, open **Ports → Gen1recomp**.
|
||||
5. On the launcher, move the cursor with the D-pad or left stick, press **A**
|
||||
to click. Choose the Red or Blue tab, then **Choose ROM** — with no file
|
||||
picker on stock OS, that scans `lovegame/` for the `.gb` you dropped in.
|
||||
|
||||
The port ships with `portable.txt` already in place, so after import the
|
||||
saves and ROM-derived cache stay next to the game on the SD card. See
|
||||
[Portable Mode](../README.md#portable-mode) for what that means.
|
||||
|
||||
Only the canonical 1 MiB US carts import:
|
||||
|
||||
- Red: `ea9bcae617fdf159b045185467ae58b2e4a48b9a`
|
||||
- Blue: `d7037c83e1ae5b39bde3c30787637ba1d4c48ce2`
|
||||
|
||||
## Launcher controls
|
||||
|
||||
| Input | Action |
|
||||
| ------------------ | ------------------ |
|
||||
| D-pad / left stick | Move cursor |
|
||||
| A | Click |
|
||||
| L1 / R1 | Switch tabs |
|
||||
| Right stick | Scroll lists |
|
||||
| Start / Select | Play or Choose ROM |
|
||||
|
||||
In-game controls use the normal PortMaster / SDL pad map, rebindable under
|
||||
**OPTIONS → CONTROLS**.
|
||||
|
||||
## Notes
|
||||
|
||||
**GBC FX is off on this device.** The launcher exports `POKEPORT_GBCFX=0`,
|
||||
which hides the GBC FX row from OPTIONS, pins the level to OFF, and clears a
|
||||
level carried over in an `options.lua` from another machine. The H700's Mali
|
||||
GPU is in the same class as the phone GPUs that compile that present pass and
|
||||
then show a black frame (issue #136), and `love.system.getOS()` reports
|
||||
`"Linux"` here, so the Android gate would not have caught it. Every other
|
||||
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.
|
||||
|
||||
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
|
||||
paths relative to its own directory rather than PortMaster's `$directory`,
|
||||
because stock Anbernic firmware runs ports out of `roms/PORTS/` with its own
|
||||
casing and mount points.
|
||||
|
||||
If a launch fails, `Roms/PORTS/gen1recomp/log.txt` holds the output of the
|
||||
last run.
|
||||
|
||||
## Building the port
|
||||
|
||||
Release runs build this automatically (see `.github/workflows/release.yml`).
|
||||
To build it by hand:
|
||||
|
||||
```sh
|
||||
./build-rg34xxsp.sh --version 0.1.0 # -> dist/rg34xxsp/gen1recomp-rg34xxsp-stockos64-mod.zip
|
||||
```
|
||||
|
||||
`install-rg34xxsp.sh` copies that pack straight onto a mounted SD card for
|
||||
local testing.
|
||||
|
||||
Stock OS 64-bit MOD comes from
|
||||
[cbepx-me](https://github.com/cbepx-me/Anbernic-H700-RG-xx-StockOS-Modification).
|
||||
@@ -59,7 +59,7 @@ the same core data and graphics into the source tree for verification.
|
||||
| | `src/battle/Experience.lua`, `Catching.lua`, `TrainerAI.lua` | exp/levels, Gen 1 catch algorithm, AI |
|
||||
| | `src/battle/rulesets/` | `gen1_faithful` (default) vs `modern_clean` |
|
||||
| ui | `src/ui/*` | start menu, generic menu, yes/no box, party/bag lists |
|
||||
| | `tools/save-editor/` | Standalone save editor (`love . --editor`) |
|
||||
| | `tools/save-editor/` | Save editor: shipped in every build, opened from the launcher's Edit button or standalone with `love . --editor` |
|
||||
|
||||
## Map scripts
|
||||
|
||||
|
||||
@@ -69,6 +69,15 @@ keeps working unchanged.
|
||||
- **Registry.** The ordered slot list and which one is active persist in
|
||||
`options.lua` (via the existing `SaveData.loadOptions`/`saveOptions`):
|
||||
`options.saveSlots = { [version] = { list = {"slot1", ...}, active = "slot1" } }`.
|
||||
Custom slot labels (#205) live alongside them in the same registry:
|
||||
`options.saveSlots[version].names = { slot1 = "Nuzlocke" }`, written by
|
||||
`SaveData.renameSlot` (trimmed; an empty label clears it) and surfaced on
|
||||
each `listSlots` row as `label` (the launcher row shows `label`, falling
|
||||
back to the player name). `deleteSlot` drops the label with the slot.
|
||||
Renaming never touches the save file, so an empty slot can be labeled.
|
||||
On desktop, right-clicking a slot row opens the inline rename modal
|
||||
(Enter commits, Esc cancels); touch has no secondary button, so the
|
||||
affordance is desktop-only.
|
||||
- **Active slot resolution.** `saveNames(version)`, the function every
|
||||
existing caller (`TitleState` hasSave/load/save, recovery order) already
|
||||
goes through, now resolves the *active* slot instead of a fixed flat name.
|
||||
|
||||
+6
-6
@@ -1,21 +1,21 @@
|
||||
# Native modding
|
||||
|
||||
The modding book lives on the
|
||||
[project wiki](https://github.com/bryanthaboi/pokemon-gen1-recomp-project/wiki).
|
||||
[project wiki](https://github.com/bryanthaboi/gen1recomp/wiki).
|
||||
|
||||
- [Getting started](https://github.com/bryanthaboi/pokemon-gen1-recomp-project/wiki/Getting-Started)
|
||||
- [Getting started](https://github.com/bryanthaboi/gen1recomp/wiki/Getting-Started)
|
||||
— install a mod, write a first one, enable and disable it.
|
||||
- [Tutorials](https://github.com/bryanthaboi/pokemon-gen1-recomp-project/wiki/Tutorials)
|
||||
- [Tutorials](https://github.com/bryanthaboi/gen1recomp/wiki/Tutorials)
|
||||
— twelve dependency-ordered rungs, each a runnable mod.
|
||||
- [Cookbook](https://github.com/bryanthaboi/pokemon-gen1-recomp-project/wiki/Cookbook)
|
||||
- [Cookbook](https://github.com/bryanthaboi/gen1recomp/wiki/Cookbook)
|
||||
— task-sized recipes.
|
||||
- [Registry reference](https://github.com/bryanthaboi/pokemon-gen1-recomp-project/wiki/Reference-Registries)
|
||||
- [Registry reference](https://github.com/bryanthaboi/gen1recomp/wiki/Reference-Registries)
|
||||
— every registry, generated from `src/mods/Schemas.lua`.
|
||||
|
||||
Regenerate the reference straight into a wiki checkout:
|
||||
|
||||
```sh
|
||||
luajit tools/gen_registry_docs.lua ../pokemon-gen1-recomp-project.wiki
|
||||
luajit tools/gen_registry_docs.lua ../gen1recomp.wiki
|
||||
```
|
||||
|
||||
## Rendering pipelines
|
||||
|
||||
+116
-6
@@ -86,7 +86,7 @@ Game Boy equivalent:
|
||||
## Colors mode
|
||||
|
||||
The `2` key (and the Options menu COLORS row) cycles the display mode
|
||||
through **OG RED → SGB → RED++ → OG → OG INV → SGB INV → CLASSIC → OG RED**.
|
||||
through **OG RED → SGB → ADVANCED → OG → OG INV → SGB INV → CLASSIC → OG RED**.
|
||||
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
|
||||
@@ -97,8 +97,9 @@ The first three are the real colorizations; the rest are DMG-shade novelties:
|
||||
- **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".)
|
||||
- **RED++**: pokered-gbc SuperPalettes -- real per-tile GBC coloring plus
|
||||
per-species mon colors (`data/palettes_gbc.lua`).
|
||||
- **ADVANCED**: pokered-gbc SuperPalettes -- real per-tile GBC coloring plus
|
||||
per-species mon colors (`data/palettes_gbc.lua`). (Formerly labeled
|
||||
"RED++"; it is the richest colorization rather than anything Red-specific.)
|
||||
- **OG**: force the four DMG grays (colorization off).
|
||||
- **OG INV**: inverted DMG grays.
|
||||
- **SGB INV**: each SGB zone palette with shade order reversed.
|
||||
@@ -109,7 +110,8 @@ The shade-remap transform is applied centrally in `PaletteFX.sendColors`, so
|
||||
it covers overworld, menus, battles, and tilt upright billboards. OG RED's
|
||||
global BG palette is supplied by `OverworldState:overworldBgColors` (per-map
|
||||
override in the overworld pass). Persisted as `save.options.colors`; the
|
||||
`gbc` / `gbc_inv` save ids are kept for back-compat under the new labels.
|
||||
`gbc` / `gbc_inv` / `redpp` save ids are kept for back-compat under the new
|
||||
labels.
|
||||
|
||||
## GBC FX
|
||||
|
||||
@@ -127,6 +129,14 @@ It runs as a final present pass after world + UI composite in
|
||||
([github.com/mattakins/Pixel_Transparency](https://github.com/mattakins/Pixel_Transparency)).
|
||||
Default OFF; persisted as `save.options.gbcfx`.
|
||||
|
||||
Mobile GPUs often compile the pass but present a black frame, so Android and
|
||||
iOS hide the row entirely, pin the level to OFF, and rewrite a level already
|
||||
persisted in `options.lua` (issue #136). `POKEPORT_GBCFX` overrides that
|
||||
decision either way, same tri-state as `POKEPORT_TOUCH`: `=0` refuses the
|
||||
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)).
|
||||
|
||||
## Peer-to-peer link play (lua-enet)
|
||||
|
||||
Trades and link battles connect two copies of the game directly over
|
||||
@@ -141,6 +151,30 @@ 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.
|
||||
|
||||
## Fair play in link and online matches
|
||||
|
||||
A link session is decided by the battle and nothing else, so for its
|
||||
duration:
|
||||
|
||||
- **Game speed is pinned to normal.** The GAME SPEED option and
|
||||
`POKEPORT_SPEED` are ignored from the moment LINK PLAY opens until it
|
||||
closes, and apply again after. Fast-forward otherwise runs one peer's
|
||||
queue faster than the peer it is locked to and drains a tournament shot
|
||||
clock faster than the opponent racing it.
|
||||
- **Online play runs vanilla.** Picking ONLINE MATCH or TOURNAMENT with
|
||||
mods enabled offers to switch them all off and relaunch (mods merge at
|
||||
boot, so a restart is the only way). The restart is confirmed, not
|
||||
silent. They stay listed as disabled, ready to switch back on.
|
||||
- **Only a meaningful split ends a match.** The per-turn state signature
|
||||
both peers exchange is split three ways: `actives` and `bench` carry
|
||||
species, HP, status, stat stages, PP and the rest of the party, and a
|
||||
divergence there ends the match as a draw. `volatile` carries per-turn
|
||||
flags both sides recompute anyway - a divergence there is logged and
|
||||
reported to mods, and play continues.
|
||||
|
||||
The relay logs which component diverged on which turn, so a desync report
|
||||
names something specific.
|
||||
|
||||
## Custom boot text
|
||||
|
||||
The boot sequence replaces the Nintendo / GAME FREAK identifiers with
|
||||
@@ -161,8 +195,9 @@ migrated once into `options.lua` on load.
|
||||
- Music / SFX volume
|
||||
- Music Filter
|
||||
- OG GLITCHES on / off (Gen 1 quirks vs. modern-clean battle rules)
|
||||
- COLORS (OG RED / SGB / RED++ / OG / OG INV / SGB INV / CLASSIC), also
|
||||
hotkey `2` (OG RED = GBC boot-ROM look; RED++ uses pokered-gbc
|
||||
- BATTLE LAYOUT (OG / WIDE); see "Widescreen battle layout" below
|
||||
- COLORS (OG RED / SGB / ADVANCED / OG / OG INV / SGB INV / CLASSIC), also
|
||||
hotkey `2` (OG RED = GBC boot-ROM look; ADVANCED uses pokered-gbc
|
||||
SuperPalettes + per-species mon colors)
|
||||
- TILT (OFF / 15 / 35 / 50), also hotkey `3` while free-roaming
|
||||
- ZOOM (FIT / OUTn / INn), also hotkey `4` while free-roaming; wheel and
|
||||
@@ -181,6 +216,36 @@ void outside the OG wipe fills in lockstep. Once the battle state is up,
|
||||
letterbox voids around the battle canvas fill **white** instead of black
|
||||
so the whole window reads as one continuous battle screen.
|
||||
|
||||
## Widescreen battle layout
|
||||
|
||||
Options **BATTLE LAYOUT** picks the battle screen's composition: **OG**
|
||||
(the default: the original 160×144 arrangement, unchanged) or **WIDE**,
|
||||
which gives battles a 304×144 native-pixel surface and a Gen 3-style
|
||||
arrangement on it:
|
||||
|
||||
- the foe's status box upper left, the foe's picture upper right;
|
||||
- the player's picture lower left, the player's status box lower right,
|
||||
with a longer HP bar and the numeric HP under it;
|
||||
- a full-width message window;
|
||||
- a split "What will X do?" prompt / 2×2 command window;
|
||||
- a 2×2 move menu, navigated with all four directions, with a PP and type
|
||||
panel attached to its right.
|
||||
|
||||
Only the composition changes. Pictures, palettes, HP-bar colors, font
|
||||
pages, window borders, sounds, animations, timing and every battle rule
|
||||
stay the engine's, so a COLORS mode or an asset mod still owns the look.
|
||||
Each side's picture keeps its original pixels and placement math and is
|
||||
composited into its own region of the wider battlefield -- nothing is
|
||||
scaled or squeezed -- and animations, which are authored in the original
|
||||
160-pixel space, shift as one rigid group onto whichever side they play
|
||||
on. The whole screen is drawn at the window's integer fit scale for the
|
||||
wider surface, so a 304-pixel screen is drawn a step smaller than a
|
||||
160-pixel one in the same window.
|
||||
|
||||
The wide surface is live only while the battle itself is the screen on
|
||||
top: a party menu, the bag or a nickname prompt is a 160×144 screen and
|
||||
brings the classic surface back with it.
|
||||
|
||||
## On-screen touch controls (mobile)
|
||||
|
||||
On Android/iOS the game draws a translucent d-pad (bottom-left), A/B
|
||||
@@ -228,3 +293,48 @@ be packed). `--refresh` re-harvests after an engine update, keeping
|
||||
existing translations and parking orphaned keys rather than dropping them.
|
||||
|
||||
See the wiki's Translations guide.
|
||||
|
||||
## Save editor (bundled, reachable from the launcher)
|
||||
|
||||
The save editor ships inside every build instead of being a developer-only
|
||||
script, and the launcher's SAVE SLOT card grows an **Edit** label next to
|
||||
Delete on every slot that actually holds a save. Edit suspends the
|
||||
launcher, opens that slot's file in the editor, and **Close** hands the
|
||||
process back to the launcher with the slot list re-read (a rename, a badge
|
||||
or a dex change shows up on the row immediately). Unsaved edits arm a
|
||||
confirm first, so leaving cannot lose work. `love . --editor` still opens
|
||||
it standalone, where Close quits instead; `--save <path>` points it at any
|
||||
file, and a save can be dragged onto the window.
|
||||
|
||||
The editor now wears the launcher's visual language - the same navy radial
|
||||
field, 16px translucent cards, tri-colour version rail and green/yellow/red
|
||||
semantics - so the two windows read as one app. Six tabs:
|
||||
|
||||
- **Party**: the roster with sprites, HP bars and level chips on the left,
|
||||
and the mon inspector permanently docked on the right instead of floating
|
||||
over the list. Species, level, DVs and moves all round-trip through the
|
||||
Gen 1 formulas, so the inspector can never show illegal stats.
|
||||
- **Boxes**: the 12 PC boxes as a 5x4 grid with a fill meter per box and a
|
||||
party dock, so deposit and withdraw live in one place. Empty slots are
|
||||
clickable and create a mon there.
|
||||
- **Items**: money, a searchable item picker (replacing the arrows that
|
||||
cycled one id at a time through ~250 items), the 20-slot bag, PC storage
|
||||
with no slot cap, and the eight badges as toggle chips.
|
||||
- **Events**: flags, defeated trainers, taken items and per-map object
|
||||
toggles, with a real filter field and a two-column paged grid.
|
||||
- **Map**: any map rendered with the game's own renderer, warps followable,
|
||||
and the player / lastHeal / lastOutdoor spawn points settable by clicking
|
||||
a cell. Setting lastOutdoor on a map the game would not accept as an
|
||||
outdoor source is refused with the reason.
|
||||
- **Dex**: seen / owned completion meters and a four-column grid; owning
|
||||
implies seen and un-seeing clears owned, exactly as the game requires.
|
||||
|
||||
Two rules run through all of it. Every mutation goes through one funnel
|
||||
that sets the dirty flag and writes the status line together, so nothing
|
||||
changes silently and no branch can quietly no-op - "Party is full", "Bag is
|
||||
full", "click a cell first" all say so. And every destructive verb (Remove,
|
||||
Release, Clear all, Wipe dex) arms on the first click and commits on the
|
||||
second, relabelling itself to `Confirm?` in between.
|
||||
|
||||
A validation pill in the tab rail mirrors what the running game would
|
||||
quarantine on load; clicking it jumps to the tab holding the first problem.
|
||||
|
||||
+1
-1
@@ -65,7 +65,7 @@ Each tagged release `vX.Y.Z` carries the existing per-platform archives
|
||||
|
||||
A release missing either asset is treated as "no in-place update available":
|
||||
`Check` reports `needs_full` and sends the player to `Check.releaseUrl()`
|
||||
(`https://github.com/bryanthaboi/pokemon-gen1-recomp-project/releases/latest`).
|
||||
(`https://github.com/bryanthaboi/gen1recomp/releases/latest`).
|
||||
|
||||
## Save-directory layout
|
||||
|
||||
|
||||
Executable
+93
@@ -0,0 +1,93 @@
|
||||
#!/usr/bin/env bash
|
||||
# After first boot of the freshly flashed Stock OS Mod, reinsert the SD card
|
||||
# and run this to install gen1recomp + Red/Blue ROMs into roms/PORTS.
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")" && pwd)"
|
||||
STAGE="$ROOT/.bazinga/work/rg34xxsp-install"
|
||||
DECPREP="$(cd "$ROOT/../decprep" && pwd)"
|
||||
ZIP="$ROOT/dist/rg34xxsp/gen1recomp-rg34xxsp.zip"
|
||||
|
||||
say() { printf '\033[1;32m==>\033[0m %s\n' "$*"; }
|
||||
fail() { printf '\033[1;31merror:\033[0m %s\n' "$*" >&2; exit 1; }
|
||||
|
||||
# Find the Anbernic user/ROMs volume (usually "NO NAME" after first-boot expand).
|
||||
find_roms_root() {
|
||||
local v candidate
|
||||
for v in /Volumes/*; do
|
||||
[ -d "$v" ] || continue
|
||||
# Prefer a volume that already has Roms/ or PORTS/
|
||||
if [ -d "$v/Roms" ] || [ -d "$v/roms" ] || [ -d "$v/PORTS" ] || [ -d "$v/ports" ]; then
|
||||
echo "$v"
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
# Fallback: large FAT volume named NO NAME on disk8
|
||||
for v in "/Volumes/NO NAME" /Volumes/NO\ NAME /Volumes/ROMS /Volumes/EASYROMS; do
|
||||
if [ -d "$v" ]; then
|
||||
echo "$v"
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
say "looking for Anbernic ROMs volume"
|
||||
ROMS_ROOT="$(find_roms_root)" || fail "no ROMs volume mounted. Boot the RG34XXSP once (wait for first-boot setup), power off, reinsert the SD, then rerun."
|
||||
|
||||
say "using: $ROMS_ROOT"
|
||||
# Resolve PORTS dir (stock uses Roms/PORTS)
|
||||
if [ -d "$ROMS_ROOT/Roms/PORTS" ]; then
|
||||
PORTS="$ROMS_ROOT/Roms/PORTS"
|
||||
elif [ -d "$ROMS_ROOT/roms/PORTS" ]; then
|
||||
PORTS="$ROMS_ROOT/roms/PORTS"
|
||||
elif [ -d "$ROMS_ROOT/Roms/ports" ]; then
|
||||
PORTS="$ROMS_ROOT/Roms/ports"
|
||||
elif [ -d "$ROMS_ROOT/PORTS" ]; then
|
||||
PORTS="$ROMS_ROOT/PORTS"
|
||||
else
|
||||
mkdir -p "$ROMS_ROOT/Roms/PORTS"
|
||||
PORTS="$ROMS_ROOT/Roms/PORTS"
|
||||
fi
|
||||
say "PORTS: $PORTS"
|
||||
|
||||
# Refresh staged payload
|
||||
mkdir -p "$STAGE/PORTS"
|
||||
if [ -f "$ZIP" ]; then
|
||||
rm -rf "$STAGE/PORTS/Gen1recomp.sh" "$STAGE/PORTS/gen1recomp" "$STAGE/PORTS/port.json" \
|
||||
"$STAGE/PORTS/gameinfo.xml" "$STAGE/PORTS/README.md"
|
||||
unzip -q -o "$ZIP" -d "$STAGE/PORTS"
|
||||
else
|
||||
fail "missing $ZIP — run ./build-rg34xxsp.sh first"
|
||||
fi
|
||||
|
||||
# Ensure ROMs are in lovegame (Choose ROM scans this folder on stock OS)
|
||||
[ -f "$DECPREP/Pokemon - Red Version.gb" ] || fail "missing Red ROM in $DECPREP"
|
||||
[ -f "$DECPREP/Pokemon - Blue Version.gb" ] || fail "missing Blue ROM in $DECPREP"
|
||||
cp -f "$DECPREP/Pokemon - Red Version.gb" "$STAGE/PORTS/gen1recomp/lovegame/"
|
||||
cp -f "$DECPREP/Pokemon - Blue Version.gb" "$STAGE/PORTS/gen1recomp/lovegame/"
|
||||
|
||||
say "copying gen1recomp port"
|
||||
rm -rf "$PORTS/gen1recomp" "$PORTS/Gen1recomp.sh"
|
||||
cp -R "$STAGE/PORTS/gen1recomp" "$PORTS/"
|
||||
cp -f "$STAGE/PORTS/Gen1recomp.sh" "$PORTS/"
|
||||
cp -f "$STAGE/PORTS/port.json" "$PORTS/gen1recomp/" 2>/dev/null || true
|
||||
cp -f "$STAGE/PORTS/README.md" "$PORTS/gen1recomp/" 2>/dev/null || true
|
||||
chmod +x "$PORTS/Gen1recomp.sh" "$PORTS/gen1recomp/bin/love.aarch64"
|
||||
|
||||
# Also drop carts in the stock GB folder for the emulator library
|
||||
GB_DIR=""
|
||||
for candidate in "$ROMS_ROOT/Roms/GB" "$ROMS_ROOT/roms/GB" "$ROMS_ROOT/Roms/gb"; do
|
||||
if [ -d "$candidate" ]; then GB_DIR="$candidate"; break; fi
|
||||
done
|
||||
if [ -n "$GB_DIR" ]; then
|
||||
say "copying .gb into $GB_DIR"
|
||||
cp -f "$DECPREP/Pokemon - Red Version.gb" "$GB_DIR/"
|
||||
cp -f "$DECPREP/Pokemon - Blue Version.gb" "$GB_DIR/"
|
||||
fi
|
||||
|
||||
sync
|
||||
say "installed:"
|
||||
ls -lh "$PORTS/Gen1recomp.sh"
|
||||
ls -lh "$PORTS/gen1recomp/lovegame/"*.gb
|
||||
say "eject the SD, insert TF1 in the RG34XXSP, open Ports → Gen1recomp, Choose ROM."
|
||||
@@ -1,8 +1,12 @@
|
||||
-- Native LÖVE2D port of Pokemon Red. A packaged build creates its private
|
||||
-- game-data cache from a user-provided ROM on first boot.
|
||||
--
|
||||
-- Set POKEPORT_EDITOR=1 or pass `--editor` to `love .` to boot the save
|
||||
-- editor tool (tools/save-editor/) instead of the game.
|
||||
-- The save editor (tools/save-editor/) ships inside every build and is
|
||||
-- reachable two ways:
|
||||
-- * standalone: POKEPORT_EDITOR=1 or `love . --editor`, its own window
|
||||
-- * from the launcher: Edit on a save row, which suspends the launcher,
|
||||
-- opens the editor on that slot's file, and restores the launcher when
|
||||
-- the editor's Close button is pressed (openEditor / closeEditor below)
|
||||
|
||||
local editorMode = os.getenv("POKEPORT_EDITOR") == "1" or POKEPORT_EDITOR_MODE == true
|
||||
|
||||
@@ -31,6 +35,105 @@ local function scriptedIterations()
|
||||
return math.max(1, math.floor(require("src.core.GameSpeed").clamp(speedOverride)))
|
||||
end
|
||||
|
||||
-- ------------------------------------------------------------ save editor
|
||||
-- The launcher instance parked while the editor is up, plus the version whose
|
||||
-- cache the editor mounted (so closing can put the read path back).
|
||||
local editorHost, editorVersion, editorWindow
|
||||
local closeEditor -- forward declaration: openEditor hands it to the editor
|
||||
|
||||
-- The editor's modules use flat names (require("Kit"), require("Party")), so
|
||||
-- their directories have to be on the require path. It must be
|
||||
-- love.filesystem's path, not package.path: in a packaged build these files
|
||||
-- live inside the .love archive, which the stock Lua searcher cannot open.
|
||||
local function addEditorRequirePath()
|
||||
local fs = love.filesystem
|
||||
if not (fs.setRequirePath and fs.getRequirePath) then
|
||||
-- very old LOVE: a source checkout still resolves through package.path
|
||||
package.path = fs.getSource() .. "/tools/save-editor/?.lua;"
|
||||
.. fs.getSource() .. "/tools/save-editor/panels/?.lua;"
|
||||
.. package.path
|
||||
return
|
||||
end
|
||||
local current = fs.getRequirePath()
|
||||
if current:find("tools/save%-editor") then return end
|
||||
fs.setRequirePath("tools/save-editor/?.lua;tools/save-editor/panels/?.lua;"
|
||||
.. current)
|
||||
end
|
||||
|
||||
-- Desktop only: the launcher window (1024x768) is tighter than the editor's
|
||||
-- design size, so grow it while editing and put it back on Close. Never
|
||||
-- shrinks, never touches a fullscreen or mobile window.
|
||||
local function resizeForEditor()
|
||||
if not (love.window and love.window.getMode and love.window.setMode) then return end
|
||||
local osName = love.system.getOS()
|
||||
if osName ~= "OS X" and osName ~= "Windows" and osName ~= "Linux" then return end
|
||||
local w, h, flags = love.window.getMode()
|
||||
if flags.fullscreen then return end
|
||||
local dw, dh = love.window.getDesktopDimensions()
|
||||
local wantW = math.max(w, math.min(1360, math.floor((dw or w) * 0.92)))
|
||||
local wantH = math.max(h, math.min(860, math.floor((dh or h) * 0.88)))
|
||||
if wantW <= w and wantH <= h then return end
|
||||
editorWindow = { w = w, h = h }
|
||||
love.window.setMode(wantW, wantH, flags)
|
||||
end
|
||||
|
||||
local function restoreWindow()
|
||||
if not editorWindow then return end
|
||||
local _, _, flags = love.window.getMode()
|
||||
love.window.setMode(editorWindow.w, editorWindow.h, flags)
|
||||
editorWindow = nil
|
||||
end
|
||||
|
||||
-- Open the editor on a launcher save row. The version's cache has to be
|
||||
-- mounted before the editor's Data:load runs, or a Blue save would be edited
|
||||
-- against Red's species/item tables.
|
||||
local function openEditor(version, slotId)
|
||||
local SaveData = require("src.core.SaveData")
|
||||
local path = SaveData.slotDiskPath(version, slotId)
|
||||
if not path then
|
||||
if Importer then
|
||||
Importer.saveNotice = Importer.saveNotice or {}
|
||||
Importer.saveNotice[version] =
|
||||
{ ok = false, text = "Could not resolve that save slot on disk." }
|
||||
end
|
||||
return
|
||||
end
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
GameVersion.set(version)
|
||||
require("src.import.CacheFs").mountVersion(version)
|
||||
editorVersion = version
|
||||
editorHost = Importer
|
||||
Importer = nil
|
||||
editorMode = true
|
||||
resizeForEditor()
|
||||
addEditorRequirePath()
|
||||
EditorApp = require("App")
|
||||
EditorApp.load(path, { version = version, slotId = slotId, embedded = true,
|
||||
onClose = function() closeEditor() end })
|
||||
end
|
||||
|
||||
-- Back to the launcher. Everything the editor mounted or cached has to come
|
||||
-- back out: the version overlay (CacheFs) and the generated modules require
|
||||
-- cached behind it (Data), or pressing Play on the OTHER game would boot it
|
||||
-- with this one's data.
|
||||
function closeEditor()
|
||||
local version = editorVersion
|
||||
editorMode = false
|
||||
if EditorApp and EditorApp.unload then EditorApp.unload() end
|
||||
EditorApp = nil
|
||||
if version then
|
||||
require("src.import.CacheFs").unmountVersion(version)
|
||||
require("src.core.Data"):unloadGenerated()
|
||||
end
|
||||
editorVersion = nil
|
||||
restoreWindow()
|
||||
Importer = editorHost
|
||||
editorHost = nil
|
||||
if Importer and version and Importer.savesChanged then
|
||||
Importer:savesChanged(version)
|
||||
end
|
||||
end
|
||||
|
||||
local function bootGame(version)
|
||||
-- The launcher hands us the chosen game (Red / Blue); scripted and headless
|
||||
-- runs fall back to POKEPORT_VERSION, then Red. Set the active version and
|
||||
@@ -81,12 +184,17 @@ function love.load(args)
|
||||
end
|
||||
love.graphics.setDefaultFilter("nearest", "nearest")
|
||||
|
||||
-- Standalone editor. A bare `--editor` run has no launcher behind it, so
|
||||
-- Close quits; --save points it at a specific file, otherwise it opens the
|
||||
-- default save path for POKEPORT_VERSION (Red unless overridden), whose
|
||||
-- cache has to be mounted before the editor's Data:load.
|
||||
if editorMode then
|
||||
package.path = love.filesystem.getSource() .. "/tools/save-editor/?.lua;"
|
||||
.. love.filesystem.getSource() .. "/tools/save-editor/panels/?.lua;"
|
||||
.. package.path
|
||||
local version = os.getenv("POKEPORT_VERSION") or "red"
|
||||
require("src.core.GameVersion").set(version)
|
||||
require("src.import.CacheFs").mountVersion(version)
|
||||
addEditorRequirePath()
|
||||
EditorApp = require("App")
|
||||
EditorApp.load(savePath)
|
||||
EditorApp.load(savePath, { version = version })
|
||||
return
|
||||
end
|
||||
|
||||
@@ -127,10 +235,11 @@ function love.load(args)
|
||||
-- column shows Play when that game's ROM is already imported, or Choose ROM
|
||||
-- / drag-drop when it is not (Yellow is still a placeholder). Any dropped
|
||||
-- .gb is routed to Red or Blue by its SHA-1; pressing Play boots that game.
|
||||
-- Edit on a save row opens the bundled editor on that slot (openEditor).
|
||||
Importer = RomImporter.new(function(version)
|
||||
Importer = nil
|
||||
bootGame(version)
|
||||
end, { launcher = true, forceImport = forceImport })
|
||||
end, { launcher = true, forceImport = forceImport, onEditSave = openEditor })
|
||||
end
|
||||
|
||||
function love.update(dt)
|
||||
@@ -206,19 +315,19 @@ end
|
||||
|
||||
function love.gamepadpressed(joystick, button)
|
||||
if editorMode then return end
|
||||
if Importer 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 Importer 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 Importer then return end
|
||||
if Importer then return Importer:gamepadaxis(joystick, axis, value) end
|
||||
Game:gamepadaxis(joystick, axis, value)
|
||||
end
|
||||
|
||||
@@ -302,7 +411,7 @@ function love.mousemoved(x, y)
|
||||
end
|
||||
|
||||
function love.textinput(text)
|
||||
if Importer then return end
|
||||
if Importer then return Importer:textinput(text) end
|
||||
if editorMode and EditorApp.textinput then
|
||||
return EditorApp.textinput(text)
|
||||
end
|
||||
|
||||
@@ -2,6 +2,12 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android" >
|
||||
<uses-permission android:name="android.permission.VIBRATE" />
|
||||
<uses-permission android:name="android.permission.BLUETOOTH" />
|
||||
<!-- Link play (src/link/): both the LAN backend (enet UDP) and the relay
|
||||
backend (luasocket TCP) are ordinary sockets, and Android denies those
|
||||
to a UID without INTERNET. Bind and connect then come back EPERM, which
|
||||
the link screen shows as "(Operation not permitted)" (issue #287).
|
||||
scripts/build_android.sh must not strip this again. -->
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<!-- OpenGL ES 2.0 -->
|
||||
<uses-feature android:glEsVersion="0x00020000" />
|
||||
<!-- Touchscreen support -->
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
return {
|
||||
summary = "Oak's Charmander gift becomes a level 20 Mew with inverted sprites.",
|
||||
author = "Pokemon Gen 1 Recompilation Project",
|
||||
contact = "https://github.com/bryanthaboi/pokemon-gen1-recomp-project",
|
||||
contact = "https://github.com/bryanthaboi/gen1recomp",
|
||||
tags = { "beginner", "cosmetic", "data-only", "legacy" },
|
||||
differences = {
|
||||
changed = {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
return {
|
||||
summary = "Faster final starters, half-price TMs, a re-slotted Route 1.",
|
||||
author = "Pokemon Gen 1 Recompilation Project",
|
||||
contact = "https://github.com/bryanthaboi/pokemon-gen1-recomp-project",
|
||||
contact = "https://github.com/bryanthaboi/gen1recomp",
|
||||
tags = { "balance", "data-only", "beginner" },
|
||||
differences = {
|
||||
changed = {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
return {
|
||||
summary = "A start-menu dex overlay with seen/owned counts and an inter-mod API.",
|
||||
author = "Pokemon Gen 1 Recompilation Project",
|
||||
contact = "https://github.com/bryanthaboi/pokemon-gen1-recomp-project",
|
||||
contact = "https://github.com/bryanthaboi/gen1recomp",
|
||||
tags = { "ui", "tool", "quality-of-life" },
|
||||
differences = {
|
||||
changed = { "the START menu gains a DEXNAV row above SAVE" },
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
return {
|
||||
summary = "An authored chip song for Pallet Town, a new Mew cry, and a jukebox screen.",
|
||||
author = "Pokemon Gen 1 Recompilation Project",
|
||||
contact = "https://github.com/bryanthaboi/pokemon-gen1-recomp-project",
|
||||
contact = "https://github.com/bryanthaboi/gen1recomp",
|
||||
tags = { "audio", "chiptune", "ui" },
|
||||
differences = {
|
||||
changed = {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
return {
|
||||
summary = "A courier in Viridian lost a parcel in Pewter. Fetch it for a NUGGET.",
|
||||
author = "Pokemon Gen 1 Recompilation Project",
|
||||
contact = "https://github.com/bryanthaboi/pokemon-gen1-recomp-project",
|
||||
contact = "https://github.com/bryanthaboi/gen1recomp",
|
||||
tags = { "quest", "story", "scripting" },
|
||||
differences = {
|
||||
changed = {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
return {
|
||||
summary = "Sable Cove: one town, three species, one badge. The smallest whole conversion.",
|
||||
author = "Pokemon Gen 1 Recompilation Project",
|
||||
contact = "https://github.com/bryanthaboi/pokemon-gen1-recomp-project",
|
||||
contact = "https://github.com/bryanthaboi/gen1recomp",
|
||||
tags = { "total-conversion", "capstone" },
|
||||
differences = {
|
||||
changed = {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
return {
|
||||
summary = "A teal player recolor derived from your own cache, plus two palette records.",
|
||||
author = "Pokemon Gen 1 Recompilation Project",
|
||||
contact = "https://github.com/bryanthaboi/pokemon-gen1-recomp-project",
|
||||
contact = "https://github.com/bryanthaboi/gen1recomp",
|
||||
tags = { "cosmetic", "graphics", "beginner" },
|
||||
screenshots = {
|
||||
{ transform = "shots/pallet_town.lua", caption = "Pallet Town under the recolored palette" },
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
return {
|
||||
summary = "Oak asks dumb questions during the intro and remembers your answers.",
|
||||
author = "Pokemon Gen 1 Recompilation Project",
|
||||
contact = "https://github.com/bryanthaboi/pokemon-gen1-recomp-project",
|
||||
contact = "https://github.com/bryanthaboi/gen1recomp",
|
||||
tags = { "intro", "ui", "oak", "hooks" },
|
||||
differences = {
|
||||
changed = {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
return {
|
||||
summary = "Opt-in rain: WATER hits harder, FIRE hits softer, for five turns a battle.",
|
||||
author = "Pokemon Gen 1 Recompilation Project",
|
||||
contact = "https://github.com/bryanthaboi/pokemon-gen1-recomp-project",
|
||||
contact = "https://github.com/bryanthaboi/gen1recomp",
|
||||
tags = { "battle", "mechanic", "ruleset", "hardcore" },
|
||||
differences = {
|
||||
changed = {
|
||||
|
||||
+13
-1
@@ -56,16 +56,28 @@ done
|
||||
mkdir -p "$CACHE" "$WORK" "$DIST/mac" "$DIST/win" "$DIST/linux"
|
||||
|
||||
# --------------------------------------------------------------- game.love
|
||||
# tools/save-editor is part of the shipped app, not a dev-only script: the
|
||||
# launcher's Edit button on a save row opens it in-process (main.lua), and
|
||||
# `--editor` / POKEPORT_EDITOR=1 opens it standalone. It is required through
|
||||
# love.filesystem's require path, so it has to live inside the archive.
|
||||
say "packing game.love"
|
||||
LOVE_FILE="$WORK/game.love"
|
||||
rm -f "$LOVE_FILE"
|
||||
(cd "$ROOT" && zip -q -9 -r "$LOVE_FILE" \
|
||||
main.lua conf.lua src data assets tools/rom_manifest.json tools/rom_manifest_blue.json \
|
||||
main.lua conf.lua src data assets tools/save-editor \
|
||||
tools/rom_manifest.json tools/rom_manifest_blue.json \
|
||||
-x '*.DS_Store' 'data/generated/*' '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
|
||||
# The editor is only reachable if its entry point and both module directories
|
||||
# made it in; a silent miss would ship a launcher whose Edit button crashes.
|
||||
for required in tools/save-editor/App.lua tools/save-editor/Kit.lua \
|
||||
tools/save-editor/panels/Party.lua; do
|
||||
unzip -Z1 "$LOVE_FILE" | grep -qx "$required" \
|
||||
|| fail "game.love is missing $required (save editor would not load)"
|
||||
done
|
||||
say "game.love: $(du -h "$LOVE_FILE" | cut -f1)"
|
||||
|
||||
# ------------------------------------------------------- stamp release version
|
||||
|
||||
@@ -113,11 +113,12 @@ import pathlib, re, sys
|
||||
path = pathlib.Path(sys.argv[1])
|
||||
text = path.read_text()
|
||||
|
||||
# Drop network / mic / legacy storage, not needed for offline play.
|
||||
# Keep VIBRATE (love.system.vibrate) and BLUETOOTH (optional gamepads).
|
||||
# Drop mic / legacy storage, not needed by this game.
|
||||
# Keep VIBRATE (love.system.vibrate), BLUETOOTH (optional gamepads) and
|
||||
# INTERNET: link play is not offline-only any more, and stripping INTERNET
|
||||
# made every LAN host and every relay connect fail with EPERM (issue #287).
|
||||
# Orientation / label come from gradle.properties placeholders.
|
||||
for perm in (
|
||||
"android.permission.INTERNET",
|
||||
"android.permission.RECORD_AUDIO",
|
||||
"android.permission.WRITE_EXTERNAL_STORAGE",
|
||||
):
|
||||
@@ -136,14 +137,19 @@ pack_game_love() {
|
||||
say "packing game.love for love-android embed flavor"
|
||||
mkdir -p "$EMBED_ASSETS"
|
||||
rm -f "$LOVE_FILE"
|
||||
# tools/save-editor ships with the app: the launcher's Edit button on a save
|
||||
# row opens it in-process, so it must be inside the archive (see build.sh).
|
||||
(cd "$ROOT" && zip -q -9 -r "$LOVE_FILE" \
|
||||
main.lua conf.lua src data assets tools/rom_manifest.json tools/rom_manifest_blue.json \
|
||||
main.lua conf.lua src data assets tools/save-editor \
|
||||
tools/rom_manifest.json tools/rom_manifest_blue.json \
|
||||
-x '*.DS_Store' -x '*/.git/*' -x '*/.DS_Store' \
|
||||
-x 'data/generated/*' -x 'assets/generated/*')
|
||||
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' \
|
||||
|| fail "game.love is missing the save editor (Edit on a save row would crash)"
|
||||
say "game.love: $(du -h "$LOVE_FILE" | cut -f1) -> $LOVE_FILE"
|
||||
|
||||
# This script packs its own game.love (it does not reuse build.sh's), so it
|
||||
|
||||
@@ -164,15 +164,19 @@ pack_game_love() {
|
||||
say "packing game.love for love-ios resources"
|
||||
mkdir -p "$RESOURCES_DIR"
|
||||
rm -f "$LOVE_FILE"
|
||||
# Same payload as scripts/build.sh / build_android.sh: game sources only.
|
||||
# Same payload as scripts/build.sh / build_android.sh: game sources plus
|
||||
# tools/save-editor, which the launcher's Edit button opens in-process.
|
||||
(cd "$ROOT" && zip -q -9 -r "$LOVE_FILE" \
|
||||
main.lua conf.lua src data assets tools/rom_manifest.json tools/rom_manifest_blue.json \
|
||||
main.lua conf.lua src data assets tools/save-editor \
|
||||
tools/rom_manifest.json tools/rom_manifest_blue.json \
|
||||
-x '*.DS_Store' -x '*/.git/*' -x '*/.DS_Store' \
|
||||
-x 'data/generated/*' -x 'assets/generated/*')
|
||||
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' \
|
||||
|| fail "game.love is missing the save editor (Edit on a save row would crash)"
|
||||
say "game.love: $(du -h "$LOVE_FILE" | cut -f1) -> $LOVE_FILE"
|
||||
}
|
||||
|
||||
|
||||
@@ -116,7 +116,13 @@ if [ -f data/generated/maps.lua ]; then
|
||||
echo "-- T3 content: skipped (--quick)"
|
||||
else
|
||||
run_tier "T3 content behavior (Red)" run_content_behavior
|
||||
# The save editor ships inside every build (the launcher's Edit button on
|
||||
# a save row opens it), so its panel suites run in CI rather than by hand.
|
||||
run_tier "T3 save editor" "$LUA" tests/run_save_editor_tests.lua
|
||||
run_tier "T3 save editor: boxes + items" "$LUA" tests/save_editor_task6_tests.lua
|
||||
run_tier "T3 save editor: events + dex" "$LUA" tests/save_editor_task7_tests.lua
|
||||
run_tier "T3 save editor: map browser" "$LUA" tests/save_editor_task8_tests.lua
|
||||
run_tier "T3 save editor: mod awareness" "$LUA" tests/save_editor_mod_tests.lua
|
||||
run_tier "T5 link (loopback lockstep)" "$LUA" tests/run_link_tests.lua
|
||||
fi
|
||||
else
|
||||
|
||||
+481
-83
@@ -27,6 +27,7 @@ local TrainerAI = require("src.battle.TrainerAI")
|
||||
local TurnOrder = require("src.battle.TurnOrder")
|
||||
local TypeChart = require("src.battle.TypeChart")
|
||||
local Strings = require("src.core.Strings")
|
||||
local WideBattle = require("src.battle.WideBattle")
|
||||
|
||||
local BattleState = {}
|
||||
BattleState.__index = BattleState
|
||||
@@ -35,9 +36,33 @@ BattleState.isOpaque = true
|
||||
-- window reads as one continuous battle screen (no black bars).
|
||||
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()
|
||||
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
|
||||
end
|
||||
|
||||
-- Renderer:setUISize asks the top state for its surface before anything draws
|
||||
function BattleState:uiSize()
|
||||
if self:wideLayout() then return WideBattle.WIDTH, WideBattle.HEIGHT end
|
||||
return 160, 144
|
||||
end
|
||||
|
||||
-- Battle colors itself per-pixel (species pics + HP bar tints), so the
|
||||
-- SGB whole-screen remap must not run over it.
|
||||
function BattleState.sgbPalettes() return nil end
|
||||
-- SGB whole-screen remap must not run over it. The wide layout still
|
||||
-- needs a zone list of its own: the invented 160x144 one would leave its
|
||||
-- extra columns unremapped in the forced-mono modes (WideBattle.zones).
|
||||
function BattleState:sgbPalettes()
|
||||
if self:wideLayout() then return WideBattle.zones() end
|
||||
return nil
|
||||
end
|
||||
|
||||
local Rulesets = {
|
||||
gen1_faithful = require("src.battle.rulesets.gen1_faithful"),
|
||||
@@ -53,16 +78,22 @@ local BALL_ANIMS = {
|
||||
}
|
||||
|
||||
local imageCache = {}
|
||||
-- The three tables below are keyed by the Image OBJECT, not by a path, and a
|
||||
-- running battle holds the pics it built at enter() (battler.sprite,
|
||||
-- playerBackPic, trainerPic). Weak keys let a dropped pic's row go with it,
|
||||
-- so invalidate() can drop the path cache without orphaning what is on
|
||||
-- screen right now (#316).
|
||||
local WEAK_KEYS = { __mode = "k" }
|
||||
-- fully transparent rows below a pic's content (the extracted 32x32 back
|
||||
-- pics carry baked-in padding); used to sit the pic flush on the text box
|
||||
local imagePadBottom = {}
|
||||
local imagePadBottom = setmetatable({}, WEAK_KEYS)
|
||||
-- fully transparent columns left of a pic's content; at 2x (back pics)
|
||||
-- this is subtracted from hlcoord 1,5 so opaque pixels match hardware,
|
||||
-- where those columns were white-on-white rather than shifted content
|
||||
local imagePadLeft = {}
|
||||
local imagePadLeft = setmetatable({}, WEAK_KEYS)
|
||||
-- image -> { path, pal } so palette-fade variants (see fadeImage) can be
|
||||
-- rebuilt for any battle pic, whatever code loaded it
|
||||
local imageMeta = {}
|
||||
local imageMeta = setmetatable({}, WEAK_KEYS)
|
||||
-- pal = { name, colors } recolors the 4 GB shades like the Super Game Boy.
|
||||
-- trueColor art (14 §the 4-shade contract) opts out of the quantize
|
||||
-- entirely, so its palette variant collapses back onto the plain path.
|
||||
@@ -118,10 +149,19 @@ local function getImage(path, pal, trueColor)
|
||||
return imageCache[key]
|
||||
end
|
||||
|
||||
-- hot reload: the next getImage re-resolves every pic through the asset
|
||||
-- search path and re-measures its ground padding
|
||||
-- Hot reload / COLORS change (PaletteFX.setMode calls this): the next
|
||||
-- getImage re-resolves every pic through the asset search path and
|
||||
-- re-measures its ground padding. ONLY the path->image cache is dropped.
|
||||
-- Wiping the three per-image tables as well orphaned the pics a running
|
||||
-- battle already holds: imagePadBottom went nil, so backPlacement lost the
|
||||
-- four transparent rows it grounds the back pic on and the pic jumped
|
||||
-- pad * 2x = 8px UP the frame COLORS changed (#316), and imageMeta went nil,
|
||||
-- so picImage's forced-mono grayImage (#207), fadeImage's BGP variants and
|
||||
-- imagePathOf's battle_sprite_scales lookup all silently stopped resolving.
|
||||
-- Those rows are weak-keyed, so entries for pics nothing references any more
|
||||
-- are collected on their own rather than leaking.
|
||||
function BattleState.invalidate()
|
||||
imageCache, imagePadBottom, imagePadLeft, imageMeta = {}, {}, {}, {}
|
||||
imageCache = {}
|
||||
end
|
||||
|
||||
Assets.register(BattleState.invalidate)
|
||||
@@ -174,6 +214,25 @@ local function grayImage(img)
|
||||
return getImage(meta.path) or img
|
||||
end
|
||||
|
||||
-- The blacked-out battle screen. HandlePlayerBlackOut (core.asm:1151) runs
|
||||
-- SET_PAL_BATTLE_BLACK, i.e. SetPal_BattleBlack sends PalPacket_Black --
|
||||
-- PAL_BLACK in all four slots of BlkPacket_Battle (engine/gfx/palettes.asm:
|
||||
-- 22-25). The mon pics are drawn OVER the zone pass with their palette
|
||||
-- already baked in, so darkening them means re-baking through PAL_BLACK the
|
||||
-- way fadeImage re-bakes through a BGP permutation (#292). Reads the palette
|
||||
-- out of the active pack, exactly like sgbBattlePals, so the zone pass and
|
||||
-- the pics can never disagree. trueColor art has no DMG shades to remap.
|
||||
local function blackImage(data, img)
|
||||
local meta = imageMeta[img]
|
||||
if not meta or meta.trueColor then return img end
|
||||
local PaletteFX = require("src.render.PaletteFX")
|
||||
local pack = PaletteFX.pack(data)
|
||||
local colors = pack and pack.palettes and pack.palettes.BLACK
|
||||
if not colors then return img end
|
||||
local name = PaletteFX.usesGbcPack() and "redpp:BLACK" or "BLACK"
|
||||
return getImage(meta.path, { name = name, colors = colors }) or img
|
||||
end
|
||||
|
||||
-- the asset path a loaded battle image came from (nil for the headless
|
||||
-- stub images), so the battle_sprite_scales registry can be looked up by
|
||||
-- the same path data references
|
||||
@@ -200,6 +259,11 @@ function BattleState:picImage(img)
|
||||
local mono = PaletteFX.mode == "og" or PaletteFX.mode == "og_inv"
|
||||
or PaletteFX.mode == "classic"
|
||||
if self.grayPics or mono then return grayImage(img) end
|
||||
-- SET_PAL_BATTLE_BLACK covers every battle palette slot, so the pics go
|
||||
-- dark with the HP bars while the blackout text is up (#292). Below the
|
||||
-- mono check on purpose: the forced-mono modes re-threshold the whole
|
||||
-- frame downstream, and the DMG had no SGB darkening to begin with.
|
||||
if self.blackedOut then return blackImage(self.data, img) end
|
||||
return fadeImage(img, self:activeBgp())
|
||||
end
|
||||
|
||||
@@ -725,6 +789,7 @@ function BattleState:startMessage(item)
|
||||
-- it against self.total); the current line's revealed count is #shown[last]
|
||||
self.charIndex = 0
|
||||
self.msgWaiting = nil
|
||||
self.msgPrompt = nil
|
||||
self.scrollPx = nil
|
||||
self:beginMsgLine()
|
||||
end
|
||||
@@ -930,9 +995,18 @@ function BattleState:updateQueue()
|
||||
end))
|
||||
return true
|
||||
end
|
||||
if not (item and item.choice)
|
||||
and (input:wasPressed("a") or input:wasPressed("b")) then
|
||||
self.current = nil
|
||||
if not (item and item.choice) then
|
||||
-- The page is typed out and waiting on the player: PromptText
|
||||
-- (home/text.asm:209-217) writes '▼' at (18,16) and ManualTextScroll
|
||||
-- blinks it until A/B, so the arrow belongs on a finished page and not
|
||||
-- only on a \v CONT hold (#317). A flag of its own, not msgWaiting:
|
||||
-- that branch above scrolls the NEXT line in, which this page has not
|
||||
-- got, so reusing it would call beginMsgLine on a drained message.
|
||||
self.msgPrompt = true
|
||||
if input:wasPressed("a") or input:wasPressed("b") then
|
||||
self.msgPrompt = nil
|
||||
self.current = nil
|
||||
end
|
||||
end
|
||||
end
|
||||
return true
|
||||
@@ -1021,6 +1095,17 @@ function BattleState:enter()
|
||||
-- sides slide in; the trainer pics stay up until the send-outs
|
||||
self.introSlide = 40
|
||||
self.showEnemyTrainer = self.kind == "trainer" and self.trainerPic ~= nil
|
||||
-- DrawAllPokeballs (common_text.asm:27) puts the party ball rows AND the
|
||||
-- HUD corner/underline tiles under them (PlacePlayerHUDTiles /
|
||||
-- PlaceEnemyHUDTiles, draw_hud_pokeball_gfx.asm:119-165) on screen with
|
||||
-- the intro text; _InitBattleCommon (core.asm:6755-6762) ClearScreenArea's
|
||||
-- both HUD blocks and ClearSprites's the balls the moment that text is
|
||||
-- dismissed. This flag is exactly that window: drawHUDs draws the intro
|
||||
-- chrome while it is up and holds the real enemy HUD back, since a wild
|
||||
-- battle's DrawEnemyHUDAndHPBar only runs after the text (#317). The
|
||||
-- draw is still gated on the slide having landed, so nothing shows while
|
||||
-- the silhouettes are still coming in.
|
||||
self.introBalls = true
|
||||
-- SGB: the player-side battle palette while the back pic is up is
|
||||
-- MonsterPalettes[0] = PAL_MEWMON (wBattleMonSpecies is still 0 when
|
||||
-- the intro's SET_PAL_BATTLE runs -- SetPal_Battle,
|
||||
@@ -1052,12 +1137,27 @@ function BattleState:enter()
|
||||
queueEnemyCry()
|
||||
end
|
||||
self:say(self.introText)
|
||||
-- _InitBattleCommon (core.asm:6755-6762): the instant the intro text is
|
||||
-- dismissed both HUD blocks are cleared and ClearSprites drops the
|
||||
-- pokeball OAM, so the intro chrome never returns for the rest of the
|
||||
-- battle -- not on a switch, and not when the beaten trainer's pic
|
||||
-- scrolls back in (#317, #282)
|
||||
self:act(function() self.introBalls = nil end)
|
||||
if self.kind == "trainer" then
|
||||
-- EnemySendOutFirstMon (core.asm:1308-1310): SlideTrainerPicOffScreen
|
||||
-- walks the foe's pic off the RIGHT edge (hlcoord 18,0, a = 8 tiles,
|
||||
-- one tile every 2 frames) BEFORE TrainerSentOutText -- the pic does
|
||||
-- not blink out under the text (#317)
|
||||
self:act(function() self:slidePic("foe", 0, 64, 4) end)
|
||||
table.insert(self.queue, { wait = 16 })
|
||||
self:act(function()
|
||||
self.showEnemyTrainer = false
|
||||
self:slidePic("foe")
|
||||
end)
|
||||
self:say(Strings("%s sent\nout %s!", self.trainer.name, self.enemy.name))
|
||||
self:act(function()
|
||||
-- EnemySendOutFirstMon (core.asm:1421-1434): after the text the
|
||||
-- pic grows out of the ball (AnimateSendingOutMon), then the cry
|
||||
self.showEnemyTrainer = false
|
||||
self:startGrowIn(self.enemy)
|
||||
end)
|
||||
queueEnemyCry()
|
||||
@@ -1075,13 +1175,20 @@ function BattleState:enter()
|
||||
queueEnemyCry()
|
||||
end
|
||||
if not self.safari and not self.demo then
|
||||
self:say(self:sendOutText(self.player.name))
|
||||
-- Red's pic clears, the POOF plays, then the mon appears with its
|
||||
-- cry (SendOutMon: message -> AnimateSendingOutMon -> PlayCry)
|
||||
-- StartBattle .playerSendOutFirstMon (core.asm:236-240): the back pic
|
||||
-- walks off the LEFT edge (SlideTrainerPicOffScreen, hlcoord 1,5,
|
||||
-- a = 9 tiles, one tile every 2 frames) BEFORE SendOutMon prints
|
||||
-- "Go! X!" -- Red does not simply vanish under the message (#317)
|
||||
self:act(function() self:slidePic("back", 0, -72, 4) end)
|
||||
table.insert(self.queue, { wait = 18 })
|
||||
self:act(function()
|
||||
self.showPlayerBack = false
|
||||
self.sendingOut = true
|
||||
self:slidePic("back")
|
||||
end)
|
||||
self:say(self:sendOutText(self.player.name))
|
||||
-- then the POOF plays and the mon appears with its cry
|
||||
-- (SendOutMon: message -> AnimateSendingOutMon -> PlayCry)
|
||||
table.insert(self.queue, { anim = "POOF_ANIM", attackerIsPlayer = false })
|
||||
self:act(function()
|
||||
self.sendingOut = false
|
||||
@@ -1133,6 +1240,31 @@ local function clearTrapping(battler)
|
||||
battler.trapDamage = nil
|
||||
end
|
||||
|
||||
-- core.asm:297-300: both sides' FLINCHED bits are cleared as a turn's move
|
||||
-- selection opens, but the clear is skipped for a mon that must recharge or
|
||||
-- is locked into Rage (core.asm:293-295 -- the Hyper Beam flinch-recharge
|
||||
-- glitch).
|
||||
--
|
||||
-- A method rather than three lines inside the menu branch, because two
|
||||
-- other places need the identical rule at the identical point in the turn
|
||||
-- and both got it wrong by not having it:
|
||||
--
|
||||
-- * guarded PER BATTLER, not off self.player alone. This runs on
|
||||
-- whichever machine is looking at its own menu, and in a lockstep link
|
||||
-- battle "self.player" is the host's mon on one peer and the guest's on
|
||||
-- the other, so one shared guard let one peer clear both flags while
|
||||
-- the other cleared neither -- a bogus desync draw in a winnable match.
|
||||
-- * a tournament spectator never enters the menu phase at all (it has no
|
||||
-- decision to make), so nothing cleared a flinch in its replay: the
|
||||
-- flag survived into the next turn, ate a move the real players saw
|
||||
-- land, and from there the replay was watching a different battle.
|
||||
-- LinkBattle.newSpectator calls this at the head of every turn.
|
||||
function BattleState:clearTurnFlinches()
|
||||
for _, b in ipairs({ self.player, self.enemy }) do
|
||||
if b and not (b.mustRecharge or b.rageMove) then b.flinched = false end
|
||||
end
|
||||
end
|
||||
|
||||
-- Actions that skip DisplayBattleMenu entirely (core.asm:300-310):
|
||||
-- recharge, Rage, thrash, charge. Bide / trapping / being held do NOT
|
||||
-- skip the menu -- the player can still item/switch (and must press
|
||||
@@ -1153,11 +1285,17 @@ function BattleState:fightLockedAction(battler)
|
||||
end
|
||||
if battler.bideTurns then return { special = "bide" } end
|
||||
-- held while the OPPONENT's trapping bit is set (live mirror so a
|
||||
-- trap ended early by paralysis/faint frees the victim immediately)
|
||||
-- trap ended early by paralysis/faint frees the victim immediately).
|
||||
-- Read, not written: executeAction refreshes battler.boundTurns from the
|
||||
-- same expression when the action actually runs, and that site runs on
|
||||
-- both peers of a link battle. Storing it here instead wrote a hashed
|
||||
-- field on whichever machine happened to open its own FIGHT menu, which
|
||||
-- left the two peers holding boundTurns=0 against nil for the same
|
||||
-- battler and ended the match as a desync over a mirror of a mirror.
|
||||
local opp = battler.isPlayer and self.enemy or self.player
|
||||
battler.boundTurns = opp and opp.trappingTurns
|
||||
and math.max(1, opp.trappingTurns) or nil
|
||||
if battler.boundTurns then
|
||||
local bound = opp and opp.trappingTurns
|
||||
and math.max(1, opp.trappingTurns) or nil
|
||||
if bound then
|
||||
return { special = "bound" }
|
||||
end
|
||||
return nil
|
||||
@@ -1194,9 +1332,23 @@ function BattleState:swapMoves(i, j)
|
||||
require("src.core.Sound").play(self.data, "Swap")
|
||||
end
|
||||
|
||||
function BattleState:update(dt)
|
||||
-- One frame of the presentational clock: the BGP flash sequences, the
|
||||
-- per-battler pic slide/hide programs, the send-out grow-in, the intro
|
||||
-- slide and the screen-shake programs all advance in updateFx and nowhere
|
||||
-- else. It lives behind its own entry point because a caller that has to
|
||||
-- skip the rest of update() for a frame must still tick this, and a link
|
||||
-- battle does exactly that on two hot paths -- waiting on the peer's action,
|
||||
-- and draining a resolved lockstep turn. Skipping it there froze whatever
|
||||
-- was mid-flight: a flash stuck on its inverted BGP step repainted the whole
|
||||
-- UI in inverted shades, and a pic part-way through a slide-off or a grow-in
|
||||
-- simply stayed gone -- for as long as the opponent took to choose.
|
||||
function BattleState:tickFx()
|
||||
self.frame = self.frame + 1
|
||||
self:updateFx()
|
||||
end
|
||||
|
||||
function BattleState:update(dt)
|
||||
self:tickFx()
|
||||
local input = self.game.input
|
||||
|
||||
-- safety net: HP/status changed outside a queued drain (level-up heals,
|
||||
@@ -1211,6 +1363,15 @@ function BattleState:update(dt)
|
||||
end
|
||||
|
||||
if self.phase == "messages" then
|
||||
-- Nothing queued starts until the silhouettes have finished sliding in:
|
||||
-- SlidePlayerAndEnemySilhouettesOnScreen ends with `jpfar
|
||||
-- PrintBeginningBattleText` (engine/battle/core.asm:100), so the enemy
|
||||
-- cry and the "Wild X appeared!" box belong at the end of the slide, not
|
||||
-- on its first frame (#303). The hold sits here rather than in
|
||||
-- updateQueue because only this loop has a frame clock: updateFx above
|
||||
-- counts introSlide down, and a headless caller driving updateQueue on
|
||||
-- its own has no slide to wait for.
|
||||
if (self.introSlide or 0) > 0 then return end
|
||||
if not self:updateQueue() then
|
||||
if self.afterQueue == "menu" then
|
||||
self.phase = "menu"
|
||||
@@ -1270,13 +1431,7 @@ function BattleState:update(dt)
|
||||
end
|
||||
return
|
||||
end
|
||||
-- core.asm:297-300: both sides' FLINCHED bits are cleared during
|
||||
-- move selection, but the clear is skipped while the player must
|
||||
-- recharge or is locked into Rage (core.asm:293-295 -- the Hyper
|
||||
-- Beam flinch-recharge glitch)
|
||||
if not (self.player.mustRecharge or self.player.rageMove) then
|
||||
self.player.flinched, self.enemy.flinched = false, false
|
||||
end
|
||||
self:clearTurnFlinches()
|
||||
-- only recharge/Rage/thrash/charge skip DisplayBattleMenu; trapping
|
||||
-- victims (and wrappers) still get FIGHT/PKMN/ITEM/RUN (core.asm:312)
|
||||
local locked = self:menuLockedAction(self.player)
|
||||
@@ -1332,7 +1487,14 @@ function BattleState:update(dt)
|
||||
|
||||
if self.phase == "moveSelect" then
|
||||
local moves = self.player.curMoves
|
||||
if input:wasPressed("up") then
|
||||
-- The widescreen layout lays the four slots out as a 2x2 grid, so all
|
||||
-- four directions navigate it; nil means no direction was pressed and
|
||||
-- A / B / SELECT below behave the same in either layout.
|
||||
local grid = self:wideLayout()
|
||||
and WideBattle.navigate(self.moveIndex, #moves, input)
|
||||
if grid then
|
||||
self.moveIndex = grid
|
||||
elseif input:wasPressed("up") then
|
||||
self.moveIndex = self.moveIndex > 1 and self.moveIndex - 1 or #moves
|
||||
elseif input:wasPressed("down") then
|
||||
self.moveIndex = self.moveIndex < #moves and self.moveIndex + 1 or 1
|
||||
@@ -1374,7 +1536,13 @@ function BattleState:update(dt)
|
||||
-- (core.asm:2553-2557), so there is no backing out with B.
|
||||
if self.phase == "mimicSelect" then
|
||||
local moves = self.mimicMoves
|
||||
if input:wasPressed("up") then
|
||||
-- the copy menu shares the widescreen move grid, so it navigates the
|
||||
-- same way there (the classic layout keeps the vertical list)
|
||||
local grid = self:wideLayout()
|
||||
and WideBattle.navigate(self.mimicIndex, #moves, input)
|
||||
if grid then
|
||||
self.mimicIndex = grid
|
||||
elseif input:wasPressed("up") then
|
||||
self.mimicIndex = self.mimicIndex > 1 and self.mimicIndex - 1 or #moves
|
||||
elseif input:wasPressed("down") then
|
||||
self.mimicIndex = self.mimicIndex < #moves and self.mimicIndex + 1 or 1
|
||||
@@ -2156,19 +2324,34 @@ end
|
||||
-- battle (EndLowHealthAlarm sets wLowHealthAlarmDisabled, mirrored by
|
||||
-- playVictoryMusic) and every other outcome tears it down in
|
||||
-- end_of_battle.asm -- self.result covers those. The damage drain
|
||||
-- gates the start (the HUD redraw runs after UpdateHPBar finishes),
|
||||
-- but healing out of the red stops it at once (item_effects.asm clears
|
||||
-- the alarm before the bar animates). No alarm before the player HUD
|
||||
-- first draws (send-out), nor in the safari/old-man battles, which
|
||||
-- have no player mon HUD.
|
||||
-- gates the START (the HUD redraw runs after UpdateHPBar finishes) but
|
||||
-- never the stop, and healing out of the red silences it at once
|
||||
-- (item_effects.asm:991-994 clears the alarm before the bar animates).
|
||||
-- No alarm before the player HUD first draws (send-out), nor in the
|
||||
-- safari/old-man battles, which have no player mon HUD.
|
||||
function BattleState:lowHealthAlarmActive()
|
||||
local p = self.player
|
||||
if not p or self.safari or self.demo or self.result
|
||||
or self.lowHealthAlarmDisabled then return false end
|
||||
if self.showPlayerBack or (self.introSlide or 0) > 0 then return false end
|
||||
if p.fainted then return false end
|
||||
-- A siren that is ALREADY sounding follows the drawn bar, not the
|
||||
-- model: wLowHealthAlarm is a latch DrawPlayerHUDAndHPBar only revisits
|
||||
-- once UpdateHPBar2 has finished animating (core.asm:4727-4729 /
|
||||
-- core.asm:4845-4847 both drain first, then jp DrawHUDsAndHPBars), and
|
||||
-- a KO clears it in RemoveFaintedPlayerMon (core.asm:1011-1016), i.e.
|
||||
-- after the bar has drained empty. applyDamage takes the HP off the
|
||||
-- model while the turn is still being queued, so keying a running alarm
|
||||
-- off mon.hp cut it dead for the whole "used X!" line + move animation
|
||||
-- + drain window (#293). max() keeps a heal out of the red silencing
|
||||
-- it on the spot, the way item_effects.asm does.
|
||||
local hp = p.mon.hp
|
||||
if hp <= 0 or p.fainted then return false end
|
||||
if p.shownHP and p.shownHP > hp then return false end -- drain running
|
||||
if self.lowHealthAlarmOn then
|
||||
hp = math.max(hp, shownHP(p))
|
||||
elseif p.shownHP and p.shownHP > hp then
|
||||
return false -- drain running: the HUD redraw has not happened yet
|
||||
end
|
||||
if hp <= 0 then return false end
|
||||
local px = math.max(1, math.floor(hp * 48 / math.max(1, p.mon.stats.hp)))
|
||||
return px < 10
|
||||
end
|
||||
@@ -2184,10 +2367,47 @@ local function stepProgram(prog)
|
||||
return head
|
||||
end
|
||||
|
||||
-- Trainer-pic slides. SlideTrainerPicOffScreen (core.asm:1235) walks a
|
||||
-- trainer pic off its own screen edge one tile every 2 frames (9 tiles left
|
||||
-- for the player back pic, 8 tiles right for the foe), and
|
||||
-- _ScrollTrainerPicAfterBattle (engine/battle/scroll_draw_trainer_pic.asm)
|
||||
-- brings the beaten foe back in from the right one column every 4 frames.
|
||||
-- picOff holds the live programs by slot -- "foe" = the enemy trainer pic,
|
||||
-- "back" = the player's back pic -- as a screen-pixel x offset stepped
|
||||
-- toward `to`; updateFx advances them, drawPicsLayer adds them, and the
|
||||
-- queue rows that start them park a { wait } of the matching length. Call
|
||||
-- with no target to clear a slot (#317, #282).
|
||||
function BattleState:slidePic(slot, from, to, step)
|
||||
self.picOff = self.picOff or {}
|
||||
if to == nil then
|
||||
self.picOff[slot] = nil
|
||||
return
|
||||
end
|
||||
self.picOff[slot] = { x = from or 0, to = to, step = step or 4 }
|
||||
end
|
||||
|
||||
-- the live x offset for a pic slot, 0 when nothing is sliding
|
||||
function BattleState:picOffset(slot)
|
||||
local p = self.picOff and self.picOff[slot]
|
||||
return p and p.x or 0
|
||||
end
|
||||
|
||||
function BattleState:updateFx()
|
||||
if self.introSlide and self.introSlide > 0 then
|
||||
self.introSlide = self.introSlide - 1
|
||||
end
|
||||
-- step each live trainer-pic slide toward its target; a landed program
|
||||
-- holds its offset (the after-battle scroll-in rests two tiles right of
|
||||
-- the battle slot) until its owner clears the slot
|
||||
if self.picOff then
|
||||
for _, p in pairs(self.picOff) do
|
||||
if p.x < p.to then
|
||||
p.x = math.min(p.to, p.x + p.step)
|
||||
elseif p.x > p.to then
|
||||
p.x = math.max(p.to, p.x - p.step)
|
||||
end
|
||||
end
|
||||
end
|
||||
local fx = self.fx
|
||||
if fx then
|
||||
if fx.shake and fx.shake > 0 then fx.shake = fx.shake - 1 end
|
||||
@@ -2276,7 +2496,12 @@ function BattleState:updateFx()
|
||||
-- low-HP alarm (audio/low_health_alarm.asm): the two-tone siren
|
||||
-- loops while the player's bar is red; see lowHealthAlarmActive
|
||||
local Sound = require("src.core.Sound")
|
||||
if self:lowHealthAlarmActive() then
|
||||
-- self.lowHealthAlarmOn mirrors wLowHealthAlarm's bit 7: a latch read
|
||||
-- back inside lowHealthAlarmActive (the RHS sees last frame's value)
|
||||
-- so a sounding siren rides out the next hit's HP drain instead of
|
||||
-- dropping out mid-announcement (#293)
|
||||
self.lowHealthAlarmOn = self:lowHealthAlarmActive()
|
||||
if self.lowHealthAlarmOn then
|
||||
Sound.startLoop(self.data, "Low_Health_Alarm")
|
||||
else
|
||||
Sound.stopLoop("Low_Health_Alarm")
|
||||
@@ -2548,7 +2773,7 @@ function BattleState:performMove(user, target, moveInst, isCalled)
|
||||
end
|
||||
|
||||
-- PP: not for continuations, struggle, called moves, or (under
|
||||
-- gen1_faithful) wild/trainer enemies — pokered DecrementPP only ever
|
||||
-- gen1_faithful) wild/trainer enemies -- pokered DecrementPP only ever
|
||||
-- mutates wBattleMonPP / party PP (engine/battle/decrement_pp.asm).
|
||||
-- That rule doesn't apply in a link battle: "the enemy" there is a real
|
||||
-- human peer independently tracking their own PP the normal way, not an
|
||||
@@ -2913,6 +3138,16 @@ function BattleState:enemyMonFainted()
|
||||
local style = tostring((self.game.save.options or {}).battleStyle or "shift")
|
||||
:lower()
|
||||
local partyCount = #self.game.save.party
|
||||
-- ReplaceFaintedEnemyMon (core.asm:892-896): DrawEnemyPokeballs puts the
|
||||
-- foe's party ball row -- and the HUD chrome PlaceEnemyHUDTiles lays
|
||||
-- down under it (draw_hud_pokeball_gfx.asm:9-11, 33-45, 134-141) -- into
|
||||
-- the block FaintEnemyPokemon just cleared, after the exp text and
|
||||
-- BEFORE the next send-out. It survives EnemySendOutFirstMon's
|
||||
-- SlideTrainerPicOffScreen (core.asm:1308-1310, 8 steps x DelayFrames 2)
|
||||
-- so SET style gets the brief flash, and stays up through the whole
|
||||
-- SHIFT prompt below (#283).
|
||||
self:act(function() self.showEnemyBalls = true end)
|
||||
table.insert(self.queue, { wait = 16 })
|
||||
-- SwitchPlayerMon runs AFTER TrainerSentOutText (core.asm:1436-1443)
|
||||
local shiftSwitchMon = nil
|
||||
if style ~= "set" and partyCount > 1 and self.player.mon.hp > 0 then
|
||||
@@ -2948,6 +3183,10 @@ function BattleState:enemyMonFainted()
|
||||
})
|
||||
self.aiUses = self:aiUsesFor()
|
||||
markSeen(self.game, self.enemy.mon.species)
|
||||
-- EnemySendOutFirstMon .next4 (core.asm:1413-1417): ClearSprites and
|
||||
-- the 4x11 ClearScreenArea take the ball row away with the rest of
|
||||
-- the enemy HUD block, right before TrainerSentOutText (#283)
|
||||
self.showEnemyBalls = nil
|
||||
self:markParticipant()
|
||||
-- EnemySendOutFirstMon (core.asm:1413-1435): the enemy HUD area
|
||||
-- clears, TrainerSentOutText prints, THEN the pic appears
|
||||
@@ -2975,6 +3214,20 @@ function BattleState:enemyMonFainted()
|
||||
battle = self, side = self.sides[1],
|
||||
battler = self.player, previous = previous,
|
||||
})
|
||||
-- Taking the SHIFT offer ZEROES wPartyGainExpFlags and
|
||||
-- wPartyFoughtCurrentEnemyFlags before jumping to SwitchPlayerMon
|
||||
-- (EnemySendOutFirstMon tail, core.asm:1436-1443), and SwitchPlayerMon
|
||||
-- then FLAG_SETs only the mon coming in (core.asm:2424-2433). Without
|
||||
-- the reset the mon that was out when the enemy fainted -- marked by
|
||||
-- the send-out act above, which mirrors EnemySendOut's own re-flag
|
||||
-- (core.asm:1276-1289) -- stayed a participant, so the exp divisor in
|
||||
-- enemyMonFainted counted two mons and the switch-in earned half the
|
||||
-- next KO (#275). Voluntary switches (resolveSwitch) and post-faint
|
||||
-- replacements (openReplacementMenu) must NOT do this: pokered's
|
||||
-- party-menu SwitchPlayerMon keeps the outgoing mon flagged, which is
|
||||
-- the deliberate exp-share, and a fainted mon is already dropped by
|
||||
-- onFaint mirroring RemoveFaintedPlayerMon (core.asm:1002-1007).
|
||||
self.participants = {}
|
||||
self:markParticipant()
|
||||
self.nextInsert = 0
|
||||
self.sendingOut = true
|
||||
@@ -2990,16 +3243,39 @@ function BattleState:enemyMonFainted()
|
||||
end
|
||||
local prize = (self.trainer.baseMoney or 0) * self.enemy.mon.level
|
||||
self.game.save.money = self.game.save.money + prize
|
||||
-- the beaten trainer's pic returns for the defeat text (pokered
|
||||
-- DisplayBattleMenu's defeat flow)
|
||||
self:act(function() self.showEnemyTrainer = self.trainerPic ~= nil end)
|
||||
-- TrainerBattleVictory (core.asm:915-933): EndLowHealthAlarm, then
|
||||
-- the victory theme starts BEFORE TrainerDefeatedText and the
|
||||
-- prize money
|
||||
-- TrainerBattleVictory (core.asm:915-949) in order: EndLowHealthAlarm
|
||||
-- and the victory theme, TrainerDefeatedText, ScrollTrainerPicAfterBattle
|
||||
-- (the beaten trainer scrolls back in from the right, one column every 4
|
||||
-- frames, resting two tiles right of the battle slot), DelayFrames 40,
|
||||
-- PrintEndBattleText -- the trainer's OWN loss line, on the battle
|
||||
-- screen -- and only then MoneyForWinningText. Every row rides the
|
||||
-- *Next inserters so it keeps that order behind the running queue item;
|
||||
-- the plain act() the pic used to ride appended to the END of the queue,
|
||||
-- which is why the trainer only flashed up for a frame or two as the
|
||||
-- battle popped and the loss line had to be printed by the overworld
|
||||
-- afterwards, stranding any evolution between two cuts (#282).
|
||||
-- endBattleText is filled in by whoever started the battle
|
||||
-- (OverworldState:engageTrainer in src/world/OverworldController.lua);
|
||||
-- scripted battles that print their own follow-up leave it nil.
|
||||
self:actNext(function() self:playVictoryMusic() end)
|
||||
-- _TrainerDefeatedText: "<PLAYER> defeated\nTRAINER!"
|
||||
self:sayNext(Strings("%s defeated\n%s!", self.game.save.player.name,
|
||||
self.trainer.name))
|
||||
self:actNext(function()
|
||||
self.showEnemyTrainer = self.trainerPic ~= nil
|
||||
if self.showEnemyTrainer then self:slidePic("foe", 64, 16, 2) end
|
||||
end)
|
||||
-- the 24-frame scroll-in plus the DelayFrames 40 that follows it
|
||||
self.nextInsert = (self.nextInsert or 0) + 1
|
||||
table.insert(self.queue, self.nextInsert, { wait = 64 })
|
||||
if self.endBattleText then
|
||||
-- PrintEndBattleText prints one text box; a `para` (\f) inside it
|
||||
-- starts a fresh page, which is a message row of its own here (five
|
||||
-- EndBattleTexts carry one, e.g. _Route9Youngster1EndBattleText)
|
||||
for page in (self.endBattleText .. "\f"):gmatch("(.-)\f") do
|
||||
if page ~= "" then self:sayNext(page) end
|
||||
end
|
||||
end
|
||||
self:sayNext(Strings("%s got ¥%d\nfor winning!", self.game.save.player.name, prize))
|
||||
end
|
||||
self.result = "win"
|
||||
@@ -3067,6 +3343,15 @@ function BattleState:playerMonFainted()
|
||||
-- Oak's Lab starter rival: Rival1WinText only (no blackout lines).
|
||||
-- Any other wipe, including Route 22 RIVAL1, still blacks out.
|
||||
if not BattleState.isOaksLabStarterRival(self) then
|
||||
-- HandlePlayerBlackOut (core.asm:1150-1159): SET_PAL_BATTLE_BLACK runs
|
||||
-- BEFORE PlayerBlackedOutText2, so the enemy pic and both HP bars are
|
||||
-- already dark under the blackout lines (#292). The Oak's Lab starter
|
||||
-- rival returns one line above that call and never darkens. Set here
|
||||
-- rather than queued: this whole function already runs from a queued
|
||||
-- act after "<mon> fainted!" was dismissed, which is where the palette
|
||||
-- command sits. (The Route 22 RIVAL1 wipe darkens one box early, over
|
||||
-- Rival1WinText, which pokered prints just before the same command.)
|
||||
self.blackedOut = true
|
||||
self:sayNext(Strings("%s is out of\nuseable POKéMON!", self.game.save.player.name))
|
||||
self:sayNext(Strings("%s blacked\nout!", self.game.save.player.name))
|
||||
end
|
||||
@@ -3475,13 +3760,41 @@ end
|
||||
|
||||
-- called by BagMenu when a ball is thrown
|
||||
function BattleState:throwBall(ball)
|
||||
self:say(Strings("%s used\n%s!", self.game.save.player.name,
|
||||
self.data.items[ball].name))
|
||||
-- ItemUseBall branches to ThrowBallAtTrainerMon on wIsInBattle != 1
|
||||
-- (item_effects.asm:109-113) BEFORE it reaches `ld hl, ItemUseText00 /
|
||||
-- call PrintText` (:146-147), so a trainer battle never shows the
|
||||
-- "<PLAYER> used <ITEM>!" line (#291). Safari and the old man demo are
|
||||
-- still wIsInBattle == 1, and this port models both as kind == "wild".
|
||||
if self.kind == "wild" then
|
||||
self:say(Strings("%s used\n%s!", self.game.save.player.name,
|
||||
self.data.items[ball].name))
|
||||
end
|
||||
self:act(function()
|
||||
require("src.core.Sound").play(self.data, "Ball_Toss")
|
||||
if self.kind ~= "wild" then
|
||||
self:sayNext(Strings("The TRAINER\nblocked the BALL!"))
|
||||
self:sayNext(Strings("Don't be a thief!"))
|
||||
-- ThrowBallAtTrainerMon (item_effects.asm:2292-2303) still animates the
|
||||
-- toss: MoveAnimation routes TOSS_ANIM to TossBallAnimation, which takes
|
||||
-- its .BlockBall branch in a trainer battle (animations.asm:2582-2585,
|
||||
-- 2629-2637) -- the plain TOSS arc whatever the ball tier, then
|
||||
-- SFX_FAINT_THUD and BLOCKBALL_ANIM, and only then the two texts. The
|
||||
-- ball still counts as used: UseItem_ sets
|
||||
-- wActionResultOrTookBattleTurn = 1 (item_effects.asm:1-3) and this path
|
||||
-- never clears it, so UseBagItem does not fall back to the bag
|
||||
-- (core.asm:2257-2259) and the turn is spent -- the foe moves (#291).
|
||||
local t = self.data.text
|
||||
self:animNext("TOSS_ANIM", true, nil, ball)
|
||||
self:actNext(function()
|
||||
require("src.core.Sound").play(self.data, "Faint_Thud")
|
||||
end)
|
||||
self:animNext("BLOCKBALL_ANIM", true)
|
||||
self:sayNext(t._ThrowBallAtTrainerMonText1
|
||||
or Strings("The trainer\nblocked the BALL!"))
|
||||
self:sayNext(t._ThrowBallAtTrainerMonText2
|
||||
or Strings("Don't be a thief!"))
|
||||
self:act(function()
|
||||
self:executeAction(self.enemy, self.player, self:enemyAction())
|
||||
end)
|
||||
self:act(function() self:endOfTurn() end)
|
||||
return
|
||||
end
|
||||
if self.ghost then
|
||||
@@ -3802,7 +4115,12 @@ function BattleState:drawBattlerPic(battler, x, y, scale)
|
||||
-- while an SE effect displaces the pic, confine it to its side's
|
||||
-- tile window like the GB tilemap does (the pic can never overwrite
|
||||
-- the HUD columns or the text box rows)
|
||||
-- ...except under the widescreen layout, where the side's own region
|
||||
-- scissor is already that window on a battlefield the classic tile
|
||||
-- columns do not describe (an 88..160 clip would fall entirely outside
|
||||
-- the enemy's region and erase the pic).
|
||||
local clip = love.graphics.setScissor and love.graphics.intersectScissor
|
||||
and not self.wideRegion
|
||||
local scx, scy, scw, sch
|
||||
if clip then
|
||||
scx, scy, scw, sch = love.graphics.getScissor()
|
||||
@@ -3932,6 +4250,16 @@ function BattleState:sgbBattlePals()
|
||||
local pack = PaletteFX.pack(self.data)
|
||||
local pals = pack and pack.palettes
|
||||
if not pals then return nil end
|
||||
-- HandlePlayerBlackOut (core.asm:1151) runs SET_PAL_BATTLE_BLACK:
|
||||
-- SetPal_BattleBlack sends PalPacket_Black, PAL_BLACK in all four slots of
|
||||
-- BlkPacket_Battle (engine/gfx/palettes.asm:22-25), so every zone of the
|
||||
-- battle screen -- both HP bars and both mon regions -- goes dark behind
|
||||
-- the blackout text. picImage re-bakes the pics through the same palette,
|
||||
-- since those draw over the zone pass rather than through it (#292).
|
||||
if self.blackedOut and pals.BLACK then
|
||||
local b = pals.BLACK
|
||||
return { [0] = b, [1] = b, [2] = b, [3] = b }
|
||||
end
|
||||
local function bar(b)
|
||||
if not b then return pals.GREENBAR end
|
||||
local hp = b.shownHP or b.mon.hp
|
||||
@@ -4006,9 +4334,10 @@ end
|
||||
|
||||
-- recolor the grayscale BG canvas per zone; an active BGP fade permutes
|
||||
-- the zone palette (the SGB colors the remapped DMG shade). A window
|
||||
-- shake draws a second, offset copy over the base one: the color
|
||||
-- regions themselves never move on the SGB, and the vacated strip
|
||||
-- shows the unshifted BG map like the hardware.
|
||||
-- shake draws only the offset copy: the baked canvas holds the HUDs and
|
||||
-- text box (window-layer content), so compositing an unshifted copy
|
||||
-- underneath ghosted every name in the vacated strip (#295). The strip
|
||||
-- shows blank color 0 instead, like the hardware revealing empty BG.
|
||||
function BattleState:drawZonePass(src, sx, sy)
|
||||
local PaletteFX = require("src.render.PaletteFX")
|
||||
local shader = PaletteFX.shader()
|
||||
@@ -4016,13 +4345,17 @@ function BattleState:drawZonePass(src, sx, sy)
|
||||
local bgp = self:activeBgp()
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
love.graphics.setShader(shader)
|
||||
local shaking = sx ~= 0 or sy ~= 0
|
||||
for _, z in ipairs(BATTLE_ZONES) do
|
||||
PaletteFX.sendColors(shader, PaletteFX.permute(pals[z.pal], bgp))
|
||||
love.graphics.setScissor(z[1] * 8, z[2] * 8,
|
||||
(z[3] - z[1] + 1) * 8, (z[4] - z[2] + 1) * 8)
|
||||
love.graphics.draw(src, 0, 0)
|
||||
if sx ~= 0 or sy ~= 0 then
|
||||
local zx, zy = z[1] * 8, z[2] * 8
|
||||
local zw, zh = (z[3] - z[1] + 1) * 8, (z[4] - z[2] + 1) * 8
|
||||
love.graphics.setScissor(zx, zy, zw, zh)
|
||||
if shaking then
|
||||
love.graphics.rectangle("fill", zx, zy, zw, zh)
|
||||
love.graphics.draw(src, sx, sy)
|
||||
else
|
||||
love.graphics.draw(src, 0, 0)
|
||||
end
|
||||
end
|
||||
love.graphics.setScissor()
|
||||
@@ -4145,15 +4478,21 @@ end
|
||||
|
||||
-- the two mon pics (or the trainer/back pics), offset by the window
|
||||
-- shake -- on the GB the pics are BG tiles, so they move with it
|
||||
function BattleState:drawPicsLayer(slide, sx, sy)
|
||||
-- onlySide ("player" / "enemy") draws one side's pic alone, and
|
||||
-- skipMenuClip drops the move-menu row clip below: the widescreen layout
|
||||
-- composites each side into its own region of a taller battlefield, where
|
||||
-- neither the other side's pixels nor the classic menu rows apply.
|
||||
function BattleState:drawPicsLayer(slide, sx, sy, onlySide, skipMenuClip)
|
||||
-- The move-select boxes are BG tiles on the GB, so they REPLACE the
|
||||
-- player pic's rows: the TYPE/PP box at (0,8) (PrintMenuItem) wipes
|
||||
-- pic rows 8+, and Mimic's copy menu at (0,7) (MoveSelectionMenu
|
||||
-- .mimicmenu) wipes rows 7+. The port draws pics above the menu
|
||||
-- layer in the colorized pipeline, so clip them to the visible rows.
|
||||
local g = love.graphics
|
||||
local clipY = self.phase == "mimicSelect" and 56
|
||||
or self.phase == "moveSelect" and 64 or nil
|
||||
local clipY = not skipMenuClip
|
||||
and (self.phase == "mimicSelect" and 56
|
||||
or self.phase == "moveSelect" and 64)
|
||||
or nil
|
||||
local clipped, cs1, cs2, cs3, cs4
|
||||
if clipY and g.getScissor and g.intersectScissor then
|
||||
cs1, cs2, cs3, cs4 = g.getScissor()
|
||||
@@ -4161,13 +4500,15 @@ function BattleState:drawPicsLayer(slide, sx, sy)
|
||||
clipped = true
|
||||
end
|
||||
-- Enemy: front sprite in the 7x7 slot at hlcoord 12,0.
|
||||
if self.showEnemyTrainer and self.trainerPic then
|
||||
if onlySide ~= "player" and self.showEnemyTrainer and self.trainerPic then
|
||||
-- the enemy trainer pic holds the mon slot until the send-out
|
||||
local img = self:picImage(self.trainerPic)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
local ex, ey = enemyPicXY(img, slide, sx, sy)
|
||||
love.graphics.draw(img, ex, ey)
|
||||
elseif self.enemy and self.enemy.sprite and not self.enemyHidden
|
||||
-- SlideTrainerPicOffScreen / _ScrollTrainerPicAfterBattle offset (#317)
|
||||
love.graphics.draw(img, ex + self:picOffset("foe"), ey)
|
||||
elseif onlySide ~= "player"
|
||||
and self.enemy and self.enemy.sprite and not self.enemyHidden
|
||||
and not self.enemySendingOut and not self:fxHidden(self.enemy) then
|
||||
local img = self:picImage(self.enemy.sprite)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
@@ -4196,7 +4537,7 @@ function BattleState:drawPicsLayer(slide, sx, sy)
|
||||
-- Left transparent columns (matted white) are pulled back so opaque
|
||||
-- pixels land where hardware's white-on-white columns left them.
|
||||
local hidePlayer = self.safari or self.demo
|
||||
if self.showPlayerBack and self.playerBackPic then
|
||||
if onlySide ~= "enemy" and self.showPlayerBack and self.playerBackPic then
|
||||
-- Red's (or the old man's) back pic until "Go!"; it stays up for
|
||||
-- the whole safari / catch-demo battle like the original
|
||||
local img = self:picImage(self.playerBackPic)
|
||||
@@ -4209,8 +4550,11 @@ function BattleState:drawPicsLayer(slide, sx, sy)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
local dx, dy = BattleState.backPlacement(img:getWidth(), img:getHeight(),
|
||||
pad, padL, s)
|
||||
love.graphics.draw(img, dx + slide + sx, dy + sy, 0, s, s)
|
||||
elseif self.player and self.player.sprite and not hidePlayer
|
||||
-- picOffset: SlideTrainerPicOffScreen walking the back pic off the left
|
||||
love.graphics.draw(img, dx + slide + sx + self:picOffset("back"),
|
||||
dy + sy, 0, s, s)
|
||||
elseif onlySide ~= "enemy"
|
||||
and self.player and self.player.sprite and not hidePlayer
|
||||
and not self.sendingOut and not self:fxHidden(self.player) then
|
||||
local img = self:picImage(self.player.sprite)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
@@ -4262,9 +4606,13 @@ function BattleState:drawHUDs(slide)
|
||||
local hudShake = (fx and fx.hudShakeX) or 0
|
||||
-- FaintEnemyPokemon clears the enemy HUD area; it stays blank through
|
||||
-- TrainerAboutToUseText until DrawEnemyHUDAndHPBar after the next send-out
|
||||
-- ...and it is not up yet during the intro text either: a wild battle's
|
||||
-- DrawEnemyHUDAndHPBar is called from _InitBattleCommon (core.asm:6763)
|
||||
-- AFTER PrintBeginningBattleText returns, so "Wild X appeared!" shows the
|
||||
-- player's ball row with no enemy HUD beside it (#317)
|
||||
if self.enemy and not self.showEnemyTrainer and not self.enemySendingOut
|
||||
and not self:growInScale(self.enemy) and slide == 0
|
||||
and not self.enemy.fainted then
|
||||
and not self.introBalls and not self.enemy.fainted then
|
||||
-- enemy HUD (DrawEnemyHUDAndHPBar): name row 0, <LV>+level (4,1),
|
||||
-- HP bar (2,2) with the vertical tick at (1,2), underline row 3;
|
||||
-- AnimationShakeEnemyHUD nudges just this block via SCX
|
||||
@@ -4292,28 +4640,60 @@ function BattleState:drawHUDs(slide)
|
||||
end
|
||||
end
|
||||
|
||||
-- ReplaceFaintedEnemyMon -> DrawEnemyPokeballs (core.asm:896,
|
||||
-- draw_hud_pokeball_gfx.asm:9-11 -> SetupEnemyPartyPokeballs :33-45):
|
||||
-- between a KO and the next send-out the foe's ball row sits in the enemy
|
||||
-- HUD block FaintEnemyPokemon cleared, over the chrome PlaceEnemyHUDTiles
|
||||
-- writes with it -- the same $73 (1,2) / $74 (1,3) / $76 run / $78 tiles
|
||||
-- the live HUD draws, minus the HP bar (#283). Its own block rather than
|
||||
-- a third arm of showIntroBalls below: that window is DrawAllPokeballs's
|
||||
-- (#317) and clears for the rest of the battle, this one reopens on every
|
||||
-- enemy faint. wBaseCoordX $48 / wBaseCoordY $20 stepping -8 is screen
|
||||
-- (64,16) leftward, the same row the intro draws.
|
||||
if self.showEnemyBalls and self.enemyParty and slide == 0 then
|
||||
hudTile(0x73, 8, 16)
|
||||
hudTile(0x74, 8, 24)
|
||||
for i = 2, 9 do hudTile(0x76, i * 8, 24) end
|
||||
hudTile(0x78, 80, 24)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
self:drawBallRow(self.enemyParty, 64, 16, -8)
|
||||
end
|
||||
|
||||
-- Safari shows only the ball count; the old man demo shows neither mon
|
||||
if self.safari then
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
Font.draw(("BALLx%2d"):format(self.safari.balls), 88, 72)
|
||||
end
|
||||
-- trainer/link party pokeball rows during the intro
|
||||
-- (SetupPlayerAndEnemyPokeballs, draw_hud_pokeball_gfx.asm)
|
||||
local showIntroBalls = slide == 0 and (
|
||||
(self.kind == "trainer" and (self.showEnemyTrainer or self.showPlayerBack))
|
||||
or (self.kind == "link" and (self.showPlayerBack or self.enemySendingOut))
|
||||
)
|
||||
-- Party pokeball rows and the HUD chrome under them, for exactly the
|
||||
-- window DrawAllPokeballs owns (common_text.asm:27, with the intro text).
|
||||
-- SetupOwnPartyPokeballs runs in EVERY battle, so the player's row belongs
|
||||
-- on the wild intro too -- keying it off the enemy trainer pic meant a
|
||||
-- wild battle never drew one (#317) -- and SetupEnemyPartyPokeballs is
|
||||
-- skipped when wIsInBattle == 1, so only a trainer/link battle gets the
|
||||
-- foe's row. Gating on introBalls rather than on the pics also stops the
|
||||
-- rows coming back when the beaten trainer scrolls in (#282):
|
||||
-- _ScrollTrainerPicAfterBattle redraws tilemap columns and never touches
|
||||
-- OAM, which ClearSprites emptied when the intro text was dismissed.
|
||||
local showIntroBalls = self.introBalls and slide == 0
|
||||
if showIntroBalls then
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
if self.enemyParty and (
|
||||
(self.kind == "trainer" and self.showEnemyTrainer)
|
||||
or (self.kind == "link" and self.enemySendingOut)
|
||||
) then
|
||||
if self.enemyParty and (self.kind == "trainer" or self.kind == "link") then
|
||||
-- PlaceEnemyHUDTiles (hlcoord 1,2): $73, then $74 + 8x $76 + $78
|
||||
-- rightward along row 3 (draw_hud_pokeball_gfx.asm:133-165)
|
||||
hudTile(0x73, 8, 16)
|
||||
hudTile(0x74, 8, 24)
|
||||
for i = 2, 9 do hudTile(0x76, i * 8, 24) end
|
||||
hudTile(0x78, 80, 24)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
self:drawBallRow(self.enemyParty, 64, 16, -8)
|
||||
end
|
||||
if self.showPlayerBack then
|
||||
self:drawBallRow(self.playerParty or self.game.save.party, 88, 80, 8)
|
||||
end
|
||||
-- PlacePlayerHUDTiles (hlcoord 18,10): $73, then $77 + 8x $76 + $6F
|
||||
-- LEFTWARD along row 11 (draw_hud_pokeball_gfx.asm:119-131)
|
||||
hudTile(0x73, 144, 80)
|
||||
hudTile(0x77, 144, 88)
|
||||
for i = 10, 17 do hudTile(0x76, i * 8, 88) end
|
||||
hudTile(0x6F, 72, 88)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
self:drawBallRow(self.playerParty or self.game.save.party, 88, 80, 8)
|
||||
end
|
||||
local hidePlayer = self.safari or self.demo
|
||||
if self.player and not hidePlayer and not self.showPlayerBack
|
||||
@@ -4363,9 +4743,10 @@ function BattleState:drawTextArea()
|
||||
Font.drawCode(line[i], 8 + (i - 1) * 8, y)
|
||||
end
|
||||
end
|
||||
-- the blinking down arrow ('▼', glyph $EE) while a \v CONT wait holds the
|
||||
-- box, bottom-right of the box like TextBox / home/text.asm
|
||||
if self.msgWaiting and self.frame % 60 < 30 then
|
||||
-- the blinking down arrow ('▼', glyph $EE) while a \v CONT wait
|
||||
-- (_ContText) or a typed-out page (PromptText) holds the box; both write
|
||||
-- it at (18,16), bottom-right, like TextBox / home/text.asm (#317)
|
||||
if (self.msgWaiting or self.msgPrompt) and self.frame % 60 < 30 then
|
||||
Font.drawCode(0xEE, (0 + 20 - 2) * 8, (12 + 6 - 1) * 8 - 4)
|
||||
end
|
||||
elseif self.phase == "menu" and self.demo then
|
||||
@@ -4405,6 +4786,18 @@ function BattleState:drawTextArea()
|
||||
-- the move box's top border ('─' at (4,12), '┘' at (10,12)).
|
||||
Font.drawBox(0, 8, 11, 5)
|
||||
Font.drawBox(4, 12, 16, 6)
|
||||
-- Those two cells are REPLACED on hardware: MoveSelectionMenu writes them
|
||||
-- straight into the tilemap over the border it just laid down
|
||||
-- (core.asm:2492-2501), and PrintMenuItem's own TextBoxBorder then redraws
|
||||
-- the whole row on top (core.asm:2838-2844). Font.drawCode blits a
|
||||
-- black-on-transparent glyph instead, so the tile underneath survives: the
|
||||
-- move box's '┌' keeps its Poké Ball corner showing through the '─', and
|
||||
-- the '─' the move box drew at (10,12) pokes two dots out from under the
|
||||
-- '┘' (#240). Wipe each cell back to box white first, the way a tilemap
|
||||
-- write does.
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
love.graphics.rectangle("fill", 32, 96, 8, 8)
|
||||
love.graphics.rectangle("fill", 80, 96, 8, 8)
|
||||
Font.drawCode(Font.BORDER.h, 32, 96)
|
||||
Font.drawCode(Font.BORDER.br, 80, 96)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
@@ -4444,7 +4837,12 @@ function BattleState:drawTextArea()
|
||||
end
|
||||
|
||||
function BattleState:draw()
|
||||
-- AskName: ClearSprites + wild ClearScreenArea — white field under the
|
||||
if self:wideLayout() then return WideBattle.draw(self) end
|
||||
return self:drawClassic()
|
||||
end
|
||||
|
||||
function BattleState:drawClassic()
|
||||
-- AskName: ClearSprites + wild ClearScreenArea -- white field under the
|
||||
-- nickname TextBox / YES/NO (naming_screen.asm); overlays draw on top.
|
||||
if self.blankForAskName then
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
-- the MINIMUM-scored move is chosen, ties broken uniformly among the
|
||||
-- tied minima (core.asm:2971-3002). A non-minimal move is never
|
||||
-- selectable. Respects Disable (and PP only when the ruleset depletes
|
||||
-- enemy PP — Gen 1 AI never reads wEnemyMonPP).
|
||||
-- enemy PP -- Gen 1 AI never reads wEnemyMonPP).
|
||||
|
||||
local TypeChart = require("src.battle.TypeChart")
|
||||
local Strings = require("src.core.Strings")
|
||||
|
||||
@@ -0,0 +1,379 @@
|
||||
-- Widescreen battle layout (OPTION -> BATTLE LAYOUT -> WIDE).
|
||||
--
|
||||
-- The battle simulation, timing, animations and rules stay BattleState's;
|
||||
-- this module only replaces the composition and asks the renderer for a
|
||||
-- 304x144 native-pixel UI surface while it is up. Pictures, font pages,
|
||||
-- border glyphs, species palettes and HP tiles all resolve through the
|
||||
-- engine, so a COLORS mode or an asset mod still owns the look.
|
||||
--
|
||||
-- The extra 144 pixels of width buy a Gen 3-style arrangement: foe status
|
||||
-- upper left with its picture upper right, the player's picture lower left
|
||||
-- with their status lower right, a full-width message window, a split
|
||||
-- prompt/command window, and a 2x2 move menu with an attached PP/type panel.
|
||||
|
||||
local Font = require("src.render.Font")
|
||||
local HudTiles = require("src.render.HudTiles")
|
||||
local PaletteFX = require("src.render.PaletteFX")
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
local Strings = require("src.core.Strings")
|
||||
local TypeChart = require("src.battle.TypeChart")
|
||||
|
||||
local WideBattle = {
|
||||
WIDTH = 304,
|
||||
HEIGHT = 144,
|
||||
-- everything above this line is battlefield; the 40 rows below it are
|
||||
-- the message / command / move windows
|
||||
FIELD_BOTTOM = 104,
|
||||
}
|
||||
|
||||
-- The forced-mono display modes re-threshold the whole finished frame
|
||||
-- through the shade shader, and picImage hands them raw DMG grays for that
|
||||
-- (#207). The wide layout has to know: it exposes a matching whole-surface
|
||||
-- zone and leaves the HP bar fill gray, exactly like the zone pass does in
|
||||
-- the classic layout (#229). Keep in sync with picImage / ensureZones.
|
||||
local function monoMode()
|
||||
local m = PaletteFX.mode
|
||||
return m == "og" or m == "og_inv" or m == "classic"
|
||||
end
|
||||
|
||||
local function shownHP(battler)
|
||||
return math.max(0, math.floor(battler.shownHP or battler.mon.hp or 0))
|
||||
end
|
||||
|
||||
-- a name truncated to `pixels` with a trailing '.', measured through the
|
||||
-- font's own advances so a variable-width page still fits
|
||||
local function fitName(text, pixels)
|
||||
local spans = Font.split(text or "")
|
||||
local n = Font.spansFitting(spans, pixels)
|
||||
if n >= #spans then return text or "" end
|
||||
local out = {}
|
||||
for i = 1, math.max(0, n - 1) do
|
||||
out[#out + 1] = (text or ""):sub(spans[i].from, spans[i].to)
|
||||
end
|
||||
return table.concat(out) .. "."
|
||||
end
|
||||
|
||||
local function saveScissor()
|
||||
if not love.graphics.getScissor then return nil end
|
||||
local x, y, w, h = love.graphics.getScissor()
|
||||
if x == nil then return false end
|
||||
return { x, y, w, h }
|
||||
end
|
||||
|
||||
local function restoreScissor(saved)
|
||||
if not love.graphics.setScissor then return end
|
||||
if saved and saved ~= false then
|
||||
love.graphics.setScissor(saved[1], saved[2], saved[3], saved[4])
|
||||
else
|
||||
love.graphics.setScissor()
|
||||
end
|
||||
end
|
||||
|
||||
-- Draw fn's content translated by (dx, dy) and clipped to a surface rect.
|
||||
-- The scissor is in canvas space, so it bounds the region itself while the
|
||||
-- translate moves the classic 160x144 coordinates into it.
|
||||
local function inRegion(x, y, w, h, dx, dy, fn)
|
||||
local g = love.graphics
|
||||
local saved = saveScissor()
|
||||
g.setScissor(x, y, w, h)
|
||||
g.push()
|
||||
g.translate(dx, dy)
|
||||
fn()
|
||||
g.pop()
|
||||
restoreScissor(saved)
|
||||
end
|
||||
|
||||
local function levelAt(battle, battler, x, y)
|
||||
if battler.shownStatus then
|
||||
Font.draw(battle:statusLabel({ status = battler.shownStatus }), x, y)
|
||||
else
|
||||
HudTiles.tile(0x6E, x, y) -- '<LV>'
|
||||
Font.draw(tostring(battler.mon.level), x + 8, y)
|
||||
end
|
||||
end
|
||||
|
||||
-- One side's status box: name and level on the first line, a long HP bar
|
||||
-- under it, and the numeric HP on the player's box only (the foe's exact
|
||||
-- HP is never shown, like the original).
|
||||
local function drawStatusPanel(battle, battler, x, y, player)
|
||||
local tx, ty = math.floor(x / 8), math.floor(y / 8)
|
||||
local tw, th = player and 15 or 16, player and 5 or 4
|
||||
Font.drawBox(tx, ty, tw, th)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
|
||||
local nameWidth = player and 64 or 80
|
||||
Font.draw(fitName(battler.name, nameWidth), x + 8, y + 8)
|
||||
levelAt(battle, battler, x + tw * 8 - 40, y + 8)
|
||||
|
||||
HudTiles.drawHPBar(battle.data, tx + 1, ty + 2, {
|
||||
hp = shownHP(battler),
|
||||
stats = battler.mon.stats,
|
||||
}, nil, monoMode(), tw - 5)
|
||||
|
||||
if player then
|
||||
Font.draw(("%3d/%3d"):format(shownHP(battler), battler.mon.stats.hp),
|
||||
x + tw * 8 - 64, y + 24)
|
||||
end
|
||||
end
|
||||
|
||||
-- the party ball rows DrawAllPokeballs puts up with the intro text, moved
|
||||
-- out to the wide screen's own corners
|
||||
local function drawIntroBalls(battle)
|
||||
if not battle.introBalls then return end
|
||||
if battle.enemyParty and
|
||||
(battle.kind == "trainer" or battle.kind == "link") then
|
||||
battle:drawBallRow(battle.enemyParty, 88, 40, -8)
|
||||
end
|
||||
battle:drawBallRow(battle.playerParty or battle.game.save.party, 216, 96, 8)
|
||||
end
|
||||
|
||||
local function drawHUDs(battle, slide)
|
||||
if battle.enemy and not battle.showEnemyTrainer
|
||||
and not battle.enemySendingOut and not battle:growInScale(battle.enemy)
|
||||
and slide == 0 and not battle.introBalls and not battle.enemy.fainted then
|
||||
drawStatusPanel(battle, battle.enemy, 0, 0, false)
|
||||
end
|
||||
|
||||
if battle.safari then
|
||||
Font.drawBox(23, 7, 15, 4)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
Font.draw(("BALLx%2d"):format(battle.safari.balls), 200, 72)
|
||||
elseif battle.player and not battle.demo and not battle.showPlayerBack
|
||||
and slide == 0 then
|
||||
drawStatusPanel(battle, battle.player, 184, 56, true)
|
||||
end
|
||||
drawIntroBalls(battle)
|
||||
end
|
||||
|
||||
local function drawMessageBox(battle)
|
||||
Font.drawBox(0, 13, 38, 5)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
if battle.scrollPx and battle.scrollPx > 0 then
|
||||
battle.scrollPx = battle.scrollPx - 2
|
||||
if battle.scrollPx <= 0 then battle.scrollPx = nil end
|
||||
end
|
||||
local off = battle.scrollPx or 0
|
||||
local ys = { 112, 128 }
|
||||
for li, line in ipairs(battle.shown or {}) do
|
||||
local y = (ys[li] or 128) + off
|
||||
for i = 1, #line do
|
||||
Font.drawCode(line[i], 8 + (i - 1) * 8, y)
|
||||
end
|
||||
end
|
||||
if (battle.msgWaiting or battle.msgPrompt) and battle.frame % 60 < 30 then
|
||||
Font.drawCode(0xEE, 288, 132)
|
||||
end
|
||||
end
|
||||
|
||||
local function drawCommandMenu(battle)
|
||||
local col = (battle.menuIndex - 1) % 2
|
||||
local row = math.floor((battle.menuIndex - 1) / 2)
|
||||
if battle.safari then
|
||||
Font.drawBox(0, 13, 38, 5)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
Font.draw(Strings("BALLx"), 16, 112)
|
||||
Font.draw(Strings("BAIT"), 168, 112)
|
||||
Font.draw(Strings("THROW ROCK"), 16, 128)
|
||||
Font.draw(Strings("RUN"), 168, 128)
|
||||
Font.drawCode(0xED, col == 0 and 8 or 160, 112 + row * 16)
|
||||
return
|
||||
end
|
||||
|
||||
-- the prompt on the left, the 2x2 commands on the right
|
||||
Font.drawBox(0, 13, 20, 5)
|
||||
Font.drawBox(20, 13, 18, 5)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
Font.draw(Strings("What will"), 8, 112)
|
||||
local who = battle.player and battle.player.name or ""
|
||||
Font.draw(fitName(who, 112) .. Strings(" do?"), 8, 128)
|
||||
Font.draw(Strings("FIGHT"), 176, 112)
|
||||
Font.drawCode(0xE1, 240, 112) -- 'PK'
|
||||
Font.drawCode(0xE2, 248, 112) -- 'MN'
|
||||
Font.draw(Strings("ITEM"), 176, 128)
|
||||
Font.draw(Strings("RUN"), 240, 128)
|
||||
Font.drawCode(0xED, col == 0 and 168 or 232, 112 + row * 16)
|
||||
end
|
||||
|
||||
local function drawMoveDetails(battle, move)
|
||||
Font.drawBox(28, 13, 10, 5)
|
||||
if not move then return end
|
||||
local def = battle.data.moves[move.id]
|
||||
if not def then return end
|
||||
local maxPP = def.pp + (move.ppUps or 0) * math.floor(def.pp / 5)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
Font.draw(("PP %2d/%2d"):format(move.pp or 0, maxPP), 232, 112)
|
||||
Font.draw(fitName(TypeChart.displayName(def.type), 64), 232, 128)
|
||||
end
|
||||
|
||||
local function drawMoveGrid(battle, moves, selected)
|
||||
-- The 8px font needs 28 tiles for two complete twelve-character move
|
||||
-- names plus their cursors; the details panel gets the other ten.
|
||||
Font.drawBox(0, 13, 28, 5)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
for i, move in ipairs(moves or {}) do
|
||||
local col = (i - 1) % 2
|
||||
local row = math.floor((i - 1) / 2)
|
||||
local x, y = col == 0 and 16 or 120, 112 + row * 16
|
||||
local def = battle.data.moves[move.id]
|
||||
Font.draw(fitName(def and def.name or move.id or "", 96), x, y)
|
||||
end
|
||||
local col = (selected - 1) % 2
|
||||
local row = math.floor((selected - 1) / 2)
|
||||
Font.drawCode(0xED, col == 0 and 8 or 112, 112 + row * 16)
|
||||
drawMoveDetails(battle, moves and moves[selected])
|
||||
end
|
||||
|
||||
local function drawMoveMenu(battle)
|
||||
drawMoveGrid(battle, battle.player.curMoves, battle.moveIndex)
|
||||
if battle.moveSwapIndex then
|
||||
local col = (battle.moveSwapIndex - 1) % 2
|
||||
local row = math.floor((battle.moveSwapIndex - 1) / 2)
|
||||
Font.drawCode(0xEC, col == 0 and 8 or 112, 112 + row * 16)
|
||||
end
|
||||
end
|
||||
|
||||
local function drawTextArea(battle)
|
||||
if battle.phase == "messages" and (battle.current or battle.animPlaying) then
|
||||
drawMessageBox(battle)
|
||||
elseif battle.phase == "menu" then
|
||||
drawCommandMenu(battle)
|
||||
elseif battle.phase == "moveSelect" then
|
||||
drawMoveMenu(battle)
|
||||
elseif battle.phase == "mimicSelect" then
|
||||
drawMoveGrid(battle, battle.mimicMoves, battle.mimicIndex)
|
||||
else
|
||||
Font.drawBox(0, 13, 38, 5)
|
||||
end
|
||||
end
|
||||
|
||||
-- Battle animations are authored in the original 160px coordinate space.
|
||||
-- Shift each complete OAM frame as one rigid group between the new player
|
||||
-- and enemy anchors: drawing the whole animation through both side regions
|
||||
-- would duplicate any tiles overlapping the other side's source range (most
|
||||
-- visibly the send-out POOF reappearing on the far right).
|
||||
function WideBattle.animationOffset(sprites)
|
||||
if not sprites or #sprites == 0 then return 0, 0 end
|
||||
local minX, maxX = math.huge, -math.huge
|
||||
for _, sprite in ipairs(sprites) do
|
||||
minX = math.min(minX, sprite.x - 8)
|
||||
maxX = math.max(maxX, sprite.x)
|
||||
end
|
||||
local center = (minX + maxX) / 2
|
||||
local t = math.max(0, math.min(1, (center - 40) / 80))
|
||||
return math.floor(20 + 116 * t + 0.5),
|
||||
math.floor(8 * (1 - t) + 0.5)
|
||||
end
|
||||
|
||||
local function currentAnimationSprites(battle)
|
||||
if battle.animPlaying and battle.animPlayer then
|
||||
local step = battle.animPlayer.steps[battle.animPlayer.stepIndex]
|
||||
return step and step.sprites
|
||||
end
|
||||
if battle.lockedBall and battle.animPlayer then
|
||||
return battle.lockedBall
|
||||
end
|
||||
end
|
||||
|
||||
local function drawAnimationLayer(battle)
|
||||
local sprites = currentAnimationSprites(battle)
|
||||
if not sprites or #sprites == 0 then return end
|
||||
local dx, dy = WideBattle.animationOffset(sprites)
|
||||
inRegion(0, 0, WideBattle.WIDTH, WideBattle.FIELD_BOTTOM, dx, dy,
|
||||
function() battle:drawAnimLayer(false) end)
|
||||
end
|
||||
|
||||
-- The whole 304x144 composition for one frame.
|
||||
function WideBattle.draw(battle)
|
||||
local g = love.graphics
|
||||
-- The field is the display mode's paper. Under a forced-mono mode the
|
||||
-- whole surface is remapped downstream (WideBattle.zones), so the field
|
||||
-- goes down as DMG white and comes out of that pass as the mode's paper;
|
||||
-- painting the resolved shade there would run it through the remap twice
|
||||
-- and land a shade off the letterbox the renderer fills around it.
|
||||
if monoMode() then
|
||||
g.setColor(1, 1, 1, 1)
|
||||
else
|
||||
g.setColor(PaletteFX.paperShade(battle.data))
|
||||
end
|
||||
g.rectangle("fill", 0, 0, WideBattle.WIDTH, WideBattle.HEIGHT)
|
||||
-- AskName clears the field the same way the classic layout does
|
||||
if battle.blankForAskName then return end
|
||||
|
||||
local fx = battle.fx
|
||||
local sx = (fx and fx.shakeX) or 0
|
||||
local sy = (fx and fx.shakeY) or 0
|
||||
if sx == 0 and sy == 0 and fx and fx.shake and fx.shake > 0 then
|
||||
sx = battle.frame % 4 < 2 and 2 or -2
|
||||
end
|
||||
local slide = (battle.introSlide or 0) * 4
|
||||
|
||||
-- Each side keeps its original sprite pixels and placement math: the two
|
||||
-- 160x144 OAM regions are translated apart and clipped into the wider
|
||||
-- battlefield rather than either monster being scaled. wideRegion tells
|
||||
-- drawBattlerPic its own side window is already the clip.
|
||||
battle.wideRegion = true
|
||||
inRegion(0, 32, 160, WideBattle.FIELD_BOTTOM - 32, 20 + sx, 8 + sy,
|
||||
function() battle:drawPicsLayer(slide, 0, 0, "player", true) end)
|
||||
inRegion(160, 0, 144, WideBattle.FIELD_BOTTOM, 136 + sx, sy,
|
||||
function() battle:drawPicsLayer(slide, 0, 0, "enemy", true) end)
|
||||
battle.wideRegion = nil
|
||||
|
||||
drawHUDs(battle, slide)
|
||||
drawAnimationLayer(battle)
|
||||
drawTextArea(battle)
|
||||
|
||||
if fx and fx.flash and fx.flash > 0 and battle.frame % 4 < 2 then
|
||||
g.setColor(1, 1, 1, 0.85)
|
||||
g.rectangle("fill", 0, 0, WideBattle.WIDTH, WideBattle.HEIGHT)
|
||||
end
|
||||
g.setColor(1, 1, 1, 1)
|
||||
if Runtime.wantsHook("battle.overlay") then
|
||||
Runtime.call("battle.overlay", function() end, battle)
|
||||
end
|
||||
end
|
||||
|
||||
-- The palette zones for the wide surface. The composition already resolves
|
||||
-- species colors, paper shade and HP-bar colors itself, so the colorized
|
||||
-- modes take the trueColor opt-out (`colors = false`) over the whole
|
||||
-- surface; the forced-mono modes still want their whole-screen remap, and
|
||||
-- get one sized to the wide surface instead of the 160x144 rectangle
|
||||
-- PaletteFX.ensureZones would invent (which would leave 144 columns raw).
|
||||
function WideBattle.zones()
|
||||
local w, h = WideBattle.WIDTH, WideBattle.HEIGHT
|
||||
if monoMode() then
|
||||
-- sendColors runs the mode's own substitution (CLASSIC's pea greens,
|
||||
-- the inverted permutation), exactly as it does for ensureZones' zone
|
||||
return { PaletteFX.zone(PaletteFX.GRAYS, 0, 0, w / 8 - 1, h / 8 - 1) }
|
||||
end
|
||||
return { { colors = false, x = 0, y = 0, w = w, h = h } }
|
||||
end
|
||||
|
||||
-- 2x2 move-grid navigation: LEFT/RIGHT cross the row, UP/DOWN the column,
|
||||
-- and a direction pointing at an empty slot holds the current one.
|
||||
function WideBattle.moveGridIndex(index, count, direction)
|
||||
if count < 1 then return nil end
|
||||
local row = math.floor((index - 1) / 2)
|
||||
local col = (index - 1) % 2
|
||||
if direction == "left" or direction == "right" then
|
||||
local other = row * 2 + (1 - col) + 1
|
||||
return other <= count and other or index
|
||||
end
|
||||
local otherRow = 1 - row
|
||||
local other = otherRow * 2 + col + 1
|
||||
return other <= count and other or index
|
||||
end
|
||||
|
||||
local DIRECTIONS = { "left", "right", "up", "down" }
|
||||
|
||||
-- the slot a directional press selects, or nil when none was pressed (the
|
||||
-- caller then runs its normal list navigation / A / B / SELECT handling)
|
||||
function WideBattle.navigate(index, count, input)
|
||||
for _, key in ipairs(DIRECTIONS) do
|
||||
if input:wasPressed(key) then
|
||||
return WideBattle.moveGridIndex(index, count, key)
|
||||
end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
return WideBattle
|
||||
+30
-4
@@ -206,10 +206,15 @@ function Data:load()
|
||||
(function() local n = 0 for _ in pairs(self.moves) do n = n + 1 end return n end)())
|
||||
end
|
||||
|
||||
-- dev-mode hot reload only (src/dev/HotReload.lua): drop every namespace the
|
||||
-- mod merge created, then re-require the generated modules so base records
|
||||
-- return to their on-disk values even where a mod edited them in place
|
||||
function Data:reloadGenerated()
|
||||
-- Drop every namespace the mod merge created and evict the generated modules
|
||||
-- from package.loaded, so the next load() re-reads them off disk instead of
|
||||
-- handing back the cached tables. Two callers:
|
||||
-- * reloadGenerated below (dev hot reload)
|
||||
-- * main.lua, when the launcher closes the save editor -- the editor may
|
||||
-- have loaded the OTHER game's cache, and require would otherwise serve
|
||||
-- those modules to a subsequent Play (see CacheFs.unmountVersion, which
|
||||
-- clears the matching read-path overlay).
|
||||
function Data:unloadGenerated()
|
||||
local pristine = self._pristineKeys
|
||||
if pristine then
|
||||
for key in pairs(self) do
|
||||
@@ -222,6 +227,13 @@ function Data:reloadGenerated()
|
||||
for _, name in ipairs(OPTIONAL) do
|
||||
package.loaded["data.generated." .. name] = nil
|
||||
end
|
||||
end
|
||||
|
||||
-- dev-mode hot reload only (src/dev/HotReload.lua): drop every namespace the
|
||||
-- mod merge created, then re-require the generated modules so base records
|
||||
-- return to their on-disk values even where a mod edited them in place
|
||||
function Data:reloadGenerated()
|
||||
self:unloadGenerated()
|
||||
self:load()
|
||||
end
|
||||
|
||||
@@ -246,6 +258,20 @@ function Data:resolveText(mapLabel, textConst)
|
||||
local s = self.text[entry.text]
|
||||
if s then return s, entry.asm end
|
||||
end
|
||||
-- A text_asm entry names its wrapper label but carries no string, because
|
||||
-- the extractor cannot follow asm. A handful of those wrappers are plain
|
||||
-- `text_far <label> / text_end` pairs with no logic at all -- BoulderText
|
||||
-- (home/overworld_text.asm:16), MartSignText, PokeCenterSignText -- and
|
||||
-- for those the extracted _Label string IS the whole behavior, so the
|
||||
-- boulders and signs printed nothing at all (#318). Wrappers that really
|
||||
-- do run logic have no _Label string to find, so they still fall through
|
||||
-- to their hand-ported script in data/scripts/.
|
||||
-- needsAsm comes back false here on purpose: showMapText's warning tells
|
||||
-- the reader to go port a script, and for these there is nothing to port.
|
||||
if entry.label then
|
||||
local s = self.text["_" .. entry.label]
|
||||
if s then return s, false end
|
||||
end
|
||||
return nil, entry.asm
|
||||
end
|
||||
|
||||
|
||||
@@ -191,6 +191,15 @@ end
|
||||
-- or screenshot run does not depend on whatever the player last chose.
|
||||
function Game:logicSpeed()
|
||||
local GameSpeed = require("src.core.GameSpeed")
|
||||
-- Link play is always 1X on both machines, and this wins over every other
|
||||
-- source including POKEPORT_SPEED. Fast-forward multiplies the logic
|
||||
-- clock, so a peer at 10X burned a tournament shot clock ten times faster
|
||||
-- than the opponent it is racing, and drove its own animation/message
|
||||
-- queue at a different rate than the peer it is locked to. Nothing about
|
||||
-- a match should depend on what either player set this to.
|
||||
if self.linkSession or (self.linkNet and not self.linkNet.closed) then
|
||||
return 1
|
||||
end
|
||||
if self.speedOverride then return GameSpeed.clamp(self.speedOverride) end
|
||||
local opts = self.save and self.save.options
|
||||
return GameSpeed.clamp(opts and opts.speed or GameSpeed.DEFAULT)
|
||||
@@ -238,6 +247,16 @@ function Game:draw()
|
||||
-- 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.
|
||||
local top = self.stack:top()
|
||||
if top and top.uiSize then
|
||||
Renderer:setUISize(top:uiSize())
|
||||
else
|
||||
Renderer:setUISize(Renderer.WIDTH, Renderer.HEIGHT)
|
||||
end
|
||||
Renderer:beginFrame(worldBelow)
|
||||
self.stack:draw()
|
||||
-- SGB colorization: the topmost state that knows its palette owns the
|
||||
|
||||
+1
-1
@@ -144,7 +144,7 @@ function Input:step()
|
||||
elseif next(sources) ~= nil then
|
||||
self.state[btn] = true
|
||||
end
|
||||
-- sources == {}: real press fully released before this step — keep up
|
||||
-- sources == {}: real press fully released before this step -- keep up
|
||||
end
|
||||
for btn, sources in pairs(self.sources) do
|
||||
if next(sources) == nil then
|
||||
|
||||
+88
-11
@@ -16,6 +16,7 @@ local SaveSerializer = require("src.core.SaveSerializer")
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
local Semver = require("src.mods.Semver")
|
||||
local Boxes = require("src.pokemon.Boxes")
|
||||
local Stats = require("src.pokemon.Stats")
|
||||
local Bag = require("src.inventory.Bag")
|
||||
local Badges = require("src.inventory.Badges")
|
||||
|
||||
@@ -132,17 +133,26 @@ local function detectPortable()
|
||||
-- Recover the folder containing the .app so a packaged app finds its
|
||||
-- marker. On Windows/Linux the executable is not a bundle, so this is nil
|
||||
-- and the plain source-base directory (next to the .exe/AppImage) is used.
|
||||
local function appContainer(path)
|
||||
local appPath = path and path:match("^(.*%.app)/Contents/")
|
||||
return appPath and appPath:match("^(.*)/[^/]+$") or nil
|
||||
local function parentDir(path)
|
||||
return path and path:match("^(.*)/[^/]+$") or nil
|
||||
end
|
||||
-- Order: the .app's containing folder (packaged macOS), then the
|
||||
-- source-base directory (next to a packaged .exe/AppImage), then the
|
||||
-- source itself (a `love <gamedir>` run drops portable.txt in the game
|
||||
-- folder). First one holding the marker wins. Built by appending so a
|
||||
-- nil (e.g. no .app in the path) never truncates the ipairs scan.
|
||||
local function appContainer(path)
|
||||
return parentDir(path and path:match("^(.*%.app)/Contents/"))
|
||||
end
|
||||
-- The Linux AppImage build works similarly to macOs, but the runtime mounts
|
||||
-- the squashfs at a temp path, and that becomes the base directory.
|
||||
-- The runtime exports the real .AppImage path in $APPIMAGE, so we get the
|
||||
-- base dir from there.
|
||||
local function appImageContainer()
|
||||
return parentDir(os.getenv("APPIMAGE"))
|
||||
end
|
||||
-- Order: the packaged-app containing folder (macOS .app / Linux AppImage),
|
||||
-- then the source-base directory (next to a packaged .exe), then the source
|
||||
-- itself (a `love <gamedir>` run drops portable.txt in the game folder).
|
||||
-- First one holding the marker wins. Built by appending so a nil (e.g. no
|
||||
-- .app in the path) never truncates the ipairs scan.
|
||||
local candidates = {}
|
||||
local appDir = appContainer(src) or appContainer(sbd)
|
||||
local appDir = appContainer(src) or appContainer(sbd) or appImageContainer()
|
||||
if appDir then candidates[#candidates + 1] = appDir end
|
||||
if sbd then candidates[#candidates + 1] = sbd end
|
||||
if src then candidates[#candidates + 1] = src end
|
||||
@@ -193,6 +203,9 @@ function SaveData.defaultOptions()
|
||||
textSpeed = 3,
|
||||
animations = true,
|
||||
battleStyle = "shift",
|
||||
-- battle screen composition: og (the 160x144 original) | wide
|
||||
-- (304x144, src/battle/WideBattle.lua)
|
||||
battleLayout = "og",
|
||||
ruleset = "gen1_faithful",
|
||||
-- 0-7 like the GB's NR50 master volume
|
||||
musicVol = 7,
|
||||
@@ -442,6 +455,25 @@ function SaveData.slotSummary(save)
|
||||
}
|
||||
end
|
||||
|
||||
-- The absolute on-disk path of a slot's save file, for the one caller that
|
||||
-- cannot go through love.filesystem: the save editor reads and writes with
|
||||
-- raw io.* so it can also open a file the player dragged in from anywhere.
|
||||
-- Resolves against the same root persistFs would write to -- the portable
|
||||
-- game folder when portable mode is on, otherwise LOVE's save directory --
|
||||
-- so Edit on a launcher save row lands on the file the game actually plays.
|
||||
-- nil when neither root is available (headless tests with an injected fs).
|
||||
function SaveData.slotDiskPath(version, slotId)
|
||||
version = version or GameVersion.get()
|
||||
if not knownVersion(version) or not slotId then return nil end
|
||||
local base = SaveData.portableBaseDir()
|
||||
or (love and love.filesystem and love.filesystem.getSaveDirectory
|
||||
and love.filesystem.getSaveDirectory())
|
||||
if not base then return nil end
|
||||
local sep = package.config:sub(1, 1)
|
||||
local rel = select(1, slotNames(version, slotId))
|
||||
return base .. sep .. rel:gsub("/", sep)
|
||||
end
|
||||
|
||||
-- Slots visible to the launcher: every registered slot for a version, each
|
||||
-- with whether it holds a save and the cheap summary above. A fresh
|
||||
-- install with nothing registered returns an empty array; a legacy install
|
||||
@@ -458,11 +490,44 @@ function SaveData.listSlots(version)
|
||||
for _, id in ipairs(list) do
|
||||
local save = decodeSlot(fs, version, id)
|
||||
local name, meta = SaveData.slotSummary(save)
|
||||
out[#out + 1] = { id = id, exists = save ~= nil, name = name, meta = meta }
|
||||
out[#out + 1] = { id = id, exists = save ~= nil, name = name, meta = meta,
|
||||
label = reg.names and reg.names[id] or nil }
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
-- Give a registered slot a custom label (#205: "a way to name save slots so
|
||||
-- you can see that in the launcher"). The label lives in the options
|
||||
-- registry next to list/active, never in the save file itself, so renaming
|
||||
-- needs no save rewrite and an empty slot can be labeled too. The label is
|
||||
-- trimmed; an empty (or whitespace-only) one clears it. Returns true, or
|
||||
-- false + an error string when the id is not registered.
|
||||
function SaveData.renameSlot(version, slotId, name)
|
||||
version = version or GameVersion.get()
|
||||
if not knownVersion(version) then return false, "unknown version" end
|
||||
if type(slotId) ~= "string" or slotId == "" then
|
||||
return false, "missing slot id"
|
||||
end
|
||||
local fs = persistFs(nil)
|
||||
local opts = SaveData.loadOptions(fs)
|
||||
opts.saveSlots = opts.saveSlots or {}
|
||||
local reg = opts.saveSlots[version]
|
||||
if not reg or not reg.list then return false, "slot not registered" end
|
||||
local found = false
|
||||
for _, id in ipairs(reg.list) do
|
||||
if id == slotId then found = true break end
|
||||
end
|
||||
if not found then return false, "slot not registered" end
|
||||
local label = type(name) == "string" and name:match("^%s*(.-)%s*$") or nil
|
||||
if label == "" then label = nil end
|
||||
reg.names = reg.names or {}
|
||||
reg.names[slotId] = label
|
||||
if next(reg.names) == nil then reg.names = nil end
|
||||
opts.saveSlots[version] = reg
|
||||
SaveData.saveOptions(opts, fs)
|
||||
return true
|
||||
end
|
||||
|
||||
-- Point the active slot at slotId (registering it if new) and persist the
|
||||
-- choice to options.lua; also update the process-global cache so the very
|
||||
-- next save/load lands in the chosen slot.
|
||||
@@ -580,6 +645,7 @@ function SaveData.deleteSlot(version, slotId)
|
||||
remove(fs, tmp)
|
||||
|
||||
table.remove(reg.list, idx)
|
||||
if reg.names then reg.names[slotId] = nil end
|
||||
if reg.active == slotId then
|
||||
reg.active = reg.list[1] -- may be nil when the list is now empty
|
||||
end
|
||||
@@ -972,6 +1038,17 @@ local function scrubKnownMon(mon, data)
|
||||
for stat, v in pairs(mon.statExp) do mon.statExp[stat] = clamp(v, 0, 65535, 0) end
|
||||
end
|
||||
mon.level = clamp(mon.level, 1, 100, 1)
|
||||
-- Box mons imported from a real .sav carry NO stat block: box_struct stops
|
||||
-- before MON_LEVEL/MON_STATS, so src/save_convert/GenSave.lua decodeMon
|
||||
-- only fills `stats` for party slots. Every HP-bar draw then nil-indexes
|
||||
-- mon.stats: the status screen opened in the box (#233) and the party list
|
||||
-- after withdrawing one (#304). The original derives them on demand
|
||||
-- (status_screen.asm:66-76, add_mon.asm _MoveMon); deriving once here means
|
||||
-- every later reader (menus, battle, items, SGB bar zones, the link
|
||||
-- fingerprint) sees a party-shaped mon. Runs after the level clamp above
|
||||
-- so the derived stats use a sane level. A save that already has stats is
|
||||
-- untouched.
|
||||
Stats.ensure(data.pokemon and data.pokemon[mon.species], mon)
|
||||
local moves = mon.moves
|
||||
if type(moves) ~= "table" then return end
|
||||
local hadMoves = #moves > 0
|
||||
@@ -1216,7 +1293,7 @@ function SaveData.newGame(boot)
|
||||
inventory = {},
|
||||
-- Vanilla Gen1 seeds one Potion in the player's item PC
|
||||
-- (wBoxItems / players_pc.asm); existing saves keep whatever they
|
||||
-- already have — this only applies to New Game.
|
||||
-- already have -- this only applies to New Game.
|
||||
pcItems = { POTION = 1 },
|
||||
party = {},
|
||||
box = {},
|
||||
|
||||
@@ -75,6 +75,32 @@ local function resolveMkdir()
|
||||
return mkdirFn
|
||||
end
|
||||
|
||||
-- Lazily-resolved windowless rmdir, the mirror of resolveMkdir above:
|
||||
-- function(absolutePath) or false when FFI is unavailable. Both syscalls
|
||||
-- refuse a non-empty directory, so a caller has to delete the files first.
|
||||
local rmdirFn = nil
|
||||
|
||||
local function resolveRmdir()
|
||||
if rmdirFn ~= nil then return rmdirFn end
|
||||
rmdirFn = false
|
||||
local ok, ffi = pcall(require, "ffi")
|
||||
if not ok then return rmdirFn end
|
||||
if ffi.os == "Windows" then
|
||||
pcall(ffi.cdef, "int RemoveDirectoryA(const char *lpPathName);")
|
||||
local resolved = pcall(function() return ffi.C.RemoveDirectoryA end)
|
||||
if resolved then
|
||||
rmdirFn = function(path) pcall(ffi.C.RemoveDirectoryA, path) end
|
||||
end
|
||||
else
|
||||
pcall(ffi.cdef, "int rmdir(const char *pathname);")
|
||||
local resolved = pcall(function() return ffi.C.rmdir end)
|
||||
if resolved then
|
||||
rmdirFn = function(path) pcall(ffi.C.rmdir, path) end
|
||||
end
|
||||
end
|
||||
return rmdirFn
|
||||
end
|
||||
|
||||
-- Mount an external directory onto the physfs read path (appended, so the
|
||||
-- game's own source always wins a name clash). Returns true on success.
|
||||
--
|
||||
@@ -122,6 +148,37 @@ local function mountReadable(dir, append)
|
||||
return fn(dir, append)
|
||||
end
|
||||
|
||||
-- PHYSFS_unmount, resolved the same way PHYSFS_mount is. Only
|
||||
-- CacheFs.unmountVersion needs it: the launcher can open the save editor on
|
||||
-- one game's cache and then Play the other, and an overlay left mounted
|
||||
-- would win the read path for the rest of the process.
|
||||
local physfsUnmountFn = nil
|
||||
local function resolveUnmount()
|
||||
if physfsUnmountFn ~= nil then return physfsUnmountFn end
|
||||
physfsUnmountFn = false
|
||||
local ok, ffi = pcall(require, "ffi")
|
||||
if not ok then return physfsUnmountFn end
|
||||
pcall(ffi.cdef, "int PHYSFS_unmount(const char *oldDir);")
|
||||
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_unmount end)
|
||||
if oks and fn then
|
||||
physfsUnmountFn = function(d)
|
||||
local okr, ret = pcall(fn, d)
|
||||
return okr and ret ~= 0
|
||||
end
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
return physfsUnmountFn
|
||||
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.
|
||||
@@ -229,6 +286,23 @@ function CacheFs.remove(rel)
|
||||
love.filesystem.remove(rel)
|
||||
end
|
||||
|
||||
-- Remove a single cache-relative directory once its files are gone. Needed
|
||||
-- because os.remove cannot delete a directory on Windows and
|
||||
-- love.filesystem.remove never reaches outside the save directory, so the
|
||||
-- portable game folder gets the same FFI-syscall treatment as its mkdir
|
||||
-- (issue #74: os.execute would flash a console window per call). Used by the
|
||||
-- mod installer so an uninstall leaves nothing behind (#330).
|
||||
function CacheFs.removeDir(rel)
|
||||
rel = withPrefix(rel)
|
||||
local root = CacheFs.root()
|
||||
if root then
|
||||
local rmdir = resolveRmdir()
|
||||
if rmdir then rmdir(realPath(root, rel)) end
|
||||
return
|
||||
end
|
||||
love.filesystem.remove(rel)
|
||||
end
|
||||
|
||||
-- Remove the game-folder copy of a cache subtree before a fresh import, so a
|
||||
-- cache-format bump does not leave orphaned files behind. No-op when the
|
||||
-- portable cache is inactive (the save-directory copy is cleared by
|
||||
@@ -280,4 +354,35 @@ function CacheFs.mountVersion(version)
|
||||
return false
|
||||
end
|
||||
|
||||
-- Undo mountVersion. A process normally mounts exactly one version and then
|
||||
-- boots it, but the launcher can open the save editor on a Blue save, close
|
||||
-- it, and press Play on Red: with blue/ still prepended, Red's
|
||||
-- require("data.generated.*") and its generated art would silently resolve to
|
||||
-- Blue's files. Callers must also drop the generated modules from
|
||||
-- package.loaded (src.core.Data:unloadGenerated) -- unmounting alone only
|
||||
-- fixes the read path, not what require already cached.
|
||||
--
|
||||
-- Returns true when nothing was mounted or the unmount took. Red is a no-op
|
||||
-- because its cache lives at the root and was never overlaid.
|
||||
function CacheFs.unmountVersion(version)
|
||||
local prefix = require("src.core.GameVersion").cachePrefix(version)
|
||||
if prefix == "" then return true end
|
||||
local sub = prefix:gsub("/+$", "")
|
||||
local base = CacheFs.root()
|
||||
if not base and love.filesystem.getSaveDirectory then
|
||||
base = love.filesystem.getSaveDirectory()
|
||||
end
|
||||
local done = false
|
||||
local fn = resolveUnmount()
|
||||
if fn and base then
|
||||
done = fn(base .. SEP .. sub) or done
|
||||
end
|
||||
-- also drop the love.filesystem.mount fallback, which registers the folder
|
||||
-- under its bare name rather than its absolute path
|
||||
if love.filesystem.unmount then
|
||||
done = love.filesystem.unmount(sub) or done
|
||||
end
|
||||
return done
|
||||
end
|
||||
|
||||
return CacheFs
|
||||
|
||||
+423
-20
@@ -255,7 +255,36 @@ local function fileUrl(path)
|
||||
return "file://" .. encoded
|
||||
end
|
||||
|
||||
-- The native pickers below block the whole loop inside io.popen, and they are
|
||||
-- opened straight out of mousepressed -- with the button still physically
|
||||
-- down. SDL auto-captures the pointer for the length of a press (on X11 an
|
||||
-- XGrabPointer with owner_events) and only drops that capture when it
|
||||
-- processes the matching button-up, which it cannot do while we sit in popen
|
||||
-- and never pump. The grab then outlives the click and every pointer event
|
||||
-- over the file chooser is still routed to our window: the dialog draws and
|
||||
-- keyboard-navigates (keyboard focus is a separate grab) but ignores the
|
||||
-- mouse entirely -- issue #254 on Linux. Whether it bites is a race with how
|
||||
-- long the click was held, which is why the same build picks one ROM fine and
|
||||
-- then hangs the mouse on the next. So pump until no button is held, letting
|
||||
-- SDL see the release and let go first; bounded, so a stuck button costs a
|
||||
-- moment and never the launcher. pump() only drains OS events into LOVE's
|
||||
-- queue -- it dispatches nothing -- so there is no reentry into mousepressed
|
||||
-- and the release is still delivered normally on the next frame.
|
||||
local function releasePointerGrab()
|
||||
if not (love.mouse and love.mouse.isDown and love.event and love.event.pump
|
||||
and love.timer) then
|
||||
return
|
||||
end
|
||||
local deadline = love.timer.getTime() + 1
|
||||
while love.mouse.isDown(1, 2, 3) do
|
||||
love.event.pump()
|
||||
if love.timer.getTime() > deadline then break end
|
||||
love.timer.sleep(0.005)
|
||||
end
|
||||
end
|
||||
|
||||
local function commandOutput(command)
|
||||
releasePointerGrab()
|
||||
local pipe = io.popen(command, "r")
|
||||
if not pipe then return nil end
|
||||
local result = pipe:read("*a")
|
||||
@@ -338,7 +367,10 @@ local function chooseRom(promptName)
|
||||
"$d=New-Object System.Windows.Forms.OpenFileDialog;",
|
||||
"$d.Title='" .. prompt .. "';",
|
||||
"$d.Filter='Game Boy ROM (*.gb)|*.gb|All files (*.*)|*.*';",
|
||||
"if($d.ShowDialog() -eq 'OK'){[Console]::Write($d.FileName)}",
|
||||
-- write the pick as UTF-8: the console's OEM codepage would mangle
|
||||
-- non-ASCII names (Pokémon -> Pok\x82mon) and crash any text draw
|
||||
-- that shows them (#325)
|
||||
"if($d.ShowDialog() -eq 'OK'){[Console]::OutputEncoding=[Text.Encoding]::UTF8; [Console]::Write($d.FileName)}",
|
||||
})
|
||||
return commandOutput(
|
||||
'powershell -NoProfile -STA -Command "' .. script .. '"')
|
||||
@@ -369,7 +401,16 @@ local function chooseZip()
|
||||
"$d=New-Object System.Windows.Forms.OpenFileDialog;",
|
||||
"$d.Title='" .. prompt .. "';",
|
||||
"$d.Filter='Mod archive (*.zip)|*.zip|All files (*.*)|*.*';",
|
||||
"if($d.ShowDialog() -eq 'OK'){[Console]::Write($d.FileName)}",
|
||||
-- copy the pick to a plain-ASCII temp name and answer with that:
|
||||
-- the console's OEM codepage would mangle a non-ASCII path
|
||||
-- (Pokémon -> Pok\x82mon) and io.open on Windows needs ANSI bytes,
|
||||
-- so returning the original name both crashed the notice draw and
|
||||
-- could never have opened the file (#325)
|
||||
"if($d.ShowDialog() -eq 'OK'){",
|
||||
"$t=Join-Path $env:TEMP 'pokeport_mod_pick.zip';",
|
||||
"Copy-Item -LiteralPath $d.FileName -Destination $t -Force;",
|
||||
"[Console]::OutputEncoding=[Text.Encoding]::UTF8;",
|
||||
"[Console]::Write($t)}",
|
||||
})
|
||||
return commandOutput(
|
||||
'powershell -NoProfile -STA -Command "' .. script .. '"')
|
||||
@@ -400,7 +441,8 @@ local function chooseSav()
|
||||
"$d=New-Object System.Windows.Forms.OpenFileDialog;",
|
||||
"$d.Title='" .. prompt .. "';",
|
||||
"$d.Filter='Game Boy save (*.sav)|*.sav|All files (*.*)|*.*';",
|
||||
"if($d.ShowDialog() -eq 'OK'){[Console]::Write($d.FileName)}",
|
||||
-- UTF-8, like the ROM and mod pickers (#325)
|
||||
"if($d.ShowDialog() -eq 'OK'){[Console]::OutputEncoding=[Text.Encoding]::UTF8; [Console]::Write($d.FileName)}",
|
||||
})
|
||||
return commandOutput(
|
||||
'powershell -NoProfile -STA -Command "' .. script .. '"')
|
||||
@@ -432,7 +474,10 @@ end
|
||||
-- own cache (Red at the root, Blue under blue/), so both can be imported and
|
||||
-- played side by side. onComplete(version) hands the chosen game off to boot.
|
||||
-- opts: launcher (a fresh import stays on the launcher instead of auto-booting),
|
||||
-- forceImport (treat every version as not-yet-imported, so re-import is forced).
|
||||
-- forceImport (treat every version as not-yet-imported, so re-import is forced),
|
||||
-- onEditSave(version, slotId) (host handler for the Edit affordance on a save
|
||||
-- row -- main.lua opens the bundled save editor on that slot; when it is not
|
||||
-- supplied the Edit label is not drawn at all).
|
||||
function RomImporter.new(onComplete, opts)
|
||||
opts = opts or {}
|
||||
local android = love.system.getOS() == "Android"
|
||||
@@ -441,6 +486,7 @@ function RomImporter.new(onComplete, opts)
|
||||
onComplete = onComplete,
|
||||
launcher = opts.launcher or false,
|
||||
forceImport = opts.forceImport or false,
|
||||
onEditSave = opts.onEditSave,
|
||||
android = android,
|
||||
tab = "red", -- active launcher tab: "red"/"blue"/"yellow"/"mods"
|
||||
logo = love.graphics.newImage("assets/logo/logo.png"),
|
||||
@@ -473,6 +519,14 @@ function RomImporter.new(onComplete, opts)
|
||||
-- Android SAF create-document: which game's SAVE FILES card should show
|
||||
-- "Save exported." when export_done.flag appears on focus.
|
||||
androidPendingExportVersion = nil,
|
||||
-- Virtual pointer for handhelds / gamepads (Anbernic stock OS has no
|
||||
-- mouse). D-pad + left stick move it; A clicks; shoulders cycle tabs;
|
||||
-- right stick scrolls the save-slot / mods lists.
|
||||
_padCursor = { x = 0, y = 0 },
|
||||
_padCursorActive = false,
|
||||
_padAxis = { leftx = 0, lefty = 0, righty = 0 },
|
||||
_padDir = {},
|
||||
_padInited = false,
|
||||
}, RomImporter)
|
||||
|
||||
for _, version in ipairs(GameVersion.ORDER) do
|
||||
@@ -525,6 +579,15 @@ function RomImporter.new(onComplete, opts)
|
||||
end
|
||||
end
|
||||
|
||||
-- On Linux handhelds a gamepad is usually already connected at boot; arm
|
||||
-- the virtual cursor immediately so the player does not have to press a
|
||||
-- button before seeing something move.
|
||||
if self.launcher and love.system.getOS() == "Linux"
|
||||
and love.joystick and love.joystick.getJoystickCount
|
||||
and love.joystick.getJoystickCount() > 0 then
|
||||
self:_activatePadCursor()
|
||||
end
|
||||
|
||||
return self
|
||||
end
|
||||
|
||||
@@ -928,15 +991,37 @@ function RomImporter:choose(version)
|
||||
local path = chooseRom(GameVersion.info(self.chooseVersion).displayName)
|
||||
if path then
|
||||
self:startPath(path)
|
||||
elseif love.system.getOS() ~= "OS X"
|
||||
and love.system.getOS() ~= "Windows"
|
||||
and love.system.getOS() ~= "Linux" then
|
||||
return
|
||||
end
|
||||
-- Handheld Linux (Anbernic stock OS / PortMaster) rarely has zenity or
|
||||
-- kdialog. Fall back to the same "drop a .gb next to the game" scan used
|
||||
-- on Android, which works when the game is launched as an unpacked
|
||||
-- directory (see build-rg34xxsp.sh).
|
||||
local name, data = findPendingRom(self.ready)
|
||||
if name then
|
||||
self:startData(data, name)
|
||||
return
|
||||
end
|
||||
if love.system.getOS() == "Linux" then
|
||||
local where = love.filesystem.getSourceBaseDirectory
|
||||
and love.filesystem.getSourceBaseDirectory()
|
||||
or love.filesystem.getSource and love.filesystem.getSource()
|
||||
or "the game folder"
|
||||
self.notice = {
|
||||
version = self.chooseVersion,
|
||||
status = "No file picker. Copy your .gb into:",
|
||||
detail = where,
|
||||
}
|
||||
return
|
||||
end
|
||||
if love.system.getOS() ~= "OS X" and love.system.getOS() ~= "Windows" then
|
||||
self:setError("File selection is unavailable here. Drop the .gb file onto the window.")
|
||||
end
|
||||
end
|
||||
|
||||
function RomImporter:update(dt)
|
||||
self.pulse = self.pulse + dt
|
||||
self:_updatePadCursor(dt)
|
||||
if self.workState ~= "working" or not self.worker then return end
|
||||
local started = love.timer.getTime()
|
||||
repeat
|
||||
@@ -953,6 +1038,125 @@ function RomImporter:update(dt)
|
||||
until love.timer.getTime() - started >= 0.008
|
||||
end
|
||||
|
||||
-- ------- gamepad virtual cursor (handheld / PortMaster) --------------------
|
||||
local PAD_DEAD = 0.28
|
||||
local PAD_SPEED = 560 -- px/s at full stick deflection
|
||||
local PAD_DPAD_SPEED = 420
|
||||
|
||||
function RomImporter:_activatePadCursor()
|
||||
if self._padCursorActive then return end
|
||||
local w, h = love.graphics.getDimensions()
|
||||
if not self._padInited then
|
||||
self._padCursor.x = w * 0.5
|
||||
self._padCursor.y = h * 0.45
|
||||
self._padInited = true
|
||||
end
|
||||
self._padCursorActive = true
|
||||
end
|
||||
|
||||
function RomImporter:_cycleTab(delta)
|
||||
local order = { "red", "blue", "yellow", "mods" }
|
||||
local idx = 1
|
||||
for i, id in ipairs(order) do
|
||||
if id == self.tab then idx = i; break end
|
||||
end
|
||||
idx = ((idx - 1 + delta) % #order) + 1
|
||||
self.tab = order[idx]
|
||||
self._slotPress = nil
|
||||
self._modPress = nil
|
||||
end
|
||||
|
||||
function RomImporter:_updatePadCursor(dt)
|
||||
-- Real mouse motion yields the pad cursor so desktop users keep a normal
|
||||
-- pointer after bumping a stick once.
|
||||
local mx, my = love.mouse.getPosition()
|
||||
if self._lastMouseX and self._padCursorActive then
|
||||
if math.abs(mx - self._lastMouseX) > 3 or math.abs(my - self._lastMouseY) > 3 then
|
||||
self._padCursorActive = false
|
||||
end
|
||||
end
|
||||
self._lastMouseX, self._lastMouseY = mx, my
|
||||
|
||||
local ax = self._padAxis.leftx or 0
|
||||
local ay = self._padAxis.lefty or 0
|
||||
local dx, dy = 0, 0
|
||||
if math.abs(ax) > PAD_DEAD then dx = dx + ax end
|
||||
if math.abs(ay) > PAD_DEAD then dy = dy + ay end
|
||||
if self._padDir.dpleft then dx = dx - 1 end
|
||||
if self._padDir.dpright then dx = dx + 1 end
|
||||
if self._padDir.dpup then dy = dy - 1 end
|
||||
if self._padDir.dpdown then dy = dy + 1 end
|
||||
|
||||
if dx ~= 0 or dy ~= 0 then
|
||||
self:_activatePadCursor()
|
||||
local mag = math.sqrt(dx * dx + dy * dy)
|
||||
if mag > 1 then dx, dy = dx / mag, dy / mag end
|
||||
local speed = (math.abs(ax) > PAD_DEAD or math.abs(ay) > PAD_DEAD)
|
||||
and PAD_SPEED or PAD_DPAD_SPEED
|
||||
local w, h = love.graphics.getDimensions()
|
||||
local nx = self._padCursor.x + dx * speed * dt
|
||||
local ny = self._padCursor.y + dy * speed * dt
|
||||
self._padCursor.x = math.max(0, math.min(w, nx))
|
||||
self._padCursor.y = math.max(0, math.min(h, ny))
|
||||
end
|
||||
|
||||
-- Right stick scrolls the active list (save slots or mods).
|
||||
local ry = self._padAxis.righty or 0
|
||||
if math.abs(ry) > PAD_DEAD then
|
||||
self:_activatePadCursor()
|
||||
local step = -ry * 480 * dt
|
||||
if self.tab == "mods" then
|
||||
local maxS = self._modMax or 0
|
||||
if maxS > 0 then
|
||||
local next = (self.modScroll or 0) + step
|
||||
self.modScroll = math.max(0, math.min(maxS, next))
|
||||
end
|
||||
elseif self.tab == "red" or self.tab == "blue" then
|
||||
local maxS = (self._slotMax and self._slotMax[self.tab]) or 0
|
||||
if maxS > 0 then
|
||||
local next = (self.slotScroll[self.tab] or 0) + step
|
||||
self.slotScroll[self.tab] = math.max(0, math.min(maxS, next))
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
function RomImporter:gamepadpressed(_, button)
|
||||
self:_activatePadCursor()
|
||||
if button == "a" then
|
||||
-- Instant click at the virtual pointer (same path as a mouse/touch tap).
|
||||
self:mousepressed(self._padCursor.x, self._padCursor.y, 1)
|
||||
elseif button == "leftshoulder" then
|
||||
self:_cycleTab(-1)
|
||||
elseif button == "rightshoulder" then
|
||||
self:_cycleTab(1)
|
||||
elseif button == "dpup" or button == "dpdown"
|
||||
or button == "dpleft" or button == "dpright" then
|
||||
self._padDir[button] = true
|
||||
elseif button == "start" or button == "back" then
|
||||
-- Start / Select: Play if ready, else Choose ROM on the active game tab.
|
||||
if self.workState == "working" then return end
|
||||
local version = self.tab
|
||||
if version == "red" or version == "blue" then
|
||||
if self.ready[version] then self:play(version) else self:choose(version) end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
function RomImporter:gamepadreleased(_, button)
|
||||
if button == "dpup" or button == "dpdown"
|
||||
or button == "dpleft" or button == "dpright" then
|
||||
self._padDir[button] = nil
|
||||
end
|
||||
end
|
||||
|
||||
function RomImporter:gamepadaxis(_, axis, value)
|
||||
if axis == "leftx" or axis == "lefty" or axis == "righty" then
|
||||
self._padAxis[axis] = value
|
||||
if math.abs(value) > PAD_DEAD then self:_activatePadCursor() end
|
||||
end
|
||||
end
|
||||
|
||||
-- Player pressed Play on a game whose ROM is imported: hand off to boot.
|
||||
function RomImporter:play(version)
|
||||
if self.workState == "working" then return end
|
||||
@@ -992,6 +1196,31 @@ local function printB(text, x, y)
|
||||
love.graphics.print(text, x + 0.6, y)
|
||||
end
|
||||
|
||||
-- UTF-8 helpers for the slot-rename field (#205). The `utf8` library only
|
||||
-- exists inside LOVE (plain luajit, which loads this module in tests, has
|
||||
-- none), so codepoint walking is done by hand -- the same lead-byte width
|
||||
-- classes GenSave's encodeName uses. utf8Back drops the last codepoint;
|
||||
-- utf8Cap truncates to maxChars whole codepoints.
|
||||
local function utf8Back(t)
|
||||
local i = #t
|
||||
while i > 0 do
|
||||
local b = t:byte(i)
|
||||
i = i - 1
|
||||
if b < 0x80 or b >= 0xC0 then break end -- lead or ASCII: dropped, done
|
||||
end
|
||||
return t:sub(1, i)
|
||||
end
|
||||
local function utf8Cap(t, maxChars)
|
||||
local count, i = 0, 1
|
||||
while i <= #t do
|
||||
count = count + 1
|
||||
if count > maxChars then return t:sub(1, i - 1) end
|
||||
local b = t:byte(i)
|
||||
i = i + ((b < 0x80) and 1 or (b < 0xE0) and 2 or (b < 0xF0) and 3 or 4)
|
||||
end
|
||||
return t
|
||||
end
|
||||
|
||||
-- One reusable unit quad, recoloured per call, for every vertical gradient
|
||||
-- fill (LOVE has no gradient primitive and a per-frame newMesh would churn
|
||||
-- the GPU). Callers set the blend mode; this only touches colour + geometry.
|
||||
@@ -1147,12 +1376,17 @@ function RomImporter:draw()
|
||||
local pulse = self.pulse
|
||||
self._s = s
|
||||
|
||||
-- Hover state (desktop only -- touch has no cursor). Panel methods read the
|
||||
-- pointer + set self._anyHover through self:_hover; the cursor is set at the
|
||||
-- end. Reset the per-frame hit rects so a tab with no controls (mods) cannot
|
||||
-- Hover state. Desktop mouse, or the gamepad virtual cursor on handhelds
|
||||
-- (Android stays touch-only -- no hover). Panel methods read the pointer +
|
||||
-- set self._anyHover through self:_hover; the cursor is set at the end.
|
||||
-- Reset the per-frame hit rects so a tab with no controls (mods) cannot
|
||||
-- inherit last frame's game-panel buttons.
|
||||
self._mx, self._my = love.mouse.getPosition()
|
||||
self._hoverEnabled = not self.android
|
||||
if self._padCursorActive then
|
||||
self._mx, self._my = self._padCursor.x, self._padCursor.y
|
||||
else
|
||||
self._mx, self._my = love.mouse.getPosition()
|
||||
end
|
||||
self._hoverEnabled = self._padCursorActive or not self.android
|
||||
self._anyHover = false
|
||||
self.romButtonRect = nil
|
||||
self.playButtonRect = nil
|
||||
@@ -1160,6 +1394,7 @@ function RomImporter:draw()
|
||||
-- Rebuilt only by the active version's SAVE SLOT panel, so the mods tab (or a
|
||||
-- version with no panel drawn this frame) cannot inherit last frame's rows.
|
||||
self.slotRects = nil
|
||||
self.slotEditRects = nil
|
||||
self.newSlotRect = nil
|
||||
-- Rebuilt only by the mods panel; nil elsewhere so a game tab cannot inherit
|
||||
-- last frame's mod toggles / import button.
|
||||
@@ -1509,8 +1744,53 @@ function RomImporter:draw()
|
||||
-- events reach the launcher, so click-vs-drag is resolved here)
|
||||
self:_updateSlotDrag()
|
||||
|
||||
-- save-slot rename modal (#205), drawn over everything
|
||||
if self._rename then
|
||||
col(PAL.bgBot, 0.72)
|
||||
love.graphics.rectangle("fill", 0, 0, width, height)
|
||||
local dw = math.min(appW - 32 * s, 420 * s)
|
||||
local dh = 128 * s
|
||||
local dx = appX + (appW - dw) / 2
|
||||
local dy = (height - dh) / 2
|
||||
local rr = 12 * s
|
||||
neonGlow(dx, dy, dw, dh, rr, PAL.green, 0.4)
|
||||
fillGradRounded(dx, dy, dw, dh, rr, PAL.slotBg, PAL.slotBg, 0.85, 0.85)
|
||||
love.graphics.setLineWidth(math.max(1, 1.2 * s))
|
||||
col(PAL.green, 0.5)
|
||||
love.graphics.rectangle("line", dx, dy, dw, dh, rr, rr)
|
||||
|
||||
love.graphics.setFont(self.slotNameFont)
|
||||
col(PAL.white)
|
||||
love.graphics.print(Strings("Name save slot"), dx + 16 * s, dy + 14 * s)
|
||||
|
||||
-- the field: bordered strip, current text, blinking caret on the pulse
|
||||
local fx, fy = dx + 16 * s, dy + 44 * s
|
||||
local fw, fh = dw - 32 * s, 30 * s
|
||||
col(PAL.bgBot, 0.9)
|
||||
love.graphics.rectangle("fill", fx, fy, fw, fh, 8 * s, 8 * s)
|
||||
love.graphics.setLineWidth(math.max(1, s))
|
||||
col(PAL.cardBorder, 0.45)
|
||||
love.graphics.rectangle("line", fx, fy, fw, fh, 8 * s, 8 * s)
|
||||
love.graphics.setFont(self.detailFont)
|
||||
col(PAL.heading)
|
||||
local shown = ellipsize(self.detailFont, self._rename.text, fw - 20 * s)
|
||||
love.graphics.print(shown, fx + 10 * s, fy + (fh - self.detailFont:getHeight()) / 2)
|
||||
if (self.pulse * 2 % 1) < 0.5 then
|
||||
local cx = fx + 10 * s + self.detailFont:getWidth(shown) + 2 * s
|
||||
col(PAL.green)
|
||||
love.graphics.rectangle("fill", cx, fy + 6 * s, math.max(1, 1.5 * s),
|
||||
fh - 12 * s)
|
||||
end
|
||||
|
||||
love.graphics.setFont(self.hintFont)
|
||||
col(PAL.detail)
|
||||
printfB(Strings("Enter to save - Esc to cancel - empty clears"),
|
||||
dx + 16 * s, dy + dh - 30 * s, dw - 32 * s, "left")
|
||||
end
|
||||
|
||||
-- pointer cursor over any interactive element (desktop only)
|
||||
if self._hoverEnabled and love.mouse.isCursorSupported and love.mouse.isCursorSupported() then
|
||||
if self._hoverEnabled and not self._padCursorActive
|
||||
and love.mouse.isCursorSupported and love.mouse.isCursorSupported() then
|
||||
if self._anyHover then
|
||||
self.handCursor = self.handCursor or love.mouse.getSystemCursor("hand")
|
||||
love.mouse.setCursor(self.handCursor)
|
||||
@@ -1518,6 +1798,34 @@ function RomImporter:draw()
|
||||
resetPointerCursor(self)
|
||||
end
|
||||
end
|
||||
|
||||
-- Gamepad virtual cursor (drawn last so it sits above the CRT overlay).
|
||||
if self._padCursorActive then
|
||||
local x, y = self._padCursor.x, self._padCursor.y
|
||||
local hot = self._anyHover
|
||||
love.graphics.push("all")
|
||||
love.graphics.origin()
|
||||
love.graphics.setLineWidth(1)
|
||||
-- Drop shadow
|
||||
love.graphics.setColor(0, 0, 0, 0.45)
|
||||
love.graphics.polygon("fill",
|
||||
x + 2, y + 2, x + 2, y + 22, x + 8, y + 16, x + 14, y + 26,
|
||||
x + 18, y + 24, x + 11, y + 14, x + 20, y + 14)
|
||||
-- Pointer body
|
||||
if hot then
|
||||
love.graphics.setColor(0.25, 0.95, 0.55, 1)
|
||||
else
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
end
|
||||
love.graphics.polygon("fill",
|
||||
x, y, x, y + 20, x + 6, y + 14, x + 12, y + 24,
|
||||
x + 16, y + 22, x + 9, y + 12, x + 18, y + 12)
|
||||
love.graphics.setColor(0.05, 0.07, 0.12, 1)
|
||||
love.graphics.polygon("line",
|
||||
x, y, x, y + 20, x + 6, y + 14, x + 12, y + 24,
|
||||
x + 16, y + 22, x + 9, y + 12, x + 18, y + 12)
|
||||
love.graphics.pop()
|
||||
end
|
||||
end
|
||||
|
||||
local function inside(r, x, y)
|
||||
@@ -1525,6 +1833,20 @@ local function inside(r, x, y)
|
||||
end
|
||||
|
||||
function RomImporter:mousepressed(x, y, button)
|
||||
if self._rename then return end -- the rename modal swallows all clicks
|
||||
-- right-click a save-slot row to rename it (#205); desktop only (touch
|
||||
-- has no secondary button)
|
||||
if button == 2 then
|
||||
if not self.android and self.workState ~= "working" then
|
||||
for _, r in ipairs(self.slotRects or {}) do
|
||||
if inside(r, x, y) then
|
||||
self:_beginRename(self.panelVersion, r.id)
|
||||
return
|
||||
end
|
||||
end
|
||||
end
|
||||
return
|
||||
end
|
||||
if button ~= 1 then return end
|
||||
if inside(self.bcgButton, x, y) or inside(self.linkUrlRect, x, y) then
|
||||
love.system.openURL(COMMUNITY_URL)
|
||||
@@ -1577,17 +1899,24 @@ function RomImporter:mousepressed(x, y, button)
|
||||
end
|
||||
return
|
||||
end
|
||||
-- SAVE SLOT rows / Delete. Delete is checked first so a tap on the Delete
|
||||
-- label never also selects the row. On desktop a press only ARMS a click:
|
||||
-- _updateSlotDrag commits it on release when the pointer did not move (a
|
||||
-- moved pointer scrolls instead). Android has no reliable pointer polling,
|
||||
-- so it selects on press. Delete fires immediately (small fixed target).
|
||||
-- SAVE SLOT rows / Edit / Delete. The two labels are checked first so a tap
|
||||
-- on either never also selects the row. On desktop a press only ARMS a row
|
||||
-- click: _updateSlotDrag commits it on release when the pointer did not move
|
||||
-- (a moved pointer scrolls instead). Android has no reliable pointer
|
||||
-- polling, so it selects on press. Edit and Delete fire immediately (small
|
||||
-- fixed targets, no scroll conflict).
|
||||
for _, r in ipairs(self.slotDeleteRects or {}) do
|
||||
if inside(r, x, y) then
|
||||
self:_deleteSlot(self.panelVersion, r.id)
|
||||
return
|
||||
end
|
||||
end
|
||||
for _, r in ipairs(self.slotEditRects or {}) do
|
||||
if inside(r, x, y) then
|
||||
if self.onEditSave then self.onEditSave(self.panelVersion, r.id) end
|
||||
return
|
||||
end
|
||||
end
|
||||
for _, r in ipairs(self.slotRects or {}) do
|
||||
if inside(r, x, y) then
|
||||
if self.android then
|
||||
@@ -1629,6 +1958,16 @@ function RomImporter:mousepressed(x, y, button)
|
||||
end
|
||||
|
||||
function RomImporter:keypressed(key)
|
||||
if self._rename then
|
||||
if key == "backspace" then
|
||||
self._rename.text = utf8Back(self._rename.text)
|
||||
elseif key == "return" or key == "kpenter" then
|
||||
self:_commitRename()
|
||||
elseif key == "escape" then
|
||||
self._rename = nil
|
||||
end
|
||||
return
|
||||
end
|
||||
if self.workState == "working" then return end
|
||||
if key == "return" or key == "space" or key == "kpenter" then
|
||||
-- Enter acts on the visible game tab: Play if its ROM is ready, otherwise
|
||||
@@ -2049,6 +2388,13 @@ function RomImporter:_ensureSlots(version)
|
||||
if not self.slots[version] then self:_refreshSlots(version) end
|
||||
end
|
||||
|
||||
-- The host calls this when the save editor closes: the edited slot's player
|
||||
-- name, badge count and dex total all feed the cached row summary, so it has
|
||||
-- to be re-read rather than trusted across the round trip.
|
||||
function RomImporter:savesChanged(version)
|
||||
self:_refreshSlots(version)
|
||||
end
|
||||
|
||||
-- Point the active slot at id (persisted immediately, per the contract) and
|
||||
-- reflect it in the LOADED pill without a full relist.
|
||||
function RomImporter:_selectSlot(version, id)
|
||||
@@ -2056,6 +2402,33 @@ function RomImporter:_selectSlot(version, id)
|
||||
self.activeSlot[version] = id
|
||||
end
|
||||
|
||||
-- Inline slot rename (#205): right-click arms a modal text field; Enter
|
||||
-- commits through SaveData.renameSlot (empty clears the label), Esc cancels.
|
||||
-- While it is up, keypressed/textinput/mousepressed all route here first.
|
||||
local MAX_SLOT_LABEL = 24
|
||||
|
||||
function RomImporter:_beginRename(version, id)
|
||||
local label
|
||||
for _, slot in ipairs(self.slots[version] or {}) do
|
||||
if slot.id == id then label = slot.label break end
|
||||
end
|
||||
self._rename = { version = version, id = id, text = label or "" }
|
||||
self._slotPress = nil -- cancel any armed click/drag on the list
|
||||
end
|
||||
|
||||
function RomImporter:_commitRename()
|
||||
local r = self._rename
|
||||
if not r then return end
|
||||
self._rename = nil
|
||||
require("src.core.SaveData").renameSlot(r.version, r.id, r.text)
|
||||
self:_refreshSlots(r.version)
|
||||
end
|
||||
|
||||
function RomImporter:textinput(text)
|
||||
if not self._rename then return end
|
||||
self._rename.text = utf8Cap(self._rename.text .. text, MAX_SLOT_LABEL)
|
||||
end
|
||||
|
||||
-- "+ New save slot": register an empty slot, make it active, relist, and pin the
|
||||
-- scroll to the bottom (clamped next draw) so the new row is on screen.
|
||||
function RomImporter:_newSlot(version)
|
||||
@@ -2166,6 +2539,7 @@ function RomImporter:_drawSaveSlotPanel(version, x, y, w, h)
|
||||
rw - 24 * s, "center")
|
||||
self.slotRects = {}
|
||||
self.slotDeleteRects = {}
|
||||
self.slotEditRects = {}
|
||||
elseif listH > 0 then
|
||||
local nameH = self.slotNameFont:getHeight()
|
||||
local metaH = self.labelFont:getHeight()
|
||||
@@ -2185,6 +2559,7 @@ function RomImporter:_drawSaveSlotPanel(version, x, y, w, h)
|
||||
|
||||
self.slotRects = {}
|
||||
self.slotDeleteRects = {}
|
||||
self.slotEditRects = {}
|
||||
love.graphics.setScissor(math.floor(rx), math.floor(listTop),
|
||||
math.ceil(rw), math.ceil(listH))
|
||||
for i, slot in ipairs(slots) do
|
||||
@@ -2211,6 +2586,24 @@ function RomImporter:_drawSaveSlotPanel(version, x, y, w, h)
|
||||
love.graphics.print(delText, delX, delY)
|
||||
local rightReserve = delW + 18 * s
|
||||
|
||||
-- Edit label, immediately left of Delete: opens the bundled save
|
||||
-- editor (tools/save-editor) on this slot's file. Only drawn when the
|
||||
-- host supplied onEditSave and the slot actually holds a save -- there
|
||||
-- is nothing to edit in an empty slot, and offering it would open the
|
||||
-- editor on a new-game stub the player never asked for.
|
||||
local erect = nil
|
||||
if self.onEditSave and slot.exists then
|
||||
local edText = "Edit"
|
||||
local edW = self.hintFont:getWidth(edText)
|
||||
local edX = delX - 14 * s - edW
|
||||
erect = { x = edX - 6 * s, y = delY - 4 * s,
|
||||
width = edW + 12 * s, height = delH + 8 * s, id = slot.id }
|
||||
local ehot = self:_hover(erect)
|
||||
col(ehot and PAL.blue or PAL.warning)
|
||||
love.graphics.print(edText, edX, delY)
|
||||
rightReserve = rightReserve + edW + 20 * s
|
||||
end
|
||||
|
||||
-- LOADED pill (top-right of the active row), then reserve its width
|
||||
local pillW = 0
|
||||
if selected then
|
||||
@@ -2229,7 +2622,8 @@ function RomImporter:_drawSaveSlotPanel(version, x, y, w, h)
|
||||
|
||||
love.graphics.setFont(self.slotNameFont)
|
||||
col(PAL.white)
|
||||
local name = slot.name or Strings("NEW GAME")
|
||||
-- a custom label (#205) wins over the player name; both ellipsize
|
||||
local name = slot.label or slot.name or Strings("NEW GAME")
|
||||
printB(ellipsize(self.slotNameFont, name, rw - 24 * s - math.max(pillW, rightReserve)),
|
||||
rx + 12 * s, ry + rowPadV)
|
||||
|
||||
@@ -2259,6 +2653,15 @@ function RomImporter:_drawSaveSlotPanel(version, x, y, w, h)
|
||||
self.slotDeleteRects[#self.slotDeleteRects + 1] =
|
||||
{ x = drect.x, y = dvy, width = drect.width, height = dvy2 - dvy, id = slot.id }
|
||||
end
|
||||
if erect then
|
||||
local evy = math.max(erect.y, listTop)
|
||||
local evy2 = math.min(erect.y + erect.height, listBottom)
|
||||
if evy2 > evy then
|
||||
self.slotEditRects[#self.slotEditRects + 1] =
|
||||
{ x = erect.x, y = evy, width = erect.width, height = evy2 - evy,
|
||||
id = slot.id }
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
love.graphics.setScissor()
|
||||
@@ -2463,7 +2866,7 @@ function RomImporter:_drawModsPanel(x, y, w, h)
|
||||
love.graphics.printf(m.description, nx, ny + nameH + 6 * s, L.leftW, "left")
|
||||
end
|
||||
|
||||
-- right cluster: status chip, toggle, Delete — vertically centred
|
||||
-- right cluster: status chip, toggle, Delete -- vertically centred
|
||||
local clusterX = x + w - padH - L.clusterW
|
||||
local clusterY = cy + (cardH - clusterH) / 2
|
||||
local _, chipColor = modStatusChip(m.status)
|
||||
|
||||
@@ -47,6 +47,17 @@ ItemEffects.BALLS = BALLS
|
||||
function ItemEffects.isBall(id) return BALLS[id] or false end
|
||||
function ItemEffects.isStone(id) return STONES[id] or false end
|
||||
|
||||
-- Does using this item take item_effects.asm's .healHP path, the one that
|
||||
-- plays SFX_HEAL_HP and lengthens the party HP bar with UpdateHPBar2 before
|
||||
-- the message (item_effects.asm .doneHealing)? The status-only cures branch
|
||||
-- to .playStatusAilmentCuringSound instead and never touch the bar. BagMenu
|
||||
-- keeps the party picker open for these so the fill has something to draw
|
||||
-- on (#252).
|
||||
function ItemEffects.healsHP(id)
|
||||
return HEAL_AMOUNT[id] ~= nil or id == "MAX_POTION" or id == "FULL_RESTORE"
|
||||
or id == "REVIVE" or id == "MAX_REVIVE"
|
||||
end
|
||||
|
||||
-- Does this item need a party-member target?
|
||||
function ItemEffects.needsTarget(id, itemDef)
|
||||
return HEAL_AMOUNT[id] or STATUS_HEAL[id] or id == "MAX_POTION"
|
||||
@@ -257,6 +268,11 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
|
||||
if not target or target.hp <= 0 or target.hp >= target.stats.hp then
|
||||
return "failed", { Strings("It won't have\nany effect.") }
|
||||
end
|
||||
-- wHPBarOldHP: the bar animation starts from the HP the mon had BEFORE
|
||||
-- the item landed (item_effects.asm latches it with the party menu still
|
||||
-- up), so latch it here and hand it back as extra.healedFrom for the
|
||||
-- party-menu fill (#252)
|
||||
local before = target.hp
|
||||
if itemId == "MAX_POTION" or itemId == "FULL_RESTORE" then
|
||||
target.hp = target.stats.hp
|
||||
else
|
||||
@@ -268,7 +284,7 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
|
||||
cureActiveToxic(battle, target)
|
||||
end
|
||||
require("src.core.Sound").play(data, "Heal_HP")
|
||||
return "consumed", msgs
|
||||
return "consumed", msgs, { healedFrom = before }
|
||||
end
|
||||
|
||||
local cures = STATUS_HEAL[itemId]
|
||||
@@ -289,7 +305,10 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
|
||||
target.status = nil
|
||||
target.hp = itemId == "REVIVE" and math.floor(target.stats.hp / 2) or target.stats.hp
|
||||
require("src.core.Sound").play(data, "Heal_HP")
|
||||
return "consumed", { Strings("%s\nis revitalized!", monName(data, target)) }
|
||||
-- a revive takes the same .healHP -> .doneHealing route, animating up
|
||||
-- from the fainted mon's 0 HP (#252)
|
||||
return "consumed", { Strings("%s\nis revitalized!", monName(data, target)) },
|
||||
{ healedFrom = 0 }
|
||||
end
|
||||
|
||||
if itemId == "RARE_CANDY" then
|
||||
|
||||
+63
-4
@@ -112,6 +112,19 @@ local function scalar(v)
|
||||
return tostring(v)
|
||||
end
|
||||
|
||||
-- A volatile slot is "off" three interchangeable ways -- absent, false, or
|
||||
-- a counter sitting at 0 -- because every reader tests `if b.key then` or
|
||||
-- `key > 0`. They have to hash the same, or a match ends over a difference
|
||||
-- that does not exist. They routinely disagree: the menu-phase flinch
|
||||
-- clear (BattleState:update) and the FIGHT-branch boundTurns mirror
|
||||
-- (fightLockedAction) are written by whichever machine is sitting at ITS
|
||||
-- OWN menu, and in a link battle that is a different battler on each side,
|
||||
-- so one peer holds flinched=false / boundTurns=0 where the other still
|
||||
-- holds nil with two identical simulations underneath.
|
||||
local function off(v)
|
||||
return v == nil or v == false or v == 0
|
||||
end
|
||||
|
||||
local function stageStr(b)
|
||||
local out = {}
|
||||
for i, stat in ipairs(STAGES) do
|
||||
@@ -131,7 +144,7 @@ end
|
||||
local function volStr(b)
|
||||
local out = {}
|
||||
for _, key in ipairs(VOLATILE) do
|
||||
if b[key] ~= nil then
|
||||
if not off(b[key]) then
|
||||
out[#out + 1] = key .. "=" .. scalar(b[key])
|
||||
end
|
||||
end
|
||||
@@ -168,6 +181,17 @@ end
|
||||
|
||||
local PARTS = { "actives", "volatile", "bench" }
|
||||
|
||||
-- Which components are allowed to end a match. `actives` and `bench` carry
|
||||
-- what decides one -- species, HP, status, stat stages, PP, the rest of the
|
||||
-- party -- so a divergence there is a real split between the two
|
||||
-- simulations and stays a draw. `volatile` is per-turn bookkeeping that
|
||||
-- both sides recompute from the authoritative state every turn (see `off`
|
||||
-- above): it can disagree for a turn without either side being wrong, and
|
||||
-- when it does mean something real it lands in `actives` as damage or
|
||||
-- status within a turn or two, where it is caught. Ending a match on it
|
||||
-- alone cost players games they were winning, over nothing.
|
||||
local FATAL_PART = { actives = true, bench = true }
|
||||
|
||||
-- opts: { myParty = packed, theirParty = packed, theirName, role =
|
||||
-- "host"/"guest", seed, verdict, strict }. Returns nil plus a reason when
|
||||
-- the handshake says the two link surfaces don't match: a lockstep
|
||||
@@ -315,12 +339,23 @@ function LinkBattle.new(game, net, opts)
|
||||
Logger.warn("link: desync turn %s component=%s (%s vs %s)",
|
||||
tostring(turn), component, tostring(localH), tostring(remoteH))
|
||||
Runtime.emit("link.desync", { turn = turn, component = component,
|
||||
localHash = localH, remoteHash = remoteH })
|
||||
localHash = localH, remoteHash = remoteH,
|
||||
fatal = true })
|
||||
endAsDraw(s, Strings(
|
||||
"Link desync!\n%s differs.\fAre both games\nrunning the same\nmods?",
|
||||
component))
|
||||
end
|
||||
|
||||
-- a non-fatal component split: both sides log it and carry on, so the
|
||||
-- match is decided by the battle rather than by bookkeeping
|
||||
local function noteDrift(s, turn, component, localH, remoteH)
|
||||
Logger.warn("link: %s drift on turn %s (%s vs %s) -- match continues",
|
||||
component, tostring(turn), tostring(localH), tostring(remoteH))
|
||||
Runtime.emit("link.desync", { turn = turn, component = component,
|
||||
localHash = localH, remoteHash = remoteH,
|
||||
fatal = false })
|
||||
end
|
||||
|
||||
-- a verified turn stays recorded: consuming it here left a finished
|
||||
-- battle holding 0-1 entries, so the whole-battle sweep the link suite
|
||||
-- runs over localHashes had nothing left to compare
|
||||
@@ -333,8 +368,11 @@ function LinkBattle.new(game, net, opts)
|
||||
if mine and theirs then
|
||||
for _, component in ipairs(PARTS) do
|
||||
if mine[component] ~= theirs[component] then
|
||||
reportDesync(s, turn, component, mine[component], theirs[component])
|
||||
return
|
||||
if FATAL_PART[component] then
|
||||
reportDesync(s, turn, component, mine[component], theirs[component])
|
||||
return
|
||||
end
|
||||
noteDrift(s, turn, component, mine[component], theirs[component])
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -565,10 +603,19 @@ function LinkBattle.new(game, net, opts)
|
||||
if net.closed and not s.linkEnded and not s.result then
|
||||
endAsDraw(s)
|
||||
end
|
||||
-- Both early returns below skip baseUpdate, which is where the
|
||||
-- presentational clock normally advances -- so tick it here, in the same
|
||||
-- order baseUpdate would (fx, then the queue). Without this an
|
||||
-- animation caught mid-flight froze for the whole wait: a flash stopped
|
||||
-- on its inverted BGP step and repainted the UI in inverted shades, and
|
||||
-- a pic part-way through a slide or grow-in stayed off screen, which is
|
||||
-- the "the screen went inverted" / "a Pokemon just vanished" pair.
|
||||
if s.phase == "waitRemote" then
|
||||
s:tickFx()
|
||||
return -- the other side is still choosing
|
||||
end
|
||||
if s.phase == "messages" and s.afterQueue == "linkNext" then
|
||||
s:tickFx()
|
||||
if not s:updateQueue() then
|
||||
s.afterQueue = "menu"
|
||||
s.phase = "menu"
|
||||
@@ -742,6 +789,13 @@ function LinkBattle.newSpectator(game, net, opts)
|
||||
end
|
||||
|
||||
s:act(function()
|
||||
-- the two real players cleared their flinch flags when their move
|
||||
-- menu opened; a spectator has no menu, so it does it here instead,
|
||||
-- at the same point in the turn (see BattleState:clearTurnFlinches).
|
||||
-- Without this a flinch survived into the next turn and ate a move
|
||||
-- that landed in the real match, and the replay -- sharing the RNG
|
||||
-- stream -- was watching a different battle from that point on.
|
||||
s:clearTurnFlinches()
|
||||
local hostAction = hostMsg.kind ~= "switch" and hostMsg.kind ~= "run"
|
||||
and decodeWireAction(s, hostMsg, s.player) or nil
|
||||
local guestAction = guestMsg.kind ~= "switch" and guestMsg.kind ~= "run"
|
||||
@@ -837,7 +891,11 @@ function LinkBattle.newSpectator(game, net, opts)
|
||||
if net.closed and not s.linkEnded and not s.result then
|
||||
endSpectate(s)
|
||||
end
|
||||
-- same as the real-participant loop: every path that skips baseUpdate
|
||||
-- still has to advance the presentational clock, and a spectator sits in
|
||||
-- waitBoth between every single turn
|
||||
if s.phase == "messages" and s.afterQueue == "linkNext" then
|
||||
s:tickFx()
|
||||
if not s:updateQueue() then
|
||||
s.afterQueue = "waitBoth"
|
||||
s.phase = "waitBoth"
|
||||
@@ -845,6 +903,7 @@ function LinkBattle.newSpectator(game, net, opts)
|
||||
return
|
||||
end
|
||||
if s.phase == "menu" or s.phase == "waitBoth" then
|
||||
s:tickFx()
|
||||
return -- frozen between resolved turns; never a real decision here
|
||||
end
|
||||
baseUpdate(s, dt)
|
||||
|
||||
+46
-1
@@ -73,6 +73,9 @@ end
|
||||
function LinkState.new(game)
|
||||
local self = setmetatable({}, LinkState)
|
||||
self.game = game
|
||||
-- a link session runs at 1X on both machines whatever either player set
|
||||
-- GAME SPEED to (see Game:logicSpeed); cleared in exitWith
|
||||
game.linkSession = true
|
||||
self.stage = "menu"
|
||||
self.index = 1
|
||||
self.addr = ipDigits(Net.lanIP())
|
||||
@@ -98,6 +101,7 @@ end
|
||||
|
||||
function LinkState:exitWith(message, reason)
|
||||
DiscordPresence.setJoinCode(nil)
|
||||
self.game.linkSession = nil -- back to the player's own GAME SPEED
|
||||
Runtime.emit("link.ended", { reason = reason or (message and "error" or "bye") })
|
||||
if self.net then self.net:close() end
|
||||
self.game.stack:pop()
|
||||
@@ -106,6 +110,46 @@ function LinkState:exitWith(message, reason)
|
||||
end
|
||||
end
|
||||
|
||||
-- Online play meets strangers, so it requires vanilla on both ends
|
||||
-- (Handshake.onlineAllowed). Mods merge into the shared Data registries at
|
||||
-- boot and there is no unmerge, so switching them off has to go through a
|
||||
-- relaunch -- but the player should not have to go find the mod manager and
|
||||
-- work out which mods count. This turns every enabled mod off, records
|
||||
-- them so the mod manager can put them back, and relaunches. The restart
|
||||
-- is confirmed rather than silent: it drops unsaved progress.
|
||||
function LinkState:offerVanillaRestart()
|
||||
local game = self.game
|
||||
local loader = game.mods
|
||||
local mods = Handshake.mods(game)
|
||||
local names = {}
|
||||
for i, mod in ipairs(mods) do
|
||||
if i > 2 then break end
|
||||
names[#names + 1] = tostring(mod.id):upper():sub(1, 12)
|
||||
end
|
||||
local list = table.concat(names, ", ")
|
||||
if #mods > #names then list = list .. (" +%d"):format(#mods - #names) end
|
||||
local text = Strings(
|
||||
"Online play runs\nvanilla for both\nplayers.\fTurn off %s\nand restart?", list)
|
||||
self.game.linkSession = nil
|
||||
Runtime.emit("link.ended", { reason = "error" })
|
||||
if self.net then self.net:close() end
|
||||
game.stack:pop()
|
||||
game.stack:push(TextBox.new(game, text, nil, { choice = function(yes)
|
||||
if not yes then return end
|
||||
-- setEnabled persists the toggle itself (Loader:_saveState), so the
|
||||
-- relaunch comes up vanilla and the mod manager lists them as disabled
|
||||
-- for the player to switch back on afterwards
|
||||
for _, mod in ipairs(mods) do
|
||||
if loader and loader.setEnabled then loader:setEnabled(mod.id, false) end
|
||||
end
|
||||
if game.restartWithMods then
|
||||
game:restartWithMods()
|
||||
elseif love.event and love.event.quit then
|
||||
love.event.quit("restart")
|
||||
end
|
||||
end }))
|
||||
end
|
||||
|
||||
-- -------------------------------------------------------------------
|
||||
-- handshake v2 (D8): both peers announce engine version, api version and
|
||||
-- a fingerprint of their link surface, and the verdict comes from the two
|
||||
@@ -202,7 +246,7 @@ function LinkState:update(dt)
|
||||
self.index = 1
|
||||
elseif self.index == 2 or self.index == 3 then
|
||||
if not Handshake.onlineAllowed(self.game) then
|
||||
self:exitWith(Strings("Online play needs\nno mods enabled.\fDisable them in\nSTART > MODS."))
|
||||
self:offerVanillaRestart()
|
||||
return
|
||||
end
|
||||
if self.index == 2 then
|
||||
@@ -509,6 +553,7 @@ function LinkState:updateTrade(input)
|
||||
if self.game.writeSave then self.game:writeSave() end
|
||||
local name = received.nickname or self.game.data.pokemon[received.species].name
|
||||
Runtime.emit("link.ended", { reason = "done" })
|
||||
self.game.linkSession = nil -- this path pops without exitWith
|
||||
self.net:close()
|
||||
self.game.stack:pop()
|
||||
local game = self.game
|
||||
|
||||
@@ -91,6 +91,11 @@ function Tournament.new(game)
|
||||
self.settingsIndex = 1
|
||||
self.roster = {}
|
||||
self.spectatorRoster = {}
|
||||
-- everything from here to exitWith runs at 1X regardless of the GAME
|
||||
-- SPEED option (see Game:logicSpeed): a tournament's shot clock counts
|
||||
-- down on the logic step, so fast-forward would hand one player less
|
||||
-- real time to choose than the opponent they are racing
|
||||
game.linkSession = true
|
||||
Sound.startLoop(game.data, MUSIC)
|
||||
return self
|
||||
end
|
||||
@@ -113,6 +118,7 @@ end
|
||||
|
||||
function Tournament:exitWith(message)
|
||||
DiscordPresence.setJoinCode(nil)
|
||||
self.game.linkSession = nil -- back to the player's own GAME SPEED
|
||||
Sound.stopLoop(MUSIC)
|
||||
Runtime.emit("link.ended", { reason = message and "error" or "bye" })
|
||||
if self.net then self.net:close() end
|
||||
|
||||
+58
-10
@@ -3,8 +3,18 @@
|
||||
-- manifests only. The full loader (src/mods/Loader.lua) still owns the real
|
||||
-- load at boot; this reads the same options.mods enable-state the loader
|
||||
-- writes, derives per-mod status with the pure ManagerState.resolveToggle,
|
||||
-- installs a dropped/chosen .zip into the save-dir "mods/<id>/" tree, and
|
||||
-- uninstalls a mod by removing that tree + clearing options.mods[id].
|
||||
-- installs a dropped/chosen .zip into a "mods/<id>/" tree, and uninstalls a
|
||||
-- mod by removing that tree + clearing options.mods[id].
|
||||
--
|
||||
-- Where that tree lives is CacheFs's call, not love.filesystem's: a portable
|
||||
-- install (portable.txt beside the executable) keeps its mods in the game
|
||||
-- folder like everything else it owns, and only the OS save directory
|
||||
-- otherwise (#330 -- love.filesystem.write always resolves to the save dir,
|
||||
-- so the installer used to strand every mod in appdata). Reads stay on
|
||||
-- love.filesystem: the portable folder is on the physfs read path either way
|
||||
-- (it IS the source for a `love <gamedir>` run, and CacheFs mounts it for a
|
||||
-- fused build), which is why those mods still loaded while landing in the
|
||||
-- wrong place.
|
||||
--
|
||||
-- Split in two: the pure derivation (deriveList, locateRoot) has no love and
|
||||
-- no filesystem, so the engine tier can table-drive it; the discovery,
|
||||
@@ -15,6 +25,7 @@ local ManagerState = require("src.mods.ManagerState")
|
||||
local Semver = require("src.mods.Semver")
|
||||
local Version = require("src.core.Version")
|
||||
local SaveData = require("src.core.SaveData")
|
||||
local CacheFs = require("src.import.CacheFs")
|
||||
|
||||
local LauncherMods = {}
|
||||
|
||||
@@ -143,6 +154,13 @@ local function discover()
|
||||
local fs = love and love.filesystem
|
||||
local out = {}
|
||||
if not (fs and fs.getInfo and fs.getDirectoryItems) then return out end
|
||||
-- A fused portable build keeps its mods in the game folder next to the
|
||||
-- executable; resolving the cache root is what mounts that folder onto the
|
||||
-- physfs read path, so this is what makes those mods enumerable at all
|
||||
-- (#330). A source run needs nothing (the game folder IS the source), and
|
||||
-- the launcher's readiness check has usually resolved it already; the call
|
||||
-- is cached and idempotent.
|
||||
CacheFs.root()
|
||||
if not fs.getInfo("mods") then return out end
|
||||
local seen = {}
|
||||
for _, name in ipairs(fs.getDirectoryItems("mods")) do
|
||||
@@ -233,11 +251,14 @@ local function topLevelPaths(mount)
|
||||
return paths
|
||||
end
|
||||
|
||||
-- Copy the mounted archive subtree at `src` to the install path `dst`. Reads
|
||||
-- come from love.filesystem (the .zip is mounted there); every write goes
|
||||
-- through CacheFs so it lands in the portable game folder when portable.txt is
|
||||
-- in play and in the OS save directory otherwise (#330). No explicit mkdir:
|
||||
-- CacheFs.write creates the parent chain on both paths, which also means an
|
||||
-- empty folder inside the .zip is simply not carried over (it holds nothing).
|
||||
local function copyTree(src, dst)
|
||||
local fs = love.filesystem
|
||||
if not fs.createDirectory(dst) then
|
||||
return nil, "could not create " .. dst
|
||||
end
|
||||
for _, name in ipairs(fs.getDirectoryItems(src)) do
|
||||
local s = src .. "/" .. name
|
||||
local d = dst .. "/" .. name
|
||||
@@ -248,13 +269,18 @@ local function copyTree(src, dst)
|
||||
else
|
||||
local data = fs.read(s)
|
||||
if data == nil then return nil, "could not read " .. name end
|
||||
local ok, err = fs.write(d, data)
|
||||
local ok, err = CacheFs.write(d, data)
|
||||
if not ok then return nil, "could not write " .. name .. ": " .. tostring(err) end
|
||||
end
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
-- Delete an installed mod subtree. Enumeration stays on love.filesystem (the
|
||||
-- portable game folder is on its read path), but the deletes go through
|
||||
-- CacheFs so a portable install's real files actually go away instead of
|
||||
-- love.filesystem no-opping outside the save directory (#330). Directories
|
||||
-- are removed after their children, since rmdir refuses a non-empty one.
|
||||
local function removeTree(path)
|
||||
local fs = love.filesystem
|
||||
local info = fs.getInfo(path)
|
||||
@@ -263,7 +289,15 @@ local function removeTree(path)
|
||||
for _, child in ipairs(fs.getDirectoryItems(path)) do
|
||||
removeTree(path .. "/" .. child)
|
||||
end
|
||||
CacheFs.removeDir(path)
|
||||
else
|
||||
CacheFs.remove(path)
|
||||
end
|
||||
-- A portable install can still be carrying a pre-#330 copy in the OS save
|
||||
-- directory, which is where every install used to land and which physfs
|
||||
-- searches first. CacheFs only touched the game folder, so clear the
|
||||
-- save-directory twin too or that copy would keep the mod alive; outside
|
||||
-- portable mode this repeats the delete CacheFs just did and no-ops.
|
||||
fs.remove(path)
|
||||
end
|
||||
|
||||
@@ -322,10 +356,19 @@ function LauncherMods.installZip(source)
|
||||
return nil, "a mod named '" .. manifest.id .. "' is already installed"
|
||||
end
|
||||
|
||||
fs.createDirectory("mods")
|
||||
-- CacheFs.prefix steers ROM-cache writes into a version subtree (blue/...);
|
||||
-- the mods tree is shared by Red and Blue, so pin the prefix to the root for
|
||||
-- the copy and the rollback, then hand back whatever the launcher had set
|
||||
-- (an import coroutine leaves it pointed at that version -- RomImporter.lua).
|
||||
-- No fs.createDirectory("mods") here any more: CacheFs.write creates the
|
||||
-- parent chain in both homes, and doing it through love.filesystem would
|
||||
-- only ever make the directory in the save dir (#330).
|
||||
local savedPrefix = CacheFs.prefix
|
||||
CacheFs.prefix = ""
|
||||
local copied, copyErr = copyTree(root, dest)
|
||||
if not copied then removeTree(dest) end
|
||||
CacheFs.prefix = savedPrefix
|
||||
if not copied then
|
||||
removeTree(dest)
|
||||
cleanup()
|
||||
return nil, copyErr or "could not copy the mod files"
|
||||
end
|
||||
@@ -334,8 +377,9 @@ function LauncherMods.installZip(source)
|
||||
end
|
||||
|
||||
-- uninstall(id) -> true | nil, errString
|
||||
-- Removes mods/<id>/ from the save directory and clears options.mods[id] so the
|
||||
-- loader and in-game manager no longer see it. Rejects unknown / missing ids.
|
||||
-- Removes mods/<id>/ from wherever it was installed (the portable game folder
|
||||
-- or the save directory, CacheFs decides -- #330) and clears options.mods[id]
|
||||
-- so the loader and in-game manager no longer see it. Rejects missing ids.
|
||||
-- Does not touch other mods' enable state.
|
||||
function LauncherMods.uninstall(id)
|
||||
if type(id) ~= "string" or id == "" then
|
||||
@@ -352,7 +396,11 @@ function LauncherMods.uninstall(id)
|
||||
if not fs.getInfo(dest) then
|
||||
return nil, "mod '" .. id .. "' is not installed"
|
||||
end
|
||||
-- same root pin as installZip: the mods tree is not version-prefixed (#330)
|
||||
local savedPrefix = CacheFs.prefix
|
||||
CacheFs.prefix = ""
|
||||
removeTree(dest)
|
||||
CacheFs.prefix = savedPrefix
|
||||
-- Drop the enable flag so a reinstall of the same id starts from the
|
||||
-- loader's default (enabled) rather than a stale false.
|
||||
local options = SaveData.loadOptions()
|
||||
|
||||
+5
-1
@@ -162,7 +162,11 @@ function Loader:_loadState()
|
||||
if next(options.mods or {}) == nil and self.fs.getInfo
|
||||
and self.fs.getInfo(MOD_STATE_FILE) then
|
||||
local chunk = self.fs.load(MOD_STATE_FILE)
|
||||
local ok, state = chunk and pcall(chunk)
|
||||
-- `chunk and pcall(chunk)` truncates to one value, so state came back nil
|
||||
-- however well the chunk ran and the migration below never fired once:
|
||||
-- the guard has to be a statement for pcall's second return to survive.
|
||||
local ok, state = false, nil
|
||||
if chunk then ok, state = pcall(chunk) end
|
||||
if ok and type(state) == "table" then
|
||||
for id, disabled in pairs(state) do
|
||||
if disabled then
|
||||
|
||||
@@ -187,7 +187,7 @@ end
|
||||
|
||||
-- After-battle hook: evolve mons that leveled this battle and still
|
||||
-- qualify (queued one at a time, party order). Gen1 EvolveAfterBattle
|
||||
-- only considers mons that gained a level during the fight — a B-cancel
|
||||
-- only considers mons that gained a level during the fight -- a B-cancel
|
||||
-- means "not this time", and the next offer waits for the next level-up
|
||||
-- (or Rare Candy / stone, which call Evolution.evolve directly).
|
||||
-- leveledUp is a set of party mon tables; nil/empty yields no evolutions.
|
||||
|
||||
@@ -31,7 +31,7 @@ end
|
||||
|
||||
-- Day Care retrieve (pokered WriteMonMoves + wLearningMovesFromDayCare):
|
||||
-- grant learnset moves with startLevel < moveLevel <= newLevel, shifting
|
||||
-- the oldest slot out when full. Silent — no LearnMove prompts.
|
||||
-- the oldest slot out when full. Silent -- no LearnMove prompts.
|
||||
function Pokemon.learnMovesFromDayCare(data, mon, speciesDef, startLevel, newLevel)
|
||||
if not (speciesDef and speciesDef.learnset and mon) then return end
|
||||
mon.moves = mon.moves or {}
|
||||
@@ -75,6 +75,10 @@ function Pokemon.new(data, species, level, rng)
|
||||
statExp = { hp = 0, attack = 0, defense = 0, speed = 0, special = 0 },
|
||||
stats = stats,
|
||||
hp = stats.hp,
|
||||
-- the Gen1 catch-rate byte freezes at catch time: evolution does NOT
|
||||
-- update it, so PKHeX expects a preevolution's rate on an evolved mon
|
||||
-- (#206). Evolution.apply mutates in place and never touches this.
|
||||
catchRate = def.catchRate,
|
||||
status = nil, -- "SLP"|"PSN"|"BRN"|"FRZ"|"PAR"
|
||||
moves = moves,
|
||||
}
|
||||
|
||||
@@ -42,6 +42,28 @@ function Stats.calc(speciesDef, level, dvs, statExp)
|
||||
return out
|
||||
end
|
||||
|
||||
-- Give a mon a stat block when it has none. A real Gen 1 box_struct is a
|
||||
-- byte-for-byte PREFIX of party_struct that stops before MON_LEVEL and
|
||||
-- MON_STATS (macros/ram.asm box_struct / party_struct), so mons decoded out
|
||||
-- of an imported .sav arrive without one (src/save_convert/GenSave.lua
|
||||
-- decodeMon, isParty = false). The original derives them on demand at
|
||||
-- exactly two moments: when a box or daycare mon's status screen opens
|
||||
-- (engine/pokemon/status_screen.asm:66-76, "mon is in a box or daycare" ->
|
||||
-- CalcStats) and when one is moved back into the party
|
||||
-- (engine/pokemon/add_mon.asm _MoveMon tail). The stored current HP is
|
||||
-- kept (box_struct does hold it) but clamped to the recalculated maximum so
|
||||
-- a tampered save cannot overfill the bar. A mon that already has stats is
|
||||
-- returned untouched, so a vanilla save round-trips. #233, #304
|
||||
function Stats.ensure(speciesDef, mon)
|
||||
if type(mon) ~= "table" or type(mon.stats) == "table" then return mon end
|
||||
if type(speciesDef) ~= "table" or type(speciesDef.baseStats) ~= "table" then
|
||||
return mon
|
||||
end
|
||||
mon.stats = Stats.calc(speciesDef, mon.level or 1, mon.dvs or {}, mon.statExp)
|
||||
mon.hp = math.max(0, math.min(tonumber(mon.hp) or mon.stats.hp, mon.stats.hp))
|
||||
return mon
|
||||
end
|
||||
|
||||
-- Battle stat stage multipliers (data/battle/stat_modifiers.asm): stages
|
||||
-- -6..+6 map to N/D pairs 25/100 .. 400/100.
|
||||
local STAGE_MULT = {
|
||||
|
||||
@@ -23,6 +23,20 @@ local FLASH_STEPS = { 1 / 3, 2 / 3, 1, 2 / 3, 1 / 3, 0,
|
||||
local FLASH_HOLD = 2 -- frames per palette step
|
||||
local FLASH_CYCLES = 3
|
||||
|
||||
-- The screen stays black after the wipe lands. Shrink and Split ask for it
|
||||
-- outright (BattleTransition_BlackScreen, then `ld c, 10 / jp DelayFrames`:
|
||||
-- battle_transitions.asm:390-392 and 422-424); the other six get the same gap
|
||||
-- for free, because BattleTransition_BlackScreen has already set rBGP/rOBP0/
|
||||
-- rOBP1 to $ff (:168-174) while DoBattleTransitionAndInitBattleVariables
|
||||
-- reloads the HUD tile patterns and clears the screen (core.asm:6152-6185),
|
||||
-- InitBattleCommon decompresses the front pic (core.asm:6694-6730), and
|
||||
-- SlidePlayerAndEnemySilhouettesOnScreen rebuilds the whole tilemap between
|
||||
-- DisableLCD and EnableLCD (core.asm:9-49) before anything moves. This port
|
||||
-- has no load to hide behind, so the hold has to be explicit (#315). 30 is a
|
||||
-- frame budget for that work, not a number pokered states; retune this one
|
||||
-- constant against a reference recording if it reads long or short.
|
||||
local BLACK_HOLD = 30
|
||||
|
||||
local TILE = 8
|
||||
local COLS, ROWS = 160 / TILE, 144 / TILE -- 20 x 18 tiles
|
||||
|
||||
@@ -208,7 +222,7 @@ function BattleTransition:update(dt)
|
||||
self.t = 0
|
||||
end
|
||||
else
|
||||
if self.t >= self.wipeLen + 6 then
|
||||
if self.t >= self.wipeLen + BLACK_HOLD then
|
||||
self.game.stack:pop()
|
||||
if self.onDone then self.onDone() end
|
||||
end
|
||||
|
||||
@@ -25,7 +25,16 @@ local shader -- false = unavailable (headless / no shader support)
|
||||
-- Mobile GPUs often compile this pass but present a black frame, and the
|
||||
-- level persists in options.lua -- soft-bricking the APK until a manual
|
||||
-- edit (issue #136). Desktop is unchanged; Android/iOS refuse the effect.
|
||||
--
|
||||
-- POKEPORT_GBCFX overrides the platform default either way ("1" force on,
|
||||
-- "0" force off), same tri-state as POKEPORT_TOUCH. Handheld packs whose
|
||||
-- GPU is in the same class as a phone's but whose getOS() says "Linux" set
|
||||
-- it to 0 in their launcher (build-rg34xxsp.sh); it also lets a desktop
|
||||
-- checkout exercise the unsupported path without stubbing love.system.
|
||||
function GBCFX.isSupported()
|
||||
local env = os.getenv("POKEPORT_GBCFX")
|
||||
if env == "1" then return true end
|
||||
if env == "0" then return false end
|
||||
if not love or not love.system or not love.system.getOS then return true end
|
||||
local osName = love.system.getOS()
|
||||
return osName ~= "Android" and osName ~= "iOS"
|
||||
|
||||
+80
-22
@@ -2,6 +2,11 @@
|
||||
-- screen: pokered overlays the $62-$7F font area with the HP bar /
|
||||
-- status sheet (font_battle_extra -> $62) and the HUD line tiles
|
||||
-- (battle_hud_1 -> $6D, battle_hud_2+3 -> $73).
|
||||
--
|
||||
-- The two screens do NOT use the same overlay: the status screen scatters
|
||||
-- hud_2 and hud_3 instead of copying them contiguously, which is what keeps
|
||||
-- its № and <ID> glyphs alive. HudTiles.tile draws the battle layout,
|
||||
-- HudTiles.statusTile the status one -- see STATUS_PAGES below. #280
|
||||
|
||||
local Assets = require("src.render.Assets")
|
||||
|
||||
@@ -23,32 +28,62 @@ local PAGES = {
|
||||
image = "assets/generated/battle/battle_hud_3.png", base = 0x76 },
|
||||
}
|
||||
|
||||
local tiles
|
||||
function HudTiles.tile(code, x, y, tint)
|
||||
if not tiles then
|
||||
tiles = {}
|
||||
local registered = require("src.core.Data").font
|
||||
registered = registered and registered.pages or nil
|
||||
local function add(path, base)
|
||||
local ok, img = pcall(Assets.image, path)
|
||||
if not ok then return end
|
||||
-- The STATUS SCREEN overlays the SAME sheets differently, and the layout
|
||||
-- above would break it: engine/pokemon/status_screen.asm:86-97 copies 3
|
||||
-- tiles of hud_1 to $6D, ONE tile of hud_2 to $78 and 2 tiles of hud_3 to
|
||||
-- $76, which leaves $70/$73/$74 as font_battle_extra's <to>, <ID> and № --
|
||||
-- the glyphs the screen prints "№." and "<ID>№/" from
|
||||
-- (constants/charmap.asm:69-73). The battle overlay instead copies
|
||||
-- hud_2+hud_3 contiguously over $73-$78 (engine/battle/core.asm:6520/6532),
|
||||
-- burying № under a line tile, so the status screen needs its own table.
|
||||
-- The line glyphs land identically either way -- $76 ─, $77 ┘, $6F the
|
||||
-- halfarrow -- only the vertical bar moves ($73 in battle, $78 here). #280
|
||||
local STATUS_PAGES = {
|
||||
{ id = "font_battle_extra",
|
||||
image = "assets/generated/battle/font_battle_extra.png", base = 0x62 },
|
||||
{ id = "battle_hud_1",
|
||||
image = "assets/generated/battle/battle_hud_1.png", base = 0x6D },
|
||||
{ id = "battle_hud_3",
|
||||
image = "assets/generated/battle/battle_hud_3.png", base = 0x76, count = 2 },
|
||||
{ id = "battle_hud_2",
|
||||
image = "assets/generated/battle/battle_hud_2.png", base = 0x78, count = 1 },
|
||||
}
|
||||
|
||||
local tiles, statusTiles
|
||||
|
||||
-- Build one code -> {img, quad} map from a page list. `count` caps a page
|
||||
-- at the number of tiles the asm actually copies (the extracted sheets all
|
||||
-- carry 3 tiles; the status overlay uses fewer). A mod's registered page
|
||||
-- swaps the image in either table, but only the battle table honors its
|
||||
-- `base`: the status layout is the asm's own placement, and sliding hud_2
|
||||
-- there would bury № again.
|
||||
local function build(pages, fixedBase)
|
||||
local out = {}
|
||||
local registered = require("src.core.Data").font
|
||||
registered = registered and registered.pages or nil
|
||||
for _, page in ipairs(pages) do
|
||||
local override = registered and registered[page.id]
|
||||
local path, base = page.image, page.base
|
||||
if override and override.image then path = override.image end
|
||||
if not fixedBase and override and override.base then base = override.base end
|
||||
local ok, img = pcall(Assets.image, path)
|
||||
if ok then
|
||||
local iw, ih = img:getDimensions()
|
||||
local per = iw / 8
|
||||
for i = 0, per * (ih / 8) - 1 do
|
||||
tiles[base + i] = {
|
||||
local count = page.count or per * (ih / 8)
|
||||
for i = 0, count - 1 do
|
||||
out[base + i] = {
|
||||
img = img,
|
||||
quad = love.graphics.newQuad((i % per) * 8,
|
||||
math.floor(i / per) * 8, 8, 8, iw, ih),
|
||||
}
|
||||
end
|
||||
end
|
||||
for _, page in ipairs(PAGES) do
|
||||
local override = registered and registered[page.id]
|
||||
add(override and override.image or page.image,
|
||||
override and override.base or page.base)
|
||||
end
|
||||
end
|
||||
local t = tiles[code]
|
||||
return out
|
||||
end
|
||||
|
||||
local function put(t, x, y, tint)
|
||||
if not t then return end
|
||||
local r, g, b, a = love.graphics.getColor()
|
||||
love.graphics.setColor(tint or { 1, 1, 1, 1 })
|
||||
@@ -56,9 +91,23 @@ function HudTiles.tile(code, x, y, tint)
|
||||
love.graphics.setColor(r, g, b, a)
|
||||
end
|
||||
|
||||
function HudTiles.tile(code, x, y, tint)
|
||||
if not tiles then tiles = build(PAGES) end
|
||||
put(tiles[code], x, y, tint)
|
||||
end
|
||||
|
||||
-- The same sheets under the status screen's overlay (STATUS_PAGES). The HP
|
||||
-- bar codes $62-$6D are identical in both layouts, so drawHPBar below keeps
|
||||
-- using the battle table. #280
|
||||
function HudTiles.statusTile(code, x, y, tint)
|
||||
if not statusTiles then statusTiles = build(STATUS_PAGES, true) end
|
||||
put(statusTiles[code], x, y, tint)
|
||||
end
|
||||
|
||||
-- lazy: the next tile() rebuilds every page from the search path
|
||||
function HudTiles.invalidate()
|
||||
tiles = nil
|
||||
statusTiles = nil
|
||||
end
|
||||
|
||||
Assets.register(HudTiles.invalidate)
|
||||
@@ -78,6 +127,11 @@ end
|
||||
-- GetHealthBarColor's thresholds (>= 27 px green, >= 10 yellow, else
|
||||
-- red).
|
||||
--
|
||||
-- segments: how many 8px cells the bar spans (6, the hardware width,
|
||||
-- unless a caller asks for more -- the widescreen battle layout has room
|
||||
-- for a longer bar in the same tiles). The color thresholds scale with
|
||||
-- it so a wider bar turns yellow and red at the same fractions of full.
|
||||
--
|
||||
-- grayFill (#229): when the caller will colorize this bar with an SGB
|
||||
-- region palette (BattleState's zone pass, BATTLE_ZONES pal 0/1 =
|
||||
-- GetHealthBarColor), leave the fill as its raw DMG shade-2 gray and skip
|
||||
@@ -87,18 +141,22 @@ end
|
||||
-- Tinting first would double-apply the color: GREENBAR's fill {0,189,0} has
|
||||
-- red channel 0, so the tint zeroes the whole bar's red and the zone's
|
||||
-- red-channel-keyed shade shader then maps every pixel to color 3 = black.
|
||||
function HudTiles.drawHPBar(data, tx, ty, mon, barType, grayFill)
|
||||
function HudTiles.drawHPBar(data, tx, ty, mon, barType, grayFill, segments)
|
||||
local x, y = tx * 8, ty * 8
|
||||
segments = math.max(1, math.floor(segments or 6))
|
||||
HudTiles.tile(0x71, x, y)
|
||||
HudTiles.tile(0x62, x + 8, y)
|
||||
local px = 0
|
||||
if mon.stats.hp > 0 and mon.hp > 0 then
|
||||
px = math.max(1, math.floor(mon.hp * 48 / mon.stats.hp))
|
||||
px = math.max(1, math.floor(mon.hp * segments * 8 / mon.stats.hp))
|
||||
end
|
||||
local tint
|
||||
if not grayFill then
|
||||
local PaletteFX = require("src.render.PaletteFX")
|
||||
local name = px >= 27 and "GREENBAR" or px >= 10 and "YELLOWBAR" or "REDBAR"
|
||||
local green = math.ceil(27 * segments / 6)
|
||||
local yellow = math.ceil(10 * segments / 6)
|
||||
local name = px >= green and "GREENBAR"
|
||||
or px >= yellow and "YELLOWBAR" or "REDBAR"
|
||||
local colors = PaletteFX.pal(data, name)
|
||||
if colors then
|
||||
local c = colors[3] -- GB color 2 is the fill shade
|
||||
@@ -108,11 +166,11 @@ function HudTiles.drawHPBar(data, tx, ty, mon, barType, grayFill)
|
||||
math.min(1, c[3] / 170), 1 }
|
||||
end
|
||||
end
|
||||
for i = 0, 5 do
|
||||
for i = 0, segments - 1 do
|
||||
local seg = math.min(8, math.max(0, px - i * 8))
|
||||
HudTiles.tile(seg >= 8 and 0x6B or 0x63 + seg, x + 16 + i * 8, y, tint)
|
||||
end
|
||||
HudTiles.tile(HudTiles.capTile(barType), x + 64, y)
|
||||
HudTiles.tile(HudTiles.capTile(barType), x + 16 + segments * 8, y)
|
||||
end
|
||||
|
||||
return HudTiles
|
||||
|
||||
+121
-33
@@ -5,10 +5,11 @@
|
||||
-- then drawn once per zone through a shader that remaps the four DMG
|
||||
-- shades to that zone's palette.
|
||||
--
|
||||
-- Port display option: COLORS (GBC / RED++ / OG / OG INV / GBC INV / CLASSIC)
|
||||
-- transforms every zone's palette at send time via effectiveColors.
|
||||
-- RED++ swaps the named-palette pack for pokered-gbc SuperPalettes
|
||||
-- (data/palettes_gbc.lua), including per-species mon colors.
|
||||
-- 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.
|
||||
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
|
||||
@@ -18,14 +19,18 @@ local shader -- false = unavailable (headless / no shader support)
|
||||
local gbcPack -- 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, RED++ =
|
||||
-- come first (OG RED = 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` keep their save-value ids for back-compat; their LABELS are
|
||||
-- "SGB"/"SGB INV" because that is what the mode actually is (the old "GBC"
|
||||
-- label was a misnomer -- it never was the real Game Boy Color palette).
|
||||
-- `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
|
||||
-- "GBC" label was a misnomer (it never was the real Game Boy Color palette),
|
||||
-- and "ADVANCED" because `redpp` is the richest colorization of the three
|
||||
-- rather than anything Red-specific -- it reads as a misnomer outright on a
|
||||
-- Blue playthrough. Comments elsewhere still call it RED++, the name it has
|
||||
-- carried in this file since it landed.
|
||||
PaletteFX.MODE_LABELS = {
|
||||
ogred = "OG RED", gbc = "SGB", redpp = "RED++", og = "OG",
|
||||
ogred = "OG RED", gbc = "SGB", redpp = "ADVANCED", og = "OG",
|
||||
og_inv = "OG INV", gbc_inv = "SGB INV", classic = "CLASSIC",
|
||||
}
|
||||
PaletteFX.mode = "gbc"
|
||||
@@ -88,6 +93,25 @@ function PaletteFX.ogObj()
|
||||
return PaletteFX.GBC_OBJ, "gbcobj"
|
||||
end
|
||||
|
||||
-- The DMG object ramp every mode except OG RED bakes onto overworld sprites,
|
||||
-- plus its cache group (same two-value contract as ogObj). Entry 1 is never
|
||||
-- read -- SpriteRenderer.getObpImage keys OBJ color 0 to alpha, the hardware's
|
||||
-- unconditional OBJ transparency -- and entries 2..4 are OBJ colors 1..3 sent
|
||||
-- through rOBP0 = $D0 (home/fade.asm FadePal4 `dc 3,1,0,0`, the entry
|
||||
-- LoadGBPal reads while wMapPalOffset is 0): color 1 -> DMG shade 0, color 2
|
||||
-- -> shade 1, color 3 -> shade 3. Leaving the result in DMG shades is the
|
||||
-- whole point: the zone shader then colors a character out of the same map
|
||||
-- palette it colors the ground with, which is all the Super Game Boy can do to
|
||||
-- an OBJ (#301), and the OBP0 lift is what puts Red's cap on the ROUTE
|
||||
-- palette's grass green instead of its light-blue (#150).
|
||||
PaletteFX.OBP0_SHADES = {
|
||||
{ 255, 255, 255 }, { 255, 255, 255 }, { 170, 170, 170 }, { 0, 0, 0 },
|
||||
}
|
||||
|
||||
function PaletteFX.dmgObj()
|
||||
return PaletteFX.OBP0_SHADES, "obp0"
|
||||
end
|
||||
|
||||
local INV_MAP = { [0] = 3, [1] = 2, [2] = 1, [3] = 0 }
|
||||
|
||||
function PaletteFX.shader()
|
||||
@@ -202,32 +226,45 @@ function PaletteFX.usesGbcPack(mode)
|
||||
end
|
||||
|
||||
-- Whether the active mode bakes a per-OBJ palette onto overworld sprites
|
||||
-- (the OBP bake + post-zone redraw path). OG RED and SGB both do: characters
|
||||
-- wear the GBC boot-ROM object palette (PaletteFX.ogObj -- green over Red's red
|
||||
-- background, pink over Blue's blue background), so the player and NPCs carry a
|
||||
-- fixed object color instead of tinting with whatever region palette their
|
||||
-- feet stand over. On real hardware a sprite is an OBJ colored by an OBJ
|
||||
-- palette (color/sprites.asm ColorOverworldSprite), distinct from the BG it
|
||||
-- overlaps -- so Red's cap must stay green in tall grass, not turn the ROUTE
|
||||
-- palette's light-blue (issue #150: SGB region-tinting sent the cap to shade-2
|
||||
-- = light-blue and the character clashed with the grass it should blend into).
|
||||
-- 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. An EARLIER
|
||||
-- attempt at per-sprite SGB color baked GBC_BG (the RED background ramp) onto
|
||||
-- (the OBP bake + post-zone redraw path). OG RED alone does: 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
|
||||
-- tinting with whatever region palette their feet stand over. An EARLIER
|
||||
-- attempt at per-sprite color baked GBC_BG (the RED background ramp) onto
|
||||
-- characters -- that was the "reds coloring on the player/NPCs" bug; the object
|
||||
-- palette is GBC_OBJ (green), so baking it here is the fix, not that
|
||||
-- regression. RED++ colors sprites through the usesGbcPack() path in
|
||||
-- SpriteRenderer instead.
|
||||
-- palette is GBC_OBJ (green), so baking that here is the fix, not that
|
||||
-- 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.
|
||||
--
|
||||
-- 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
|
||||
-- defines ATTR_BLK / PAL_SET / PAL_TRN / MLT_REQ / CHR_TRN / PCT_TRN and
|
||||
-- nothing else), so a character there wears the very palette its map does.
|
||||
-- Baking GBC_OBJ over it was issue #301 ("people in SGB mode are green": the
|
||||
-- boot-ROM greens sat on top of ROUTE's own greens and blues). What issue
|
||||
-- #150 actually caught was the missing rOBP0 step, not a missing object
|
||||
-- palette: overworld OBJs run through OBP0 = $D0 (home/fade.asm FadePal4
|
||||
-- `dc 3,1,0,0`), which lifts OBJ color 1 to DMG shade 0 and color 2 to shade 1,
|
||||
-- so Red's cap lands on the ROUTE palette's shade-1 GRASS GREEN and blends into
|
||||
-- the grass exactly as #150's reference shot shows. Drawn with an identity
|
||||
-- shade map it landed on shade 2 = light-blue instead, which is the clash #150
|
||||
-- reported. SpriteRenderer bakes that OBP0 ramp (PaletteFX.dmgObj) and lets
|
||||
-- the zone shader color the result. RED++ colors sprites through the
|
||||
-- usesGbcPack() path in SpriteRenderer instead.
|
||||
function PaletteFX.usesSpriteObp(mode)
|
||||
mode = mode or PaletteFX.mode
|
||||
return mode == "ogred" or mode == "gbc"
|
||||
return mode == "ogred"
|
||||
end
|
||||
|
||||
-- ------- post-zone sprite redraw (GBC mode)
|
||||
-- ------- post-zone sprite redraw (OG RED)
|
||||
--
|
||||
-- In GBC mode the world canvas still runs through the per-map zone
|
||||
-- In OG RED the world canvas still runs through the whole-screen zone
|
||||
-- shade-remap shader, which would corrupt an OBP-baked sprite's true-color
|
||||
-- pixels. So SpriteRenderer draws the baked sprite into the canvas (its
|
||||
-- pixels. (SGB used to come through here too; it no longer bakes an object
|
||||
-- palette at all, so its characters are colorized by the zone like the ground
|
||||
-- they stand on and never queue a replay -- see usesSpriteObp, #301.) So SpriteRenderer draws the baked sprite into the canvas (its
|
||||
-- pixels come out zone-tinted there) AND records the draw here;
|
||||
-- Renderer:endFrame replays the list on top of the finished zone pass,
|
||||
-- scaled into screen space -- the GBC's OBJ-over-BG compositing, one draw
|
||||
@@ -529,6 +566,34 @@ function PaletteFX.permute(colors, map)
|
||||
colors[map[2] + 1], colors[map[3] + 1] }
|
||||
end
|
||||
|
||||
-- ------- global shade map (rBGP)
|
||||
--
|
||||
-- home/fade.asm's LoadGBPal writes ONE rBGP for the whole screen, indexing
|
||||
-- FadePal4 - wMapPalOffset. A dark cave sets wMapPalOffset = 6
|
||||
-- (home/overworld.asm's ROCK_TUNNEL_1F check; the value rides in the extracted
|
||||
-- field.darkMaps.palOffset), which lands on FadePal2 = `dc 3,3,3,2`: DMG white
|
||||
-- drops to shade 2 and every darker shade goes to shade 3. So the WHOLE
|
||||
-- screen darkens -- the original never cuts a window of light around the
|
||||
-- player (#322) -- and FLASH clears wMapPalOffset again
|
||||
-- (engine/menus/start_sub_menus.asm .flash).
|
||||
--
|
||||
-- Renderer:beginFrame clears this every frame and the state that draws a dark
|
||||
-- map re-arms it while it draws, so it can never outlive the map it belongs
|
||||
-- to: a battle or a full-screen menu draws with no map beneath it and comes
|
||||
-- out lit, exactly like init_battle_variables.asm's `ld [wMapPalOffset], a`
|
||||
-- leaves the original.
|
||||
PaletteFX.DARK_BGP = { [0] = 2, [1] = 3, [2] = 3, [3] = 3 }
|
||||
|
||||
local shadeMap = nil
|
||||
|
||||
function PaletteFX.setShadeMap(map)
|
||||
shadeMap = map
|
||||
end
|
||||
|
||||
function PaletteFX.shadeMap()
|
||||
return shadeMap
|
||||
end
|
||||
|
||||
function PaletteFX.setMode(mode)
|
||||
local prev = PaletteFX.mode
|
||||
local ok = false
|
||||
@@ -598,19 +663,42 @@ end
|
||||
-- Transform a 4-color palette for the active COLORS display mode.
|
||||
-- GBC and RED++ pass the zone colors through (RED++ already swapped the
|
||||
-- pack in pal/monPal); OG* / CLASSIC replace; GBC INV permutes shades.
|
||||
-- The "paper" shade of the active display mode: what a DMG-white background
|
||||
-- pixel ends up as once colorization has run. Every palette in the SGB pack
|
||||
-- carries the same off-white (255,239,255) as color 0, so this is well
|
||||
-- defined for the whole screen rather than per zone. Goes through
|
||||
-- PaletteFX.pal so OG RED short-circuits to the one global boot-ROM BG
|
||||
-- palette and RED++ falls back to the ROM pack, then through effectiveColors
|
||||
-- so the mono and inverted modes get their own paper (CLASSIC's pea green,
|
||||
-- and the dark paper the inverted modes should have). Returns r, g, b in
|
||||
-- 0..1, white if nothing resolves.
|
||||
function PaletteFX.paperShade(data)
|
||||
local base = PaletteFX.pal(data, "GREENBAR") or PaletteFX.GRAYS
|
||||
local colors = PaletteFX.effectiveColors(base) or base
|
||||
local c = colors and colors[1]
|
||||
if not c then return 1, 1, 1 end
|
||||
return c[1] / 255, c[2] / 255, c[3] / 255
|
||||
end
|
||||
|
||||
function PaletteFX.effectiveColors(c)
|
||||
if not c then return nil end
|
||||
local mode = PaletteFX.mode or "gbc"
|
||||
local out = c
|
||||
if mode == "og" then
|
||||
return PaletteFX.GRAYS
|
||||
out = PaletteFX.GRAYS
|
||||
elseif mode == "og_inv" then
|
||||
return PaletteFX.permute(PaletteFX.GRAYS, INV_MAP)
|
||||
out = PaletteFX.permute(PaletteFX.GRAYS, INV_MAP)
|
||||
elseif mode == "classic" then
|
||||
return PaletteFX.CLASSIC
|
||||
out = PaletteFX.CLASSIC
|
||||
elseif mode == "gbc_inv" then
|
||||
return PaletteFX.permute(c, INV_MAP)
|
||||
out = PaletteFX.permute(c, INV_MAP)
|
||||
end
|
||||
return c
|
||||
-- The shade map goes on LAST: rBGP is a hardware register write, so it
|
||||
-- composes on top of whatever colors the display mode settled on -- a dark
|
||||
-- cave has to read dark in CLASSIC's pea greens and in plain DMG grays too
|
||||
-- (#322). permute() is the identity (and returns `out` itself, which the
|
||||
-- mod-graphics parity check leans on) while nothing has armed one.
|
||||
return PaletteFX.permute(out, shadeMap)
|
||||
end
|
||||
|
||||
-- send a 4-color (0-255 RGB) palette to the shade-remap shader, after
|
||||
|
||||
+47
-8
@@ -15,8 +15,15 @@ local Runtime = require("src.mods.Runtime")
|
||||
|
||||
local Renderer = {}
|
||||
|
||||
-- The Game Boy surface. WIDTH/HEIGHT are the classic dimensions every
|
||||
-- screen is laid out in; uiWidth/uiHeight are the surface actually
|
||||
-- allocated this frame, which a state may widen through setUISize (the
|
||||
-- widescreen battle layout asks for 304x144). Anything drawing a normal
|
||||
-- 160x144 screen can keep reading WIDTH/HEIGHT.
|
||||
Renderer.WIDTH = 160
|
||||
Renderer.HEIGHT = 144
|
||||
Renderer.MAX_UI_WIDTH = 640
|
||||
Renderer.MAX_UI_HEIGHT = 576
|
||||
|
||||
-- Whether a value is a real Canvas we can composite. Real LOVE canvases are
|
||||
-- userdata answering typeOf("Canvas"); the headless test stub fakes them as
|
||||
@@ -83,7 +90,8 @@ function Renderer:init()
|
||||
-- 160x144 real pixels, never DPI-scaled: see src/render/PixelCanvas.lua
|
||||
-- (#208). Every canvas below is sized in framebuffer pixels for the same
|
||||
-- reason -- worldViewSize() already works in drawable pixels.
|
||||
self.canvas = PixelCanvas.new(self.WIDTH, self.HEIGHT, "nearest")
|
||||
self.uiWidth, self.uiHeight = self.WIDTH, self.HEIGHT
|
||||
self.canvas = PixelCanvas.new(self.uiWidth, self.uiHeight, "nearest")
|
||||
self.worldCanvas = nil
|
||||
self.worldActive = false
|
||||
-- tilt mode only: a transparent overlay canvas the size of the world
|
||||
@@ -115,7 +123,31 @@ end
|
||||
-- units via / dpiX and / dpiY when drawing.
|
||||
function Renderer:fitScale()
|
||||
local _, _, pw, ph = displayMetrics()
|
||||
return math.max(1, math.floor(math.min(pw / self.WIDTH, ph / self.HEIGHT)))
|
||||
local w, h = self:uiSize()
|
||||
return math.max(1, math.floor(math.min(pw / w, ph / h)))
|
||||
end
|
||||
|
||||
-- the native-pixel UI surface in use right now
|
||||
function Renderer:uiSize()
|
||||
return self.uiWidth or self.WIDTH, self.uiHeight or self.HEIGHT
|
||||
end
|
||||
|
||||
-- Ask for a UI surface of w x h native pixels; the canvas is reallocated
|
||||
-- only when the size actually changes, so the classic path never rebuilds
|
||||
-- it. Sizes are resolved before any state draws (Game:draw) and bounded on
|
||||
-- both ends -- never smaller than the Game Boy screen every layout assumes,
|
||||
-- never large enough for a bad request to allocate an unbounded canvas.
|
||||
function Renderer:setUISize(w, h)
|
||||
if type(w) ~= "number" or type(h) ~= "number"
|
||||
or w < self.WIDTH or h < self.HEIGHT
|
||||
or w > self.MAX_UI_WIDTH or h > self.MAX_UI_HEIGHT then
|
||||
w, h = self.WIDTH, self.HEIGHT
|
||||
end
|
||||
w, h = math.floor(w), math.floor(h)
|
||||
if w == self.uiWidth and h == self.uiHeight and self.canvas then return end
|
||||
if self.canvas and self.canvas.release then self.canvas:release() end
|
||||
self.uiWidth, self.uiHeight = w, h
|
||||
self.canvas = PixelCanvas.new(w, h, "nearest")
|
||||
end
|
||||
|
||||
-- LOVE-unit draw scales endFrame uses for the UI blit: integer framebuffer
|
||||
@@ -174,6 +206,9 @@ function Renderer:beginFrame(transparent)
|
||||
-- draws this one
|
||||
PaletteFX.clearTrueColor()
|
||||
PaletteFX.clearSpriteRedraws()
|
||||
-- rBGP is a per-frame register here: the state that draws a dark map
|
||||
-- re-arms it while it draws (#322), so nothing inherits last frame's
|
||||
PaletteFX.setShadeMap(nil)
|
||||
PaletteFX.setPass("ui")
|
||||
love.graphics.setCanvas(self.canvas)
|
||||
if transparent then
|
||||
@@ -440,10 +475,11 @@ function Renderer:endFrame(zones, worldZones)
|
||||
-- Sx/Sy = LOVE-unit draw scales (may differ when dpiX ≠ dpiY).
|
||||
local Sp = self:fitScale()
|
||||
local Sx, Sy = Sp / dpiX, Sp / dpiY
|
||||
local vpw, vph = self.WIDTH * Sx, self.HEIGHT * Sy
|
||||
local uiw, uih = self:uiSize()
|
||||
local vpw, vph = uiw * Sx, uih * Sy
|
||||
-- Snap the letterbox origin to a framebuffer pixel, then convert to units.
|
||||
local ox = math.floor((pw - self.WIDTH * Sp) / 2) / dpiX
|
||||
local oy = math.floor((ph - self.HEIGHT * Sp) / 2) / dpiY
|
||||
local ox = math.floor((pw - uiw * Sp) / 2) / dpiX
|
||||
local oy = math.floor((ph - uih * Sp) / 2) / dpiY
|
||||
local GBCFX = require("src.render.GBCFX")
|
||||
-- Forced mono/Classic modes still need a whole-screen zone when a state
|
||||
-- exposes no SGB packets (raw DMG canvas), so sendColors can remap.
|
||||
@@ -480,8 +516,11 @@ function Renderer:endFrame(zones, worldZones)
|
||||
love.graphics.setCanvas(present)
|
||||
end
|
||||
-- Default letterbox is black. Battle (and any state that opts in via
|
||||
-- letterboxWhite) fills the voids white so the window matches the
|
||||
-- white battle canvas instead of showing black bars.
|
||||
-- letterboxWhite) fills the voids with the display mode's paper shade, so
|
||||
-- the bars match the canvas they frame instead of showing black. Not a
|
||||
-- literal white: the battle canvas is colorized, and in SGB mode its paper
|
||||
-- is the pack's off-white (255,239,255), which a hardcoded 1,1,1 framed in
|
||||
-- a visibly brighter border.
|
||||
local clearR, clearG, clearB = 0, 0, 0
|
||||
if not self.worldActive then
|
||||
local ok, Game = pcall(require, "src.core.Game")
|
||||
@@ -489,7 +528,7 @@ function Renderer:endFrame(zones, worldZones)
|
||||
local base = stack and stack.visibleBase and stack:visibleBase()
|
||||
local state = base and stack.states and stack.states[base]
|
||||
if state and state.letterboxWhite then
|
||||
clearR, clearG, clearB = 1, 1, 1
|
||||
clearR, clearG, clearB = PaletteFX.paperShade(Game and Game.data)
|
||||
end
|
||||
end
|
||||
love.graphics.setColor(clearR, clearG, clearB, 1)
|
||||
|
||||
@@ -18,10 +18,14 @@ local function getImage(path)
|
||||
return imageCache[path]
|
||||
end
|
||||
|
||||
-- RED++ overworld sprite OBJ-palette recolor (color/sprites.asm
|
||||
-- ColorOverworldSprite), baked into an ImageData like BattleState's mon-pic
|
||||
-- palette bake (src/battle/BattleState.lua getImage): CPU-remap the 4 DMG
|
||||
-- shades to the resolved OBP colors, cached per (image path, group).
|
||||
-- Overworld sprite OBJ-palette recolor, baked into an ImageData like
|
||||
-- BattleState's mon-pic palette bake (src/battle/BattleState.lua getImage):
|
||||
-- CPU-remap the 4 DMG shades to the resolved OBP colors, cached per
|
||||
-- (image path, group). Every colour mode goes through it now (#301): RED++
|
||||
-- resolves real per-sprite colours (color/sprites.asm ColorOverworldSprite),
|
||||
-- OG RED the one boot-ROM object palette, and everything else the plain
|
||||
-- rOBP0 = $D0 shade lift (PaletteFX.dmgObj) that leaves the sprite in DMG
|
||||
-- shades for the zone shader to colour.
|
||||
--
|
||||
-- Sprite sheets carry no real alpha (every pixel, including the
|
||||
-- background, is opaque -- confirmed by sampling the extracted PNGs): the
|
||||
@@ -110,7 +114,13 @@ function SpriteRenderer:resolveImage()
|
||||
-- collide in obpCache) -- see issue #155
|
||||
return getObpImage(self.def.image, PaletteFX.ogObj())
|
||||
end
|
||||
return self.image
|
||||
-- Every other mode (SGB and the mono/inverted novelties) leaves the sprite
|
||||
-- in DMG shades so the zone shader colors it out of the map's own palette,
|
||||
-- but still bakes rOBP0 = $D0 in and keys OBJ color 0 to alpha -- the two
|
||||
-- things a raw sheet blit cannot express (#301, #150). The sheets carry no
|
||||
-- real alpha (see getObpImage), so returning self.image here would put an
|
||||
-- opaque white box behind every character a pipeline textures.
|
||||
return getObpImage(self.def.image, PaletteFX.dmgObj())
|
||||
end
|
||||
|
||||
-- facing: down/up/left/right; walkPhase: 0 stand, 1 walk; flip: alternate
|
||||
@@ -152,6 +162,17 @@ function SpriteRenderer:draw(px, py, camX, camY, facing, walkPhase, stepFlip)
|
||||
-- top.
|
||||
image = getObpImage(self.def.image, PaletteFX.ogObj())
|
||||
redraw = true
|
||||
else
|
||||
-- SGB and the mono/inverted modes (and OG RED's tilt upright pass, which
|
||||
-- has no post-zone replay to restore a bake): the sprite stays in DMG
|
||||
-- shades -- rOBP0 = $D0 baked in, OBJ color 0 keyed to alpha -- and the
|
||||
-- whole-canvas zone shader colors it with the map's palette. That is the
|
||||
-- only thing the Super Game Boy can do to an OBJ, since pokered never
|
||||
-- sends the OBJ_TRN packet that would give sprites palettes of their own
|
||||
-- (data/sgb/sgb_packets.asm defines ATTR_BLK / PAL_SET / PAL_TRN /
|
||||
-- MLT_REQ / CHR_TRN / PCT_TRN and nothing else). No redraw is queued:
|
||||
-- being colorized by the zone IS the point (#301).
|
||||
image = getObpImage(self.def.image, PaletteFX.dmgObj())
|
||||
end
|
||||
-- single-frame sprites (item balls, fossils...) have one fixed pose;
|
||||
-- still 3-frame sprites turn to face (the nurse at her machine,
|
||||
|
||||
+20
-1
@@ -27,6 +27,11 @@ local MAX_COLS = 18
|
||||
-- WaitForSoundToFinish; nil headless), then auto.delay frames pass
|
||||
-- (default 3, Delay3) and the box pops itself + calls onDone. No
|
||||
-- blinking cursor, no Press_AB beep.
|
||||
-- opts.auto.tick, when given, runs once per frame for as long as the box
|
||||
-- is held open. It is the only per-frame hook a script has while a box is
|
||||
-- up: StateStack updates the top state only, so the overworld and its
|
||||
-- ScriptRunner are frozen underneath (the Pewter JIGGLYPUFF dance drives
|
||||
-- its spin off it, data/scripts/story5.lua, #249).
|
||||
function TextBox.new(game, text, onDone, opts)
|
||||
local self = setmetatable({}, TextBox)
|
||||
self.game = game
|
||||
@@ -179,6 +184,13 @@ function TextBox:update(dt)
|
||||
self.autoSrc = self.auto.sound and self.auto.sound() or nil
|
||||
self.autoTimer = 0
|
||||
end
|
||||
-- auto.tick: one call per frame for as long as the box is held open,
|
||||
-- run before the autoSrc gate so a tick-driven gate can clear itself
|
||||
-- on the same frame. It is the only per-frame hook a script gets
|
||||
-- while a box is up, since StateStack updates the top state only and
|
||||
-- the overworld underneath is frozen (the Pewter JIGGLYPUFF spin,
|
||||
-- #249).
|
||||
if self.auto.tick then self.auto.tick() end
|
||||
if self.autoSrc and self.autoSrc.isPlaying and self.autoSrc:isPlaying() then
|
||||
return -- the cry is still sounding (WaitForSoundToFinish)
|
||||
end
|
||||
@@ -294,10 +306,17 @@ function TextBox:draw()
|
||||
self.scrollPx = self.scrollPx - 2
|
||||
if self.scrollPx <= 0 then self.scrollPx = nil end
|
||||
end
|
||||
-- Only the retained line carries the offset: it slides up from where it
|
||||
-- already sat (line2Y) to line1Y. The incoming line is drawn at its home
|
||||
-- row instead, because offsetting it too put fresh glyphs 8px low -- on the
|
||||
-- box's bottom border -- whenever the typewriter beat the 4-frame slide
|
||||
-- (#314). The sub-tile slide is ours to begin with: ScrollTextUpOneLine
|
||||
-- (home/text.asm:283) copies the rows up whole and waits 5 frames, so
|
||||
-- nothing in the original is ever drawn between two rows.
|
||||
local off = self.scrollPx or 0
|
||||
local ys = { self.line1Y, self.line2Y }
|
||||
for i, line in ipairs(self.shown) do
|
||||
local y = (ys[i] or self.line2Y) + off
|
||||
local y = (ys[i] or self.line2Y) + (i == 1 and off or 0)
|
||||
for j, code in ipairs(line) do
|
||||
Font.drawCode(code, self.textX + (j - 1) * 8, y)
|
||||
end
|
||||
|
||||
+57
-18
@@ -11,9 +11,9 @@ TileRenderer.__index = TileRenderer
|
||||
local BORDER_BLOCKS = 3 -- ring width; > half a screen (2.5 blocks)
|
||||
|
||||
-- OVERWORLD maps fill beyond-edge space from save.options.voidFill:
|
||||
-- trees (default) — solid tree wall $0F (Viridian/Cerulean/Celadon)
|
||||
-- water — solid water $43 (Cinnabar/Route 19 border block)
|
||||
-- black — solid black (no tiled metatile)
|
||||
-- trees (default) -- solid tree wall $0F (Viridian/Cerulean/Celadon)
|
||||
-- water -- solid water $43 (Cinnabar/Route 19 border block)
|
||||
-- black -- solid black (no tiled metatile)
|
||||
-- Other tilesets keep their designated border (interiors stay black/void).
|
||||
local TREE_WALL_BLOCK = 0x0F
|
||||
local WATER_BORDER_BLOCK = 0x43
|
||||
@@ -475,6 +475,13 @@ function TileRenderer.new(map, data)
|
||||
-- match the atlas's static tiles instead of showing raw grayscale
|
||||
gbcCtx = { tilesetId = map.tileset.id, mapId = map.id, key = "#gbc:" .. 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
|
||||
-- context rather than re-deriving it per draw.
|
||||
gbcCtx.imagePath = map.tileset.image
|
||||
gbcCtx.perRow = map.tileset.tilesPerRow
|
||||
self.gbcCtx = gbcCtx
|
||||
self.gbcKeyed = {}
|
||||
end
|
||||
end
|
||||
-- a full-color atlas colors everything it paints, ring and border fill
|
||||
@@ -672,17 +679,48 @@ local function getColor0KeyShader()
|
||||
return color0KeyShader or nil
|
||||
end
|
||||
|
||||
-- draw a cell's bottom tile row without touching the shader (the caller
|
||||
-- owns it). drawCellBottom wraps this with the color-0 key; tilt mode's
|
||||
-- upright pass wraps it with a color-0-keyed palette shader instead
|
||||
-- (PaletteFX.keyedShader) so the feet patch is colorized like the ground.
|
||||
-- RED++ (COLORS=ADVANCED): the color-0 key, BAKED instead of tested for.
|
||||
local function getKeyedTile(self, tile)
|
||||
local ctx = self.gbcCtx
|
||||
local cached = self.gbcKeyed[tile]
|
||||
if cached ~= nil then return cached or nil end
|
||||
local img = false
|
||||
if ctx.groupColors and love.image and love.image.newImageData then
|
||||
local group = PaletteFX.worldGroupAt(ctx.tilesetId, ctx.mapId, tile)
|
||||
local colors = group and ctx.groupColors[group + 1]
|
||||
local src = Assets.imageData(ctx.imagePath)
|
||||
local ox = (tile % ctx.perRow) * 8
|
||||
local oy = math.floor(tile / ctx.perRow) * 8
|
||||
local out = love.image.newImageData(8, 8)
|
||||
for py = 0, 7 do
|
||||
for px = 0, 7 do
|
||||
local r, g, b, a = src:getPixel(ox + px, oy + py)
|
||||
-- read shade 0 off the RAW sheet, on recolorSample's own cutoff, so
|
||||
-- the keyed pixels are exactly the ones the shader path keys
|
||||
local shade0 = r > 0.83
|
||||
r, g, b, a = recolorSample(r, g, b, a, colors)
|
||||
out:setPixel(px, py, r, g, b, shade0 and 0 or a)
|
||||
end
|
||||
end
|
||||
img = love.graphics.newImage(out)
|
||||
end
|
||||
self.gbcKeyed[tile] = img
|
||||
return img or nil
|
||||
end
|
||||
|
||||
function TileRenderer:drawCellBottomRaw(cx, cy, camX, camY)
|
||||
local ty = cy * 2 + 1
|
||||
for i = 0, 1 do
|
||||
local tx = cx * 2 + i
|
||||
local quad = self.quads[self.map:tileAt(tx, ty)]
|
||||
if quad then
|
||||
love.graphics.draw(self.image, quad, tx * 8 - camX, ty * 8 - camY)
|
||||
local tile = self.map:tileAt(tx, ty)
|
||||
local keyed = tile and self.gbcCtx and getKeyedTile(self, tile)
|
||||
if keyed then
|
||||
love.graphics.draw(keyed, tx * 8 - camX, ty * 8 - camY)
|
||||
else
|
||||
local quad = self.quads[tile]
|
||||
if quad then
|
||||
love.graphics.draw(self.image, quad, tx * 8 - camX, ty * 8 - camY)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -690,7 +728,9 @@ end
|
||||
-- redraw a cell's bottom tile row (tall grass hides the lower half of
|
||||
-- sprites standing in it, like the GB sprite-priority trick)
|
||||
function TileRenderer:drawCellBottom(cx, cy, camX, camY)
|
||||
local shader = getColor0KeyShader()
|
||||
-- the RED++ path is pre-keyed; the white test would be a no-op there at
|
||||
-- best, and a false hit on some other group's near-white color 0 at worst
|
||||
local shader = not self.gbcCtx and getColor0KeyShader() or nil
|
||||
if shader then love.graphics.setShader(shader) end
|
||||
self:drawCellBottomRaw(cx, cy, camX, camY)
|
||||
if shader then love.graphics.setShader() end
|
||||
@@ -712,13 +752,6 @@ function TileRenderer:markCellBottomRedraw(cx, cy, camX, camY, colors)
|
||||
end
|
||||
end
|
||||
|
||||
-- Window cover for the static tile layer. Refill the reusable window batch
|
||||
-- (and the per-entry animated batches) only when the camera has scrolled past
|
||||
-- what they already cover; a small margin keeps small scrolls free. Cost
|
||||
-- scales with the view, never the map -- crossing a seam or warping in builds
|
||||
-- nothing. The beyond-body area (what the old 3-block ring drew) is painted
|
||||
-- by :drawBorderFill, whose world-aligned border-block tiling is identical
|
||||
-- there, so only body tiles are gathered here.
|
||||
local WINDOW_MARGIN = 8 -- tiles of slack kept around the view between refills
|
||||
|
||||
function TileRenderer:ensureWindow(camX, camY, vw, vh)
|
||||
@@ -873,6 +906,12 @@ end
|
||||
-- atlas -- is unique to this map (gbcAtlasCache is keyed by map id).
|
||||
function TileRenderer:release()
|
||||
self:releaseBatches()
|
||||
if self.gbcKeyed then
|
||||
-- baked per instance, shared with nobody (see getKeyedTile)
|
||||
for _, img in pairs(self.gbcKeyed) do safeRelease(img) end
|
||||
self.gbcKeyed = nil
|
||||
self.gbcCtx = nil
|
||||
end
|
||||
if self.gbcAtlas and self.image then
|
||||
local key = self.map.tileset.image .. "#gbc:" .. self.map.id
|
||||
if gbcAtlasCache[key] == self.image then gbcAtlasCache[key] = nil end
|
||||
|
||||
@@ -38,6 +38,8 @@ local NAME_LENGTH = 11
|
||||
local PARTY_LENGTH = 6
|
||||
local MONS_PER_BOX = 20
|
||||
local NUM_BADGES = 8
|
||||
local NUM_CITY_MAPS = 11 -- PALLET_TOWN..SAFFRON_CITY, the bit width of
|
||||
-- wTownVisitedFlag (constants/map_constants.asm)
|
||||
local BOX_STRUCT_SIZE = 33 -- Species,HP,Level,Status,Type1,Type2,CatchRate,
|
||||
-- Moves x4,OTID,Exp x3,HPExp,AtkExp,DefExp,
|
||||
-- SpdExp,SpcExp,DVs,PP x4 (macros/ram.asm box_struct)
|
||||
@@ -65,6 +67,22 @@ O.numPcItems = O.mainData + 579 -- 1B
|
||||
O.pcItems = O.mainData + 580 -- 101B (50 x (id,qty) + $FF term)
|
||||
O.currentBoxNum = O.mainData + 681 -- 1B (bits 0-6: box 0-11, bit 7: unused here)
|
||||
O.coins = O.mainData + 685 -- 2B BCD
|
||||
-- wTownVisitedFlag (ram/wram.asm:2057): the FLY destination set, a
|
||||
-- flag_array NUM_CITY_MAPS whose bit index IS the town's map index (see the
|
||||
-- decode note). Triangulated from both neighbours, which agree exactly:
|
||||
-- backwards from the checksum-covered, independently derived O.eventFlags
|
||||
-- below by summing every wram.asm declaration between the two labels --
|
||||
-- 2 (wTownVisitedFlag) + 2 (wSafariSteps) + 1 + 1 + 2 + 1 + 1 + 1 + 1 + 1
|
||||
-- + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 8 + 1 + 1 + 1 (wBeatGymFlags) + 1 + 1 + 1
|
||||
-- (wStatusFlags3, aliased wCableClubDestinationMap) + 1 + 1 + 1 + 1 + 1 + 1
|
||||
-- + 1 + 1 + 1 (wMovementFlags) + 2 + 2 + 1 + 1 + 2 + 1 + 1 + 2 + 1 + 1 + 2
|
||||
-- = 60, so 1104 - 60 = 1044; and forwards from O.coins (mainData + 685)
|
||||
-- with 2 (wPlayerCoins) + 32 (wToggleableObjectFlags, flag_array $100) + 7
|
||||
-- + 1 (wSavedSpriteImageIndex) + 33 (wToggleableObjectList) + 1 + 200
|
||||
-- (wGameProgressFlags..End) + 56 + 14 (wObtainedHiddenItemsFlags,
|
||||
-- flag_array MAX_HIDDEN_ITEMS = 112) + 2 (wObtainedHiddenCoinsFlags) + 1
|
||||
-- (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)
|
||||
-- Play time (wPlayTimeHours/Maxed/Minutes/Seconds/Frames) lives INSIDE the
|
||||
-- sMainData window (wMainDataStart..wMainDataEnd is copied verbatim into
|
||||
@@ -232,12 +250,17 @@ local function encodeName(buf, off, len, text)
|
||||
setByte(buf, off + i, charmap.byToken[ch] or charmap.byToken["?"] or 0x50)
|
||||
i, pos = i + 1, pos + clen
|
||||
end
|
||||
-- Write exactly ONE $50 terminator and then STOP. The bytes after it are
|
||||
-- left untouched: when encoding over a template they stay as the original
|
||||
-- save's post-terminator padding (so an unchanged name round-trips
|
||||
-- byte-identical), and on a templateless export they stay zero-filled. The
|
||||
-- game reads a name only up to the first $50, so whatever follows is inert.
|
||||
-- Write exactly ONE $50 terminator and then $50-pad the rest of the
|
||||
-- field: every real save the naming screen ever wrote fills the tail
|
||||
-- with $50, and a zero tail is what PKHeX renders as garbage glyphs
|
||||
-- after the name ("JOHN{}", #206). Template bytes that are NOT zero
|
||||
-- stay untouched, so an unchanged name still round-trips
|
||||
-- byte-identical and stale template data survives.
|
||||
if i < len then setByte(buf, off + i, 0x50) end
|
||||
for j = i + 1, len - 1 do
|
||||
local cur = buf[off + j + 1]
|
||||
if cur == nil or cur:byte() == 0 then setByte(buf, off + j, 0x50) end
|
||||
end
|
||||
end
|
||||
|
||||
-- ------------------------------------------------------------------
|
||||
@@ -375,6 +398,40 @@ function GenSave.crosswalks(data)
|
||||
}
|
||||
end
|
||||
|
||||
-- Gen1 has no "is nicknamed" bit. An un-nicknamed mon literally stores its
|
||||
-- species' standard name in the nickname slot: engine/menus/naming_screen.asm
|
||||
-- AskName's .declinedNickname copies wNameBuffer (the MonsterNames entry
|
||||
-- GetMonName just loaded) straight over the mon's nickname field. The game
|
||||
-- recovers "was it nicknamed?" by comparing the two --
|
||||
-- engine/pokemon/evos_moves.asm RenameEvolvedMon rewrites the name on
|
||||
-- evolution only while the stored one still equals the PRE-evolution
|
||||
-- species' standard name ("Renames the mon to its new, evolved form's
|
||||
-- standard name unless it had a nickname, in which case the nickname is
|
||||
-- kept"). This project models that state as mon.nickname == nil instead:
|
||||
-- every display site reads `mon.nickname or def.name` and
|
||||
-- src/pokemon/Evolution.lua deliberately never touches the field. So the
|
||||
-- two conventions must be translated at this boundary, or an imported
|
||||
-- SQUIRTLE still reads "SQUIRTLE" after it becomes a WARTORTLE and an
|
||||
-- engine-origin export writes the species CONSTANT ("NIDORAN_M", whose "_"
|
||||
-- has no charmap glyph and encodes as "?") where the cartridge keeps the
|
||||
-- display name. Both read back as a forced nickname (#257).
|
||||
--
|
||||
-- def.name is byte-for-byte what the cartridge stores: tools/extract/
|
||||
-- pokemon.py parse_names reads pokered's data/pokemon/names.asm, the very
|
||||
-- table GetMonName loads from, and all 151 names round-trip exactly through
|
||||
-- src/save_convert/data/charmap.lua, so the equality test below is exact
|
||||
-- and never mis-fires on a name the charmap mangles.
|
||||
local function speciesName(cw, species)
|
||||
local def = species and cw.speciesDefs[species]
|
||||
return (def and def.name) or species or ""
|
||||
end
|
||||
|
||||
-- stored fixed-length name -> save.lua nickname (nil when never nicknamed)
|
||||
local function importedNickname(cw, species, stored)
|
||||
if stored == speciesName(cw, species) then return nil end
|
||||
return stored
|
||||
end
|
||||
|
||||
-- ------------------------------------------------------------------
|
||||
-- Mon struct (box_struct is a byte-for-byte prefix of party_struct;
|
||||
-- decodeMon reads the box_struct fields, then Level+Stats if isParty).
|
||||
@@ -573,7 +630,10 @@ function GenSave.decode(bytes, data, opts)
|
||||
local mon = decodeMon(bytes, O.partyMons + i * PARTY_STRUCT_SIZE, true, cw)
|
||||
if mon then
|
||||
mon.ot = decodeName(bytes, O.partyMonOT + i * NAME_LENGTH, NAME_LENGTH)
|
||||
mon.nickname = decodeName(bytes, O.partyMonNicks + i * NAME_LENGTH, NAME_LENGTH)
|
||||
-- a stored name equal to the species' standard name means NOT
|
||||
-- nicknamed, which this project spells as nil (#257)
|
||||
mon.nickname = importedNickname(cw, mon.species,
|
||||
decodeName(bytes, O.partyMonNicks + i * NAME_LENGTH, NAME_LENGTH))
|
||||
save.party[#save.party + 1] = mon
|
||||
end
|
||||
end
|
||||
@@ -586,7 +646,8 @@ function GenSave.decode(bytes, data, opts)
|
||||
local mon = decodeMon(bytes, base + 22 + i * BOX_STRUCT_SIZE, false, cw)
|
||||
if mon then
|
||||
mon.ot = decodeName(bytes, base + 22 + MONS_PER_BOX * BOX_STRUCT_SIZE + i * NAME_LENGTH, NAME_LENGTH)
|
||||
mon.nickname = decodeName(bytes, base + 22 + MONS_PER_BOX * (BOX_STRUCT_SIZE + NAME_LENGTH) + i * NAME_LENGTH, NAME_LENGTH)
|
||||
mon.nickname = importedNickname(cw, mon.species,
|
||||
decodeName(bytes, base + 22 + MONS_PER_BOX * (BOX_STRUCT_SIZE + NAME_LENGTH) + i * NAME_LENGTH, NAME_LENGTH))
|
||||
table.insert(save.boxes[boxNum], mon)
|
||||
end
|
||||
end
|
||||
@@ -610,6 +671,28 @@ function GenSave.decode(bytes, data, opts)
|
||||
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
|
||||
-- from 0, storing b ("the map number of the town if it has been visited"),
|
||||
-- so bit 0 = map 0 = PALLET_TOWN, LSB first; and
|
||||
-- engine/overworld/toggleable_objects.asm
|
||||
-- MarkTownVisitedAndLoadToggleableObjects sets bit [wCurMap] on entry for
|
||||
-- any map below FIRST_ROUTE_MAP. Map indices 0-10 in
|
||||
-- data/generated/maps.lua match PALLET_TOWN..SAFFRON_CITY one for one.
|
||||
-- This project keeps the same set as save.visited[mapId]
|
||||
-- (src/ui/FlyMenu.lua, src/ui/TownMap.lua, and the only writer,
|
||||
-- src/world/OverworldController.lua's mark-on-map-entry), which an import
|
||||
-- used to leave nil: FLY then listed only the town the player happened to
|
||||
-- be standing in when the save was loaded (#263).
|
||||
save.visited = {}
|
||||
for townIdx = 0, NUM_CITY_MAPS - 1 do
|
||||
if bitGet(bytes, O.townVisited, townIdx) then
|
||||
local townId = cw.mapsByIndex[townIdx]
|
||||
if townId then save.visited[townId] = true end
|
||||
end
|
||||
end
|
||||
|
||||
-- map + position
|
||||
local mapIdx = u8(bytes, O.curMap)
|
||||
local mapId = cw.mapsByIndex[mapIdx]
|
||||
@@ -656,6 +739,19 @@ function GenSave.encode(save, data, template)
|
||||
encodeName(buf, O.playerName, NAME_LENGTH, (save.player and save.player.name) or "RED")
|
||||
encodeName(buf, O.rivalName, NAME_LENGTH, (save.player and save.player.rival) or "BLUE")
|
||||
setU16be(buf, O.playerId, (save.player and save.player.id) or 0)
|
||||
-- wOptions (engine/menus/main_menu.asm InitOptions): bit 7 = battle
|
||||
-- effects OFF, bit 6 = SET style, bits 2-0 = text speed -- the recomp's
|
||||
-- textSpeed 1/3/5 are pokered's exact FAST/MEDIUM/SLOW values
|
||||
-- (SaveData.defaultOptions). Templateless exports only: with a
|
||||
-- template the byte survives untouched (the round-trip invariant), and
|
||||
-- decode() never reads it back anyway.
|
||||
if not src then
|
||||
local opts = save.options or {}
|
||||
local ob = (tonumber(opts.textSpeed) or 3) % 8
|
||||
if opts.battleStyle == "set" then ob = bit.bor(ob, 0x40) end
|
||||
if opts.animations == false then ob = bit.bor(ob, 0x80) end
|
||||
setByte(buf, O.options, ob)
|
||||
end
|
||||
setBcd(buf, O.money, 3, math.min(save.money or 0, 999999))
|
||||
setBcd(buf, O.coins, 2, math.min(save.coins or 0, 9999))
|
||||
|
||||
@@ -700,6 +796,18 @@ function GenSave.encode(save, data, template)
|
||||
end
|
||||
end
|
||||
|
||||
-- FLY destinations back into wTownVisitedFlag (see the decode note), so a
|
||||
-- save exported from this port is flyable on hardware (#263). A save
|
||||
-- table with no `visited` key at all says nothing about the set, so leave
|
||||
-- the template's bits exactly as they are rather than blanking every town.
|
||||
if type(save.visited) == "table" then
|
||||
for townIdx = 0, NUM_CITY_MAPS - 1 do
|
||||
local townId = cw.mapsByIndex[townIdx]
|
||||
bitSet(buf, O.townVisited, townIdx,
|
||||
(townId and save.visited[townId]) and true or false)
|
||||
end
|
||||
end
|
||||
|
||||
-- party
|
||||
local party = save.party or {}
|
||||
local partyN = math.min(#party, PARTY_LENGTH)
|
||||
@@ -710,8 +818,10 @@ function GenSave.encode(save, data, template)
|
||||
setByte(buf, O.partySpecies + i, cw.pokemonIndex[mon.species] or 0)
|
||||
encodeName(buf, O.partyMonOT + i * NAME_LENGTH, NAME_LENGTH,
|
||||
mon.ot or (save.player and save.player.name) or "RED")
|
||||
-- no nickname stores the species' DISPLAY name, not its ROM constant id
|
||||
-- ("NIDORAN_M" would charmap the "_" to "?") (#257)
|
||||
encodeName(buf, O.partyMonNicks + i * NAME_LENGTH, NAME_LENGTH,
|
||||
mon.nickname or mon.species or "")
|
||||
mon.nickname or speciesName(cw, mon.species))
|
||||
end
|
||||
-- $FF-terminate the species index list right after the last real mon. The
|
||||
-- struct, OT-name and nickname bytes of the empty slots past partyN are left
|
||||
@@ -734,7 +844,7 @@ function GenSave.encode(save, data, template)
|
||||
encodeName(buf, base + 22 + MONS_PER_BOX * BOX_STRUCT_SIZE + i * NAME_LENGTH, NAME_LENGTH,
|
||||
mon.ot or (save.player and save.player.name) or "RED")
|
||||
encodeName(buf, base + 22 + MONS_PER_BOX * (BOX_STRUCT_SIZE + NAME_LENGTH) + i * NAME_LENGTH, NAME_LENGTH,
|
||||
mon.nickname or mon.species or "")
|
||||
mon.nickname or speciesName(cw, mon.species)) -- #257, as above
|
||||
end
|
||||
-- $FF-terminate the species list after the last real mon; empty slots past
|
||||
-- n keep their template bytes (byte-identical round-trip) or zero (fresh
|
||||
|
||||
@@ -107,6 +107,7 @@ local function defaultsSave()
|
||||
modData = {},
|
||||
options = {
|
||||
textSpeed = 3, animations = true, battleStyle = "shift",
|
||||
battleLayout = "og",
|
||||
ruleset = "gen1_faithful", musicVol = 7, sfxVol = 7, musicFilter = 0,
|
||||
speed = 1, colors = "gbc", tilt = 0, gbcfx = 0,
|
||||
videoMode = "windowed", mods = {},
|
||||
|
||||
+30
-6
@@ -41,10 +41,17 @@ local function showMessages(game, msgs, onDone)
|
||||
game.stack:push(TextBox.new(game, table.concat(msgs, "\f"), onDone))
|
||||
end
|
||||
|
||||
-- run the use-flow for an item on a chosen target
|
||||
local function useOn(game, battle, id, target, list, moveIndex)
|
||||
-- run the use-flow for an item on a chosen target. `picker` is the party
|
||||
-- menu when it was opened with keepOpen (HP medicine only): it is still on
|
||||
-- the stack, so every exit that prints has to close it afterwards. For
|
||||
-- every other item the picker popped itself first and closePicker's identity
|
||||
-- check makes it a no-op (#252).
|
||||
local function useOn(game, battle, id, target, list, moveIndex, picker)
|
||||
local result, payload, extra = ItemEffects.use(game.data, game.save, id, target,
|
||||
battle, moveIndex, game.overworld)
|
||||
local function closePicker()
|
||||
if picker then picker:close() end
|
||||
end
|
||||
|
||||
-- field POKé FLUTE: play the tune, then the no-effect text
|
||||
if result == "flute_field" then
|
||||
@@ -288,16 +295,29 @@ local function useOn(game, battle, id, target, list, moveIndex)
|
||||
end
|
||||
end
|
||||
list.index = math.min(list.index, math.max(1, #list.items))
|
||||
-- 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
|
||||
picker:animateTo(target, extra.healedFrom, function()
|
||||
showMessages(game, payload, closePicker)
|
||||
end)
|
||||
return
|
||||
end
|
||||
if battle then
|
||||
list:close()
|
||||
showMessages(game, payload, function() battle:itemUsed({}) end)
|
||||
else
|
||||
showMessages(game, payload)
|
||||
showMessages(game, payload, closePicker)
|
||||
end
|
||||
return
|
||||
end
|
||||
|
||||
showMessages(game, payload) -- failed
|
||||
-- .healingItemNoEffect prints over the still-drawn party menu too, so the
|
||||
-- refusal closes the picker the same way (#252)
|
||||
showMessages(game, payload, closePicker) -- failed
|
||||
end
|
||||
|
||||
local function pickTargetAndUse(game, battle, id, list)
|
||||
@@ -308,9 +328,13 @@ local function pickTargetAndUse(game, battle, id, list)
|
||||
local def = game.data.items[id]
|
||||
local opts = {
|
||||
pickOnly = true,
|
||||
onSwitch = function(mon)
|
||||
-- HP medicine animates its bar with the picker still up (#252). Only
|
||||
-- out of battle: the in-battle tail closes the bag list underneath
|
||||
-- first, which needs the picker already gone.
|
||||
keepOpen = (not battle) and ItemEffects.healsHP(id),
|
||||
onSwitch = function(mon, picker)
|
||||
if not wantsMove then
|
||||
useOn(game, battle, id, mon, list)
|
||||
useOn(game, battle, id, mon, list, nil, picker)
|
||||
return
|
||||
end
|
||||
local rows = {}
|
||||
|
||||
@@ -7,6 +7,7 @@ local Font = require("src.render.Font")
|
||||
local ListMenu = require("src.ui.ListMenu")
|
||||
local Menu = require("src.ui.Menu")
|
||||
local Party = require("src.pokemon.Party")
|
||||
local Stats = require("src.pokemon.Stats")
|
||||
local TextBox = require("src.render.TextBox")
|
||||
local Strings = require("src.core.Strings")
|
||||
|
||||
@@ -73,6 +74,14 @@ local function withdraw(game)
|
||||
list.footer = "The party is full!"
|
||||
return
|
||||
end
|
||||
-- add_mon.asm _MoveMon's tail ("returning mon to party, compute
|
||||
-- level and stats"): a box mon carries no stat block, because
|
||||
-- box_struct stops before MON_LEVEL/MON_STATS, so the party copy
|
||||
-- runs CalcStats. Without it a mon decoded out of an imported .sav
|
||||
-- reaches the party menu with mon.stats nil and the HP bar draw
|
||||
-- nil-indexes it (#304, same family as #233). Already-shaped mons
|
||||
-- (everything the engine itself put in a box) pass through.
|
||||
Stats.ensure(game.data.pokemon[mon.species], mon)
|
||||
table.remove(box, item.value)
|
||||
table.insert(game.save.party, mon)
|
||||
local name = monName(game, mon)
|
||||
|
||||
@@ -37,7 +37,11 @@ function DexEntryMenu.new(game, speciesOrOpts)
|
||||
self.def = game.data.pokemon[species]
|
||||
local path = require("src.pokemon.Sprites").path(game.data, species, "front",
|
||||
{ kind = "dex" })
|
||||
local ok, img = path and pcall(love.graphics.newImage, path)
|
||||
-- `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
|
||||
require("src.core.Sound").playCry(game.data, species)
|
||||
return self
|
||||
|
||||
@@ -20,6 +20,22 @@ EvolutionState.isOpaque = true
|
||||
-- SGB: SetPal_PokemonWholeScreen for the mon on display
|
||||
function EvolutionState:sgbPalettes(game)
|
||||
local P = require("src.render.PaletteFX")
|
||||
-- engine/movie/evolution.asm EvolveMon runs the back-and-forth flash with
|
||||
-- the whole screen on PAL_BLACK -- `ld c, 1 ; set PAL_BLACK instead of mon
|
||||
-- palette` right before .animLoop, then `ld c, 0` again at .done once the
|
||||
-- loop is over -- so both forms read as silhouettes while they trade places
|
||||
-- and only the settled form wears a mon palette (#279). PAL_BLACK is not
|
||||
-- four blacks: data/sgb/sgb_palettes.asm gives it `RGB 31,29,31, 07,07,07,
|
||||
-- 02,03,03, 03,02,02`, the usual paper white with the three darker shades
|
||||
-- crushed, which is why a hardware capture shows a dark mon on an unchanged
|
||||
-- background rather than an all-black screen. Going through P.pal keeps
|
||||
-- every COLORS mode honest for free: OG RED short-circuits every name to the
|
||||
-- one global boot-ROM palette (a Game Boy Color ignores the SGB packets, so
|
||||
-- it never blacks out) and the mono modes replace it in effectiveColors.
|
||||
if not self.done then
|
||||
local black = P.pal(game.data, "BLACK")
|
||||
if black then return { P.whole(black) } end
|
||||
end
|
||||
-- a cancelled evolution keeps the old species (never applied), so only
|
||||
-- colorize with the new form once it has actually evolved
|
||||
local species = (self.done and not self.canceled) and self.newSpecies
|
||||
|
||||
+42
-54
@@ -9,6 +9,7 @@
|
||||
|
||||
local Assets = require("src.render.Assets")
|
||||
local Font = require("src.render.Font")
|
||||
local TextBox = require("src.render.TextBox")
|
||||
local Music = require("src.core.Music")
|
||||
local Sound = require("src.core.Sound")
|
||||
local TypeChart = require("src.battle.TypeChart")
|
||||
@@ -64,17 +65,6 @@ local function dexRatingKey(owned)
|
||||
return ("_DexRatingText_Own%dTo%d"):format(lo, lo + 9)
|
||||
end
|
||||
|
||||
-- \n/\v/\f-marked extracted text, one Font.draw line at a time (same
|
||||
-- technique as DexEntryMenu.lua's dex-description block)
|
||||
local function drawTextBlock(text, x, y, maxY)
|
||||
for line in (text:gsub("\v", "\n"):gsub("\f", "\n") .. "\n"):gmatch("(.-)\n") do
|
||||
if maxY and y > maxY then break end
|
||||
Font.draw(line, x, y)
|
||||
y = y + 8
|
||||
end
|
||||
return y
|
||||
end
|
||||
|
||||
function HallOfFame.new(game, onDone)
|
||||
local self = setmetatable({}, HallOfFame)
|
||||
self.game = game
|
||||
@@ -182,26 +172,50 @@ function HallOfFame:update(dt)
|
||||
self.phase = "player_stats"
|
||||
self.timer = DEX_HOLD
|
||||
elseif self.phase == "player_stats" then
|
||||
-- name / play time / money boxes are up; then DexSeenOwnedText
|
||||
-- name / play time / money boxes are up; then the dex texts
|
||||
self.timer = self.timer - 1
|
||||
if skip or self.timer <= 0 then
|
||||
self.phase = "player_dex"
|
||||
self.timer = DEX_HOLD
|
||||
end
|
||||
elseif self.phase == "player_dex" then
|
||||
self.timer = self.timer - 1
|
||||
if skip or self.timer <= 0 then
|
||||
self.phase = "player_rating"
|
||||
self.timer = DEX_HOLD
|
||||
end
|
||||
elseif self.phase == "player_rating" then
|
||||
self.timer = self.timer - 1
|
||||
if skip or self.timer <= 0 then
|
||||
-- HoFFadeOutScreenAndMusic -> Credits lead-in (no A wait here)
|
||||
self.game.stack:pop()
|
||||
if self.onDone then self.onDone() end
|
||||
self:showDexTexts()
|
||||
end
|
||||
end
|
||||
-- player_dex / player_rating are driven by the TextBox chain that
|
||||
-- showDexTexts pushes; nothing is timed here
|
||||
end
|
||||
|
||||
-- HoFDisplayPlayerStats' three HoFPrintTextAndDelay calls: seen/owned,
|
||||
-- "POKéDEX Rating:", then the tier text DisplayDexRating copied into
|
||||
-- wDexRatingText -- each through PrintText (the standard two-row box, so
|
||||
-- the tier text's \v rows scroll inside it) followed by 120 DelayFrames
|
||||
-- with no button wait. Hand-drawing these flattened the \v scrolls and
|
||||
-- painted the third row over the box's bottom border (#314).
|
||||
function HallOfFame:showDexTexts()
|
||||
local game = self.game
|
||||
local text = game.data.text or {}
|
||||
local seen, owned = self:dexSeenOwned()
|
||||
local seenOwned = (text._DexSeenOwnedText
|
||||
or Strings("POKéDEX Seen:{NUM:wDexRatingNumMonsSeen, 1, 3}\n Owned:{NUM:wDexRatingNumMonsOwned, 1, 3}"))
|
||||
:gsub("{NUM:wDexRatingNumMonsSeen[^}]*}", tostring(seen))
|
||||
:gsub("{NUM:wDexRatingNumMonsOwned[^}]*}", tostring(owned))
|
||||
local header = (text._DexRatingText or Strings("POKéDEX Rating{COLON}"))
|
||||
:gsub("{COLON}", ":")
|
||||
local rating = text[dexRatingKey(owned)] or Strings("Keep it up!")
|
||||
local function finish()
|
||||
-- HoFFadeOutScreenAndMusic -> Credits lead-in (no A wait here)
|
||||
game.stack:pop()
|
||||
if self.onDone then self.onDone() end
|
||||
end
|
||||
local function showRating()
|
||||
game.stack:push(TextBox.new(game, rating, finish,
|
||||
{ auto = { delay = DEX_HOLD } }))
|
||||
end
|
||||
local function showHeader()
|
||||
self.phase = "player_rating"
|
||||
game.stack:push(TextBox.new(game, header, showRating,
|
||||
{ auto = { delay = DEX_HOLD } }))
|
||||
end
|
||||
game.stack:push(TextBox.new(game, seenOwned, showHeader,
|
||||
{ auto = { delay = DEX_HOLD } }))
|
||||
end
|
||||
|
||||
-- HoFDisplayMonInfo: TextBoxBorder (0,2) b=9,c=10 + LEVEL/TYPE labels
|
||||
@@ -263,28 +277,6 @@ function HallOfFame:drawPlayerStats()
|
||||
Font.draw(("¥%d"):format(save.money or 0), 4 * 8, 10 * 8)
|
||||
end
|
||||
|
||||
function HallOfFame:drawDexBox(kind)
|
||||
local save = self.game.save
|
||||
local text = self.game.data.text or {}
|
||||
Font.drawBox(0, 12, 20, 6)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
if kind == "seen" then
|
||||
local seen, owned = self:dexSeenOwned()
|
||||
local seenOwned = text._DexSeenOwnedText
|
||||
or Strings("POKéDEX Seen:{NUM:wDexRatingNumMonsSeen, 1, 3}\n Owned:{NUM:wDexRatingNumMonsOwned, 1, 3}")
|
||||
seenOwned = seenOwned
|
||||
:gsub("{NUM:wDexRatingNumMonsSeen[^}]*}", tostring(seen))
|
||||
:gsub("{NUM:wDexRatingNumMonsOwned[^}]*}", tostring(owned))
|
||||
drawTextBlock(seenOwned, 1 * 8, 14 * 8, 17 * 8)
|
||||
else
|
||||
local _, owned = self:dexSeenOwned()
|
||||
local ratingHeader = (text._DexRatingText or Strings("POKéDEX Rating{COLON}")):gsub("{COLON}", ":")
|
||||
Font.draw(ratingHeader, 1 * 8, 14 * 8)
|
||||
local rating = text[dexRatingKey(owned)] or Strings("Keep it up!")
|
||||
drawTextBlock(rating, 1 * 8, 15 * 8, 17 * 8)
|
||||
end
|
||||
end
|
||||
|
||||
function HallOfFame:draw()
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
love.graphics.rectangle("fill", 0, 0, 160, 144)
|
||||
@@ -309,14 +301,10 @@ function HallOfFame:draw()
|
||||
elseif self.phase == "player_stats" then
|
||||
self:drawPic(self.playerPic)
|
||||
self:drawPlayerStats()
|
||||
elseif self.phase == "player_dex" then
|
||||
elseif self.phase == "player_dex" or self.phase == "player_rating" then
|
||||
-- the TextBox chain draws the dex texts over the stat boxes
|
||||
self:drawPic(self.playerPic)
|
||||
self:drawPlayerStats()
|
||||
self:drawDexBox("seen")
|
||||
elseif self.phase == "player_rating" then
|
||||
self:drawPic(self.playerPic)
|
||||
self:drawPlayerStats()
|
||||
self:drawDexBox("rating")
|
||||
end
|
||||
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
|
||||
+1
-1
@@ -75,7 +75,7 @@ function ListMenu.new(game, title, items, opts)
|
||||
self.dialogue = opts.dialogue
|
||||
-- PC item lists (players_pc.asm): PrintListMenuEntries shows 4 names
|
||||
-- and PrintText footers ("How many?", stored/withdrew) use the standard
|
||||
-- bottom text box — same row budget as the mart, without the money box.
|
||||
-- bottom text box -- same row budget as the mart, without the money box.
|
||||
self.messageBox = opts.messageBox
|
||||
self.money = opts.money -- () -> current money for the box
|
||||
self.rows = opts.rows or ((opts.dialogue or opts.messageBox) and 4 or ROWS)
|
||||
|
||||
@@ -40,12 +40,12 @@ function OakSpeech:sgbPalettes(game)
|
||||
end
|
||||
|
||||
local FALLBACKS = {
|
||||
_OakSpeechText1 = "Hello there!\nWelcome to the\vworld of POKéMON!\fMy name is OAK!\nPeople call me\vthe POKéMON PROF!",
|
||||
_OakSpeechText2A = "This world is\ninhabited by\vcreatures called\vPOKéMON!",
|
||||
_OakSpeechText2B = "\fFor some people,\nPOKéMON are\vpets. Others use\vthem for fights.\fMyself...\fI study POKéMON\nas a profession.",
|
||||
_OakSpeechText3 = "{PLAYER}!\fYour very own\nPOKéMON legend is\vabout to unfold!\fA world of dreams\nand adventures\vwith POKéMON\vawaits! Let's go!",
|
||||
_IntroducePlayerText = "First, what is\nyour name?",
|
||||
_IntroduceRivalText = "This is my grand-\nson. He's been\vyour rival since\vyou were a baby.\f...Erm, what is\nhis name again?",
|
||||
_OakSpeechText1 = Strings.source("Hello there!\nWelcome to the\vworld of POKéMON!\fMy name is OAK!\nPeople call me\vthe POKéMON PROF!"),
|
||||
_OakSpeechText2A = Strings.source("This world is\ninhabited by\vcreatures called\vPOKéMON!"),
|
||||
_OakSpeechText2B = Strings.source("\fFor some people,\nPOKéMON are\vpets. Others use\vthem for fights.\fMyself...\fI study POKéMON\nas a profession."),
|
||||
_OakSpeechText3 = Strings.source("{PLAYER}!\fYour very own\nPOKéMON legend is\vabout to unfold!\fA world of dreams\nand adventures\vwith POKéMON\vawaits! Let's go!"),
|
||||
_IntroducePlayerText = Strings.source("First, what is\nyour name?"),
|
||||
_IntroduceRivalText = Strings.source("This is my grand-\nson. He's been\vyour rival since\vyou were a baby.\f...Erm, what is\nhis name again?"),
|
||||
}
|
||||
|
||||
local function textOr(game, key)
|
||||
|
||||
@@ -142,6 +142,17 @@ local function buildRows(game)
|
||||
o.battleStyle = o.battleStyle == "set" and "shift" or "set"
|
||||
return true
|
||||
end },
|
||||
-- OG is the classic 160x144 battle screen; WIDE is the 304x144
|
||||
-- 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"
|
||||
end,
|
||||
step = function(g)
|
||||
local o = g.save.options
|
||||
o.battleLayout = o.battleLayout == "wide" and "og" or "wide"
|
||||
return true
|
||||
end },
|
||||
{ id = "ruleset", label = Strings("RULESET"),
|
||||
value = function(g) return rulesetName(g) end,
|
||||
step = function(g, dir)
|
||||
|
||||
+203
-18
@@ -23,9 +23,51 @@ local PartyMenu = {}
|
||||
PartyMenu.__index = PartyMenu
|
||||
PartyMenu.isOpaque = true
|
||||
|
||||
-- SGB: generic whole-screen palette (SET_PAL_GENERIC)
|
||||
-- SGB (SetPal_PartyMenu, engine/gfx/palettes.asm:90): the party screen is
|
||||
-- NOT a one-palette screen. data/sgb/sgb_packets.asm BlkPacket_PartyMenu
|
||||
-- splits it into MEWMON over the mon-icon column with GREENBAR everywhere
|
||||
-- else, plus one block per HP bar row whose palette
|
||||
-- UpdatePartyMenuBlkPacket (engine/gfx/palettes.asm:299-325) sets from that
|
||||
-- mon's GetHealthBarColor -- pal 1 GREENBAR / 2 YELLOWBAR / 3 REDBAR
|
||||
-- (PalPacket_PartyMenu, sgb_packets.asm:219). Handing the whole screen
|
||||
-- MEWMON instead painted every bar with MEWMON's shades, which is why a
|
||||
-- full bar came out black and a low one purple (#274, absorbing #272).
|
||||
--
|
||||
-- Two rects differ from the packet's, both because this port draws pixels
|
||||
-- where the hardware drew OAM over BG:
|
||||
-- * the icon block is rows 0-11, not the packet's 0-12 -- row 12 is the
|
||||
-- message box's top edge, which on hardware was BG under an OBJ-free
|
||||
-- part of the block; here it would take MEWMON instead of the base.
|
||||
-- * the bar blocks sit one tile right of the packet's 05-11 because this
|
||||
-- port's bar starts at tile 5 where party_menu.asm:71-76 starts it at
|
||||
-- 4; the span is the same "left cap + six fill tiles".
|
||||
function PartyMenu:sgbPalettes(game)
|
||||
return require("src.render.PaletteFX").wholeNamed(game.data, "MEWMON")
|
||||
local P = require("src.render.PaletteFX")
|
||||
local base = P.pal(game.data, "GREENBAR")
|
||||
if not base then return nil end
|
||||
local zones = { P.whole(base) }
|
||||
local mew = P.pal(game.data, "MEWMON")
|
||||
if mew then zones[#zones + 1] = P.zone(mew, 1, 0, 2, 11) end
|
||||
-- the TM/HM list prints ABLE / NOT ABLE where the bar would be, so those
|
||||
-- rows have no bar to color (party_menu.asm .teachMoveMenu; #210)
|
||||
if not self.tmhm then
|
||||
local party = self.party or (game.save and game.save.party) or {}
|
||||
for i, mon in ipairs(party) do
|
||||
-- While a medicine's bar fill runs the block palette is STALE, not
|
||||
-- recomputed: SetPartyMenuHPBarColor (party_menu.asm:80/295) is only
|
||||
-- reached from the party-menu redraw loop, never from hp_bar.asm, so
|
||||
-- UpdateHPBar2 lengthens the bar under the PRE-heal color and
|
||||
-- RedrawPartyMenu snaps it green when the message prints. Hold the
|
||||
-- starting HP here for exactly that window (#252).
|
||||
local hp = mon.hp
|
||||
if self.heal and self.heal.mon == mon then hp = self.heal.from end
|
||||
local bar = P.pal(game.data, P.barPalName(hp, mon.stats.hp))
|
||||
if bar then
|
||||
zones[#zones + 1] = P.zone(bar, 6, i * 2 - 1, 12, i * 2 - 1)
|
||||
end
|
||||
end
|
||||
end
|
||||
return zones
|
||||
end
|
||||
|
||||
local function sameItems(_, items) return items end
|
||||
@@ -50,7 +92,9 @@ local DIG_TILESETS = { FOREST = true, CEMETERY = true, CAVERN = true,
|
||||
-- Frame1; SNAKE/QUADRUPED are the reverse. Sprite-reused icons draw
|
||||
-- from 16x16x6 overworld sheets where index 3 is walk-down (tile 12):
|
||||
-- MON/FAIRY/BIRD rest on the walk frame and animate to standing
|
||||
-- (tile 0); WATER (Seel) is the reverse.
|
||||
-- (tile 0); WATER (Seel) is the reverse. Only the frame's LEFT half
|
||||
-- ever reaches the screen -- see PartyMenu.mirrorsIcon (#276) -- which
|
||||
-- is why a walk frame does not look like a walk frame here.
|
||||
PartyMenu.iconFrames = {
|
||||
BUG = { rest = 1, alt = 0 }, -- BugIconFrame2 <-> BugIconFrame1
|
||||
GRASS = { rest = 1, alt = 0 }, -- PlantIconFrame2 <-> PlantIconFrame1
|
||||
@@ -71,7 +115,45 @@ function PartyMenu.frameFor(name, alt, ih)
|
||||
return alt and ((ih or 0) >= 64 and 3 or 1) or 0
|
||||
end
|
||||
|
||||
-- HELIX is the one icon WriteMonPartySpriteOAM sends down the asymmetric
|
||||
-- path (engine/gfx/mon_icons.asm:246 `cp ICON_HELIX << 2 / jr z, .helix`);
|
||||
-- every other built-in icon is drawn as a mirrored left half (see
|
||||
-- drawIcon). A mod that supplies its own image instead of a built-in icon
|
||||
-- name has no vanilla counterpart, so its art draws whole. #276
|
||||
function PartyMenu.mirrorsIcon(name)
|
||||
return name ~= nil and name ~= "HELIX"
|
||||
end
|
||||
|
||||
local iconImages = {}
|
||||
|
||||
-- Party icons are OBJs (engine/gfx/mon_icons.asm WriteMonPartySpriteOAM
|
||||
-- writes OAM blocks), so they render 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. An object never displays shade 2. This canvas has
|
||||
-- no OBJ layer, so bake that map into the icon art once per path (the same
|
||||
-- CPU-remap trick as SpriteRenderer.getObpImage, and the same "#obp" cache
|
||||
-- key convention) and let the screen's SGB zone color the result. Without
|
||||
-- it every color-2 pixel took the zone palette's shade-2 color -- the
|
||||
-- ADVANCED pack's MEWMON purple {115,33,165}, i.e. the "weirdly colored"
|
||||
-- party sprites of #274.
|
||||
local function obpIcon(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)
|
||||
-- the extracted art is the four DMG grays, keyed off the red channel
|
||||
-- exactly the way PaletteFX's shade-remap shader keys them
|
||||
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
|
||||
|
||||
local function drawIcon(game, mon, x, y, selected, counter)
|
||||
local icons = game.data.icons
|
||||
if not icons then return end
|
||||
@@ -98,14 +180,26 @@ local function drawIcon(game, mon, x, y, selected, counter)
|
||||
end
|
||||
path = require("src.pokemon.Sprites").iconPath(game.data, mon, path, { name = name })
|
||||
if not path then return end
|
||||
if iconImages[path] == nil then
|
||||
-- Built-in icon classes are DMG 2bpp OBJ art and get the OBP0 bake; a
|
||||
-- mod's own image (an entry table rather than an icon name) is authored
|
||||
-- art with no hardware counterpart, so it loads untouched -- the same
|
||||
-- split PartyMenu.mirrorsIcon makes for the OAM mirror. Both live in one
|
||||
-- cache under different keys, so a mod pointing a table entry at a
|
||||
-- built-in path still gets its unbaked copy. #274
|
||||
local key = name and (path .. "#obp") or path
|
||||
if iconImages[key] == nil then
|
||||
-- resolve through Assets so an overrides/ or transform-derived icon
|
||||
-- (e.g. a per-species image at assets/generated/icons/<name>.png) is
|
||||
-- picked up the same way battle sprites are
|
||||
local ok, img = pcall(love.graphics.newImage, Assets.resolve(path))
|
||||
iconImages[path] = ok and img or false
|
||||
local ok, img
|
||||
if name then
|
||||
ok, img = pcall(obpIcon, path)
|
||||
else
|
||||
ok, img = pcall(love.graphics.newImage, Assets.resolve(path))
|
||||
end
|
||||
iconImages[key] = ok and img or false
|
||||
end
|
||||
local img = iconImages[path]
|
||||
local img = iconImages[key]
|
||||
if not img then return end
|
||||
local alt = false
|
||||
if selected then
|
||||
@@ -118,10 +212,28 @@ local function drawIcon(game, mon, x, y, selected, counter)
|
||||
alt = false
|
||||
end
|
||||
local iw, ih = img:getDimensions()
|
||||
if ih > 16 then
|
||||
local frame = PartyMenu.frameFor(name, alt, ih)
|
||||
-- a 16x16 sheet (BALL, HELIX) is its own only frame
|
||||
local frame = ih > 16 and PartyMenu.frameFor(name, alt, ih) or 0
|
||||
if PartyMenu.mirrorsIcon(name) then
|
||||
-- WriteSymmetricMonPartySpriteOAM (engine/items/town_map.asm:494-534)
|
||||
-- lays each icon out as 2x2 OAM blocks that use only the frame's LEFT
|
||||
-- column of tiles (base+0, base+2): the inner loop writes the same
|
||||
-- wOAMBaseTile twice with the attributes alternating 0 / OAM_XFLIP and
|
||||
-- only then bumps the tile by 2, because "all the sprites other than
|
||||
-- the helix one have a vertical line of symmetry". MON / FAIRY / BIRD
|
||||
-- reuse overworld sheets whose walk-down frame is NOT symmetric, so
|
||||
-- drawing the raw 16x16 showed a tucked-back foot the hardware never
|
||||
-- displays (#276, absorbing #238).
|
||||
local half = love.graphics.newQuad(0, frame * 16, 8, 16, iw, ih)
|
||||
love.graphics.draw(img, half, x, y)
|
||||
-- sx = -1 about the block's right edge, so the flipped copy lands on
|
||||
-- x+8..x+16: the OAM_XFLIP half
|
||||
love.graphics.draw(img, half, x + 16, y, 0, -1, 1)
|
||||
elseif ih > 16 then
|
||||
love.graphics.draw(img, love.graphics.newQuad(0, frame * 16, 16, 16, iw, ih), x, y)
|
||||
else
|
||||
-- HELIX and any mod art that is a single frame: drawn whole, at
|
||||
-- whatever size the file is (unchanged path)
|
||||
love.graphics.draw(img, x, y)
|
||||
end
|
||||
end
|
||||
@@ -134,6 +246,11 @@ function PartyMenu.new(game, opts)
|
||||
self.onSwitch = opts.onSwitch
|
||||
self.onCancel = opts.onCancel
|
||||
self.pickOnly = opts.pickOnly
|
||||
-- Medicine keeps the picker on screen: item_effects.asm .doneHealing
|
||||
-- animates the party HP bar and then prints the message through
|
||||
-- RedrawPartyMenu with the menu STILL up, so BagMenu asks for keepOpen and
|
||||
-- calls :close() itself once the message is done (#252).
|
||||
self.keepOpen = opts.keepOpen
|
||||
-- TM/HM teaching: opts.tmhm = { move, kind } switches the list to Gen 1's
|
||||
-- TM/HM display (ABLE / NOT ABLE per mon instead of the HP bar, and the
|
||||
-- "Use TM on which POKeMON?" prompt). Set by BagMenu.pickTargetAndUse. #210
|
||||
@@ -148,9 +265,47 @@ function PartyMenu.new(game, opts)
|
||||
return self
|
||||
end
|
||||
|
||||
-- UpdateHPBar2 (engine/gfx/hp_bar.asm, predef'd from item_effects.asm's
|
||||
-- .doneHealing): UpdateHPBar_AnimateHPBar is documented "for (a) ticks (two
|
||||
-- waiting frames each)" over a 48-pixel bar, so the shown HP walks
|
||||
-- maxHP/96 per frame -- the same rate the battle HUD drains at
|
||||
-- (BattleState:stepHPDrain). onDone fires on the frame it lands, which is
|
||||
-- when the caller prints its message. #252
|
||||
function PartyMenu:animateTo(mon, fromHP, onDone)
|
||||
if not (mon and mon.stats) then
|
||||
if onDone then onDone() end
|
||||
return
|
||||
end
|
||||
local from = math.max(0, fromHP or mon.hp)
|
||||
-- `from` outlives `shown`: sgbPalettes above needs the pre-heal HP for the
|
||||
-- whole fill, because the SGB bar color does not move until the redraw.
|
||||
self.heal = { mon = mon, from = from, shown = from, onDone = onDone }
|
||||
end
|
||||
|
||||
-- Close a picker the caller kept open (see self.keepOpen). A TextBox pops
|
||||
-- itself BEFORE it fires onDone (src/render/TextBox.lua), so this menu is
|
||||
-- the top state by then; the identity check makes it a no-op for the pickers
|
||||
-- that already popped themselves, and stops a double close eating the bag
|
||||
-- underneath. #252
|
||||
function PartyMenu:close()
|
||||
if self.game.stack:top() == self then self.game.stack:pop() end
|
||||
end
|
||||
|
||||
function PartyMenu:update(dt)
|
||||
-- icon animation counter; 320 = a whole cycle at every HP speed
|
||||
self.blink = ((self.blink or 0) + 1) % 320
|
||||
-- The bar fill owns the menu while it runs: UpdateHPBar2 is a blocking
|
||||
-- predef in item_effects.asm, so no button is read until it lands (#252).
|
||||
local heal = self.heal
|
||||
if heal then
|
||||
heal.shown = math.min(heal.mon.hp,
|
||||
heal.shown + math.max(1, heal.mon.stats.hp) / 96)
|
||||
if heal.shown >= heal.mon.hp then
|
||||
self.heal = nil
|
||||
if heal.onDone then heal.onDone() end
|
||||
end
|
||||
return
|
||||
end
|
||||
local input = self.game.input
|
||||
local party = self.party or self.game.save.party
|
||||
|
||||
@@ -304,10 +459,12 @@ function PartyMenu:update(dt)
|
||||
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))
|
||||
self.game.stack:push(TextBox.new(self.game, t1, function()
|
||||
self.game.stack:push(TextBox.new(self.game, t2, function()
|
||||
self.game.stack:push(Transition.whiteFlash(self.game))
|
||||
end))
|
||||
self.game.stack:push(TextBox.new(self.game, t2))
|
||||
end, { auto = { sound = function()
|
||||
return require("src.core.Sound").playCry(self.game.data, mon.species)
|
||||
end } }))
|
||||
@@ -367,8 +524,12 @@ function PartyMenu:update(dt)
|
||||
end
|
||||
self.swapFrom = nil
|
||||
elseif self.onSwitch and (self.forceSwitch or self.pickOnly or not self.battle) then
|
||||
self.game.stack:pop()
|
||||
self.onSwitch(mon)
|
||||
-- keepOpen callers (HP medicine) need the menu still drawn while the
|
||||
-- bar fills and the message prints, and close it themselves; everyone
|
||||
-- else keeps the old pop-then-call order. Popping first is what made
|
||||
-- a POTION snap the picker shut before the item had even run (#252).
|
||||
if not self.keepOpen then self.game.stack:pop() end
|
||||
self.onSwitch(mon, self)
|
||||
else
|
||||
self.submenu = true
|
||||
self.subIndex = 1
|
||||
@@ -389,7 +550,7 @@ function PartyMenu:update(dt)
|
||||
-- Battle still excludes this list via `not self.battle`. Softboiled
|
||||
-- can appear for a fainted user; its heal transfer then no-ops.
|
||||
if not self.battle and ow then
|
||||
-- FLY/TELEPORT: CheckIfInOutsideMap (OVERWORLD + PLATEAU —
|
||||
-- FLY/TELEPORT: CheckIfInOutsideMap (OVERWORLD + PLATEAU --
|
||||
-- Route 23 / Indigo Plateau outdoor), not OVERWORLD alone (#83)
|
||||
local outside = Map.isOutside(ow.map.def,
|
||||
FieldDefaults.field(self.game.data, "outsideTilesets"))
|
||||
@@ -484,6 +645,16 @@ function PartyMenu:draw()
|
||||
Font.draw(Strings("No POKéMON!"), 16, 64)
|
||||
end
|
||||
local HudTiles = require("src.render.HudTiles")
|
||||
local PaletteFX = require("src.render.PaletteFX")
|
||||
-- Each bar row carries its own GREENBAR / YELLOWBAR / REDBAR zone (see
|
||||
-- sgbPalettes), so the fill must stay the raw DMG shade-2 gray and let
|
||||
-- the zone color it -- but only when a zone pass will actually run.
|
||||
-- Renderer's blit takes the shader path exactly when the zone list is
|
||||
-- non-empty AND PaletteFX.shader() resolves, which is the same pair of
|
||||
-- conditions tested here; with no shader the canvas blits unshaded and
|
||||
-- drawHPBar's per-pixel tint is the only color the bar can get. #274
|
||||
local barZoned = PaletteFX.shader() ~= nil
|
||||
and PaletteFX.pal(self.game.data, "GREENBAR") ~= nil
|
||||
for i, mon in ipairs(party) do
|
||||
local def = self.game.data.pokemon[mon.species]
|
||||
local y = PartyMenu.entryY(i)
|
||||
@@ -523,11 +694,25 @@ function PartyMenu:draw()
|
||||
elseif mon.status then
|
||||
Font.draw(mon.status, 136, y)
|
||||
end
|
||||
-- the colored tile HP bar (DrawHP2 + SetPartyMenuHPBarColor)
|
||||
-- the tile HP bar (DrawHP2 + SetPartyMenuHPBarColor). grayFill:
|
||||
-- tinting the fill AND running it through the row's zone
|
||||
-- double-applies -- a green fill has red channel 0, so the tint
|
||||
-- zeroes the bar's red and the zone's red-keyed shade shader then
|
||||
-- maps every pixel to color 3, i.e. black. That is the #229 hazard
|
||||
-- HudTiles documents; #274 (with #272) is this screen's instance.
|
||||
--
|
||||
-- While a medicine's UpdateHPBar2 fill runs, this row draws the HP the
|
||||
-- animation has reached rather than the final value; drawHPBar reads
|
||||
-- only .hp and .stats, so a shim table is enough and the real mon is
|
||||
-- never mutated for display (#252).
|
||||
local shown = mon
|
||||
if self.heal and self.heal.mon == mon then
|
||||
shown = { hp = math.floor(self.heal.shown), stats = mon.stats }
|
||||
end
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
HudTiles.drawHPBar(self.game.data, 5, (y + 8) / 8, mon)
|
||||
HudTiles.drawHPBar(self.game.data, 5, (y + 8) / 8, shown, nil, barZoned)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
Font.draw(("%3d/%3d"):format(mon.hp, mon.stats.hp), 104, y + 8)
|
||||
Font.draw(("%3d/%3d"):format(shown.hp, mon.stats.hp), 104, y + 8)
|
||||
end
|
||||
-- home/pokemon.asm PartyMenuInit seeds wTopMenuItemY/X with 1/0, so the
|
||||
-- cursor sits on the entry's *second* tile row (the level/HP line),
|
||||
|
||||
+71
-18
@@ -13,6 +13,7 @@ local Font = require("src.render.Font")
|
||||
-- battle move-type box already do (#214).
|
||||
local TypeChart = require("src.battle.TypeChart")
|
||||
local Strings = require("src.core.Strings")
|
||||
local Stats = require("src.pokemon.Stats")
|
||||
|
||||
local SummaryMenu = {}
|
||||
SummaryMenu.__index = SummaryMenu
|
||||
@@ -30,6 +31,16 @@ function SummaryMenu:sgbPalettes(game)
|
||||
end
|
||||
|
||||
function SummaryMenu.new(game, mon)
|
||||
-- status_screen.asm:66-76: StatusScreen recalculates the stat block before
|
||||
-- it draws anything when the mon came from a box or the daycare ("mon is
|
||||
-- in a box or daycare" -> CalcStats), because box_struct carries none.
|
||||
-- Bill's PC hands us that mon table directly (src/ui/BoxMenu.lua's STATS
|
||||
-- submenu entry), and for a .sav imported through
|
||||
-- src/save_convert/GenSave.lua it really does arrive with mon.stats nil,
|
||||
-- which crashed the HP bar draw below (#233). Redundant once
|
||||
-- SaveData.validate has run over a loaded save, but this is the site the
|
||||
-- original recomputes at, and it also covers a mon handed in by a mod.
|
||||
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",
|
||||
@@ -59,10 +70,31 @@ end
|
||||
-- drawn from the same HUD tiles the original loads
|
||||
local function drawLineBox(tx, ty, b, c)
|
||||
local HudTiles = require("src.render.HudTiles")
|
||||
for i = 0, b - 1 do HudTiles.tile(0x73, tx * 8, (ty + i) * 8) end
|
||||
HudTiles.tile(0x77, tx * 8, (ty + b) * 8)
|
||||
for i = 1, c do HudTiles.tile(0x76, (tx - i) * 8, (ty + b) * 8) end
|
||||
HudTiles.tile(0x6F, (tx - c - 1) * 8, (ty + b) * 8)
|
||||
-- Under the status screen's overlay the vertical is $78 -- DrawLineBox
|
||||
-- writes `ld [hl], $78` (status_screen.asm:222), and :90-93 is what puts
|
||||
-- hud_2's single bar tile there. $73 is the <ID> glyph on this screen,
|
||||
-- not a line, so the whole box has to come off statusTile (#280). The
|
||||
-- drawn shapes are unchanged: hud_2 tile 0 is the same bar the battle
|
||||
-- layout parks at $73.
|
||||
for i = 0, b - 1 do HudTiles.statusTile(0x78, tx * 8, (ty + i) * 8) end
|
||||
HudTiles.statusTile(0x77, tx * 8, (ty + b) * 8)
|
||||
for i = 1, c do HudTiles.statusTile(0x76, (tx - i) * 8, (ty + b) * 8) end
|
||||
HudTiles.statusTile(0x6F, (tx - c - 1) * 8, (ty + b) * 8)
|
||||
end
|
||||
|
||||
-- home/pokemon.asm:335-345 PrintLevel: the "<LV>" (":L") tile at (tx,ty)
|
||||
-- then the level LEFT_ALIGNed after it; at level 100 hl is decremented so
|
||||
-- the third digit is written back OVER the ":L" tile. Both status pages
|
||||
-- print a level this way, and src/ui/PartyMenu.lua models the same rule for
|
||||
-- its rows. #280
|
||||
local function printLevel(tx, ty, level)
|
||||
local HudTiles = require("src.render.HudTiles")
|
||||
local x = tx * 8
|
||||
if level < 100 then
|
||||
HudTiles.statusTile(0x6E, x, ty * 8)
|
||||
x = x + 8
|
||||
end
|
||||
Font.draw(tostring(level), x, ty * 8)
|
||||
end
|
||||
|
||||
function SummaryMenu:draw()
|
||||
@@ -73,21 +105,33 @@ function SummaryMenu:draw()
|
||||
local data = game.data
|
||||
local def = data.pokemon[mon.species]
|
||||
|
||||
-- shared header: pic (1,0), name (9,1), <LV> (14,2), No. (1,7)
|
||||
-- shared header: pic (1,0), name (9,1), № + dex number (1,7). The pic is
|
||||
-- MIRRORED -- status_screen.asm:170 draws it through
|
||||
-- LoadFlippedFrontSpriteByMonIndex (home/pokemon.asm sets wSpriteFlipped),
|
||||
-- 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,
|
||||
math.max(0, 56 - self.sprite:getHeight()))
|
||||
love.graphics.draw(self.sprite, 8 + self.sprite:getWidth(),
|
||||
math.max(0, 56 - self.sprite:getHeight()), 0, -1, 1)
|
||||
end
|
||||
local HudTiles = require("src.render.HudTiles")
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
Font.draw(mon.nickname or def.name, 72, 8)
|
||||
HudTiles.tile(0x6E, 112, 16) -- <LV>
|
||||
Font.draw(tostring(mon.level), 120, 16)
|
||||
Font.draw(("No.%03d"):format(def.dex or 0), 8, 56)
|
||||
-- status_screen.asm:109-113 backs hl up from DrawLineBox's end to write
|
||||
-- the single-tile '№' at (1,7) and '<DOT>' at (2,7); :143-146 then
|
||||
-- PrintNumbers the dex number (LEADING_ZEROES, 3 digits) at (3,7).
|
||||
-- Spelling "No." out of three letter tiles pushed every digit a column
|
||||
-- right of the original. #280
|
||||
HudTiles.statusTile(0x74, 8, 56) -- №
|
||||
Font.drawCode(0xF2, 16, 56) -- <DOT> (charmap.asm:182)
|
||||
Font.draw(("%03d"):format(def.dex or 0), 24, 56)
|
||||
|
||||
if self.page == 1 then
|
||||
-- HP bar (11,3) + numbers row 4, STATUS/ (9,6), the DrawLineBox
|
||||
-- bracket around the name/HP block
|
||||
-- bracket around the name/HP block, and PrintLevel at (14,2). The
|
||||
-- level belongs to page 1 ONLY: StatusScreen2 opens with ClearScreenArea
|
||||
-- over (9,2) 5x10 (status_screen.asm:303-305), which wipes it. #280
|
||||
printLevel(14, 2, mon.level)
|
||||
drawLineBox(19, 1, 6, 10)
|
||||
HudTiles.drawHPBar(data, 11, 3, mon, 1) -- wHPBarType 1
|
||||
Font.draw(("%3d/%3d"):format(mon.hp, mon.stats.hp), 96, 32)
|
||||
@@ -114,7 +158,12 @@ function SummaryMenu:draw()
|
||||
Font.draw(Strings("TYPE2/"), 80, 88)
|
||||
Font.draw(TypeChart.displayName(def.types[2]), 88, 96)
|
||||
end
|
||||
Font.draw(Strings("IDNo/"), 80, 104)
|
||||
-- TypesIDNoOTText's third row is "<ID>№/" (status_screen.asm:205-210):
|
||||
-- two single-tile glyphs and a slash, three columns wide, not the five
|
||||
-- letter tiles "IDNo/" this used to spell out. #280
|
||||
HudTiles.statusTile(0x73, 80, 104) -- <ID>
|
||||
HudTiles.statusTile(0x74, 88, 104) -- №
|
||||
Font.draw("/", 96, 104)
|
||||
-- the trainer ID is rolled at new game (SaveData.newGame) and
|
||||
-- backfilled on load for old saves
|
||||
Font.draw(("%05d"):format(mon.otId or game.save.player.id or 0), 96, 112)
|
||||
@@ -124,17 +173,21 @@ function SummaryMenu:draw()
|
||||
-- page 2: EXP + the moves with PP (StatusScreen2)
|
||||
drawLineBox(19, 1, 6, 10)
|
||||
Font.draw(Strings("EXP POINTS"), 72, 24)
|
||||
Font.draw(("%d"):format(mon.exp), 96, 32)
|
||||
-- StatusScreen2: "LEVEL UP" at (9,5); next-exp PrintNumber 7 cols
|
||||
-- at (7,6); space at (14,6); PrintLevel at (16,6). The old
|
||||
-- "%d to L%d" string at x=88 overflowed the DrawLineBox edge.
|
||||
-- PrintNumber at (12,4) with 7 columns: the exp is RIGHT-aligned into
|
||||
-- cols 12-18 (status_screen.asm:400-403), not left-aligned from col 12.
|
||||
-- #280
|
||||
Font.draw(("%7d"):format(mon.exp), 96, 32)
|
||||
-- StatusScreen2: "LEVEL UP" at (9,5); next-exp PrintNumber 7 cols at
|
||||
-- (7,6); the narrow '<to>' tile at (14,6); PrintLevel at (16,6)
|
||||
-- (status_screen.asm:393-403). The old "%d to L%d" string at x=88
|
||||
-- overflowed the DrawLineBox edge.
|
||||
Font.draw(Strings("LEVEL UP"), 72, 40)
|
||||
local Growth = require("src.pokemon.Growth")
|
||||
local nextExp = mon.level < 100
|
||||
and (Growth.expForLevel(def.growthRate, mon.level + 1) - mon.exp) or 0
|
||||
Font.draw(("%7d"):format(math.max(0, nextExp)), 56, 48)
|
||||
HudTiles.tile(0x6E, 128, 48) -- <LV>
|
||||
Font.draw(tostring(math.min(100, mon.level + 1)), 136, 48)
|
||||
HudTiles.statusTile(0x70, 112, 48) -- '<to>' at (14,6), was missing (#280)
|
||||
printLevel(16, 6, math.min(100, mon.level + 1))
|
||||
Font.drawBox(0, 8, 20, 10)
|
||||
for i = 1, 4 do
|
||||
local mv = mon.moves[i]
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
|
||||
local Check = {}
|
||||
|
||||
Check.REPO = "bryanthaboi/pokemon-gen1-recomp-project"
|
||||
Check.REPO = "bryanthaboi/gen1recomp"
|
||||
|
||||
local CMD = "update_check_cmd"
|
||||
local STATE = "update_check_state"
|
||||
|
||||
@@ -50,7 +50,7 @@ local osName = (love.system and love.system.getOS and love.system.getOS()) or
|
||||
local isWindows = osName == "Windows"
|
||||
local saveDir = love.filesystem.getSaveDirectory()
|
||||
|
||||
local API_URL = "https://api.github.com/repos/bryanthaboi/pokemon-gen1-recomp-project/releases/latest"
|
||||
local API_URL = "https://api.github.com/repos/bryanthaboi/gen1recomp/releases/latest"
|
||||
|
||||
-- the release picked by the last "check"; kept between commands so "download"
|
||||
-- knows the payload url/size/name without re-fetching
|
||||
|
||||
@@ -47,6 +47,24 @@ local HEAL_BALL_XY = {
|
||||
-- swaps the two middle shades of the monitor/ball art in place
|
||||
local HEAL_FLASH_MAP = { [0] = 0, [1] = 2, [2] = 1, [3] = 3 }
|
||||
|
||||
-- Fishing rod placement (FishingRodOAM, engine/overworld/player_animations
|
||||
-- .asm). Those dbsprite rows are raw shadow-OAM bytes like HEAL_BALL_XY
|
||||
-- above (screen = tile*8 + pixel - 8/16), measured against the player
|
||||
-- sprite's fixed screen spot: ResetPlayerSpriteData parks it at $3c/$40
|
||||
-- (home/reset_player_sprite.asm), i.e. screen (64,60). So what ports over
|
||||
-- is the delta from the sprite's top-left, which SpriteRenderer:draw puts at
|
||||
-- (px, py - 4). `tile` indexes the three stacked 8x8 tiles of
|
||||
-- assets/generated/fx/fishing_rod.png: FishingRodOAM only ever draws $fd
|
||||
-- (row 0, up/down) and $fe (row 1, left/right), and RIGHT is the LEFT tile
|
||||
-- x-flipped. Blitting the whole 8x24 sheet is what drew the rod as a
|
||||
-- garbage strip (#321).
|
||||
local ROD_OAM = {
|
||||
down = { dx = 4, dy = 15, tile = 0 }, -- dbsprite 9, 11, 4, 3, $fd
|
||||
up = { dx = 4, dy = -8, tile = 0 }, -- dbsprite 9, 8, 4, 4, $fd
|
||||
left = { dx = -8, dy = 4, tile = 1 }, -- dbsprite 8, 10, 0, 0, $fe
|
||||
right = { dx = 16, dy = 4, tile = 1, flip = true }, -- dbsprite 11, 10, 0, 0, $fe, XFLIP
|
||||
}
|
||||
|
||||
-- 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
|
||||
@@ -555,6 +573,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
|
||||
end
|
||||
|
||||
function OverworldState:npcByIndex(index)
|
||||
for _, n in ipairs(self.npcs) do
|
||||
if n.def.index == index then return n end
|
||||
@@ -938,6 +969,24 @@ end
|
||||
function OverworldState:handleInput()
|
||||
local input = Game.input
|
||||
|
||||
-- the wall-bonk SFX cooldown ticks with any held direction, step or not
|
||||
-- (it is a port invention, not part of JoypadOverworld, so the
|
||||
-- wWalkCounter gate below must not freeze it mid-step)
|
||||
if self:dirHeld() then
|
||||
self.bumpCooldown = math.max(0, (self.bumpCooldown or 0) - 1)
|
||||
end
|
||||
|
||||
-- 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
|
||||
|
||||
if input:wasPressed("a") then
|
||||
self:interact()
|
||||
return
|
||||
@@ -974,15 +1023,20 @@ function OverworldState:handleInput()
|
||||
self.bumpCooldown = 16
|
||||
end
|
||||
end
|
||||
self.bumpCooldown = math.max(0, (self.bumpCooldown or 0) - 1)
|
||||
return result
|
||||
end
|
||||
end
|
||||
|
||||
-- Cycling Road's downhill pull: with no d-pad held the bike rolls
|
||||
-- south (home/overworld.asm JoypadOverworld's simulated PAD_DOWN)
|
||||
-- south (home/overworld.asm JoypadOverworld's simulated PAD_DOWN).
|
||||
-- The mask there is PAD_CTRL_PAD | PAD_B | PAD_A, so HOLDING A or B
|
||||
-- brakes exactly like a held direction: what the Route 17 sign
|
||||
-- promises ("Press the A or B Button to stay in place") and what the
|
||||
-- edge-only wasPressed("a") above can never deliver, since a press
|
||||
-- stalls the roll for one frame only (issue #255).
|
||||
local fm = Game.data.field.forcedMovement
|
||||
if fm and Game.save.onBike and not self.player.moving then
|
||||
local braking = input:isDown("a") or input:isDown("b")
|
||||
if fm and Game.save.onBike and not braking and not self.player.moving then
|
||||
for _, m in ipairs(fm.slopeMaps or {}) do
|
||||
if m == self.map.id then
|
||||
self.player.facing = "down"
|
||||
@@ -1070,8 +1124,28 @@ function OverworldState:checkLedgeHop(dir)
|
||||
and ledge.facing == dir and ledge.input == dir
|
||||
and ledge.standingTile == standing and ledge.ledgeTile == front then
|
||||
local lx, ly = Collision.target(fx, fy, dir)
|
||||
if self.map:inBounds(lx, ly)
|
||||
and not Collision.occupied(self.entities, lx, ly, p)
|
||||
if not self.map:inBounds(lx, ly) then
|
||||
-- The landing is on the CONNECTED map. pokered never checks where a
|
||||
-- hop lands (engine/overworld/ledges.asm HandleLedges just simulates
|
||||
-- two presses in the hop direction) and the connection strip is
|
||||
-- loaded, so ROUTE_4's bottom-row ledge at (12,17)/(13,17) really
|
||||
-- does drop onto ROUTE_3 row 0 (south connection, offset -25 ->
|
||||
-- destX = curX + 50; ROUTE_3 (62,0)/(63,0) are walkable $39/$23):
|
||||
-- the one-way shortcut off the Mt Moon plaza that the in-bounds gate
|
||||
-- was silently refusing, which is issue #223. Validate the seam
|
||||
-- cell the way crossConnection does, hop the first cell onto the
|
||||
-- ledge tile, and hand the second to checkEdgeExit, which owns the
|
||||
-- crossing.
|
||||
local dest, ts, cx, cy = self:connectionLanding(dir)
|
||||
if not (dest and Map.defPassable(dest, ts, cx, cy, p.surfing)) then
|
||||
return false
|
||||
end
|
||||
require("src.core.Sound").play(Game.data, "Ledge")
|
||||
p.hopFrames, p.hopTotal = 32, 32 -- jump arc (cosmetic)
|
||||
self:scriptMove(p, dir, 1, function() self:checkEdgeExit(dir) end)
|
||||
return true
|
||||
end
|
||||
if not Collision.occupied(self.entities, lx, ly, p)
|
||||
and self.map:isWalkableCell(lx, ly) then
|
||||
require("src.core.Sound").play(Game.data, "Ledge")
|
||||
p.hopFrames, p.hopTotal = 32, 32 -- jump arc (cosmetic)
|
||||
@@ -1322,12 +1396,18 @@ function OverworldState:goFishing(rod)
|
||||
-- FishingInit dot animation); the rod pose draws in the meantime
|
||||
self.fishing = { facing = self.player.facing }
|
||||
Game.stack:push(TextBox.new(Game, ". . .", function()
|
||||
self.fishing = nil
|
||||
-- FishingAnim (engine/overworld/player_animations.asm) holds
|
||||
-- BIT_LEDGE_OR_FISHING -- the rod OAM and the fishing pose -- through
|
||||
-- PrintText and only clears it once the verdict box is done, so the rod
|
||||
-- must NOT vanish with the dots box (#321).
|
||||
if not enc then
|
||||
Game.stack:push(TextBox.new(Game, Strings("Not even a nibble!")))
|
||||
Game.stack:push(TextBox.new(Game, Strings("Not even a nibble!"), function()
|
||||
self.fishing = nil
|
||||
end))
|
||||
return
|
||||
end
|
||||
Game.stack:push(TextBox.new(Game, Strings("Oh!\nIt's a bite!"), function()
|
||||
self.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
|
||||
@@ -2004,23 +2084,19 @@ function OverworldState:trySurf(fx, fy)
|
||||
if not mon then return end
|
||||
local name = mon.nickname or Game.data.pokemon[mon.species].name
|
||||
local p = self.player
|
||||
p.surfing = true
|
||||
require("src.core.Music").setSurfing(Game.data, true)
|
||||
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))
|
||||
Game.stack:push(TextBox.new(Game, text, function()
|
||||
-- start_sub_menus.asm .surf: UseItem returns (mount + text done),
|
||||
-- then GBPalWhiteOutWithDelay3 blinks before the simulated forward
|
||||
-- press steps onto the water (or across a connection strip, like
|
||||
-- Cinnabar's east coast onto Route 20)
|
||||
local Transition = require("src.render.Transition")
|
||||
if Transition.whiteFlash then
|
||||
Game.stack:push(Transition.whiteFlash(Game, nil, function()
|
||||
self:stepForwardOrCrossEdge(p.facing)
|
||||
end))
|
||||
else
|
||||
self:stepForwardOrCrossEdge(p.facing)
|
||||
end
|
||||
p.surfing = true
|
||||
require("src.core.Music").setSurfing(Game.data, true)
|
||||
self:stepForwardOrCrossEdge(p.facing)
|
||||
end))
|
||||
end
|
||||
|
||||
@@ -2532,22 +2608,27 @@ function OverworldState:engageTrainer(npc, onDone)
|
||||
local BattleState = require("src.battle.BattleState")
|
||||
Game.stack:push(TextBox.new(Game, battleText, function()
|
||||
local battle = BattleState.newTrainer(Game, d.trainerClass, d.trainerParty)
|
||||
-- PrintEndBattleText (home/trainers.asm:341) is called from
|
||||
-- TrainerBattleVictory (engine/battle/core.asm:942), i.e. ON the battle
|
||||
-- screen once ScrollTrainerPicAfterBattle has brought the beaten trainer
|
||||
-- back, and before MoneyForWinningText -- not in the overworld after the
|
||||
-- battle screen has torn down. Handing the line to the battle also
|
||||
-- stops a post-battle evolution being sandwiched between two overworld
|
||||
-- cuts (#282). Substituted here because BattleState:say takes finished
|
||||
-- text, while TextBox expanded the {PLAYER}/{RIVAL} tokens itself.
|
||||
battle.endBattleText = wonText and TextBox.substitute(Game, wonText) or nil
|
||||
battle.onFinish = function(result)
|
||||
if result == "win" then
|
||||
Game.save.defeatedTrainers[npc.id] = true
|
||||
if header and header.event then
|
||||
Game.save.flags[header.event] = true
|
||||
end
|
||||
-- checkVictoryRewards pushes the badge/prize box and starts the map's
|
||||
-- onVictory script UNDER whatever runs next, so the player still sees
|
||||
-- EndBattle (now inside the battle), then the reward, then AfterBattle
|
||||
self:checkVictoryRewards(d.trainerClass, d.trainerParty)
|
||||
local after = function()
|
||||
self:afterBattle(result, battle)
|
||||
if onDone then onDone() end
|
||||
end
|
||||
if wonText then
|
||||
Game.stack:push(TextBox.new(Game, wonText, after))
|
||||
else
|
||||
after()
|
||||
end
|
||||
self:afterBattle(result, battle)
|
||||
if onDone then onDone() end
|
||||
else
|
||||
self:afterBattle(result, battle)
|
||||
if onDone then onDone() end
|
||||
@@ -2560,7 +2641,7 @@ end
|
||||
-- Badges/items awarded after specific battles (data/scripts/victories.lua).
|
||||
-- `deactivate` retires unfought gym/dojo trainers the way the originals'
|
||||
-- SetEvent / SetEventRange do after the leader victory.
|
||||
-- `hide` is { { mapId, objName }, ... } — HideObject on those toggles
|
||||
-- `hide` is { { mapId, objName }, ... } -- HideObject on those toggles
|
||||
-- (e.g. Brock victory clears PEWTERCITY_YOUNGSTER / ROUTE22_RIVAL1).
|
||||
function OverworldState:checkVictoryRewards(trainerClass, partyIndex)
|
||||
local victories = require("data.scripts.victories")
|
||||
@@ -2928,14 +3009,25 @@ function OverworldState:onStepComplete()
|
||||
self.warpEntryCell = nil
|
||||
entry = nil
|
||||
end
|
||||
if self.justWarped then
|
||||
self.justWarped = false
|
||||
elseif entry then
|
||||
-- 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
|
||||
if entry then
|
||||
-- still standing on the warp we arrived through; do not re-trigger it
|
||||
else
|
||||
-- CheckWarpsNoCollision: door/warp tiles fire immediately; otherwise
|
||||
-- ExtraWarpCheck must pass AND either a d-pad is held or BIT_FORCED_WARP
|
||||
-- is set (Seafoam B3F currents — home/overworld.asm).
|
||||
-- is set (Seafoam B3F currents -- home/overworld.asm).
|
||||
local w = Warp.onArrive(self.map, p.cellX, p.cellY)
|
||||
if not w and (self:dirHeld() or self.forcedWarp) then
|
||||
w = Warp.onCollision(self.map, Game.data.field.warpCarpets,
|
||||
@@ -3021,7 +3113,7 @@ function OverworldState:runSpinnerMoves(moves, i)
|
||||
-- Scripted steps skip onStepComplete while they run; once the RLE
|
||||
-- finishes, re-enter the normal landing pipeline so chained spinners,
|
||||
-- Seafoam currents, and CheckWarpsNoCollision (incl. BIT_FORCED_WARP)
|
||||
-- see the tile we stopped on — same as pokered after simulated joypad.
|
||||
-- see the tile we stopped on -- same as pokered after simulated joypad.
|
||||
self:onStepComplete()
|
||||
return
|
||||
end
|
||||
@@ -3758,6 +3850,13 @@ function OverworldState:billboard(fx, fy, vw, vh, colors, keyed, drawFn)
|
||||
end
|
||||
|
||||
function OverworldState:drawWorld()
|
||||
-- Dark-map BG shade shift, armed for the whole frame before anything draws.
|
||||
-- home/fade.asm's LoadGBPal writes ONE rBGP for the screen, so terrain, the
|
||||
-- characters standing on it and any dialog over them darken together (#322);
|
||||
-- Renderer:beginFrame cleared it, so a battle or a full-screen menu -- which
|
||||
-- draws with no map beneath it -- stays lit exactly like
|
||||
-- init_battle_variables.asm's `ld [wMapPalOffset], a` leaves the original.
|
||||
PaletteFX.setShadeMap(self.dark and PaletteFX.DARK_BGP or nil)
|
||||
-- advance the water/flower tile animation (runs under dialogs too).
|
||||
-- TileRenderer.tick uses wall-clock 60Hz steps so display refresh rate
|
||||
-- does not speed or slow the cycle (issue #4).
|
||||
@@ -3974,19 +4073,20 @@ function OverworldState:drawWorld()
|
||||
end
|
||||
end
|
||||
|
||||
-- Rock Tunnel darkness: a small window of light around the player
|
||||
-- until FLASH is used (the original darkens the palette instead);
|
||||
-- fills the whole world view, so surveying doesn't peek past it
|
||||
-- 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.dark then return end
|
||||
local px = self.player.px - cam.x + 8
|
||||
local py = self.player.py - cam.y + 8
|
||||
local r = 28
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
love.graphics.rectangle("fill", 0, 0, vw, math.max(0, py - r))
|
||||
love.graphics.rectangle("fill", 0, py + r, vw, vh - (py + r))
|
||||
love.graphics.rectangle("fill", 0, py - r, math.max(0, px - r), r * 2)
|
||||
love.graphics.rectangle("fill", px + r, py - r, vw - (px + r), r * 2)
|
||||
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
|
||||
|
||||
@@ -4020,11 +4120,25 @@ function OverworldState:drawWorld()
|
||||
end
|
||||
if self.rodImg then
|
||||
local p = self.player
|
||||
local vec = DIRVEC[self.fishing.facing] or DIRVEC.down
|
||||
local rx = p.px - cam.x + 4 + vec[1] * 12
|
||||
local ry = p.py - cam.y + 4 + vec[2] * 12
|
||||
local oam = ROD_OAM[self.fishing.facing] or ROD_OAM.down
|
||||
if not self.rodQuads then
|
||||
-- one quad per 8x8 tile of the stacked sheet (ROD_OAM.tile)
|
||||
local iw, ih = self.rodImg:getDimensions()
|
||||
self.rodQuads = {}
|
||||
for i = 0, math.floor(ih / 8) - 1 do
|
||||
self.rodQuads[i] = love.graphics.newQuad(0, i * 8, 8, 8, iw, ih)
|
||||
end
|
||||
end
|
||||
local quad = self.rodQuads[oam.tile]
|
||||
-- the sprite's top-left is 4px above its cell (SpriteRenderer:draw)
|
||||
local rx = p.px - cam.x + oam.dx
|
||||
local ry = p.py - cam.y - 4 + oam.dy
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
love.graphics.draw(self.rodImg, rx, ry)
|
||||
if quad and oam.flip then
|
||||
love.graphics.draw(self.rodImg, quad, rx + 8, ry, 0, -1, 1)
|
||||
elseif quad then
|
||||
love.graphics.draw(self.rodImg, quad, rx, ry)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -4104,10 +4218,12 @@ 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 light window, 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.
|
||||
if self.dark then
|
||||
-- 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()
|
||||
@@ -4138,9 +4254,12 @@ function OverworldState:drawWorld()
|
||||
-- the pipeline owns the whole frame; nothing else draws into the world
|
||||
elseif not tilt then
|
||||
-- === FLAT PATH: everything into the one world canvas, as before =====
|
||||
-- OBP-baked sprites replay after the zone pass in GBC mode, so their
|
||||
-- OBP-baked sprites replay after the zone pass in OG RED mode, so their
|
||||
-- grass feet-overdraw must replay over them too, colorized with the
|
||||
-- current map's palette (see PaletteFX.markSpriteRedraw)
|
||||
-- current map's palette (see PaletteFX.markSpriteRedraw). SGB no longer
|
||||
-- takes that path -- its characters are colorized by the zone just like
|
||||
-- the ground under them (#301) -- so there the first overdraw is already
|
||||
-- the final one.
|
||||
local grassColors = PaletteFX.usesSpriteObp()
|
||||
and PaletteFX.pal(Game.data, self:paletteNameFor(self.map)) or nil
|
||||
for _, g in ipairs(self.ghosts) do
|
||||
@@ -4261,10 +4380,10 @@ function OverworldState:drawWorld()
|
||||
self:billboard(fx, fy, vw, vh, zoneColorsAt(zones, fx, fy), false, fxRod)
|
||||
end
|
||||
|
||||
-- Rock Tunnel darkness is a screen-space light window, not a ground
|
||||
-- object -- draw it flat into the upright canvas so it darkens the
|
||||
-- final composited scene uniformly (the subtle tilt keeps the
|
||||
-- projected player near the flat light centre).
|
||||
-- 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()
|
||||
|
||||
@@ -152,7 +152,7 @@ function Player:update()
|
||||
self.stepFlip = not self.stepFlip
|
||||
-- keep animClock's pose on this frame (issue #82): bike steps land
|
||||
-- mid-cycle (animClock % 16 == 8), and walkPhase used to snap to
|
||||
-- stand whenever moving cleared — a stand flash every tile on the
|
||||
-- stand whenever moving cleared -- a stand flash every tile on the
|
||||
-- bike, and sometimes after dismount when the clock is desynced
|
||||
self.stepLanded = true
|
||||
return true
|
||||
|
||||
@@ -1,27 +1,8 @@
|
||||
-- Driver: the bag's USE/TOSS submenu box (#284). A manual eye check, not a
|
||||
-- pass/fail run.
|
||||
--
|
||||
-- pokered evidence. data/text_boxes.asm pins the whole thing as one entry:
|
||||
--
|
||||
-- text_box_text USE_TOSS_MENU_TEMPLATE, 13, 10, 19, 14, UseTossText, 15, 11
|
||||
-- ; text box ID, upper-left X, upper-left Y, lower-right X, lower-right Y,
|
||||
-- ; text pointer, text X, text Y
|
||||
--
|
||||
-- so the box covers tiles (13,10)-(19,14) and "USE" starts at tile (15,11).
|
||||
-- engine/menus/start_sub_menus.asm then sets wTopMenuItemY/X to 11/14 for
|
||||
-- the cursor, and UseTossText is "USE" + next "TOSS", i.e. two rows.
|
||||
--
|
||||
-- The bug: the port opened the submenu with a (12,10) 8x6 box, one column
|
||||
-- too wide on the left and one row too tall at the bottom. The labels
|
||||
-- themselves were already on the right pixel rows, but the extra bottom row
|
||||
-- left them stranded near the top edge, which is what reads as "too high".
|
||||
-- The fix passes the real geometry (13,10,7,5); src/ui/Menu.lua derives the
|
||||
-- label and cursor columns from the box, so it needed no change and its
|
||||
-- eight other callers are untouched.
|
||||
--
|
||||
-- Do NOT run this under POKEPORT_SPEED: fast-forward scales only the logic
|
||||
-- clock, so a sped-up run can capture a half-drawn frame.
|
||||
--
|
||||
-- Eye check on the bag's USE/TOSS submenu box (#284).
|
||||
-- pokered data/text_boxes.asm: USE_TOSS_MENU_TEMPLATE covers tiles
|
||||
-- (13,10)-(19,14) with "USE" at (15,11), and start_sub_menus.asm puts the
|
||||
-- cursor at wTopMenuItemY/X 11/14. The port opened it one column wider and
|
||||
-- one row taller, which stranded the labels near the top edge.
|
||||
-- POKEPORT_DRIVER=tests/drivers/bag_usetoss_bug284_test.lua POKEPORT_IDENTITY=bug284 love .
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
@@ -33,10 +14,8 @@ return function(game)
|
||||
return ok
|
||||
end
|
||||
|
||||
-- ---- preconditions the eye cannot separate from the bug ----------------
|
||||
-- An empty bag, or an item whose branch skips the submenu, both end with
|
||||
-- "no USE/TOSS box on screen", which looks identical to a broken box.
|
||||
|
||||
-- an empty bag, or an item whose branch skips the submenu, leaves no box on
|
||||
-- screen at all, which looks the same as a broken one
|
||||
game.save.party = { Pokemon.new(game.data, "CHARIZARD", 50) }
|
||||
game.save.player.name = "bryan"
|
||||
game.save.inventory = game.save.inventory or {}
|
||||
@@ -59,27 +38,14 @@ return function(game)
|
||||
U.wait(30)
|
||||
U.shot(game, "bug284_bag_list.png")
|
||||
|
||||
-- A on the first item opens USE/TOSS
|
||||
U.tap(game, "a")
|
||||
U.wait(30)
|
||||
U.shot(game, "bug284_usetoss.png")
|
||||
|
||||
U.log("........................................................")
|
||||
U.log("LOOK NOW: the USE/TOSS box should be open over the bag list.")
|
||||
U.log(" Screenshot: bug284_usetoss.png in the LOVE save dir.")
|
||||
U.log(" RIGHT: a box hugging the bottom-right corner, its border running")
|
||||
U.log(" tiles (13,10) to (19,14). USE and TOSS sit on alternating")
|
||||
U.log(" rows with the cursor one column to their left, and there")
|
||||
U.log(" is exactly one border row below TOSS -- no empty gap.")
|
||||
U.log(" BUG #284 looks like: a roomier box whose labels crowd the top,")
|
||||
U.log(" with a blank row of dead space under TOSS, and the whole")
|
||||
U.log(" box starting one column further left than the original.")
|
||||
U.log(" ALSO WRONG: labels pushed down but the box left oversized (that")
|
||||
U.log(" moves the text off pokered's row 11), or the cursor column")
|
||||
U.log(" no longer lining up one tile left of the labels.")
|
||||
U.log("Compare against the original screenshot in issue #284.")
|
||||
U.log("Input is yours from here: up/down moves between USE and TOSS.")
|
||||
U.log("........................................................")
|
||||
U.log("USE/TOSS is open over the bag list; shot in bug284_usetoss.png.")
|
||||
U.log("It should run tiles (13,10)-(19,14) with the cursor one column left")
|
||||
U.log("of the labels and a single border row under TOSS, no blank gap (#284).")
|
||||
U.log("Up/down moves between USE and TOSS.")
|
||||
|
||||
while true do
|
||||
coroutine.yield()
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
-- Driver: cycling COLORS mid-battle must not move a sprite (#316). pokered
|
||||
-- SlidePlayerAndEnemySilhouettesOnScreen (engine/battle/core.asm:13-15) puts
|
||||
-- the back pic at `hlcoord 1, 5` so its bottom row sits on the text box, and a
|
||||
-- palette swap must not lift it off.
|
||||
-- POKEPORT_DRIVER=tests/drivers/battle_colors_bug316_test.lua \
|
||||
-- POKEPORT_IDENTITY=bug316 POKEPORT_TOUCH=0 SHOT_DIR=/tmp/shots love .
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
local BattleState = require("src.battle.BattleState")
|
||||
local PaletteFX = require("src.render.PaletteFX")
|
||||
|
||||
-- Route 1 (5, 5) is open walkable ground; the battle is pushed straight in.
|
||||
local MAP = "ROUTE_1"
|
||||
local STAND = { x = 5, y = 5, facing = "down" }
|
||||
-- ARTICUNO's back pic has left padding as well as bottom padding.
|
||||
local PARTY = { { "BULBASAUR", 12 }, { "ARTICUNO", 40 } }
|
||||
|
||||
local function check(label, ok)
|
||||
U.log(ok and "PASS" or "FAIL", label)
|
||||
return ok
|
||||
end
|
||||
|
||||
-- ---- preconditions the eye cannot check --------------------------------
|
||||
-- No transparent padding on the back pics means no jump to see, so a broken
|
||||
-- fix would read as a pass.
|
||||
local function padOf(path)
|
||||
if not (love.image and love.image.newImageData) then return nil end
|
||||
local ok, id = pcall(love.image.newImageData, path)
|
||||
if not ok then return nil end
|
||||
local w, h = id:getDimensions()
|
||||
local bottom = h - 1
|
||||
while bottom >= 0 do
|
||||
local opaque = false
|
||||
for x = 0, w - 1 do
|
||||
local _, _, _, a = id:getPixel(x, bottom)
|
||||
if a > 0 then opaque = true break end
|
||||
end
|
||||
if opaque then break end
|
||||
bottom = bottom - 1
|
||||
end
|
||||
local left = 0
|
||||
while left < w do
|
||||
local opaque = false
|
||||
for y = 0, h - 1 do
|
||||
local _, _, _, a = id:getPixel(left, y)
|
||||
if a > 0 then opaque = true break end
|
||||
end
|
||||
if opaque then break end
|
||||
left = left + 1
|
||||
end
|
||||
return h - 1 - bottom, left, w, h
|
||||
end
|
||||
|
||||
for _, path in ipairs({ "assets/generated/battle/redb.png",
|
||||
"assets/generated/battle/back/bulbasaurb.png",
|
||||
"assets/generated/battle/back/articunob.png" }) do
|
||||
local pad, padL, w, h = padOf(path)
|
||||
if pad == nil then
|
||||
check(path .. " could be measured", false)
|
||||
else
|
||||
U.log((" %s is %dx%d, %d transparent bottom rows, %d left columns")
|
||||
:format(path, w, h, pad, padL))
|
||||
check(path .. " still carries bottom padding to lose", pad > 0)
|
||||
end
|
||||
end
|
||||
check("BattleState.invalidate still exists (PaletteFX.setMode calls it)",
|
||||
type(BattleState.invalidate) == "function")
|
||||
U.log(" COLORS ladder:", table.concat(PaletteFX.MODES, ", "))
|
||||
|
||||
-- Where drawPicsLayer puts every pic this frame; love.graphics.draw is
|
||||
-- shadowed so nothing reaches the screen. Both call shapes matter:
|
||||
-- draw(img, x, y, r, sx, sy) and the faint-clip draw(img, quad, x, y, ...).
|
||||
local function picGeometry(battle)
|
||||
local out = {}
|
||||
local realDraw = love.graphics.draw
|
||||
love.graphics.draw = function(_, a, b, c, d, e)
|
||||
if type(a) == "number" then
|
||||
out[#out + 1] = { x = a, y = b, s = d }
|
||||
else
|
||||
out[#out + 1] = { x = b, y = c, s = e }
|
||||
end
|
||||
end
|
||||
pcall(battle.drawPicsLayer, battle, 0, 0, 0)
|
||||
love.graphics.draw = realDraw
|
||||
return out
|
||||
end
|
||||
|
||||
local function describe(geo)
|
||||
local parts = {}
|
||||
for _, g in ipairs(geo) do
|
||||
parts[#parts + 1] = ("(%s, %s x%s)"):format(tostring(g.x), tostring(g.y),
|
||||
tostring(g.s or 1))
|
||||
end
|
||||
return table.concat(parts, " ")
|
||||
end
|
||||
|
||||
local function same(a, b)
|
||||
if #a ~= #b then return false end
|
||||
for i = 1, #a do
|
||||
if a[i].x ~= b[i].x or a[i].y ~= b[i].y or a[i].s ~= b[i].s then
|
||||
return false
|
||||
end
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
-- The back pic is the only thing drawn at 2x (BattleState.backPlacement's
|
||||
-- third return); the enemy front pic is 1x.
|
||||
local function backEntry(geo)
|
||||
for _, g in ipairs(geo) do
|
||||
if g.s == 2 then return g end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
-- Ground truth off the art on disk, not off the cache: comparing against an
|
||||
-- earlier frame only catches a pad lost DURING the run, and this one is also
|
||||
-- lost by any pic loaded after an invalidate().
|
||||
local bpad, bpadL, bw, bh = padOf("assets/generated/battle/back/bulbasaurb.png")
|
||||
local expX, expY = nil, nil
|
||||
if bpad then
|
||||
expX, expY = BattleState.backPlacement(bw, bh, bpad, bpadL, 2)
|
||||
U.log((" a %dx%d back pic with %d ground rows belongs at (%d, %d) at 2x")
|
||||
:format(bw, bh, bpad, expX, expY))
|
||||
end
|
||||
|
||||
local function grounded(label, geo)
|
||||
if not expY then return end
|
||||
local b = backEntry(geo)
|
||||
check(label .. ": the back pic stands on the text box at y = "
|
||||
.. tostring(expY) .. " (got " .. tostring(b and b.y) .. ")",
|
||||
b ~= nil and b.y == expY and b.x == expX)
|
||||
end
|
||||
|
||||
-- ---- get a back pic on screen ------------------------------------------
|
||||
game.save.party = {}
|
||||
for _, slot in ipairs(PARTY) do
|
||||
table.insert(game.save.party, Pokemon.new(game.data, slot[1], slot[2]))
|
||||
end
|
||||
U.teleport(game, MAP, STAND.x, STAND.y, STAND.facing)
|
||||
U.wait(10)
|
||||
local ow = game.overworld
|
||||
check("the overworld is up on " .. MAP, ow ~= nil)
|
||||
|
||||
local battle = BattleState.newWild(game, "PIDGEY", 6)
|
||||
battle.onFinish = function() end
|
||||
if ow then ow:pushBattle(battle) end
|
||||
for _ = 1, 400 do
|
||||
if game.stack:top() == battle and (battle.introSlide or 0) == 0 then break end
|
||||
U.wait(1)
|
||||
end
|
||||
check("the battle reached the screen", game.stack:top() == battle)
|
||||
|
||||
-- Red's own back pic is up during the intro, and that is the pic the report
|
||||
-- names, so check it first.
|
||||
check("Red's back pic is on screen", battle.showPlayerBack == true)
|
||||
local introBefore = picGeometry(battle)
|
||||
U.log(" intro pics before:", describe(introBefore))
|
||||
grounded("before any COLORS change", introBefore)
|
||||
U.shot(game, DIR .. "/bug316_1_intro_before.png")
|
||||
game:keypressed("2")
|
||||
U.wait(4)
|
||||
local introAfter = picGeometry(battle)
|
||||
U.log(" intro pics after :", describe(introAfter))
|
||||
U.log(" COLORS is now", PaletteFX.modeLabel(PaletteFX.mode))
|
||||
check("cycling COLORS did not move Red's back pic (#316)",
|
||||
same(introBefore, introAfter))
|
||||
grounded("after one COLORS change", introAfter)
|
||||
U.shot(game, DIR .. "/bug316_2_intro_after.png")
|
||||
|
||||
-- now the mon's own back pic, at the action menu, where a player actually
|
||||
-- sits when they press 2
|
||||
for _ = 1, 80 do
|
||||
if battle.phase == "menu" then break end
|
||||
U.tap(game, "a")
|
||||
U.wait(4)
|
||||
end
|
||||
check("the battle reached its action menu", battle.phase == "menu")
|
||||
check("the mon's back pic replaced Red's", battle.showPlayerBack == false)
|
||||
|
||||
local base = picGeometry(battle)
|
||||
U.log(" menu pics baseline:", describe(base))
|
||||
grounded("the mon's own back pic at the menu", base)
|
||||
U.shot(game, DIR .. "/bug316_3_menu_" .. tostring(PaletteFX.mode) .. ".png")
|
||||
|
||||
-- walk the whole COLORS ladder; every mode has to leave the geometry alone
|
||||
local moved, ungrounded = {}, {}
|
||||
for i = 1, #PaletteFX.MODES do
|
||||
game:keypressed("2")
|
||||
U.wait(4)
|
||||
local geo = picGeometry(battle)
|
||||
local label = PaletteFX.modeLabel(PaletteFX.mode)
|
||||
if not same(base, geo) then
|
||||
moved[#moved + 1] = label
|
||||
U.log(" MOVED under " .. label .. ":", describe(geo))
|
||||
end
|
||||
local b = backEntry(geo)
|
||||
if expY and not (b and b.y == expY and b.x == expX) then
|
||||
ungrounded[#ungrounded + 1] = label
|
||||
end
|
||||
if i == 1 then
|
||||
U.shot(game, DIR .. "/bug316_4_menu_" .. tostring(PaletteFX.mode) .. ".png")
|
||||
end
|
||||
end
|
||||
check("no COLORS mode moved a pic (walked the whole ladder of "
|
||||
.. #PaletteFX.MODES .. ")", #moved == 0)
|
||||
check("the back pic is still grounded in every COLORS mode",
|
||||
#ungrounded == 0)
|
||||
if #ungrounded > 0 then
|
||||
U.log(" modes where it floated:", table.concat(ungrounded, ", "))
|
||||
end
|
||||
if #moved > 0 then
|
||||
U.log(" modes that moved something:", table.concat(moved, ", "))
|
||||
end
|
||||
U.log(" back on", PaletteFX.modeLabel(PaletteFX.mode))
|
||||
|
||||
-- ---- hand off ----------------------------------------------------------
|
||||
U.log("At the FIGHT/PKMN/ITEM/RUN menu: press 2 to cycle COLORS and watch the")
|
||||
U.log("back sprite's feet. They stay planted on the top edge of the text box;")
|
||||
U.log("#316 hopped the sprite 8 pixels up on the first press. Switching to")
|
||||
U.log("ARTICUNO in slot 2 shows the sideways half of it.")
|
||||
U.log("Sprites already on screen keep their baked palette, which is deliberate.")
|
||||
U.log("Screenshots: " .. DIR .. "/bug316_*.png")
|
||||
|
||||
while true do
|
||||
coroutine.yield()
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,255 @@
|
||||
-- Driver: the battle intro's pokeball rows, HUD chrome, prompt arrow and
|
||||
-- trainer-pic slides (#317). engine/battle/common_text.asm:22-27,
|
||||
-- draw_hud_pokeball_gfx.asm:1-45 (player row (88,80) +8, foe row (64,16) -8),
|
||||
-- SlideTrainerPicOffScreen at core.asm:1235-1253. Ordering is asserted in
|
||||
-- parity_battle_intro_chrome.lua. No POKEPORT_SPEED: audio has its own clock.
|
||||
-- POKEPORT_DRIVER=tests/drivers/battle_intro_bug317_test.lua POKEPORT_IDENTITY=bug317 SHOT_DIR=/tmp/shots love .
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
local BattleState = require("src.battle.BattleState")
|
||||
local HudTiles = require("src.render.HudTiles")
|
||||
local Font = require("src.render.Font")
|
||||
|
||||
-- pokered data/maps/objects/Route3.asm: object_event 10, 6, ... STAY, RIGHT,
|
||||
-- OPP_BUG_CATCHER, 4. Range RIGHT means the sight line runs east along row
|
||||
-- 6, so (13,6) is one cell outside it and walking west from there trips it.
|
||||
local MAP = "ROUTE_3"
|
||||
local TRAINER = "ROUTE3_YOUNGSTER1"
|
||||
local STAND = { x = 13, y = 6, facing = "left" }
|
||||
|
||||
local function check(label, ok)
|
||||
U.log(ok and "PASS" or "FAIL", label)
|
||||
return ok
|
||||
end
|
||||
|
||||
-- ---- preconditions the eye cannot check --------------------------------
|
||||
-- A missing sheet draws nothing, which looks identical to the bug.
|
||||
|
||||
-- drawBallRow bails silently if this image will not load
|
||||
local balls = io.open("assets/generated/battle/balls.png", "rb")
|
||||
check("assets/generated/battle/balls.png exists", balls ~= nil)
|
||||
if balls then balls:close() end
|
||||
|
||||
-- HudTiles.tile is a silent no-op for a code the sheets do not carry, so
|
||||
-- count the draws it issues rather than trusting the call
|
||||
local function tileDraws(code)
|
||||
local real = love.graphics.draw
|
||||
local n = 0
|
||||
love.graphics.draw = function() n = n + 1 end
|
||||
pcall(HudTiles.tile, code, 0, 0)
|
||||
love.graphics.draw = real
|
||||
return n
|
||||
end
|
||||
local CHROME = {
|
||||
[0x73] = "corner tick", [0x74] = "bar left cap", [0x76] = "underline run",
|
||||
[0x77] = "player underline left", [0x78] = "enemy underline right",
|
||||
[0x6F] = "player underline end",
|
||||
}
|
||||
for code, what in pairs(CHROME) do
|
||||
check(("HUD chrome tile $%02X (%s) resolves"):format(code, what),
|
||||
tileDraws(code) > 0)
|
||||
end
|
||||
|
||||
local mapDef = game.data.maps[MAP]
|
||||
local trainerDef
|
||||
for _, o in ipairs(mapDef and mapDef.objects or {}) do
|
||||
if o.name == TRAINER then trainerDef = o end
|
||||
end
|
||||
check(TRAINER .. " is still on " .. MAP, trainerDef ~= nil)
|
||||
if trainerDef then
|
||||
check("it is still a trainer object",
|
||||
trainerDef.trainerClass ~= nil and trainerDef.trainerParty ~= nil)
|
||||
U.log(" ", TRAINER, trainerDef.trainerClass, "party", trainerDef.trainerParty,
|
||||
"at", trainerDef.x, trainerDef.y, "range", tostring(trainerDef.range))
|
||||
end
|
||||
|
||||
-- Record what drawHUDs puts on screen for one frame without drawing it:
|
||||
-- drawBallRow is shadowed on the instance, Font.draw on the module
|
||||
-- BattleState routes its HUD strings through.
|
||||
local function snapshot(battle)
|
||||
local rows, strings = {}, {}
|
||||
local realRow, realFont = battle.drawBallRow, Font.draw
|
||||
local realDraw = love.graphics.draw
|
||||
battle.drawBallRow = function(_, party, x, y, dx)
|
||||
rows[#rows + 1] = { count = #party, x = x, y = y, step = dx }
|
||||
end
|
||||
Font.draw = function(text) strings[#strings + 1] = tostring(text) end
|
||||
love.graphics.draw = function() end
|
||||
pcall(battle.drawHUDs, battle, 0)
|
||||
battle.drawBallRow, Font.draw = realRow, realFont
|
||||
love.graphics.draw = realDraw
|
||||
return rows, strings
|
||||
end
|
||||
|
||||
local function drewString(strings, want)
|
||||
for _, s in ipairs(strings) do
|
||||
if s:find(want, 1, true) then return true end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
-- ---- part 1: a WILD intro, driven and photographed ---------------------
|
||||
game.save.party = {
|
||||
Pokemon.new(game.data, "BULBASAUR", 12),
|
||||
Pokemon.new(game.data, "PIDGEY", 9),
|
||||
}
|
||||
U.teleport(game, MAP, STAND.x, STAND.y, STAND.facing)
|
||||
U.wait(10)
|
||||
local ow = game.overworld
|
||||
check("the overworld is up on " .. MAP, ow ~= nil)
|
||||
|
||||
local wild = BattleState.newWild(game, "RATTATA", 3)
|
||||
wild.onFinish = function() end
|
||||
if ow then ow:pushBattle(wild) end
|
||||
for _ = 1, 400 do
|
||||
if game.stack:top() == wild and (wild.introSlide or 0) == 0 then break end
|
||||
U.wait(1)
|
||||
end
|
||||
check("the wild battle reached the screen",
|
||||
game.stack:top() == wild and (wild.introSlide or 0) == 0)
|
||||
check("the DrawAllPokeballs window is open", wild.introBalls == true)
|
||||
|
||||
local rows, strings = snapshot(wild)
|
||||
check("a WILD intro draws exactly one ball row", #rows == 1)
|
||||
if rows[1] then
|
||||
check("it is the player's row at (88, 80) stepping +8",
|
||||
rows[1].x == 88 and rows[1].y == 80 and rows[1].step == 8)
|
||||
check("it carries all " .. #game.save.party .. " party slots",
|
||||
rows[1].count == #game.save.party)
|
||||
end
|
||||
check("the enemy HUD is NOT up under the intro box",
|
||||
not drewString(strings, wild.enemy.name))
|
||||
if not U.shot(game, DIR .. "/bug317_1_wild_intro.png") then
|
||||
U.log("FAIL could not capture the wild intro")
|
||||
end
|
||||
|
||||
-- the page finishes typing, then PromptText's arrow blinks
|
||||
local prompted = false
|
||||
for _ = 1, 300 do
|
||||
if wild.msgPrompt then prompted = true break end
|
||||
U.wait(1)
|
||||
end
|
||||
check("the typed-out intro page raises the blinking prompt", prompted)
|
||||
-- two shots half a blink apart; drawTextArea draws '▼' for frame % 60 < 30
|
||||
for _ = 1, 60 do
|
||||
if (wild.frame % 60) < 6 then break end
|
||||
U.wait(1)
|
||||
end
|
||||
U.shot(game, DIR .. "/bug317_2_arrow_on.png")
|
||||
for _ = 1, 60 do
|
||||
if (wild.frame % 60) >= 34 then break end
|
||||
U.wait(1)
|
||||
end
|
||||
U.shot(game, DIR .. "/bug317_3_arrow_off.png")
|
||||
|
||||
-- dismiss it: ClearSprites + both ClearScreenAreas, then the enemy HUD
|
||||
U.tap(game, "a")
|
||||
U.wait(4)
|
||||
check("dismissing the box closes the window", wild.introBalls == nil)
|
||||
local rows2, strings2 = snapshot(wild)
|
||||
check("no ball row survives the dismissal", #rows2 == 0)
|
||||
check("the enemy HUD appears once the box is gone",
|
||||
drewString(strings2, wild.enemy.name))
|
||||
U.shot(game, DIR .. "/bug317_4_enemy_hud.png")
|
||||
|
||||
-- The back pic walking off the left edge before "Go! X!". Keep watching
|
||||
-- past the mid-slide shot: U.shot spins frames of its own, so breaking on
|
||||
-- it would under-report how far the pic travelled.
|
||||
local lowest, shot = 0, false
|
||||
for _ = 1, 400 do
|
||||
local off = wild:picOffset("back")
|
||||
if off < lowest then lowest = off end
|
||||
if not shot and off <= -24 and off >= -52 then
|
||||
shot = true
|
||||
U.shot(game, DIR .. "/bug317_5_back_slide.png")
|
||||
end
|
||||
if wild.phase == "menu" or wild.showPlayerBack == false then break end
|
||||
U.wait(1)
|
||||
end
|
||||
check("a mid-slide frame was captured", shot)
|
||||
check("the back pic walked the full 9 tiles off the left edge (reached "
|
||||
.. lowest .. "px of -72)", lowest <= -72)
|
||||
|
||||
-- ---- part 2: a LIVE trainer intro, handed over -------------------------
|
||||
U.teleport(game, MAP, STAND.x, STAND.y, STAND.facing)
|
||||
U.wait(10)
|
||||
ow = game.overworld
|
||||
|
||||
local function liveBattle()
|
||||
for _, s in ipairs(game.stack.states or {}) do
|
||||
if getmetatable(s) == BattleState then return s end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
local function trainerNpc()
|
||||
for _, n in ipairs((ow and ow.npcs) or {}) do
|
||||
if n.def and n.def.name == TRAINER then return n end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
local npc = trainerNpc()
|
||||
check("the trainer object loaded on the live map", npc ~= nil)
|
||||
|
||||
-- Walk WEST along row 6 into the sight line. If a map edit moved the
|
||||
-- trainer, re-approach at whatever row it now sits on.
|
||||
if npc and (npc.cellY ~= STAND.y) then
|
||||
U.log("trainer moved to", npc.cellX, npc.cellY, "-- re-approaching")
|
||||
U.teleport(game, MAP, math.min(npc.cellX + 3, (ow.map.widthCells or 60) - 1),
|
||||
npc.cellY, "left")
|
||||
U.wait(10)
|
||||
end
|
||||
|
||||
local battle
|
||||
for _ = 1, 12 do
|
||||
U.hold(game, "left", 12)
|
||||
U.wait(20)
|
||||
battle = liveBattle()
|
||||
if battle then break end
|
||||
-- the pre-battle line ("I like shorts!") holds the overworld; clear it
|
||||
U.tap(game, "a")
|
||||
U.wait(20)
|
||||
battle = liveBattle()
|
||||
if battle then break end
|
||||
end
|
||||
|
||||
if not battle then
|
||||
-- rather than park the player facing nothing, push the same battle
|
||||
U.log("FAIL the sight line did not trip; pushing the battle directly")
|
||||
local cls = trainerDef and trainerDef.trainerClass or "OPP_BUG_CATCHER"
|
||||
local pty = trainerDef and trainerDef.trainerParty or 4
|
||||
battle = BattleState.newTrainer(game, cls, pty)
|
||||
battle.onFinish = function() end
|
||||
if ow then ow:pushBattle(battle) end
|
||||
end
|
||||
|
||||
for _ = 1, 400 do
|
||||
if game.stack:top() == battle and (battle.introSlide or 0) == 0 then break end
|
||||
U.wait(1)
|
||||
end
|
||||
check("a trainer battle is on screen", liveBattle() ~= nil)
|
||||
check("its intro window is open", battle.introBalls == true)
|
||||
local rows3 = snapshot(battle)
|
||||
check("a TRAINER intro draws BOTH ball rows", #rows3 == 2)
|
||||
if rows3[1] and rows3[2] then
|
||||
check("the foe's row is at (64, 16) stepping -8",
|
||||
rows3[1].x == 64 and rows3[1].y == 16 and rows3[1].step == -8)
|
||||
check("the player's row is at (88, 80) stepping +8",
|
||||
rows3[2].x == 88 and rows3[2].y == 80 and rows3[2].step == 8)
|
||||
end
|
||||
U.shot(game, DIR .. "/bug317_6_trainer_intro.png")
|
||||
|
||||
-- ---- hand off ----------------------------------------------------------
|
||||
U.log("The BUG CATCHER's intro box is up. Press A to walk the rest of it.")
|
||||
U.log("Correct: six ball slots per side with a thin underline under each row,")
|
||||
U.log("an arrow blinking bottom-right, and each trainer pic WALKING off its")
|
||||
U.log("own edge before its send-out text prints (#317).")
|
||||
U.log("Screenshots of the wild half: " .. DIR .. "/bug317_*.png")
|
||||
|
||||
while true do
|
||||
coroutine.yield()
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,134 @@
|
||||
-- Driver: the black pause between the battle wipe and the battle screen (#315).
|
||||
-- Shrink and Split end with BattleTransition_BlackScreen then DelayFrames 10
|
||||
-- (engine/battle/battle_transitions.asm:390-392, 422-424); the other six get
|
||||
-- the same gap free from the load the original hides behind. BLACK_HOLD in
|
||||
-- src/render/BattleTransition.lua is the knob. Not under POKEPORT_SPEED.
|
||||
-- POKEPORT_DRIVER=tests/drivers/battle_transition_hold_bug315_test.lua SHOT_DIR=/tmp/shots love .
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
local BattleState = require("src.battle.BattleState")
|
||||
local BattleTransition = require("src.render.BattleTransition")
|
||||
|
||||
local function check(label, ok)
|
||||
U.log(ok and "PASS" or "FAIL", label)
|
||||
return ok
|
||||
end
|
||||
|
||||
-- BLACK_HOLD is a file-local, so this is the driver's own copy of it
|
||||
local TARGET = 30
|
||||
|
||||
game.save.player.name = "bryan"
|
||||
game.save.party = { Pokemon.new(game.data, "CHARIZARD", 50) }
|
||||
|
||||
U.teleport(game, "ROUTE_1", 5, 5, "down")
|
||||
U.wait(20)
|
||||
local ow = game.overworld
|
||||
check("overworld is up to push the battle from", ow ~= nil)
|
||||
|
||||
-- ---- measure the hold --------------------------------------------------
|
||||
-- No screenshots in this pass: U.shot spins frames of its own and would
|
||||
-- corrupt the count.
|
||||
local function measure(opts)
|
||||
local battle = BattleState.newWild(game, "PIDGEY", 8)
|
||||
battle.onFinish = function() end
|
||||
ow:pushBattle(battle)
|
||||
local tr = game.stack:top()
|
||||
if getmetatable(tr) ~= BattleTransition then return nil, "no transition" end
|
||||
local style, wipeLen = tr.style, tr.wipeLen
|
||||
local f, wipeDone, popped = 0, nil, nil
|
||||
while f < 600 do
|
||||
f = f + 1
|
||||
if not wipeDone and tr.phase == "wipe" and tr.t >= wipeLen then
|
||||
wipeDone = f
|
||||
end
|
||||
if game.stack:top() ~= tr then popped = f break end
|
||||
U.wait(1)
|
||||
end
|
||||
return { style = style, wipeLen = wipeLen, wipeDone = wipeDone,
|
||||
popped = popped, battle = battle, tr = tr }, nil
|
||||
end
|
||||
|
||||
local m, err = measure()
|
||||
check("a BattleTransition was pushed for the battle", m ~= nil)
|
||||
if not m then
|
||||
U.log("could not measure:", tostring(err))
|
||||
while true do coroutine.yield() end
|
||||
end
|
||||
U.log(("style %s: wipe is %d frames"):format(tostring(m.style), m.wipeLen))
|
||||
check("the wipe reached its last frame", m.wipeDone ~= nil)
|
||||
check("the transition popped itself", m.popped ~= nil)
|
||||
local hold = (m.wipeDone and m.popped) and (m.popped - m.wipeDone) or -1
|
||||
U.log(("black hold measured: %d frames (%.2f s at 60 Hz); target %d")
|
||||
:format(hold, hold / 60, TARGET))
|
||||
check(("the screen holds black well past the old 6 frames (got %d) (#315)")
|
||||
:format(hold), hold >= 20)
|
||||
check(("the hold is not absurdly long either (got %d)"):format(hold),
|
||||
hold <= 90)
|
||||
|
||||
-- unwind that battle before the next one
|
||||
while game.stack:top() ~= ow do game.stack:pop() end
|
||||
U.wait(10)
|
||||
|
||||
-- ---- screenshot the beat ------------------------------------------------
|
||||
-- One frame inside the hold (solid black edge to edge) and one after the pop.
|
||||
local battle = BattleState.newWild(game, "PIDGEY", 8)
|
||||
battle.onFinish = function() end
|
||||
ow:pushBattle(battle)
|
||||
local tr = game.stack:top()
|
||||
check("second transition pushed", getmetatable(tr) == BattleTransition)
|
||||
for _ = 1, 600 do
|
||||
if tr.phase == "wipe" and tr.t >= tr.wipeLen then break end
|
||||
U.wait(1)
|
||||
end
|
||||
check("hold screenshot reached disk",
|
||||
U.shot(game, DIR .. "/bug315_black_hold.png"))
|
||||
U.log("captured", DIR .. "/bug315_black_hold.png",
|
||||
"-- this one must be SOLID BLACK")
|
||||
for _ = 1, 300 do
|
||||
if game.stack:top() ~= tr then break end
|
||||
U.wait(1)
|
||||
end
|
||||
U.wait(2)
|
||||
check("post-hold screenshot reached disk",
|
||||
U.shot(game, DIR .. "/bug315_battle_appears.png"))
|
||||
U.log("captured", DIR .. "/bug315_battle_appears.png")
|
||||
|
||||
-- back to the overworld and into tall grass, so the hand-off is a real
|
||||
-- encounter rather than a scripted push
|
||||
while game.stack:top() ~= ow do game.stack:pop() end
|
||||
U.wait(10)
|
||||
|
||||
-- Grass cell comes off the loaded map, not a hard-coded pair, so a map edit
|
||||
-- degrades to "somewhere else in the grass" instead of a wall.
|
||||
local map = ow.map
|
||||
local gx, gy
|
||||
for cy = 0, map.heightCells - 1 do
|
||||
for cx = 0, map.widthCells - 1 do
|
||||
if map:isGrassCell(cx, cy) and map:isWalkableCell(cx, cy)
|
||||
and map:isWalkableCell(cx, cy + 1) then
|
||||
gx, gy = cx, cy
|
||||
break
|
||||
end
|
||||
end
|
||||
if gx then break end
|
||||
end
|
||||
check("found a tall-grass cell on ROUTE_1 to hand off in", gx ~= nil)
|
||||
if gx then
|
||||
U.log(("standing in the grass at (%d, %d)"):format(gx, gy))
|
||||
U.teleport(game, "ROUTE_1", gx, gy, "down")
|
||||
U.wait(10)
|
||||
end
|
||||
|
||||
-- ---- hand off, then stay out of the way --------------------------------
|
||||
U.log("You are in the tall grass on ROUTE 1. Walk until an encounter fires and")
|
||||
U.log("watch the join: the wipe should finish, the screen sit solid black for")
|
||||
U.log(("about half a second (%d frames this run), and only then the battle")
|
||||
:format(hold))
|
||||
U.log("screen appear (#315). Wipe style varies, so try it a few times.")
|
||||
|
||||
while true do
|
||||
coroutine.yield()
|
||||
end
|
||||
end
|
||||
@@ -1,4 +1,4 @@
|
||||
-- Driver: Bill's PC (#177) — chrome (What? / BOX No. / <PK><MN>) and
|
||||
-- Driver: Bill's PC (#177) -- chrome (What? / BOX No. / <PK><MN>) and
|
||||
-- withdraw/deposit returning to BillsPCMenu instead of closing the PC.
|
||||
|
||||
return function(game)
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
-- Driver: SET_PAL_BATTLE_BLACK darkens the whole battle screen while the
|
||||
-- blackout text is up (#292). pokered engine/battle/core.asm:1147-1159
|
||||
-- runs the palette command before the text, and returns early on OAKS_LAB.
|
||||
-- No POKEPORT_SPEED here: audio runs on its own real-time clock.
|
||||
-- POKEPORT_DRIVER=tests/drivers/blackout_palette_bug292_test.lua \
|
||||
-- POKEPORT_IDENTITY=bug292 POKEPORT_TOUCH=0 SHOT_DIR=/tmp/shots love .
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
local BattleState = require("src.battle.BattleState")
|
||||
local PaletteFX = require("src.render.PaletteFX")
|
||||
|
||||
-- Route 1: no scripted battle, not the Oak's Lab exception, and (5,5) is
|
||||
-- open floor there (data/generated/maps.lua).
|
||||
local MAP = "ROUTE_1"
|
||||
local STAND = { x = 5, y = 5, facing = "down" }
|
||||
|
||||
local function check(label, ok)
|
||||
U.log(ok and "PASS" or "FAIL", label)
|
||||
return ok
|
||||
end
|
||||
|
||||
-- ---- preconditions the eye cannot check --------------------------------
|
||||
-- A missing PAL_BLACK looks exactly like the bug on screen.
|
||||
local pack = PaletteFX.pack(game.data)
|
||||
local BLACK = pack and pack.palettes and pack.palettes.BLACK
|
||||
check("the active COLORS pack carries PAL_BLACK", BLACK ~= nil)
|
||||
if BLACK then
|
||||
U.log(" PAL_BLACK shades:",
|
||||
("0 = %d,%d,%d"):format(BLACK[1][1], BLACK[1][2], BLACK[1][3]),
|
||||
("1 = %d,%d,%d"):format(BLACK[2][1], BLACK[2][2], BLACK[2][3]),
|
||||
("3 = %d,%d,%d"):format(BLACK[4][1], BLACK[4][2], BLACK[4][3]))
|
||||
check("shade 0 is the near-white paper", BLACK[1][1] > 200)
|
||||
check("shades 1-3 are near-black ink",
|
||||
BLACK[2][1] < 90 and BLACK[3][1] < 90 and BLACK[4][1] < 90)
|
||||
end
|
||||
|
||||
-- A DMG never had SGB darkening, so the fix deliberately does nothing in
|
||||
-- the forced-mono modes and a run in one of them proves nothing.
|
||||
local mode = PaletteFX.mode
|
||||
local mono = mode == "og" or mode == "og_inv" or mode == "classic"
|
||||
U.log("COLORS mode:", tostring(mode), "(" .. PaletteFX.modeLabel(mode) .. ")")
|
||||
if mono then
|
||||
U.log("WARNING: " .. PaletteFX.modeLabel(mode) .. " is a forced-mono mode.")
|
||||
U.log("WARNING: PAL_BLACK is an SGB packet and a DMG never darkened, so")
|
||||
U.log("WARNING: nothing below will look dark and that is CORRECT. Press 2")
|
||||
U.log("WARNING: (or set COLORS in OPTION) to SGB / GBC first, then re-run.")
|
||||
end
|
||||
check("the COLORS mode is one that can show the darkening", not mono)
|
||||
|
||||
-- ---- a losing battle ---------------------------------------------------
|
||||
-- One mon on 1 HP against something that outspeeds it.
|
||||
game.save.party = { Pokemon.new(game.data, "RATTATA", 3) }
|
||||
game.save.party[1].hp = 1
|
||||
U.teleport(game, MAP, STAND.x, STAND.y, STAND.facing)
|
||||
U.wait(10)
|
||||
local ow = game.overworld
|
||||
check("the overworld is up on " .. MAP, ow ~= nil)
|
||||
check("this is not the Oak's Lab starter-rival exception",
|
||||
(ow and ow.map and ow.map.id) ~= "OAKS_LAB")
|
||||
|
||||
local battle = BattleState.newWild(game, "RATICATE", 50)
|
||||
battle.onFinish = function() end
|
||||
if ow then ow:pushBattle(battle) end
|
||||
for _ = 1, 400 do
|
||||
if game.stack:top() == battle and (battle.introSlide or 0) == 0 then break end
|
||||
U.wait(1)
|
||||
end
|
||||
check("the battle reached the screen", game.stack:top() == battle)
|
||||
|
||||
-- photograph the full-color screen so the "after" shot has a reference
|
||||
for _ = 1, 60 do
|
||||
if battle.phase == "menu" then break end
|
||||
U.tap(game, "a")
|
||||
U.wait(4)
|
||||
end
|
||||
check("the battle reached its action menu", battle.phase == "menu")
|
||||
check("nothing is darkened yet", battle.blackedOut == nil)
|
||||
if not U.shot(game, DIR .. "/bug292_1_full_color.png") then
|
||||
U.log("FAIL could not capture the full-color battle")
|
||||
end
|
||||
|
||||
-- ---- lose it -----------------------------------------------------------
|
||||
-- Stop the instant the wipe registers, so the blackout text is still ahead
|
||||
-- of the player.
|
||||
for _ = 1, 700 do
|
||||
if battle.blackedOut then break end
|
||||
if battle.result then break end
|
||||
U.tap(game, "a")
|
||||
U.wait(4)
|
||||
end
|
||||
if not battle.blackedOut and not battle.result then
|
||||
-- the foe kept rolling status moves: take the engine's own faint path
|
||||
U.log("the foe never landed a hit; forcing the KO through onFaint")
|
||||
battle.player.mon.hp = 0
|
||||
battle.nextInsert = 0
|
||||
battle:onFaint(battle.player)
|
||||
for _ = 1, 400 do
|
||||
if battle.blackedOut then break end
|
||||
U.tap(game, "a")
|
||||
U.wait(4)
|
||||
end
|
||||
end
|
||||
check("the party was wiped out and the screen blacked", battle.blackedOut == true)
|
||||
|
||||
-- Two code paths darken the screen (the zone pass for HUD and bars, a
|
||||
-- re-baked pic for the sprites) and both have to fire.
|
||||
if BLACK then
|
||||
local pals = battle:sgbBattlePals()
|
||||
check("all four battle zones are PAL_BLACK",
|
||||
pals ~= nil and pals[0] == BLACK and pals[1] == BLACK
|
||||
and pals[2] == BLACK and pals[3] == BLACK)
|
||||
end
|
||||
|
||||
U.wait(20)
|
||||
if not U.shot(game, DIR .. "/bug292_2_blacked_out.png") then
|
||||
U.log("FAIL could not capture the blacked-out battle")
|
||||
end
|
||||
local cur = battle.current
|
||||
U.log("box on screen:", cur and cur.text and (cur.text:gsub("\n", " / "))
|
||||
or "(between rows)")
|
||||
|
||||
-- ---- hand off ----------------------------------------------------------
|
||||
U.log("Your last POKeMON has fainted; press A through the blackout text.")
|
||||
U.log("Behind the box the enemy sprite, the HP bar and the HUD should all")
|
||||
U.log("be near-black on near-white for both lines (#292). Compare")
|
||||
U.log(DIR .. "/bug292_1_full_color.png with bug292_2_blacked_out.png.")
|
||||
U.log("OG / OG INV / CLASSIC and the Oak's lab rival fight never darken.")
|
||||
|
||||
while true do
|
||||
coroutine.yield()
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,112 @@
|
||||
-- Manual check that a boulder answers an A press (#318).
|
||||
-- Every boulder points at BoulderText (pokered home/overworld_text.asm:16),
|
||||
-- i.e. _BoulderText "This requires\nSTRENGTH to move!", which the extractor
|
||||
-- stores as an asm label with no text: resolveText returned nil and A did
|
||||
-- nothing. The data half is asserted in tests/parity_asm_plain_text.lua.
|
||||
-- POKEPORT_DRIVER=tests/drivers/boulder_text_bug318_test.lua POKEPORT_IDENTITY=bug318 POKEPORT_TOUCH=0 POKEPORT_VERSION=red love .
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
|
||||
-- pokered data/maps/objects/VictoryRoad1F.asm: BOULDER3 sits at (2, 10) with
|
||||
-- wall to its east and west, so the only free approach is the floor below it.
|
||||
-- Talking to a boulder needs no STRENGTH and no badge.
|
||||
local MAP = "VICTORY_ROAD_1F"
|
||||
local BOULDER = "VICTORYROAD1F_BOULDER3"
|
||||
local TEXT = "TEXT_VICTORYROAD1F_BOULDER3"
|
||||
local MAP_LABEL = "VictoryRoad1F"
|
||||
local STAND = { x = 2, y = 11, facing = "up" }
|
||||
|
||||
local function check(label, ok)
|
||||
U.log(ok and "PASS" or "FAIL", label)
|
||||
return ok
|
||||
end
|
||||
|
||||
-- a missing string, a renamed label and an unreached fallback all show up as
|
||||
-- the same nothing-happens the bug did
|
||||
local text = game.data:resolveText(MAP_LABEL, TEXT)
|
||||
check(MAP_LABEL .. "/" .. TEXT .. " resolves to a string",
|
||||
type(text) == "string" and text ~= "")
|
||||
check("it is _BoulderText verbatim", text == game.data.text._BoulderText)
|
||||
check("it mentions STRENGTH",
|
||||
type(text) == "string" and text:find("STRENGTH", 1, true) ~= nil)
|
||||
if type(text) == "string" then
|
||||
U.log("boulder text reads:", (text:gsub("\n", " / ")))
|
||||
end
|
||||
|
||||
-- park the player against the boulder
|
||||
U.teleport(game, MAP, STAND.x, STAND.y, STAND.facing)
|
||||
U.wait(10)
|
||||
|
||||
local function boulderIn(ow)
|
||||
for _, n in ipairs(ow.npcs or {}) do
|
||||
if n.def and n.def.name == BOULDER then return n end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
-- re-reads game.overworld every call: the fallback below teleports again and
|
||||
-- that rebuilds the state and its npc list
|
||||
local function facingTheBoulder()
|
||||
local ow = game.overworld
|
||||
local rock = ow and boulderIn(ow)
|
||||
if not rock then return false end
|
||||
local fx, fy = ow.player:facingCell()
|
||||
return ow:npcAtCell(fx, fy) == rock
|
||||
end
|
||||
|
||||
local ow = game.overworld
|
||||
local rock = ow and boulderIn(ow)
|
||||
check("boulder object loaded on " .. MAP, rock ~= nil)
|
||||
|
||||
if rock and not facingTheBoulder() then
|
||||
-- a map edit or a mod moved the object: fall back to any free walkable
|
||||
-- neighbour. {dx, dy, facing} is the offset from the boulder to the stand
|
||||
-- cell plus the direction that looks back at it, so +1 on x means left.
|
||||
local sides = {
|
||||
{ 0, 1, "up" }, { 0, -1, "down" }, { 1, 0, "left" }, { -1, 0, "right" },
|
||||
}
|
||||
for _, s in ipairs(sides) do
|
||||
local cx, cy = rock.cellX + s[1], rock.cellY + s[2]
|
||||
if ow.map:isWalkableCell(cx, cy) and not ow:npcAtCell(cx, cy) then
|
||||
U.log(("approach cell (%d, %d) is blocked, standing on")
|
||||
:format(STAND.x, STAND.y), cx, cy, "facing", s[3])
|
||||
U.teleport(game, MAP, cx, cy, s[3])
|
||||
U.wait(10)
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
check("player is standing against the boulder", facingTheBoulder())
|
||||
|
||||
-- press A here so the log can tell "no text entry" from "the press never
|
||||
-- reached the boulder"; on screen the two look identical
|
||||
local TextBox = require("src.render.TextBox")
|
||||
U.tap(game, "a")
|
||||
U.wait(30)
|
||||
|
||||
local top = game.stack:top()
|
||||
local isBox = getmetatable(top) == TextBox
|
||||
check("pressing A opened a text box", isBox)
|
||||
if isBox then
|
||||
local shown = {}
|
||||
for _, page in ipairs(top.pages or {}) do
|
||||
for _, line in ipairs(page) do shown[#shown + 1] = line end
|
||||
end
|
||||
local joined = table.concat(shown, " / ")
|
||||
U.log("box reads:", joined)
|
||||
check("the box is the boulder line, not something else",
|
||||
joined:find("STRENGTH", 1, true) ~= nil)
|
||||
local SHOT_DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
|
||||
U.shot(game, SHOT_DIR .. "/bug318_boulder.png")
|
||||
U.log("captured", SHOT_DIR .. "/bug318_boulder.png")
|
||||
end
|
||||
|
||||
U.log("The boulder has been talked to already; the box on screen is that.")
|
||||
U.log("It should type \"This requires\" / \"STRENGTH to move!\" and wait for")
|
||||
U.log("A or B. Before #318 the press did nothing at all: no box, no sound.")
|
||||
U.log("Two more boulders on this floor, at (5,15) and (14,2).")
|
||||
|
||||
while true do
|
||||
coroutine.yield()
|
||||
end
|
||||
end
|
||||
@@ -1,32 +1,8 @@
|
||||
-- Driver: the Viridian caterpillar speech waits for the player (#250).
|
||||
-- A manual eye check, not a pass/fail run -- nothing in this repo can judge
|
||||
-- Eye check on the Viridian caterpillar speech (#250): no test can judge
|
||||
-- "the text went by too fast to read".
|
||||
--
|
||||
-- pokered evidence. text/ViridianCity.asm spells the answer with three
|
||||
-- different break markers, and they are not interchangeable:
|
||||
--
|
||||
-- ViridianCityYoungster2CaterpieAndWeedleDescriptionText::
|
||||
-- text "CATERPIE has no"
|
||||
-- line "poison, but"
|
||||
-- cont "WEEDLE does."
|
||||
--
|
||||
-- para "Watch out for its"
|
||||
-- line "POISON STING!"
|
||||
-- done
|
||||
--
|
||||
-- src/render/TextBox.lua maps those to \n (second line), \v (scroll one
|
||||
-- line, after the down-arrow and a button press) and \f (page break, clear
|
||||
-- and wait). This text is not in data/generated/text.lua -- pokered
|
||||
-- declares it without the leading underscore the extractor keys on -- so
|
||||
-- the port carries a literal fallback, and that fallback had spelled both
|
||||
-- `cont` and `para` as plain \n. Six lines then landed on one page with
|
||||
-- nothing to wait on, so the whole speech scrolled past untouched.
|
||||
--
|
||||
-- Do NOT run this under POKEPORT_SPEED. Fast-forward scales only the logic
|
||||
-- clock while the typewriter and its SFX run on their own real-time
|
||||
-- accumulator (src/core/Game.lua), which is precisely the pacing being
|
||||
-- judged here.
|
||||
--
|
||||
-- pokered text/ViridianCity.asm spells that answer with line, cont and para,
|
||||
-- which TextBox maps to \n, \v and \f. The port's literal fallback (the label
|
||||
-- has no leading underscore, so it is not in text.lua) had all three as \n.
|
||||
-- POKEPORT_DRIVER=tests/drivers/caterpillar_text_bug250_test.lua POKEPORT_IDENTITY=bug250 love .
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
@@ -36,20 +12,17 @@ return function(game)
|
||||
return ok
|
||||
end
|
||||
|
||||
-- ---- preconditions the eye cannot separate from the bug ----------------
|
||||
-- A missing handler, or an NPC who wandered off, both end in "no text",
|
||||
-- which is not the same failure as "text that does not wait".
|
||||
|
||||
local MAP = "VIRIDIAN_CITY"
|
||||
local NPC = "VIRIDIANCITY_YOUNGSTER2"
|
||||
local TEXT = "TEXT_VIRIDIANCITY_YOUNGSTER2"
|
||||
|
||||
-- a missing handler, or an NPC who wandered off, ends in "no text", which is
|
||||
-- not the same failure as "text that does not wait"
|
||||
local mapScripts = require("data.scripts.init")
|
||||
check("a hand-ported handler exists for " .. TEXT,
|
||||
mapScripts.talkScript(MAP, TEXT) ~= nil)
|
||||
|
||||
-- the fix itself: the paginator has to find a page break and a scroll in
|
||||
-- the description, because that is what makes it wait for a button
|
||||
-- the page break and the scroll are what make the box wait for a button
|
||||
local TextBox = require("src.render.TextBox")
|
||||
local desc = "CATERPIE has no\npoison, but\vWEEDLE does.\fWatch out for its\nPOISON STING!"
|
||||
local pages = TextBox.paginate(desc)
|
||||
@@ -58,8 +31,7 @@ return function(game)
|
||||
pages[1] ~= nil and #pages[1] == 3)
|
||||
check("page 2 is two lines (para + line)",
|
||||
pages[2] ~= nil and #pages[2] == 2)
|
||||
-- the wait itself: contBefore marks the line the box holds on until the
|
||||
-- player presses a button, which is the whole point of the fix
|
||||
-- contBefore marks the line the box holds on until a button press
|
||||
check("line 3 waits for a button before scrolling in",
|
||||
pages.contBefore and pages.contBefore[1] and pages.contBefore[1][3] == true)
|
||||
|
||||
@@ -78,10 +50,9 @@ return function(game)
|
||||
-- pin the wander: he strolls out from under the A press between reads
|
||||
if npc then npc.wanders = false end
|
||||
|
||||
-- objects live at ../pokered/data/maps/objects/ViridianCity.asm and in
|
||||
-- data/generated/maps.lua; he is at (30,25), so (30,26) faces him. If a
|
||||
-- map edit ever moves him, stand on any free walkable neighbour instead
|
||||
-- of parking the player at a wall.
|
||||
-- pokered data/maps/objects/ViridianCity.asm puts him at (30,25), so (30,26)
|
||||
-- faces him; if a map edit moves him, the fallback below picks a free
|
||||
-- walkable neighbour instead of parking the player at a wall.
|
||||
local function facingTarget()
|
||||
local ow = game.overworld
|
||||
if not (ow and npc) then return false end
|
||||
@@ -104,22 +75,10 @@ return function(game)
|
||||
end
|
||||
check("player is standing in front of the youngster", facingTarget())
|
||||
|
||||
U.log("........................................................")
|
||||
U.log("READ NOW: press A to talk, then answer YES to his question.")
|
||||
U.log(" RIGHT: the answer stops and waits for you three times. After")
|
||||
U.log(" 'WEEDLE does.' the box waits with a down-arrow, then the")
|
||||
U.log(" page CLEARS before 'Watch out for its POISON STING!'.")
|
||||
U.log(" BUG #250 looks like: all six lines pouring past in one go, the")
|
||||
U.log(" box scrolling itself, and the conversation ending before")
|
||||
U.log(" you have pressed anything.")
|
||||
U.log(" ALSO WRONG: it waits but never clears the page (that is \\v where")
|
||||
U.log(" pokered has para), or it clears after every single line")
|
||||
U.log(" (that is \\f where pokered has line/cont).")
|
||||
U.log("Control case: answer NO instead and you should get the one-line")
|
||||
U.log(" 'Oh, OK then!', which needs no waits at all.")
|
||||
U.log("Input is yours from here, so you can re-run the talk as often as")
|
||||
U.log("you like.")
|
||||
U.log("........................................................")
|
||||
U.log("Press A to talk, answer YES, and read the description.")
|
||||
U.log("It should stop for you three times, and the page should CLEAR before")
|
||||
U.log("'Watch out for its POISON STING!' -- under #250 all six lines poured")
|
||||
U.log("past in one go. Answering NO is the control: one line, no waits.")
|
||||
|
||||
while true do
|
||||
coroutine.yield()
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
-- Driver: holding A or B on Cycling Road holds you in place (#255). pokered
|
||||
-- home/overworld.asm:1825 masks PAD_A and PAD_B along with the d-pad before it
|
||||
-- forces PAD_DOWN. Hands check, so no POKEPORT_SPEED.
|
||||
-- POKEPORT_DRIVER=tests/drivers/cycling_road_brake_bug255_test.lua \
|
||||
-- POKEPORT_IDENTITY=bug255 POKEPORT_TOUCH=0 POKEPORT_VERSION=red \
|
||||
-- SHOT_DIR=/tmp/shots love .
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local SHOT_DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
|
||||
|
||||
local function check(label, ok)
|
||||
U.log(ok and "PASS" or "FAIL", label)
|
||||
return ok
|
||||
end
|
||||
|
||||
-- x=2 is open road from y=8 past y=34; y=20 keeps the demo roll clear of the
|
||||
-- bikers at (4,18) and (7,32).
|
||||
local MAP, START_X, START_Y = "ROUTE_17", 2, 20
|
||||
|
||||
-- ---- preconditions -----------------------------------------------------
|
||||
-- "the bike does not roll" and "the brake works" look the same, so prove the
|
||||
-- hill is pulling first.
|
||||
|
||||
local fm = game.data.field.forcedMovement
|
||||
local isSlope = false
|
||||
for _, id in ipairs((fm and fm.slopeMaps) or {}) do
|
||||
if id == MAP then isSlope = true end
|
||||
end
|
||||
check("field.forcedMovement.slopeMaps names ROUTE_17", isSlope)
|
||||
|
||||
game.save.onBike = true
|
||||
check("the player is on the BICYCLE (no bike, no roll)", game.save.onBike == true)
|
||||
|
||||
U.teleport(game, MAP, START_X, START_Y, "down")
|
||||
U.wait(15)
|
||||
local ow = game.overworld
|
||||
check("standing on Cycling Road", ow.map.id == MAP)
|
||||
local clear = true
|
||||
for dy = 1, 8 do
|
||||
if not ow.map:isWalkableCell(START_X, START_Y + dy) then clear = false end
|
||||
end
|
||||
check(("open road for 8 cells south of (%d,%d)"):format(START_X, START_Y), clear)
|
||||
|
||||
-- ---- the hill pulls ----------------------------------------------------
|
||||
local y0 = ow.player.cellY
|
||||
U.wait(48)
|
||||
local rolled = ow.player.cellY - y0
|
||||
check(("hands off the pad, the bike rolls south (%d cells in 48 frames)")
|
||||
:format(rolled), rolled > 0)
|
||||
U.shot(game, SHOT_DIR .. "/bug255_rolling.png")
|
||||
|
||||
-- ---- and A / B stop it -------------------------------------------------
|
||||
-- Input:beginStep rebuilds `pressed` from the queue every fixed step, so one
|
||||
-- queued edge plus a sticky input.state is exactly a button held down.
|
||||
local function holdFor(btn, frames)
|
||||
local ow2 = game.overworld
|
||||
table.insert(game.input.pressQueue, btn) -- the key-down edge
|
||||
game.input.state[btn] = true
|
||||
-- the mask only suppresses the NEXT simulated PAD_DOWN, so a step already
|
||||
-- under way still lands; the brake is measured from where it puts you
|
||||
for _ = 1, 24 do
|
||||
if not ow2.player.moving then break end
|
||||
coroutine.yield()
|
||||
end
|
||||
local start = ow2.player.cellY
|
||||
local drift = 0
|
||||
for _ = 1, frames do
|
||||
coroutine.yield()
|
||||
if ow2.player.cellY ~= start then drift = ow2.player.cellY - start end
|
||||
end
|
||||
check(("holding %s for %d frames: the player does not drift south (%d cells)")
|
||||
:format(btn:upper(), frames, drift), drift == 0)
|
||||
U.shot(game, ("%s/bug255_braking_%s.png"):format(SHOT_DIR, btn))
|
||||
game.input.state[btn] = false
|
||||
local held = ow2.player.cellY
|
||||
U.wait(48)
|
||||
check(("releasing %s resumes the roll (%d cells in 48 frames)")
|
||||
:format(btn:upper(), ow2.player.cellY - held),
|
||||
ow2.player.cellY > held)
|
||||
end
|
||||
|
||||
holdFor("a", 120)
|
||||
holdFor("b", 120)
|
||||
|
||||
-- park somewhere with road left to roll before handing over
|
||||
U.teleport(game, MAP, START_X, START_Y, "down")
|
||||
U.wait(10)
|
||||
game.save.onBike = true
|
||||
|
||||
-- ---- hand off ----------------------------------------------------------
|
||||
U.log(("On the bike on Cycling Road at (%d,%d), already rolling south.")
|
||||
:format(START_X, START_Y))
|
||||
U.log("Hold A, then B, then let go, then hold A while pressing DOWN.")
|
||||
U.log("Correct: dead stop while held, roll resumes the instant you release,")
|
||||
U.log("a held direction still steers. #255 was B doing nothing and A")
|
||||
U.log("stalling one frame. Shots in " .. SHOT_DIR .. "/bug255_*.png")
|
||||
|
||||
while true do
|
||||
coroutine.yield()
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,71 @@
|
||||
-- Look at the Pokedex entry pic (#307).
|
||||
-- `local ok, img = path and pcall(love.graphics.newImage, path)` is one
|
||||
-- expression, not a guarded pcall: img was always nil and every dex page drew
|
||||
-- an empty pic box. parity_dex_pic.lua asserts the image loads; placement
|
||||
-- (top-left of the page, bottom-aligned to y=60) is the half only an eye can judge.
|
||||
-- POKEPORT_DRIVER=tests/drivers/dex_pic_bug307_test.lua POKEPORT_IDENTITY=bug307 POKEPORT_TOUCH=0 POKEPORT_VERSION=red love .
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local Screens = require("src.ui.Screens")
|
||||
local Sprites = require("src.pokemon.Sprites")
|
||||
|
||||
-- the starters are the reported case (Oak's lab preview); PIDGEY is an
|
||||
-- ordinary list entry as the control
|
||||
local CASES = { "BULBASAUR", "CHARMANDER", "SQUIRTLE", "PIDGEY" }
|
||||
|
||||
local function check(label, ok)
|
||||
U.log(ok and "PASS" or "FAIL", label)
|
||||
return ok
|
||||
end
|
||||
|
||||
-- an unresolved path, a PNG missing from the cache and the truncation bug
|
||||
-- itself all render as the same empty box
|
||||
local DexEntryMenu = require("src.ui.DexEntryMenu")
|
||||
for _, species in ipairs(CASES) do
|
||||
local path = Sprites.path(game.data, species, "front", { kind = "dex" })
|
||||
check(species .. " resolves a dex front-pic path",
|
||||
type(path) == "string" and path ~= "")
|
||||
local page = DexEntryMenu.new(game, { species = species, forceOwned = true })
|
||||
local held = page.sprite ~= nil
|
||||
check(species .. " dex page holds its pic", held)
|
||||
if held and page.sprite.getDimensions then
|
||||
local w, h = page.sprite:getDimensions()
|
||||
U.log(" " .. species .. " pic is", w .. "x" .. h,
|
||||
"drawn at x=8, y=" .. math.max(0, 60 - h))
|
||||
end
|
||||
end
|
||||
|
||||
-- mark the cases seen so the list can reach them
|
||||
local dex = game.save.pokedex
|
||||
if dex then
|
||||
for _, species in ipairs(CASES) do
|
||||
dex.seen[species] = true
|
||||
dex.owned[species] = true
|
||||
end
|
||||
U.log("flagged", #CASES, "species as seen+owned so the list can open them")
|
||||
end
|
||||
|
||||
-- screenshots, one page at a time
|
||||
local SHOT_DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
|
||||
for _, species in ipairs(CASES) do
|
||||
while game.stack:top() do game.stack:pop() end
|
||||
Screens.push(game, "DexEntryMenu", { species = species, forceOwned = true })
|
||||
U.wait(30)
|
||||
U.shot(game, SHOT_DIR .. "/dex_" .. species:lower() .. ".png")
|
||||
U.log("captured", SHOT_DIR .. "/dex_" .. species:lower() .. ".png")
|
||||
end
|
||||
|
||||
-- leave a live page up for the hand-off
|
||||
while game.stack:top() do game.stack:pop() end
|
||||
Screens.push(game, "DexEntryMenu", { species = "BULBASAUR", forceOwned = true })
|
||||
U.wait(10)
|
||||
|
||||
U.log("A BULBASAUR dex entry is open; shots of all four cases are in " .. SHOT_DIR)
|
||||
U.log("The front sprite belongs in the top-left, standing on the line above")
|
||||
U.log("HT/WT, not floating or overlapping the text. Under #307 that whole")
|
||||
U.log("side was blank white. A or B closes the page.")
|
||||
|
||||
while true do
|
||||
coroutine.yield()
|
||||
end
|
||||
end
|
||||
@@ -56,7 +56,7 @@ return function(game)
|
||||
end
|
||||
|
||||
-- Youngster5 @ (27,40): "I ran out of POKé / BALLs to catch" then
|
||||
-- \v "POKéMON with!" — the bug auto-scrolled past this with no ▼.
|
||||
-- \v "POKéMON with!" -- the bug auto-scrolled past this with no ▼.
|
||||
U.teleport(game, "VIRIDIAN_FOREST", 27, 41, "up")
|
||||
U.tap(game, "a")
|
||||
local box = waitForTextBox(90)
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
-- Driver: the foe's party ball row between a KO and the next send-out (#283).
|
||||
-- pokered ReplaceFaintedEnemyMon (engine/battle/core.asm:892-896) callfars
|
||||
-- DrawEnemyPokeballs before it falls into EnemySendOut; the port never did.
|
||||
-- POKEPORT_DRIVER=tests/drivers/enemy_balls_bug283_test.lua \
|
||||
-- POKEPORT_IDENTITY=bug283 POKEPORT_TOUCH=0 POKEPORT_VERSION=red \
|
||||
-- SHOT_DIR=/tmp/shots love . (never under POKEPORT_SPEED: audio-timed)
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
local BattleState = require("src.battle.BattleState")
|
||||
|
||||
local function check(label, ok)
|
||||
U.log(ok and "PASS" or "FAIL", label)
|
||||
return ok
|
||||
end
|
||||
|
||||
-- roster 1 is RATTATA 11 / EKANS 11 in both versions: two slots, so the row
|
||||
-- has a KO'd ball, a live one and four empties to tell apart.
|
||||
local OPP, ROSTER = "OPP_YOUNGSTER", 1
|
||||
|
||||
-- ---- preconditions ------------------------------------------------------
|
||||
-- Each of these fails silently as "no ball row on screen", same as the bug.
|
||||
|
||||
check("trainer class " .. OPP .. " is in the data",
|
||||
game.data.trainers ~= nil and game.data.trainers[OPP] ~= nil)
|
||||
|
||||
-- drawBallRow bails out silently when the sheet will not load, and the
|
||||
-- underline comes off the $73/$74/$76/$78 HUD glyph pages
|
||||
local ballSheet = love.filesystem.getInfo("assets/generated/battle/balls.png")
|
||||
check("assets/generated/battle/balls.png is in the cache", ballSheet ~= nil)
|
||||
local okImg = pcall(love.graphics.newImage, "assets/generated/battle/balls.png")
|
||||
check("the ball sheet actually decodes", okImg)
|
||||
check("BattleState:drawBallRow exists",
|
||||
type(BattleState.drawBallRow) == "function")
|
||||
local HudTiles = require("src.render.HudTiles")
|
||||
check("HudTiles.tile is available for the underline",
|
||||
type(HudTiles.tile) == "function")
|
||||
|
||||
-- SHIFT keeps the row up through the whole YES/NO prompt (showEnemyBalls is
|
||||
-- not cleared until the send-out act); SET only flashes it for 16 frames.
|
||||
game.save.options = game.save.options or {}
|
||||
game.save.options.battleStyle = "shift"
|
||||
game.save.player.name = "bryan"
|
||||
-- two party slots: SHIFT only offers the switch when wPartyCount > 1
|
||||
game.save.party = {
|
||||
Pokemon.new(game.data, "MEWTWO", 70),
|
||||
Pokemon.new(game.data, "CHARIZARD", 50),
|
||||
}
|
||||
check("a one-shot lead is in the party", game.save.party[1] ~= nil)
|
||||
|
||||
U.teleport(game, "PALLET_TOWN", 10, 8, "down")
|
||||
U.wait(20)
|
||||
local ow = game.overworld
|
||||
check("overworld is up to push the battle from", ow ~= nil)
|
||||
|
||||
local ok, battle = pcall(BattleState.newTrainer, game, OPP, ROSTER)
|
||||
check("trainer battle constructed", ok and battle ~= nil)
|
||||
if not ok then
|
||||
U.log("could not start", OPP, "->", tostring(battle))
|
||||
while true do coroutine.yield() end
|
||||
end
|
||||
check("the foe has at least two mons to swap between", #battle.enemyParty >= 2)
|
||||
battle.onFinish = function() end
|
||||
ow:pushBattle(battle)
|
||||
|
||||
-- tap A until cond(), polling every frame so a one-frame window is caught
|
||||
local function tapUntil(cond, taps, gap)
|
||||
for _ = 1, (taps or 60) do
|
||||
if cond() then return true end
|
||||
U.tap(game, "a")
|
||||
for _ = 1, (gap or 6) do
|
||||
if cond() then return true end
|
||||
U.wait(1)
|
||||
end
|
||||
end
|
||||
return cond()
|
||||
end
|
||||
|
||||
check("reached the FIGHT/PKMN/ITEM/RUN menu",
|
||||
tapUntil(function() return battle.phase == "menu" end, 60))
|
||||
|
||||
-- FIGHT, then the first move: L70 MEWTWO one-shots a L11 RATTATA, so the KO
|
||||
-- is the real sequence and not a poked hp value.
|
||||
U.tap(game, "a")
|
||||
U.wait(10)
|
||||
U.tap(game, "a")
|
||||
U.wait(10)
|
||||
|
||||
-- Stop the instant showEnemyBalls goes up: that flag is raised exactly where
|
||||
-- ReplaceFaintedEnemyMon calls DrawEnemyPokeballs.
|
||||
local reached = tapUntil(function() return battle.showEnemyBalls == true end,
|
||||
90, 4)
|
||||
check("the foe's first mon was KO'd and the ball row went up (#283)", reached)
|
||||
check("the KO'd slot really is fainted",
|
||||
battle.enemyParty[1] ~= nil and battle.enemyParty[1].hp <= 0)
|
||||
check("a live reserve is still in the row",
|
||||
battle.enemyParty[2] ~= nil and battle.enemyParty[2].hp > 0)
|
||||
|
||||
if reached then
|
||||
check("row screenshot reached disk",
|
||||
U.shot(game, DIR .. "/bug283_balls_after_ko.png"))
|
||||
U.log("captured", DIR .. "/bug283_balls_after_ko.png")
|
||||
-- a few more presses put the SHIFT prompt on top of the still-visible row
|
||||
tapUntil(function() return battle.showEnemyBalls ~= true end, 3, 20)
|
||||
check("row screenshot during the SHIFT prompt reached disk",
|
||||
U.shot(game, DIR .. "/bug283_balls_with_prompt.png"))
|
||||
U.log("captured", DIR .. "/bug283_balls_with_prompt.png")
|
||||
U.log("showEnemyBalls is still up at hand-off:",
|
||||
tostring(battle.showEnemyBalls))
|
||||
end
|
||||
|
||||
-- ---- hand off -----------------------------------------------------------
|
||||
U.log("The foe's first mon is KO'd and we are paused before the send-out.")
|
||||
U.log("Top left should carry six small Poke Balls running right to left from")
|
||||
U.log("(64,16) on a HUD rule: one crossed out, one solid, four empty.")
|
||||
U.log("#283 was that corner empty. Press A, the row clears on the send-out.")
|
||||
|
||||
while true do
|
||||
coroutine.yield()
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,315 @@
|
||||
-- Driver: the evolution flash must put the WHOLE screen on PAL_BLACK (#279).
|
||||
-- pokered engine/movie/evolution.asm EvolveMon flashes under PAL_BLACK; only the
|
||||
-- settled form wears a mon palette, and PAL_BLACK keeps colour 0 as paper white
|
||||
-- (sgb_palettes.asm:46), so the background behind the silhouettes stays put.
|
||||
-- POKEPORT_DRIVER=tests/drivers/evolution_black_bug279_test.lua \
|
||||
-- POKEPORT_IDENTITY=bug279 SHOT_DIR=/tmp/shots love .
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local Probe = dofile("tests/drivers/shot_probe.lua")
|
||||
local PaletteFX = require("src.render.PaletteFX")
|
||||
local EvolutionState = require("src.ui.EvolutionState")
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
local Evolution = require("src.pokemon.Evolution")
|
||||
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
|
||||
|
||||
local fails = 0
|
||||
local function check(ok, msg)
|
||||
U.log(ok and "PASS" or "FAIL", msg)
|
||||
if not ok then fails = fails + 1 end
|
||||
return ok
|
||||
end
|
||||
local function rgb(c)
|
||||
return c and ("(%d,%d,%d)"):format(c[1], c[2], c[3]) or "nil"
|
||||
end
|
||||
local function ramp(p)
|
||||
if not p then return "nil" end
|
||||
local s = {}
|
||||
for i = 1, 4 do s[i] = rgb(p[i]) end
|
||||
return table.concat(s, " ")
|
||||
end
|
||||
local function samePal(a, b)
|
||||
if not a or not b then return false end
|
||||
for i = 1, 4 do
|
||||
if not a[i] or not b[i] then return false end
|
||||
for k = 1, 3 do if a[i][k] ~= b[i][k] then return false end end
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
-- Text speed 1 keeps the "Congratulations!" box brisk; FLASH_FRAMES is a
|
||||
-- frame count, so the flash itself is unaffected.
|
||||
game.save.options = game.save.options or {}
|
||||
game.save.options.textSpeed = 1
|
||||
|
||||
-- =====================================================================
|
||||
-- Part 1: the halves the eye cannot check. A missing BLACK entry or a zone
|
||||
-- that does not cover the screen looks exactly like the bug.
|
||||
-- =====================================================================
|
||||
game.save.options.colors = "gbc"
|
||||
PaletteFX.setMode("gbc")
|
||||
|
||||
local BLACK = PaletteFX.pal(game.data, "BLACK")
|
||||
check(BLACK ~= nil, "data.palettes carries a BLACK entry (PAL_BLACK)")
|
||||
U.log("BLACK ramp:", ramp(BLACK))
|
||||
-- sgb_palettes.asm RGB 31,29,31 / 07,07,07 / 02,03,03 / 03,02,02, scaled
|
||||
-- to 8 bits by the extractor
|
||||
check(BLACK and BLACK[1][1] == 255 and BLACK[1][2] == 239 and BLACK[1][3] == 255,
|
||||
"PAL_BLACK colour 0 is still paper white (the background does NOT black out)")
|
||||
check(BLACK and BLACK[2][1] == 58 and BLACK[3][1] == 16 and BLACK[4][1] == 25,
|
||||
"PAL_BLACK shades 1..3 are crushed (58,58,58 / 16,25,25 / 25,16,16)")
|
||||
|
||||
-- sgbPalettes is a pure function of the state's own fields, so drive it
|
||||
-- directly for the cases the live run cannot hold still. MAGIKARP is REDMON
|
||||
-- and GYARADOS BLUEMON: a CATERPIE line settles on GREENMON either way and
|
||||
-- would prove nothing.
|
||||
local function zonesFor(fields)
|
||||
local fake = setmetatable(fields, EvolutionState)
|
||||
return EvolutionState.sgbPalettes(fake, game)
|
||||
end
|
||||
local function colorsFor(fields)
|
||||
local z = zonesFor(fields)
|
||||
return z and z[1] and z[1].colors, z and z[1]
|
||||
end
|
||||
|
||||
local flashCols, flashZone = colorsFor({
|
||||
done = false, canceled = false,
|
||||
mon = { species = "MAGIKARP" }, newSpecies = "GYARADOS",
|
||||
})
|
||||
check(samePal(flashCols, BLACK),
|
||||
"mid-flash the whole screen is PAL_BLACK, not a mon palette")
|
||||
U.log("mid-flash zone resolves to:", ramp(flashCols))
|
||||
check(flashZone ~= nil and flashZone.x == 0 and flashZone.y == 0
|
||||
and flashZone.w == 160 and flashZone.h == 144,
|
||||
"that zone covers the whole 160x144 screen (SetPal_PokemonWholeScreen)")
|
||||
|
||||
local settled = colorsFor({
|
||||
done = true, canceled = false,
|
||||
mon = { species = "MAGIKARP" }, newSpecies = "GYARADOS",
|
||||
})
|
||||
check(samePal(settled, PaletteFX.monPal(game.data, "GYARADOS")),
|
||||
"once .done, the NEW form wears its own palette again (GYARADOS/BLUEMON)")
|
||||
local cancelled = colorsFor({
|
||||
done = true, canceled = true,
|
||||
mon = { species = "MAGIKARP" }, newSpecies = "GYARADOS",
|
||||
})
|
||||
check(samePal(cancelled, PaletteFX.monPal(game.data, "MAGIKARP")),
|
||||
"a B-cancelled evolution settles on the OLD form (MAGIKARP/REDMON)")
|
||||
|
||||
-- Mode guards: routing the blackout through PaletteFX.pal is what keeps the
|
||||
-- other COLORS modes honest. A hardcoded {0,0,0} passes everything above.
|
||||
PaletteFX.setMode("ogred")
|
||||
local ogFlash = colorsFor({
|
||||
done = false, canceled = false,
|
||||
mon = { species = "WEEDLE" }, newSpecies = "KAKUNA",
|
||||
})
|
||||
check(samePal(ogFlash, PaletteFX.ogBg()),
|
||||
"OG RED does NOT black out (a Game Boy Color never sees the SGB packet)")
|
||||
PaletteFX.setMode("redpp")
|
||||
check(PaletteFX.pal(game.data, "BLACK") ~= nil,
|
||||
"RED++ resolves BLACK from data/palettes_gbc.lua (no ROM fallback needed)")
|
||||
PaletteFX.setMode("og")
|
||||
check(samePal(PaletteFX.effectiveColors(BLACK), PaletteFX.GRAYS),
|
||||
"plain DMG replaces it wholesale, so the mono modes are unchanged")
|
||||
PaletteFX.setMode("gbc")
|
||||
game.save.options.colors = "gbc"
|
||||
|
||||
-- The bytes the pixel probe hunts for, read out of the data and read while
|
||||
-- the mode is SGB (under OG RED, PaletteFX.pal short-circuits every name to
|
||||
-- the one palette).
|
||||
local YELLOW = PaletteFX.monPal(game.data, "WEEDLE") -- YELLOWMON
|
||||
local REDMON = PaletteFX.monPal(game.data, "MAGIKARP") -- REDMON
|
||||
local BLUE = PaletteFX.monPal(game.data, "GYARADOS") -- BLUEMON
|
||||
local OGBG = PaletteFX.GBC_BG
|
||||
check(YELLOW ~= nil and REDMON ~= nil and BLUE ~= nil,
|
||||
"WEEDLE/MAGIKARP/GYARADOS mon palettes resolve")
|
||||
U.log("WEEDLE:", ramp(YELLOW), " MAGIKARP:", ramp(REDMON),
|
||||
" GYARADOS:", ramp(BLUE))
|
||||
|
||||
local function probe(label, wanted)
|
||||
local shot = Probe.grab()
|
||||
if not shot then
|
||||
U.log("WARN pixel probe unavailable (no screenshot capture); judge",
|
||||
label, "by eye only")
|
||||
return nil
|
||||
end
|
||||
local counts, total = Probe.count(shot, wanted)
|
||||
local parts = {}
|
||||
for name, n in pairs(counts) do
|
||||
parts[#parts + 1] = ("%s=%d"):format(name, n)
|
||||
end
|
||||
table.sort(parts)
|
||||
U.log(("probe[%s] %d px sampled: %s"):format(label, total,
|
||||
table.concat(parts, " ")))
|
||||
return counts
|
||||
end
|
||||
|
||||
-- =====================================================================
|
||||
-- Part 2: reach the moment and photograph it.
|
||||
-- =====================================================================
|
||||
U.teleport(game, "ROUTE_1", 5, 5, "down")
|
||||
U.wait(10)
|
||||
|
||||
local function evoTop()
|
||||
local t = game.stack:top()
|
||||
return (t and t.screenId == "EvolutionState") and t or nil
|
||||
end
|
||||
local function waitFor(cond, max)
|
||||
for _ = 1, max or 600 do
|
||||
if cond() then return true end
|
||||
U.wait(1)
|
||||
end
|
||||
return false
|
||||
end
|
||||
local function pagesText(st)
|
||||
if not st or not st.pages then return nil end
|
||||
local parts = {}
|
||||
for _, page in ipairs(st.pages) do
|
||||
if type(page) == "table" then
|
||||
for _, line in ipairs(page) do
|
||||
if type(line) == "string" then parts[#parts + 1] = line end
|
||||
end
|
||||
elseif type(page) == "string" then
|
||||
parts[#parts + 1] = page
|
||||
end
|
||||
end
|
||||
return table.concat(parts, " ")
|
||||
end
|
||||
local function findText(needle)
|
||||
for _, st in ipairs(game.stack.states or {}) do
|
||||
local blob = pagesText(st)
|
||||
if blob and blob:find(needle, 1, true) then return st end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
local function mashUntil(cond, max)
|
||||
for _ = 1, max or 200 do
|
||||
if cond() then return true end
|
||||
U.tap(game, "a")
|
||||
U.wait(3)
|
||||
end
|
||||
return cond()
|
||||
end
|
||||
|
||||
-- Jump the flash clock to just under FLASH_FRAMES (220) once the shot is
|
||||
-- taken. EvolutionState:update only compares self.t against that constant,
|
||||
-- so this ends the animation early without touching the palette logic.
|
||||
local function skipToEnd()
|
||||
local st = evoTop()
|
||||
if st then st.t = 214 end
|
||||
end
|
||||
|
||||
local function startEvo(species, into)
|
||||
local mon = Pokemon.new(game.data, species, 7)
|
||||
table.insert(game.save.party, 1, mon)
|
||||
Evolution.evolve(game, mon, into)
|
||||
if not waitFor(evoTop, 300) then
|
||||
check(false, "EvolutionState opened for " .. species .. " -> " .. into)
|
||||
return nil
|
||||
end
|
||||
return mon
|
||||
end
|
||||
|
||||
-- ---- case 1: the reported case, SGB, mid-flash --------------------------
|
||||
local weedle = startEvo("WEEDLE", "KAKUNA")
|
||||
U.wait(24) -- into the flash, far short of FLASH_FRAMES = 220
|
||||
U.shot(game, DIR .. "/evo279_1_flash_sgb.png")
|
||||
local c1 = probe("flash/SGB", {
|
||||
monYellow1 = YELLOW[2], monYellow2 = YELLOW[3],
|
||||
black1 = BLACK[2], black2 = BLACK[3], paper = BLACK[1],
|
||||
})
|
||||
if c1 then
|
||||
check(c1.monYellow1 == 0 and c1.monYellow2 == 0,
|
||||
"no WEEDLE/KAKUNA yellow anywhere on screen during the flash")
|
||||
check(c1.black1 + c1.black2 > 0,
|
||||
"the crushed PAL_BLACK shades ARE on screen (the silhouette)")
|
||||
check(c1.paper > 0,
|
||||
"paper white still fills the background (PAL_BLACK keeps colour 0)")
|
||||
end
|
||||
|
||||
-- ---- case 1b: it comes back once the flash is over ----------------------
|
||||
skipToEnd()
|
||||
if not waitFor(function() return findText("evolved into") ~= nil end, 240) then
|
||||
check(false, "the Congratulations text appears when the flash ends")
|
||||
end
|
||||
U.wait(50) -- let the box finish typing before the shot
|
||||
U.shot(game, DIR .. "/evo279_2_congrats_sgb.png")
|
||||
local c2 = probe("congrats/SGB", {
|
||||
monYellow1 = YELLOW[2], monYellow2 = YELLOW[3], black1 = BLACK[2],
|
||||
})
|
||||
if c2 then
|
||||
check(c2.monYellow1 + c2.monYellow2 > 0,
|
||||
"the settled KAKUNA is yellow again under the Congratulations box")
|
||||
end
|
||||
check(weedle == nil or weedle.species == "KAKUNA",
|
||||
"the mon actually evolved (control: the fix touched no game logic)")
|
||||
mashUntil(function() return not evoTop() and not findText("evolved into") end, 90)
|
||||
U.wait(10)
|
||||
|
||||
-- ---- case 2: B-cancel settles on the OLD form's colours -----------------
|
||||
local karp = startEvo("MAGIKARP", "GYARADOS")
|
||||
U.wait(24)
|
||||
U.hold(game, "b", 20) -- evolution.asm Evolution_CheckForCancel
|
||||
if not waitFor(function() return findText("stopped evolving") ~= nil end, 240) then
|
||||
check(false, "holding B prints \"stopped evolving\"")
|
||||
end
|
||||
U.wait(50)
|
||||
U.shot(game, DIR .. "/evo279_3_cancelled_sgb.png")
|
||||
local c3 = probe("cancelled/SGB", {
|
||||
red1 = REDMON[2], red2 = REDMON[3], blue1 = BLUE[2], blue2 = BLUE[3],
|
||||
})
|
||||
if c3 then
|
||||
check(c3.red1 + c3.red2 > 0,
|
||||
"a cancelled MAGIKARP settles back into MAGIKARP's own red")
|
||||
check(c3.blue1 == 0 and c3.blue2 == 0,
|
||||
"no GYARADOS blue leaks in (wEvoOldSpecies, not the new species)")
|
||||
end
|
||||
check(karp == nil or karp.species == "MAGIKARP", "the cancelled mon kept its species")
|
||||
mashUntil(function() return not evoTop() and not findText("stopped evolving") end, 90)
|
||||
U.wait(10)
|
||||
|
||||
-- ---- case 3: OG RED must NOT black out ---------------------------------
|
||||
-- A Game Boy Color never sees the SGB packet, so its one global BG palette
|
||||
-- applies all the way through. Switch mode with only the overworld on the
|
||||
-- stack: setMode's cache drop / map reload must not land on a live evolution.
|
||||
game.save.options.colors = "ogred"
|
||||
PaletteFX.setMode("ogred")
|
||||
U.wait(20)
|
||||
startEvo("WEEDLE", "KAKUNA")
|
||||
U.wait(24)
|
||||
U.shot(game, DIR .. "/evo279_4_flash_ogred.png")
|
||||
local c4 = probe("flash/OG RED", {
|
||||
ogPink = OGBG[2], ogRed = OGBG[3], black1 = BLACK[2], black2 = BLACK[3],
|
||||
})
|
||||
if c4 then
|
||||
check(c4.ogPink + c4.ogRed > 0, "OG RED stays red/pink through the flash")
|
||||
check(c4.black1 == 0 and c4.black2 == 0,
|
||||
"PAL_BLACK's crushed shades never appear in OG RED")
|
||||
end
|
||||
skipToEnd()
|
||||
waitFor(function() return findText("evolved into") ~= nil end, 240)
|
||||
mashUntil(function() return not evoTop() and not findText("evolved into") end, 90)
|
||||
|
||||
game.save.options.colors = "gbc"
|
||||
PaletteFX.setMode("gbc")
|
||||
U.wait(20)
|
||||
|
||||
U.log(fails == 0 and "all #279 preconditions passed"
|
||||
or (fails .. " #279 precondition(s) FAILED -- read up"))
|
||||
|
||||
-- =====================================================================
|
||||
-- Part 3: one more, live, for the human.
|
||||
-- =====================================================================
|
||||
startEvo("WEEDLE", "KAKUNA")
|
||||
|
||||
U.log("A WEEDLE is evolving now in SGB colours, about four seconds. While the")
|
||||
U.log("two forms trade places the whole screen is on PAL_BLACK: near-black")
|
||||
U.log("silhouettes on the unchanged off-white background, colour back only")
|
||||
U.log("under the Congratulations box. #279 flashed both forms in full colour.")
|
||||
U.log("Hold B mid-flash to cancel; it settles on the OLD form's colours.")
|
||||
U.log("Screenshots: " .. DIR .. "/evo279_*.png")
|
||||
|
||||
while true do
|
||||
coroutine.yield()
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,189 @@
|
||||
-- Driver: the fishing rod is ONE 8x8 tile in the player's hands, mirrored
|
||||
-- between left and right, and it stays up through the verdict text (#321).
|
||||
-- engine/overworld/player_animations.asm:471 FishingRodOAM draws one tile per
|
||||
-- facing ($fd up/down, $fe sides, OAM_XFLIP on right) out of the 8x24 sheet
|
||||
-- at :489; :437 clears BIT_LEDGE_OR_FISHING only after PrintText.
|
||||
-- POKEPORT_DRIVER=tests/drivers/fishing_rod_bug321_test.lua POKEPORT_IDENTITY=bug321 SHOT_DIR=/tmp/shots love .
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
local Screens = require("src.ui.Screens")
|
||||
local TextBox = require("src.render.TextBox")
|
||||
local SHOT_DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
|
||||
|
||||
local function check(label, ok)
|
||||
U.log(ok and "PASS" or "FAIL", label)
|
||||
return ok
|
||||
end
|
||||
|
||||
-- The Viridian City pond (water block x=8..13, y=24..27) is the one spot
|
||||
-- where all four shores are a few steps apart, so every facing fits on one
|
||||
-- screen. shoreFor() below re-derives each stand cell from the map data.
|
||||
local MAP = "VIRIDIAN_CITY"
|
||||
local SPOTS = {
|
||||
{ x = 10, y = 23, facing = "down", note = "north shore" },
|
||||
{ x = 8, y = 28, facing = "up", note = "south shore" },
|
||||
{ x = 14, y = 24, facing = "left", note = "east shore" },
|
||||
{ x = 7, y = 24, facing = "right", note = "west shore" },
|
||||
}
|
||||
local DELTA = { up = { 0, -1 }, down = { 0, 1 }, left = { -1, 0 }, right = { 1, 0 } }
|
||||
local ROD = "OLD_ROD"
|
||||
|
||||
-- ---- preconditions the eye cannot separate from the bug ----------------
|
||||
-- A missing sheet, a rod that never reaches goFishing and a rod drawn at
|
||||
-- garbage coordinates all look the same: no rod in the hands.
|
||||
|
||||
local fx = game.data.field.overworldFx
|
||||
local rodDef = fx and fx.fishingRod
|
||||
check("field.overworldFx.fishingRod resolves",
|
||||
rodDef ~= nil and type(rodDef.path) == "string")
|
||||
if rodDef then U.log("rod sheet:", rodDef.path) end
|
||||
|
||||
-- three stacked 8x8 tiles, of which exactly one is ever drawn; if the sheet
|
||||
-- is not 8x24 the ROD_OAM tile indices mean nothing
|
||||
if rodDef and rodDef.path then
|
||||
local ok, img = pcall(love.graphics.newImage, rodDef.path)
|
||||
if check("the rod sheet loads", ok and img ~= nil) then
|
||||
local w, h = img:getDimensions()
|
||||
U.log(("rod sheet is %dx%d"):format(w, h))
|
||||
check("it is 8 wide (one tile)", w == 8)
|
||||
check("it is 24 tall (three stacked 8x8 tiles, RedFishingRodTiles)", h == 24)
|
||||
end
|
||||
end
|
||||
|
||||
check("OLD ROD is a real item", game.data.items[ROD] ~= nil)
|
||||
local ItemEffects = require("src.inventory.ItemEffects")
|
||||
local result = ItemEffects.use(game.data, game.save, ROD, nil, nil, nil, game.overworld)
|
||||
check("using it routes to the fishing branch", result == "fish")
|
||||
|
||||
-- Old Rod always hooks a L5 MAGIKARP, so the verdict box is deterministic
|
||||
local rodFishing = (game.data.field.fishing or {})[ROD]
|
||||
check("OLD ROD always gets a bite (field.fishing.OLD_ROD.always)",
|
||||
rodFishing ~= nil and rodFishing.always ~= nil)
|
||||
|
||||
-- the bite ends in a battle once the box is dismissed
|
||||
game.save.party = { Pokemon.new(game.data, "CHARIZARD", 50) }
|
||||
game.save.inventory = { [ROD] = 1 }
|
||||
game.save.bagOrder = { ROD }
|
||||
check("the rod is the only thing in the bag (so it is item #1)",
|
||||
(game.save.inventory[ROD] or 0) > 0)
|
||||
|
||||
-- ---- helpers -----------------------------------------------------------
|
||||
|
||||
-- Walk the real path, START-menu bag -> rod -> USE: BagMenu's fish branch
|
||||
-- is what calls goFishing, and it refuses unless the faced cell is water,
|
||||
-- so this doubles as proof the stand cell is right.
|
||||
local function useRod()
|
||||
Screens.push(game, "BagMenu")
|
||||
U.wait(20)
|
||||
U.tap(game, "a") -- select OLD ROD -> USE/TOSS
|
||||
U.wait(20)
|
||||
U.tap(game, "a") -- USE
|
||||
U.wait(30)
|
||||
end
|
||||
|
||||
local function topBox()
|
||||
local top = game.stack:top()
|
||||
if getmetatable(top) ~= TextBox then return nil end
|
||||
local shown = {}
|
||||
for _, page in ipairs(top.pages or {}) do
|
||||
for _, line in ipairs(page) do shown[#shown + 1] = line end
|
||||
end
|
||||
return top, table.concat(shown, " / ")
|
||||
end
|
||||
|
||||
-- A press while the box is still typing only finishes the line, so tap
|
||||
-- until the box on top is a different one (or we run out of patience).
|
||||
local function advance(box)
|
||||
for _ = 1, 12 do
|
||||
U.tap(game, "a")
|
||||
U.wait(12)
|
||||
if game.stack:top() ~= box then return true end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
-- if a map edit moved the pond, find any shore facing water the same way
|
||||
-- rather than parking the player at a wall
|
||||
local function shoreFor(map, spot)
|
||||
local d = DELTA[spot.facing]
|
||||
if map:inBounds(spot.x + d[1], spot.y + d[2])
|
||||
and map:isWaterCell(spot.x + d[1], spot.y + d[2])
|
||||
and map:isWalkableCell(spot.x, spot.y) then
|
||||
return spot.x, spot.y
|
||||
end
|
||||
for y = 0, map.def.height * 2 - 1 do
|
||||
for x = 0, map.def.width * 2 - 1 do
|
||||
if map:isWalkableCell(x, y) and not map:isWaterCell(x, y)
|
||||
and map:inBounds(x + d[1], y + d[2])
|
||||
and map:isWaterCell(x + d[1], y + d[2]) then
|
||||
U.log(("(%d,%d) no longer faces water; using (%d,%d) for %s")
|
||||
:format(spot.x, spot.y, x, y, spot.facing))
|
||||
return x, y
|
||||
end
|
||||
end
|
||||
end
|
||||
return spot.x, spot.y
|
||||
end
|
||||
|
||||
-- ---- the four facings --------------------------------------------------
|
||||
|
||||
for i, spot in ipairs(SPOTS) do
|
||||
local last = (i == #SPOTS)
|
||||
U.teleport(game, MAP, spot.x, spot.y, spot.facing)
|
||||
U.wait(15)
|
||||
local ow = game.overworld
|
||||
local sx, sy = shoreFor(ow.map, spot)
|
||||
if sx ~= spot.x or sy ~= spot.y then
|
||||
U.teleport(game, MAP, sx, sy, spot.facing)
|
||||
U.wait(15)
|
||||
ow = game.overworld
|
||||
end
|
||||
local fcx, fcy = ow.player:facingCell()
|
||||
check(("facing %s from (%d,%d), the %s: water in front")
|
||||
:format(spot.facing, sx, sy, spot.note),
|
||||
ow.map:inBounds(fcx, fcy) and ow.map:isWaterCell(fcx, fcy))
|
||||
|
||||
useRod()
|
||||
|
||||
local dots, dotsText = topBox()
|
||||
check(("%s: USE opened the fishing box"):format(spot.facing), dots ~= nil)
|
||||
if dotsText then U.log("box reads:", dotsText) end
|
||||
check(("%s: the rod is up (overworld.fishing is set)"):format(spot.facing),
|
||||
ow.fishing ~= nil)
|
||||
check(("%s: the rod remembers this facing"):format(spot.facing),
|
||||
ow.fishing ~= nil and ow.fishing.facing == spot.facing)
|
||||
if not U.shot(game, ("%s/bug321_rod_%s_dots.png"):format(SHOT_DIR, spot.facing)) then
|
||||
check(("%s: dots screenshot reached disk"):format(spot.facing), false)
|
||||
end
|
||||
|
||||
-- the timing half of #321: the dots box closes, the verdict box opens,
|
||||
-- and the rod must still be in the player's hands the whole time
|
||||
if dots then advance(dots) end
|
||||
local verdict, verdictText = topBox()
|
||||
check(("%s: the verdict box opened"):format(spot.facing), verdict ~= nil)
|
||||
if verdictText then U.log("box reads:", verdictText) end
|
||||
check(("%s: the rod is STILL up while the verdict text shows"):format(spot.facing),
|
||||
ow.fishing ~= nil)
|
||||
if not U.shot(game, ("%s/bug321_rod_%s_verdict.png"):format(SHOT_DIR, spot.facing)) then
|
||||
check(("%s: verdict screenshot reached disk"):format(spot.facing), false)
|
||||
end
|
||||
|
||||
-- The last facing is left live for the hand-off; the others are cleared
|
||||
-- by the next teleport, which pops the stack, so no battle starts here.
|
||||
if last then
|
||||
U.log("leaving this box open for you")
|
||||
end
|
||||
end
|
||||
|
||||
-- ---- hand off ----------------------------------------------------------
|
||||
U.log("West shore of the Viridian pond, facing right, mid-cast.")
|
||||
U.log("Correct: one 8x8 rod stroke touching the player's hands, a mirror")
|
||||
U.log("image left vs right, still up for the whole verdict box (#321).")
|
||||
U.log("Shots: " .. SHOT_DIR .. "/bug321_rod_<facing>_{dots,verdict}.png")
|
||||
U.log("Still unported: the fishing pose, the sprite shake, the ! bubble.")
|
||||
|
||||
while true do
|
||||
coroutine.yield()
|
||||
end
|
||||
end
|
||||
@@ -1,27 +1,45 @@
|
||||
-- Visual + render-decision regression for #150 ("Grass transparency is off").
|
||||
--
|
||||
-- The reporter's should_be.png shows Red standing in Route 1 tall grass with a
|
||||
-- GREEN cap that blends into the grass. The default SGB color mode instead
|
||||
-- region-tints the player with the per-map SGB BG palette: Red's dark-gray cap
|
||||
-- (DMG shade 2) maps to the ROUTE palette's shade-2 = light-blue (165,214,255)
|
||||
-- (data/generated/palettes.lua ROUTE), so the character clashes with the grass.
|
||||
-- On real GBC/SGB an OBJ carries its own object palette; OG RED already bakes
|
||||
-- PaletteFX.ogObj() (green over Red, pink over Blue -- color/sprites.asm
|
||||
-- ColorOverworldSprite) onto overworld characters. The fix makes SGB mode do
|
||||
-- the same while leaving terrain on its per-map SGB palette.
|
||||
-- GREEN cap that blends into the grass. That green is the ROUTE palette's own
|
||||
-- entry 2 (173,230,90), not an object palette of the character's: overworld
|
||||
-- OBJs run through rOBP0 = $D0 (home/fade.asm FadePal4 `dc 3,1,0,0`), which
|
||||
-- lifts OBJ color 1 to DMG shade 0 and color 2 to shade 1, so the cap lands on
|
||||
-- the very colour the grass beside it uses. Drawn with an identity shade map
|
||||
-- the cap landed on shade 2 = light-blue (165,214,255) instead -- the clash
|
||||
-- #150 reported. The first fix baked PaletteFX.ogObj() (the Game Boy Color
|
||||
-- boot ROM's green) onto SGB characters, which is a different machine's answer
|
||||
-- and became #301 ("people in SGB mode are green"): the Super Game Boy cannot
|
||||
-- colour an OBJ apart from the BG at all, since pokered never sends OBJ_TRN
|
||||
-- (data/sgb/sgb_packets.asm defines ATTR_BLK / PAL_SET / PAL_TRN / MLT_REQ /
|
||||
-- CHR_TRN / PCT_TRN and nothing else). So SGB bakes the OBP0 ramp
|
||||
-- (PaletteFX.dmgObj) and lets the zone shader colour the result.
|
||||
--
|
||||
-- Gate (fails before the fix, passes after):
|
||||
-- * PaletteFX.usesSpriteObp("gbc") == true
|
||||
-- * the player SpriteRenderer bakes a distinct OBJ image in SGB mode
|
||||
-- (resolveImage() ~= the raw grayscale sheet) -- the pixel path that
|
||||
-- recolors the cap green
|
||||
-- * that baked OBJ palette's shade-2 (the cap) is green, not ROUTE light-blue
|
||||
-- * PaletteFX.usesSpriteObp("gbc") == false -- SGB owns no object palette
|
||||
-- * the player SpriteRenderer still bakes a distinct image in SGB mode
|
||||
-- (resolveImage() ~= the raw grayscale sheet): rOBP0 plus the alpha key
|
||||
-- * that bake puts the cap (sheet shade 2) on DMG shade 1, which the zone
|
||||
-- shader then reads out of the map palette as its entry 2 -- ROUTE's grass
|
||||
-- green, not its light-blue
|
||||
-- Regression guard (must hold before AND after -- terrain is untouched):
|
||||
-- * the ROUTE terrain palette still carries BOTH grass green (173,230,90) and
|
||||
-- light-blue (165,214,255), so the grass field keeps its green+blue dither.
|
||||
--
|
||||
-- Screenshots (SHOT_DIR): grass_bug150_sgb.png (the reported view) and
|
||||
-- grass_bug150_ogred.png (OG RED reference -- green character, red terrain).
|
||||
-- The SECOND half of #150 is the feet overdraw itself. Tall grass hides the
|
||||
-- lower half of whoever stands in it (the GB OBJ-priority trick: an OBJ shows
|
||||
-- through BG colour 0 and hides under colours 1-3), which the port reproduces
|
||||
-- by redrawing the cell's bottom tile row over the sprite with shade 0 keyed
|
||||
-- to alpha. Every mode but ADVANCED still has DMG white sitting in shade 0 at
|
||||
-- draw time, so a white test finds it; ADVANCED bakes the real per-tile GBC
|
||||
-- palette into the atlas first (TileRenderer.getGbcAtlas), which turns the
|
||||
-- grass tile's shade 0 into its palette's light green -- the white test then
|
||||
-- never fires and the patch paints an opaque block over the player. Hence the
|
||||
-- gap-pixel gate below, run in BOTH modes so the two can't drift apart again.
|
||||
--
|
||||
-- Screenshots (SHOT_DIR): grass_bug150_sgb.png (the reported view),
|
||||
-- grass_bug150_advanced.png (the ADVANCED view) and grass_bug150_ogred.png
|
||||
-- (OG RED reference -- green character, red terrain).
|
||||
--
|
||||
-- Run: POKEPORT_DRIVER=tests/drivers/grass_overlay_bug150_test.lua \
|
||||
-- POKEPORT_IDENTITY=bug150 POKEPORT_TOUCH=0 love .
|
||||
@@ -43,6 +61,31 @@ return function(game)
|
||||
return false
|
||||
end
|
||||
|
||||
-- How many of the 16x8 feet-overdraw pixels let the thing underneath show
|
||||
-- through, measured the way the screen does it: render the cell's bottom
|
||||
-- tile row over an opaque magenta field and count the magenta survivors.
|
||||
-- Mode-agnostic on purpose -- it asks what reached the framebuffer, not
|
||||
-- which of the two keying paths (shader or baked alpha) produced it.
|
||||
local function grassGapPixels(ow)
|
||||
local p = ow.player
|
||||
local canvas = love.graphics.newCanvas(16, 8)
|
||||
love.graphics.setCanvas(canvas)
|
||||
love.graphics.clear(1, 0, 1, 1)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
-- camera placed so the cell's bottom tile row lands at the canvas origin
|
||||
ow.map.renderer:drawCellBottom(p.cellX, p.cellY, p.cellX * 16, p.cellY * 16 + 8)
|
||||
love.graphics.setCanvas()
|
||||
local id = canvas:newImageData()
|
||||
local n = 0
|
||||
for y = 0, 7 do
|
||||
for x = 0, 15 do
|
||||
local r, g, b = id:getPixel(x, y)
|
||||
if r > 0.9 and g < 0.1 and b > 0.9 then n = n + 1 end
|
||||
end
|
||||
end
|
||||
return n
|
||||
end
|
||||
|
||||
-- a party + starter flag so the overworld is fully usable
|
||||
game.save.flags.EVENT_GOT_STARTER = true
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
@@ -64,24 +107,35 @@ return function(game)
|
||||
U.wait(40) -- let the grass/flower tile animation cycle
|
||||
U.shot(game, DIR .. "/grass_bug150_sgb.png")
|
||||
|
||||
-- === render-decision gate: fails before the fix, passes after =========
|
||||
check(PaletteFX.usesSpriteObp("gbc") == true,
|
||||
"SGB mode bakes an OBJ palette onto overworld characters")
|
||||
-- the feet overdraw is see-through in SGB (the mode the reporter compared
|
||||
-- ADVANCED against), so this side of the gate holds before AND after
|
||||
local sgbGaps = grassGapPixels(ow)
|
||||
check(sgbGaps > 0,
|
||||
"SGB grass feet overdraw shows the sprite through its gaps ("
|
||||
.. sgbGaps .. "/128 px)")
|
||||
|
||||
-- the player's sprite must resolve to a baked OBJ image (not the raw
|
||||
-- grayscale sheet) in SGB mode -- this is the exact path that colors the cap
|
||||
-- === render-decision gate: fails before the fix, passes after =========
|
||||
check(PaletteFX.usesSpriteObp("gbc") == false,
|
||||
"SGB owns no object palette (it cannot colour an OBJ apart from the BG)")
|
||||
|
||||
-- the player's sprite must still resolve to a baked image (not the raw
|
||||
-- grayscale sheet) in SGB mode -- that bake is rOBP0 plus the alpha key
|
||||
local spr = p.sprite
|
||||
check(spr and spr.image and spr:resolveImage() ~= spr.image,
|
||||
"player sprite bakes a distinct OBJ image in SGB mode")
|
||||
"player sprite bakes a distinct OBP0 image in SGB mode")
|
||||
|
||||
-- the baked object palette's shade-2 (the cap) is a green, and specifically
|
||||
-- NOT the ROUTE light-blue the region shader used to hand it
|
||||
local obj = PaletteFX.ogObj() -- {white, brightgreen, darkgreen, black}
|
||||
local cap = obj and obj[3] -- DMG shade 2 -> 3rd palette entry
|
||||
-- OBP0 (`dc 3,1,0,0`) sends the cap -- sheet shade 2 -- to DMG shade 1, so
|
||||
-- the zone shader hands it the map palette's entry 2 rather than the entry 3
|
||||
-- light-blue an identity shade map used to pick
|
||||
local obp = PaletteFX.dmgObj()
|
||||
check(obp and obp[3][1] == 170 and obp[3][2] == 170 and obp[3][3] == 170,
|
||||
"OBP0 bake puts sheet shade 2 on DMG shade 1 (170)")
|
||||
local cap = PaletteFX.pal(game.data, ow:paletteNameFor(ow.map))
|
||||
cap = cap and cap[2] -- DMG shade 1 -> 2nd palette entry
|
||||
check(cap and cap[2] > cap[1] and cap[2] > cap[3],
|
||||
"OBJ cap color is green-dominant (g>r and g>b)")
|
||||
"the cap's resolved map colour is green-dominant (g>r and g>b)")
|
||||
check(cap and not (cap[1] == 165 and cap[2] == 214 and cap[3] == 255),
|
||||
"OBJ cap color is NOT ROUTE light-blue (165,214,255)")
|
||||
"the cap's resolved map colour is NOT ROUTE light-blue (165,214,255)")
|
||||
|
||||
-- === regression guard: terrain palette untouched =====================
|
||||
local terrain = PaletteFX.pal(game.data, ow:paletteNameFor(ow.map))
|
||||
@@ -90,7 +144,47 @@ return function(game)
|
||||
check(hasColor(terrain, 165, 214, 255),
|
||||
"ROUTE terrain palette still contains light-blue (grass keeps its blue dither)")
|
||||
|
||||
-- === ADVANCED (RED++): the same overdraw, over a baked true-colour atlas ==
|
||||
-- setMode drops every cached Map/TileRenderer and reloads the visible one,
|
||||
-- so re-read the state's map before touching its renderer.
|
||||
game.save.options.colors = "redpp"
|
||||
PaletteFX.setMode("redpp")
|
||||
U.wait(20)
|
||||
ow = game.overworld
|
||||
check(ow.map.renderer.gbcAtlas ~= nil,
|
||||
"ADVANCED baked the per-tile GBC atlas for ROUTE_1")
|
||||
local advGaps = grassGapPixels(ow)
|
||||
check(advGaps > 0,
|
||||
"ADVANCED grass feet overdraw shows the sprite through its gaps ("
|
||||
.. advGaps .. "/128 px)")
|
||||
-- the gaps must be the SAME pixels the other modes key -- a baked-in green
|
||||
-- shade 0 is what #150 saw, so the count has to match SGB's exactly
|
||||
check(advGaps == sgbGaps,
|
||||
"ADVANCED keys the same shade-0 pixels SGB does (" .. advGaps
|
||||
.. " vs " .. sgbGaps .. ")")
|
||||
U.shot(game, DIR .. "/grass_bug150_advanced.png")
|
||||
|
||||
-- ROUTE_1 is the OVERWORLD tileset; the other two tilesets that own a grass
|
||||
-- tile (FOREST $20, PLATEAU $45) file it under the very same pack group 2,
|
||||
-- so all three baked the same green over shade 0 and all three broke
|
||||
-- together. One tileset passing proves nothing about the other two.
|
||||
for _, spot in ipairs({ { "VIRIDIAN_FOREST", 6, 6, "FOREST" },
|
||||
{ "ROUTE_23", 10, 44, "PLATEAU" } }) do
|
||||
local id, cx, cy, tsId = spot[1], spot[2], spot[3], spot[4]
|
||||
U.teleport(game, id, cx, cy, "down")
|
||||
U.wait(10)
|
||||
ow = game.overworld
|
||||
check(ow.map.tileset.id == tsId
|
||||
and ow.map:isGrassCell(ow.player.cellX, ow.player.cellY),
|
||||
id .. ": player stands on " .. tsId .. " tall grass")
|
||||
check(ow.map.renderer.gbcAtlas ~= nil, id .. ": ADVANCED baked its atlas")
|
||||
local gaps = grassGapPixels(ow)
|
||||
check(gaps > 0, id .. ": ADVANCED grass feet overdraw shows the sprite "
|
||||
.. "through its gaps (" .. gaps .. "/128 px)")
|
||||
end
|
||||
|
||||
-- OG RED reference for the human diff (green character over red terrain)
|
||||
U.teleport(game, "ROUTE_1", 10, 6, "down")
|
||||
game.save.options.colors = "ogred"
|
||||
PaletteFX.setMode("ogred")
|
||||
U.wait(20)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
-- Driver: gym-leader post-battle dialogue chain (#164).
|
||||
-- Invokes checkVictoryRewards for Brock then Misty (same path as a win)
|
||||
-- and screenshots pages that must include badge-effect + TM explanation
|
||||
-- text — not just a synthetic "received badge/TM" stub.
|
||||
-- text -- not just a synthetic "received badge/TM" stub.
|
||||
--
|
||||
-- SHOT_DIR=/tmp/gym164 POKEPORT_DRIVER=tests/drivers/gym_leader_victory_test.lua love .
|
||||
return function(game)
|
||||
|
||||
@@ -0,0 +1,264 @@
|
||||
-- Driver: the Pewter JIGGLYPUFF sings AND dances (#249).
|
||||
-- scripts/PewterPokecenter.asm PewterPokecenterJigglypuffText: stop the music,
|
||||
-- DelayFrames 32, MUSIC_JIGGLYPUFF_SONG, then a clockwise quarter turn every 24
|
||||
-- frames until the song ends, 48 more frames, PlayDefaultMusic. TextScriptEnd
|
||||
-- is the only thing that closes the box. Not under POKEPORT_SPEED.
|
||||
-- POKEPORT_DRIVER=tests/drivers/jigglypuff_bug249_test.lua love .
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
|
||||
local mapScripts = require("data.scripts.init")
|
||||
local TextBox = require("src.render.TextBox")
|
||||
local Music = require("src.core.Music")
|
||||
|
||||
local MAP = "PEWTER_POKECENTER"
|
||||
local NPC = "PEWTERPOKECENTER_JIGGLYPUFF"
|
||||
local TEXT = "TEXT_PEWTERPOKECENTER_JIGGLYPUFF"
|
||||
local SONG, MAP_SONG = "Music_JigglypuffSong", "Music_Pokecenter"
|
||||
-- scripts/PewterPokecenter.asm .FacingDirections, in order
|
||||
local RING = { "down", "left", "up", "right" }
|
||||
local SILENCE, STEP, TAIL = 32, 24, 48 -- the three DelayFrames counts
|
||||
|
||||
local function check(label, ok)
|
||||
U.log(ok and "PASS" or "FAIL", label)
|
||||
return ok
|
||||
end
|
||||
|
||||
local function nextInRing(dir)
|
||||
for i, d in ipairs(RING) do
|
||||
if d == dir then return RING[i % #RING + 1] end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
-- ---- preconditions the eye cannot check --------------------------------
|
||||
-- A missing handler, text entry or song def all look the same on screen: a
|
||||
-- JIGGLYPUFF that just stands there.
|
||||
local handler = mapScripts.talkScript(MAP, TEXT)
|
||||
check(MAP .. "/" .. TEXT .. " has a talk handler",
|
||||
type(handler) == "function")
|
||||
|
||||
local line = game.data.text and game.data.text._PewterPokecenterJigglypuffText
|
||||
check("_PewterPokecenterJigglypuffText resolves to a string",
|
||||
type(line) == "string" and line ~= "")
|
||||
if type(line) == "string" then
|
||||
U.log("the box should read:", (line:gsub("\n", " / ")))
|
||||
end
|
||||
|
||||
-- Music.playOnce returns false on a missing def, and the dance then skips
|
||||
-- straight to its 48-frame tail with no spin at all.
|
||||
local songs = game.data.audio and game.data.audio.songs
|
||||
check("audio.songs." .. SONG .. " resolves (PlayMusic MUSIC_JIGGLYPUFF_SONG)",
|
||||
songs ~= nil and songs[SONG] ~= nil)
|
||||
check("audio.songs." .. MAP_SONG .. " resolves (PlayDefaultMusic comes back to it)",
|
||||
songs ~= nil and songs[MAP_SONG] ~= nil)
|
||||
local mapSong = game.data.audio and game.data.audio.mapSongs
|
||||
and game.data.audio.mapSongs[MAP]
|
||||
check("the Center's map theme is " .. MAP_SONG, mapSong == MAP_SONG)
|
||||
|
||||
-- SPRITE_FAIRY has to be a walker or three of the four facings have no
|
||||
-- frames to draw and the "turn" is invisible even when it happens
|
||||
local fairy = game.data.sprites and game.data.sprites.SPRITE_FAIRY
|
||||
check("SPRITE_FAIRY renders all four facings",
|
||||
fairy ~= nil and fairy.walker == true and (fairy.frames or 0) >= 4)
|
||||
|
||||
local opts = game.save.options or {}
|
||||
U.log("audio device present:", love.audio ~= nil,
|
||||
" MUSIC VOL (0-7):", tostring(opts.musicVol),
|
||||
" SFX VOL (0-7):", tostring(opts.sfxVol))
|
||||
if not love.audio or opts.musicVol == 0 then
|
||||
U.log("WARNING: music output is off, so the cut to silence and the song",
|
||||
"itself will not be audible; raise MUSIC VOL in OPTION first")
|
||||
end
|
||||
|
||||
-- ---- park the player against the JIGGLYPUFF -----------------------------
|
||||
-- data/maps/objects/PewterPokecenter.asm: object_event 1, 3, SPRITE_FAIRY.
|
||||
-- It sits against the west wall, so approach from the floor to its east.
|
||||
local STAND = { x = 2, y = 3, facing = "left" }
|
||||
U.teleport(game, MAP, STAND.x, STAND.y, STAND.facing)
|
||||
U.wait(10)
|
||||
|
||||
local function puffIn(ow)
|
||||
for _, n in ipairs(ow.npcs or {}) do
|
||||
if n.def and n.def.name == NPC then return n end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
-- re-reads game.overworld every call: the fallback below teleports again,
|
||||
-- which rebuilds the state and its npc list
|
||||
local function facingThePuff()
|
||||
local ow = game.overworld
|
||||
local puff = ow and puffIn(ow)
|
||||
if not puff then return false end
|
||||
local fx, fy = ow.player:facingCell()
|
||||
return ow:npcAtCell(fx, fy) == puff
|
||||
end
|
||||
|
||||
local ow = game.overworld
|
||||
local puff = ow and puffIn(ow)
|
||||
check("JIGGLYPUFF object loaded on " .. MAP, puff ~= nil)
|
||||
|
||||
if puff and not facingThePuff() then
|
||||
-- Approach cell is blocked (map edit, or a mod moved the object): take any
|
||||
-- free neighbour instead. {dx, dy, facing} is the offset from the fairy
|
||||
-- plus the direction that looks back at it, so +1 on x means facing left.
|
||||
local sides = {
|
||||
{ 1, 0, "left" }, { 0, 1, "up" }, { 0, -1, "down" }, { -1, 0, "right" },
|
||||
}
|
||||
for _, s in ipairs(sides) do
|
||||
local cx, cy = puff.cellX + s[1], puff.cellY + s[2]
|
||||
if ow.map:isWalkableCell(cx, cy) and not ow:npcAtCell(cx, cy) then
|
||||
U.log(("approach cell (%d, %d) is blocked, standing on")
|
||||
:format(STAND.x, STAND.y), cx, cy, "facing", s[3])
|
||||
U.teleport(game, MAP, cx, cy, s[3])
|
||||
U.wait(10)
|
||||
break
|
||||
end
|
||||
end
|
||||
puff = game.overworld and puffIn(game.overworld)
|
||||
end
|
||||
check("player is standing against the JIGGLYPUFF", facingThePuff())
|
||||
|
||||
-- ---- one frame, sampling everything at once ----------------------------
|
||||
local function boxOnTop()
|
||||
local top = game.stack:top()
|
||||
if getmetatable(top) == TextBox then return top end
|
||||
return nil
|
||||
end
|
||||
|
||||
local function sample(pressA)
|
||||
if pressA then U.tap(game, "a") else U.wait(1) end
|
||||
local box = boxOnTop()
|
||||
return {
|
||||
box = box ~= nil,
|
||||
typed = box ~= nil and box.done == true,
|
||||
tick = box ~= nil and box.auto ~= nil and type(box.auto.tick) == "function",
|
||||
facing = puff and puff.facing or nil,
|
||||
song = Music.oneShotPlaying(),
|
||||
}
|
||||
end
|
||||
|
||||
-- ---- 1. talk to it and watch the whole dance, mashing A throughout -----
|
||||
U.log("--- run 1: the full dance, with A held down on it ---------------")
|
||||
local before = puff and puff.facing
|
||||
U.tap(game, "a")
|
||||
U.wait(2)
|
||||
local box = boxOnTop()
|
||||
check("pressing A opened a text box", box ~= nil)
|
||||
if box then
|
||||
U.log("the JIGGLYPUFF turned to face the player:",
|
||||
tostring(before), "->", tostring(puff.facing))
|
||||
check("the box carries an auto.tick hook (the per-frame dance driver)",
|
||||
box.auto ~= nil and type(box.auto.tick) == "function")
|
||||
check("the box is an auto box, so no blinking arrow and no A dismissal",
|
||||
box.auto ~= nil)
|
||||
end
|
||||
|
||||
-- Mash A every 3rd frame for the whole run; in the original the box cannot
|
||||
-- be dismissed at all, so this must change nothing.
|
||||
local log, turns = {}, {}
|
||||
local typedAt, songAt, songEnd, closedAt = nil, nil, nil, nil
|
||||
local last = puff and puff.facing
|
||||
for i = 1, 3000 do
|
||||
log[i] = sample(i % 3 == 0)
|
||||
if not typedAt and log[i].typed then typedAt = i end
|
||||
if not songAt and log[i].song then songAt = i end
|
||||
if songAt and not songEnd and not log[i].song then songEnd = i end
|
||||
if log[i].facing and log[i].facing ~= last then
|
||||
turns[#turns + 1] = { at = i, from = last, to = log[i].facing }
|
||||
last = log[i].facing
|
||||
end
|
||||
if not log[i].box then closedAt = i break end
|
||||
end
|
||||
|
||||
U.log(("frames: text finished typing at %s, song started at %s, song ended " ..
|
||||
"at %s, box closed at %s")
|
||||
:format(tostring(typedAt), tostring(songAt), tostring(songEnd),
|
||||
tostring(closedAt)))
|
||||
for n, t in ipairs(turns) do
|
||||
U.log((" turn %d on frame %d: %s -> %s"):format(n, t.at, tostring(t.from),
|
||||
tostring(t.to)))
|
||||
end
|
||||
|
||||
if check("the song played", songAt ~= nil) then
|
||||
-- SFX_STOP_ALL_MUSIC, DelayFrames 32, PlayMusic
|
||||
local gap = songAt - (typedAt or 1)
|
||||
U.log(("silence between the text finishing and the song starting: %d frames " ..
|
||||
"(pokered waits %d)"):format(gap, SILENCE))
|
||||
check(("that silence is about %d frames"):format(SILENCE),
|
||||
gap >= SILENCE - 4 and gap <= SILENCE + 8)
|
||||
end
|
||||
|
||||
check("the JIGGLYPUFF turned at least four times", #turns >= 4)
|
||||
local ringOk, spacingOk = true, true
|
||||
for n, t in ipairs(turns) do
|
||||
if t.to ~= nextInRing(t.from) then
|
||||
ringOk = false
|
||||
U.log((" turn %d is not a clockwise quarter turn: %s -> %s (expected %s)")
|
||||
:format(n, tostring(t.from), tostring(t.to),
|
||||
tostring(nextInRing(t.from))))
|
||||
end
|
||||
if n > 1 then
|
||||
local d = t.at - turns[n - 1].at
|
||||
if d < STEP - 6 or d > STEP + 6 then
|
||||
spacingOk = false
|
||||
U.log((" turn %d came %d frames after the last one (expected %d)")
|
||||
:format(n, d, STEP))
|
||||
end
|
||||
end
|
||||
end
|
||||
check("every turn is one clockwise quarter turn (DOWN->LEFT->UP->RIGHT)",
|
||||
ringOk and #turns >= 4)
|
||||
check(("the turns are %d frames apart"):format(STEP),
|
||||
spacingOk and #turns >= 2)
|
||||
|
||||
-- not just "it eventually closed": an A-dismissable box closes too, only far
|
||||
-- too early, and that is the bug
|
||||
check("mashing A never closed the box early",
|
||||
(closedAt or #log) >= SILENCE + 4 * STEP)
|
||||
if check("the box closed itself with no button press", closedAt ~= nil) then
|
||||
check("it stayed up for the whole song, A mashing and all",
|
||||
songEnd ~= nil and closedAt > songEnd)
|
||||
if songEnd then
|
||||
local tail = closedAt - songEnd
|
||||
U.log(("the box lingered %d frames after the song (pokered waits %d, " ..
|
||||
"plus the box's own pop delay)"):format(tail, TAIL))
|
||||
check(("that tail is about %d frames"):format(TAIL),
|
||||
tail >= TAIL - 6 and tail <= TAIL + 20)
|
||||
end
|
||||
end
|
||||
|
||||
-- ---- 2. do it again, screenshot each quarter turn, hand off mid-song ---
|
||||
U.log("--- run 2: screenshots, then the pad is yours -------------------")
|
||||
U.wait(30)
|
||||
puff = game.overworld and puffIn(game.overworld)
|
||||
check("still standing against the JIGGLYPUFF", facingThePuff())
|
||||
U.tap(game, "a")
|
||||
U.wait(2)
|
||||
U.shot(game, DIR .. "/bug249_0_box.png")
|
||||
|
||||
local shots, seen = 0, puff and puff.facing
|
||||
for _ = 1, 600 do
|
||||
U.wait(1)
|
||||
if not boxOnTop() then break end
|
||||
if puff and puff.facing ~= seen then
|
||||
seen = puff.facing
|
||||
shots = shots + 1
|
||||
-- U.shot costs a frame or two, which is why run 1 did the timing
|
||||
local path = ("%s/bug249_%d_%s.png"):format(DIR, shots, seen)
|
||||
if U.shot(game, path) then U.log("captured", path) end
|
||||
if shots >= 4 then break end
|
||||
end
|
||||
end
|
||||
check("captured four quarter turns", shots >= 4)
|
||||
|
||||
U.log("The JIGGLYPUFF has been talked to and is mid-song. Mash A: the box has no")
|
||||
U.log("arrow and must not close. The fairy should turn a quarter turn clockwise")
|
||||
U.log("(down, left, up, right) every 24 frames until the song ends, then close")
|
||||
U.log("itself (#249). The Center theme returns just before it does; expected.")
|
||||
|
||||
while true do
|
||||
coroutine.yield()
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,237 @@
|
||||
-- Driver: manual audio check for the low-health siren cutting out (#293).
|
||||
-- pokered sets wLowHealthAlarm on a red bar (core.asm:1858-1875) and only
|
||||
-- RemoveFaintedPlayerMon (core.asm:1011-1016) clears it, so a sounding siren
|
||||
-- rides through the next hit. No POKEPORT_SPEED: audio has its own clock.
|
||||
-- POKEPORT_DRIVER=tests/drivers/lowhp_alarm_bug293_test.lua \
|
||||
-- POKEPORT_IDENTITY=bug293 POKEPORT_TOUCH=0 love .
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
local BattleState = require("src.battle.BattleState")
|
||||
local Sound = require("src.core.Sound")
|
||||
|
||||
local ALARM = "Low_Health_Alarm"
|
||||
local function siren() return Sound.isLooping(ALARM) end
|
||||
|
||||
local function check(label, ok)
|
||||
U.log(ok and "PASS" or "FAIL", label)
|
||||
return ok
|
||||
end
|
||||
|
||||
-- ---- preconditions the ear cannot check --------------------------------
|
||||
-- A siren with no source and a siren stopped every frame sound the same.
|
||||
check("BattleState:lowHealthAlarmActive exists",
|
||||
type(BattleState.lowHealthAlarmActive) == "function")
|
||||
check("BattleState:updateFx exists (it is what latches the siren)",
|
||||
type(BattleState.updateFx) == "function")
|
||||
|
||||
-- Sound.startLoop uses the registered sfx def when there is one and
|
||||
-- otherwise synthesizes the siren; a ROM import has no def, so the synth
|
||||
-- path is the normal one.
|
||||
local def = game.data.audio and game.data.audio.sfx
|
||||
and game.data.audio.sfx[ALARM]
|
||||
if def then
|
||||
check("data.audio.sfx." .. ALARM .. " resolves", true)
|
||||
else
|
||||
local ok, src = pcall(require("src.core.ChipAudio").newLowHealthAlarm)
|
||||
check("no " .. ALARM .. " def, so ChipAudio synthesizes the siren",
|
||||
ok and src ~= nil)
|
||||
if ok and src and src.stop then pcall(src.stop, src) end
|
||||
end
|
||||
|
||||
local vol = game.save.options and game.save.options.sfxVol
|
||||
U.log("audio device present:", love.audio ~= nil,
|
||||
" SFX VOL (0-7):", tostring(vol))
|
||||
if not love.audio or vol == 0 then
|
||||
U.log("WARNING: sound output is off, so nothing below will be audible;",
|
||||
"raise SFX VOL in OPTION first -- a muted run and a dead siren",
|
||||
"are the same thing to an ear")
|
||||
end
|
||||
|
||||
-- ---- a lead whose bar is red and who cannot end the fight --------------
|
||||
-- :L20 so a weak foe cannot one-shot it, and TAIL_WHIP only (power 0), so
|
||||
-- the wild mon survives every turn and keeps hitting back.
|
||||
local function freshLead()
|
||||
local mon = Pokemon.new(game.data, "RATTATA", 20)
|
||||
mon.moves = { { id = "TAIL_WHIP", pp = 30 } }
|
||||
game.save.party = { mon }
|
||||
return mon
|
||||
end
|
||||
|
||||
-- put the drawn bar at 9 of 48 px: HP_BAR_RED is < 10 px
|
||||
-- (GetHealthBarColor, core.asm), the same threshold HudTiles.drawHPBar
|
||||
-- tints with
|
||||
local function intoTheRed(mon)
|
||||
mon.hp = math.max(1, math.floor(mon.stats.hp * 9 / 48))
|
||||
return math.max(1, math.floor(mon.hp * 48 / math.max(1, mon.stats.hp)))
|
||||
end
|
||||
|
||||
local lead = freshLead()
|
||||
local px = intoTheRed(lead)
|
||||
U.log(("lead: RATTATA :L20 %d/%d HP -> %d of 48 px")
|
||||
:format(lead.hp, lead.stats.hp, px))
|
||||
check("the lead's bar is red (red is under 10 px)", px < 10)
|
||||
check("the lead has HP to spare for a non-lethal hit", lead.hp >= 6)
|
||||
|
||||
U.teleport(game, "ROUTE_1", 5, 5, "down")
|
||||
|
||||
local function sample(battle, pressA)
|
||||
if pressA then U.tap(game, "a") else U.wait(1) end
|
||||
return {
|
||||
on = siren(),
|
||||
hp = battle.player.mon.hp,
|
||||
shown = battle.player.shownHP,
|
||||
text = battle.current and battle.current.text or "",
|
||||
}
|
||||
end
|
||||
|
||||
-- longest run of consecutive silent frames in [from, to], and where it
|
||||
-- started: this is the number a human hears as a gap
|
||||
local function longestGap(log, from, to)
|
||||
local best, bestAt, run, runAt = 0, nil, 0, nil
|
||||
for i = from, math.min(to, #log) do
|
||||
if log[i].on then
|
||||
run, runAt = 0, nil
|
||||
else
|
||||
if run == 0 then runAt = i end
|
||||
run = run + 1
|
||||
if run > best then best, bestAt = run, runAt end
|
||||
end
|
||||
end
|
||||
return best, bestAt
|
||||
end
|
||||
|
||||
local function startBattle(species, level, enemyMoves)
|
||||
local battle = BattleState.newWild(game, species, level)
|
||||
battle.onFinish = function() end
|
||||
if enemyMoves then
|
||||
-- BOTH: the battler's curMoves aliases mon.moves at construction
|
||||
-- (BattleState.lua:345) and TrainerAI.chooseMove reads curMoves, so
|
||||
-- replacing only mon.moves leaves the AI on the original moveset
|
||||
battle.enemy.mon.moves = enemyMoves
|
||||
battle.enemy.curMoves = enemyMoves
|
||||
end
|
||||
game.overworld:pushBattle(battle)
|
||||
for _ = 1, 120 do
|
||||
if battle.phase == "menu" then break end
|
||||
U.tap(game, "a")
|
||||
U.wait(4)
|
||||
end
|
||||
return battle
|
||||
end
|
||||
|
||||
-- The A presses are folded into the sampling loop rather than done with
|
||||
-- U.tap first: applyDamage takes the HP off the model at queue-build time,
|
||||
-- so a faster foe can land its hit within a couple of frames of the move
|
||||
-- being chosen and a U.tap would sample straight past it.
|
||||
local function playTurn(battle, maxFrames)
|
||||
local pre = battle.player.mon.hp
|
||||
local log, hitAt, emptyAt, settleAt = {}, nil, nil, nil
|
||||
for i = 1, maxFrames or 900 do
|
||||
local pressA = i == 1 or i == 6
|
||||
or (i > 6 and i % 6 == 0 and battle.phase ~= "menu")
|
||||
log[i] = sample(battle, pressA)
|
||||
if not hitAt and log[i].hp < pre then hitAt = i end
|
||||
if hitAt and not emptyAt and (log[i].shown or 0) <= 0 then emptyAt = i end
|
||||
if hitAt and not settleAt and log[i].shown == log[i].hp then
|
||||
settleAt = i
|
||||
end
|
||||
if settleAt and i > settleAt + 40 then break end
|
||||
end
|
||||
return log, pre, hitAt, settleAt, emptyAt
|
||||
end
|
||||
|
||||
-- =====================================================================
|
||||
-- 1. the non-lethal hit: the siren must ride it out unbroken
|
||||
-- =====================================================================
|
||||
U.log("--- battle 1: a hit you survive -------------------------------")
|
||||
-- PIDGEY :L3 knows only GUST (SAND_ATTACK is not until :L5), accurate and
|
||||
-- worth a couple of HP against a :L20 lead, so it lands and never kills.
|
||||
local b1 = startBattle("PIDGEY", 3)
|
||||
check("reached the action menu", b1.phase == "menu")
|
||||
check("the siren is sounding before anything happens", siren())
|
||||
U.shot(game, DIR .. "/bug293_1_menu.png")
|
||||
|
||||
local log, pre, hitAt, settleAt = playTurn(b1)
|
||||
U.log(("turn: %d frames sampled, HP %d -> %d, hit landed on frame %s, ")
|
||||
:format(#log, pre, b1.player.mon.hp, tostring(hitAt))
|
||||
.. ("bar settled on frame %s"):format(tostring(settleAt)))
|
||||
if check("the foe's attack connected", hitAt ~= nil) then
|
||||
local to = settleAt and (settleAt + 20) or #log
|
||||
local gap, gapAt = longestGap(log, hitAt, to)
|
||||
-- the reported symptom, in frames: 60 frames is one second
|
||||
U.log(("longest silence between the hit and the bar settling: %d frames%s")
|
||||
:format(gap, gapAt and (" (from frame " .. gapAt .. ")") or ""))
|
||||
check("the siren never dropped out across the hit (#293)", gap == 0)
|
||||
if gap > 0 then
|
||||
U.log(" it went quiet during:", log[gapAt].text ~= "" and
|
||||
log[gapAt].text:gsub("\n", " / ") or "(no text on screen)")
|
||||
end
|
||||
check("and it is still sounding once the bar has settled",
|
||||
siren() and b1.player.mon.hp > 0)
|
||||
U.shot(game, DIR .. "/bug293_2_after_hit.png")
|
||||
end
|
||||
|
||||
-- =====================================================================
|
||||
-- 2. the killing blow: it must hold until the bar has drained empty
|
||||
-- =====================================================================
|
||||
U.log("--- battle 2: the hit that kills you --------------------------")
|
||||
U.teleport(game, "ROUTE_1", 5, 5, "down")
|
||||
local lead2 = freshLead()
|
||||
local px2 = intoTheRed(lead2)
|
||||
check("the lead's bar is red again", px2 < 10)
|
||||
-- One guaranteed-lethal move instead of the wild AI's random pick: only
|
||||
-- one of a :L40 PIDGEY's four real moves does any damage.
|
||||
local b2 = startBattle("PIDGEY", 40, { { id = "WING_ATTACK", pp = 35 } })
|
||||
check("reached the action menu", b2.phase == "menu")
|
||||
check("the siren is sounding before the killing blow", siren())
|
||||
|
||||
local log2, pre2, hitAt2, settleAt2, emptyAt2 = playTurn(b2)
|
||||
U.log(("lethal turn: %d frames sampled, HP %d -> %d, hit on frame %s, ")
|
||||
:format(#log2, pre2, b2.player.mon.hp, tostring(hitAt2))
|
||||
.. ("bar empty on frame %s"):format(tostring(emptyAt2)))
|
||||
if check("the killing blow landed", hitAt2 ~= nil and b2.player.mon.hp <= 0) then
|
||||
local to = emptyAt2 or settleAt2 or #log2
|
||||
local gap, gapAt = longestGap(log2, hitAt2, to)
|
||||
U.log(("longest silence between the killing blow and the empty bar: " ..
|
||||
"%d frames%s"):format(gap, gapAt and (" (from frame " .. gapAt ..
|
||||
")") or ""))
|
||||
check("the siren held all the way down to an empty bar (#293)", gap == 0)
|
||||
if gap > 0 then
|
||||
U.log(" it went quiet during:", log2[gapAt].text ~= "" and
|
||||
log2[gapAt].text:gsub("\n", " / ") or "(no text on screen)")
|
||||
end
|
||||
if emptyAt2 then
|
||||
-- and only then does it stop (RemoveFaintedPlayerMon)
|
||||
local stopped = false
|
||||
for i = emptyAt2, #log2 do
|
||||
if not log2[i].on then stopped = true break end
|
||||
end
|
||||
check("and went quiet once the bar was empty (RemoveFaintedPlayerMon)",
|
||||
stopped)
|
||||
end
|
||||
U.shot(game, DIR .. "/bug293_3_ko.png")
|
||||
end
|
||||
|
||||
-- =====================================================================
|
||||
-- 3. hand off, already at the FIGHT menu with the siren going
|
||||
-- =====================================================================
|
||||
U.teleport(game, "ROUTE_1", 5, 5, "down")
|
||||
local lead3 = freshLead()
|
||||
intoTheRed(lead3)
|
||||
local b3 = startBattle("PIDGEY", 3)
|
||||
check("handing off at the action menu", b3.phase == "menu")
|
||||
check("handing off with the siren sounding", siren())
|
||||
U.shot(game, DIR .. "/bug293_4_handoff.png")
|
||||
|
||||
U.log("FIGHT menu, wild PIDGEY, RATTATA in the red and the siren going.")
|
||||
U.log("A, A uses TAIL WHIP (power 0), so the PIDGEY keeps hitting back;")
|
||||
U.log("it takes several turns to die.")
|
||||
U.log("The siren must not break under the foe's move, the animation or the")
|
||||
U.log("bar drain, and on the lethal hit it holds to an empty bar (#293).")
|
||||
|
||||
while true do
|
||||
coroutine.yield()
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,99 @@
|
||||
-- Driver: the seam where the TYPE/PP box meets the move box (#240). pokered
|
||||
-- MoveSelectionMenu (engine/battle/core.asm:2492-2501) writes '─' at (4,12) and
|
||||
-- '┘' at (10,12) into the tilemap, REPLACING the tile. Eye check, no SPEED.
|
||||
-- POKEPORT_DRIVER=tests/drivers/moveselect_border_bug240_test.lua \
|
||||
-- POKEPORT_IDENTITY=bug240 POKEPORT_TOUCH=0 POKEPORT_VERSION=red \
|
||||
-- SHOT_DIR=/tmp/shots love .
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
local BattleState = require("src.battle.BattleState")
|
||||
local Font = require("src.render.Font")
|
||||
|
||||
local function check(label, ok)
|
||||
U.log(ok and "PASS" or "FAIL", label)
|
||||
return ok
|
||||
end
|
||||
|
||||
-- ---- preconditions -----------------------------------------------------
|
||||
-- a missing glyph page draws nothing in those cells, which reads as a clean
|
||||
-- seam and would pass by accident
|
||||
|
||||
check("Font.BORDER.h (the '─' patch glyph) is defined",
|
||||
Font.BORDER ~= nil and Font.BORDER.h ~= nil)
|
||||
check("Font.BORDER.br (the '┘' patch glyph) is defined",
|
||||
Font.BORDER ~= nil and Font.BORDER.br ~= nil)
|
||||
check("Font.drawCode exists (the transparent blit at the heart of this)",
|
||||
type(Font.drawCode) == "function")
|
||||
check("Font.drawBox exists (the white fill the patch has to match)",
|
||||
type(Font.drawBox) == "function")
|
||||
local extra = love.filesystem.getInfo("assets/generated/fonts/font_extra.png")
|
||||
check("assets/generated/fonts/font_extra.png is in the cache", extra ~= nil)
|
||||
|
||||
game.save.player.name = "bryan"
|
||||
-- CHARIZARD at 50 knows four moves, so the list fills all four rows and the
|
||||
-- PP figure sits directly above the (10,12) cell being judged
|
||||
game.save.party = { Pokemon.new(game.data, "CHARIZARD", 50) }
|
||||
local lead = game.save.party[1]
|
||||
check("the lead knows at least one move", #lead.moves >= 1)
|
||||
U.log("move list:", (function()
|
||||
local names = {}
|
||||
for _, m in ipairs(lead.moves) do
|
||||
names[#names + 1] = game.data.moves[m.id].name
|
||||
end
|
||||
return table.concat(names, ", ")
|
||||
end)())
|
||||
|
||||
U.teleport(game, "PALLET_TOWN", 10, 8, "down")
|
||||
U.wait(20)
|
||||
local ow = game.overworld
|
||||
check("overworld is up to push the battle from", ow ~= nil)
|
||||
|
||||
local battle = BattleState.newWild(game, "PIDGEY", 8)
|
||||
battle.onFinish = function() end
|
||||
ow:pushBattle(battle)
|
||||
|
||||
local function tapUntil(cond, taps, gap)
|
||||
for _ = 1, (taps or 60) do
|
||||
if cond() then return true end
|
||||
U.tap(game, "a")
|
||||
for _ = 1, (gap or 6) do
|
||||
if cond() then return true end
|
||||
U.wait(1)
|
||||
end
|
||||
end
|
||||
return cond()
|
||||
end
|
||||
|
||||
check("reached the FIGHT/PKMN/ITEM/RUN menu",
|
||||
tapUntil(function() return battle.phase == "menu" end, 60))
|
||||
check("cursor starts on FIGHT", battle.menuIndex == 1)
|
||||
|
||||
-- A on FIGHT opens the move list: this is the screen being judged.
|
||||
U.tap(game, "a")
|
||||
U.wait(20)
|
||||
check("the FIGHT move list is open (#240 lives on this screen)",
|
||||
battle.phase == "moveSelect")
|
||||
check("move-list screenshot reached disk",
|
||||
U.shot(game, DIR .. "/bug240_move_list.png"))
|
||||
U.log("captured", DIR .. "/bug240_move_list.png")
|
||||
|
||||
-- move the cursor down one row too: the PP figure changes, and the '┘'
|
||||
-- corner under it must stay identical
|
||||
U.tap(game, "down")
|
||||
U.wait(20)
|
||||
check("move-list screenshot on the second move reached disk",
|
||||
U.shot(game, DIR .. "/bug240_move_list_row2.png"))
|
||||
U.log("captured", DIR .. "/bug240_move_list_row2.png")
|
||||
|
||||
-- ---- hand off ----------------------------------------------------------
|
||||
U.log("The FIGHT move list is open. Judge tiles (4,12) and (10,12) on the row")
|
||||
U.log("where the TYPE/PP box meets the move box; (10,12) sits under the PP")
|
||||
U.log("count. Both want clean border: no black blobs showing through, no")
|
||||
U.log("white gap, and no change as you move the cursor with UP/DOWN. (#240)")
|
||||
|
||||
while true do
|
||||
coroutine.yield()
|
||||
end
|
||||
end
|
||||
@@ -1,35 +1,9 @@
|
||||
-- Driver: manual audio check for the silent Pewter NIDORAN (#247).
|
||||
--
|
||||
-- pokered scripts/PewterNidoranHouse.asm, PewterNidoranHouseNidoranText:
|
||||
-- text_far _PewterNidoranHouseNidoranText
|
||||
-- text_asm
|
||||
-- ld a, NIDORAN_M
|
||||
-- call PlayCry
|
||||
-- call WaitForSoundToFinish
|
||||
-- jp TextScriptEnd
|
||||
-- so the box types "NIDORAN: Bowbow!" first and the male NIDORAN cry sounds
|
||||
-- second, once the typewriter is done. The box then still waits for a
|
||||
-- button: PewterNidoranHouse_Script is `jp EnableAutoTextBoxDrawing`, and
|
||||
-- AutoTextBoxDrawingCommon (pokered home/window.asm) zeroes
|
||||
-- wDoNotWaitForButtonPressAfterDisplayingText, so DisplayTextID falls through
|
||||
-- to WaitForTextScrollButtonPress like any other NPC line.
|
||||
-- data/scripts/flavor/pewter_nidoran_house.lua ported the dialogue row but
|
||||
-- never the cry row, so the NIDORAN was silent.
|
||||
--
|
||||
-- This is an audio fix, so nothing here can assert it. What this driver does
|
||||
-- is check the halves an ear cannot check (the cry row is wired into the talk
|
||||
-- script in the adjacency Commands.play_cry/show_text require, and the
|
||||
-- NIDORAN_M cry sample resolves the way Sound.playCry looks it up), park the
|
||||
-- player one cell below the NIDORAN already facing it, and then hand input
|
||||
-- straight back so a human hears the cry live.
|
||||
--
|
||||
-- Do NOT add POKEPORT_SPEED to this run: fast-forward scales only the logic
|
||||
-- clock while audio runs on its own real-time 60 Hz accumulator
|
||||
-- (src/core/Game.lua), so the cry would stop lining up with the box it
|
||||
-- belongs to.
|
||||
--
|
||||
-- POKEPORT_DRIVER=tests/drivers/nidoran_cry_bug247_test.lua \
|
||||
-- POKEPORT_IDENTITY=bug247 POKEPORT_TOUCH=0 POKEPORT_VERSION=red love .
|
||||
-- Audio check for the silent Pewter NIDORAN (#247). pokered
|
||||
-- scripts/PewterNidoranHouse.asm types the line, then `ld a, NIDORAN_M / call
|
||||
-- PlayCry / call WaitForSoundToFinish`, and the box still waits for a button;
|
||||
-- the port ported the dialogue row and not the cry row.
|
||||
-- Don't add POKEPORT_SPEED: audio runs on its own real-time accumulator.
|
||||
-- POKEPORT_DRIVER=tests/drivers/nidoran_cry_bug247_test.lua POKEPORT_IDENTITY=bug247 POKEPORT_TOUCH=0 POKEPORT_VERSION=red love .
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local mapScripts = require("data.scripts.init")
|
||||
@@ -44,11 +18,9 @@ return function(game)
|
||||
return ok
|
||||
end
|
||||
|
||||
-- ---- preconditions the ear cannot check --------------------------------
|
||||
-- Commands.play_cry only stashes ctx.pendingCry; Commands.show_text is what
|
||||
-- turns it into the box's opts.auto. The two rows therefore have to be
|
||||
-- adjacent and in that order, or the cry is handed to the wrong box or
|
||||
-- never consumed at all -- and either way the NIDORAN stays quiet.
|
||||
-- turns it into the box's opts.auto, so the two rows have to be adjacent and
|
||||
-- in that order or the cry goes to the wrong box, or to none at all
|
||||
local rows = mapScripts.talkScript(MAP, TEXT)
|
||||
local cryAt, textAt
|
||||
for i, row in ipairs(rows or {}) do
|
||||
@@ -58,16 +30,15 @@ return function(game)
|
||||
local cry = cryAt and rows[cryAt]
|
||||
check("talk script carries a play_cry row", cry ~= nil)
|
||||
check("cry species is " .. SPECIES, cry ~= nil and cry[2] == SPECIES)
|
||||
-- the third arg is play_cry's waitForButton form, which keeps
|
||||
-- DisplayTextID's trailing WaitForTextScrollButtonPress instead of letting
|
||||
-- the box pop itself the instant the cry goes quiet
|
||||
-- the third arg is play_cry's waitForButton form, which keeps DisplayTextID's
|
||||
-- trailing WaitForTextScrollButtonPress instead of letting the box pop itself
|
||||
-- the instant the cry goes quiet
|
||||
check("cry row keeps the button wait", cry ~= nil and cry[3] == true)
|
||||
check("play_cry sits immediately before show_text",
|
||||
cryAt ~= nil and textAt == cryAt + 1)
|
||||
|
||||
-- Sound.playCry reads data.audio.cries[species]; a missing or misspelled
|
||||
-- key is a silent no-op with no error, which is indistinguishable by ear
|
||||
-- from the bug itself
|
||||
-- Sound.playCry reads data.audio.cries[species]; a missing or misspelled key
|
||||
-- is a silent no-op with no error, which sounds exactly like the bug
|
||||
local cries = game.data.audio and game.data.audio.cries
|
||||
check("data.audio.cries." .. SPECIES .. " resolves",
|
||||
cries ~= nil and cries[SPECIES] ~= nil)
|
||||
@@ -80,10 +51,9 @@ return function(game)
|
||||
"raise SFX VOL in OPTION first")
|
||||
end
|
||||
|
||||
-- ---- park the player in front of the NIDORAN ---------------------------
|
||||
-- pokered data/maps/objects/PewterNidoranHouse.asm puts the NIDORAN on
|
||||
-- (4, 5) facing LEFT at the little boy on (3, 5), so the free approach cell
|
||||
-- is the floor directly below it.
|
||||
-- pokered data/maps/objects/PewterNidoranHouse.asm puts the NIDORAN on (4, 5)
|
||||
-- facing LEFT at the little boy on (3, 5), so the free approach cell is the
|
||||
-- floor directly below it
|
||||
U.teleport(game, MAP, 4, 6, "up")
|
||||
U.wait(10)
|
||||
|
||||
@@ -94,8 +64,8 @@ return function(game)
|
||||
return nil
|
||||
end
|
||||
|
||||
-- re-reads game.overworld every call: the fallback below teleports again,
|
||||
-- which rebuilds the whole state and its npc list
|
||||
-- re-reads game.overworld every call: the fallback below teleports again and
|
||||
-- that rebuilds the state and its npc list
|
||||
local function facingTheMon()
|
||||
local ow = game.overworld
|
||||
local mon = ow and nidoranIn(ow)
|
||||
@@ -109,10 +79,9 @@ return function(game)
|
||||
check("NIDORAN object loaded on " .. MAP, mon ~= nil)
|
||||
|
||||
if mon and not facingTheMon() then
|
||||
-- the hard-coded approach cell stopped working (map edit, or a mod moved
|
||||
-- the object): take any free walkable neighbour and turn toward the mon.
|
||||
-- {dx, dy, facing} is the offset from the mon to the stand cell plus the
|
||||
-- direction that looks back at it, so +1 on x means facing left.
|
||||
-- a map edit or a mod moved the object: fall back to any free walkable
|
||||
-- neighbour. {dx, dy, facing} is the offset from the mon to the stand cell
|
||||
-- plus the direction that looks back at it, so +1 on x means left.
|
||||
local sides = {
|
||||
{ 0, 1, "up" }, { 1, 0, "left" }, { -1, 0, "right" }, { 0, -1, "down" },
|
||||
}
|
||||
@@ -129,20 +98,10 @@ return function(game)
|
||||
end
|
||||
check("player is standing in front of the NIDORAN", facingTheMon())
|
||||
|
||||
-- ---- hand off, then stay out of the way --------------------------------
|
||||
U.log("........................................................")
|
||||
U.log("LISTEN NOW: press A to talk to the NIDORAN in front of you.")
|
||||
U.log(" RIGHT: the box types out \"NIDORAN: Bowbow!\", the male NIDORAN")
|
||||
U.log(" cry sounds ONCE after the last character lands, then the")
|
||||
U.log(" arrow blinks and the box waits for your A or B.")
|
||||
U.log(" BUG #247 sounds like: the line types out and then nothing, dead")
|
||||
U.log(" silence under the house music, arrow blinking.")
|
||||
U.log(" ALSO WRONG: the cry fires while the text is still typing (row")
|
||||
U.log(" order broken), or the box closes itself the moment the cry")
|
||||
U.log(" ends with no button press (waitForButton lost).")
|
||||
U.log("Input is yours from here on -- talk again as often as you like, and")
|
||||
U.log("the little boy on the left is the silent control to compare against.")
|
||||
U.log("........................................................")
|
||||
U.log("Press A to talk to the NIDORAN in front of you.")
|
||||
U.log("The box types \"NIDORAN: Bowbow!\", then the cry sounds once after the")
|
||||
U.log("last character lands and the box waits for A or B; under #247 the line")
|
||||
U.log("typed and nothing followed. The boy on the left is the silent control.")
|
||||
|
||||
while true do
|
||||
coroutine.yield()
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
-- Driver: play a real online match, hosting side.
|
||||
--
|
||||
-- Pair with tests/drivers/online_match_join.lua in a second instance:
|
||||
--
|
||||
-- POKEPORT_IDENTITY=pokehost ONLINE_CODE_FILE=/tmp/poke_code.txt \
|
||||
-- POKEPORT_DRIVER=tests/drivers/online_match_host.lua love .
|
||||
-- POKEPORT_IDENTITY=pokeguest ONLINE_CODE_FILE=/tmp/poke_code.txt \
|
||||
-- POKEPORT_DRIVER=tests/drivers/online_match_join.lua love .
|
||||
--
|
||||
-- Two real windows, the real relay (POKEPORT_RELAY_ADDR to point elsewhere),
|
||||
-- the real LinkState menus and the real lockstep battle -- the loopback
|
||||
-- suites can't see anything the transport, the two separate processes or
|
||||
-- the two save identities contribute. The host writes its room code to
|
||||
-- ONLINE_CODE_FILE for the joiner to pick up, since on a real screen that
|
||||
-- code is read aloud to a friend.
|
||||
--
|
||||
-- Prints ONLINE_MATCH_HOST: lines; the wrapper script greps them.
|
||||
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
local LinkState = require("src.link.LinkState")
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
|
||||
local CODE_FILE = os.getenv("ONLINE_CODE_FILE") or "/tmp/poke_code.txt"
|
||||
local TAG = "ONLINE_MATCH_HOST:"
|
||||
|
||||
local function log(...) U.log(TAG, ...) end
|
||||
|
||||
-- a desync is the whole point of the exercise, so make it loud
|
||||
local desyncs = {}
|
||||
-- wrap emit rather than subscribing: with no mods loaded Runtime.events is
|
||||
-- the null sink, and a vanilla online run is exactly the case under test
|
||||
local realEmit = Runtime.emit
|
||||
Runtime.emit = function(name, p)
|
||||
if name == "link.desync" and p then
|
||||
desyncs[#desyncs + 1] = p
|
||||
log(("DESYNC turn=%s component=%s fatal=%s"):format(
|
||||
tostring(p.turn), tostring(p.component), tostring(p.fatal)))
|
||||
end
|
||||
return realEmit(name, p)
|
||||
end
|
||||
|
||||
game.save.player.name = "HOST"
|
||||
game.save.party = {
|
||||
Pokemon.new(game.data, "CHARIZARD", 50),
|
||||
Pokemon.new(game.data, "SNORLAX", 50),
|
||||
Pokemon.new(game.data, "ALAKAZAM", 50),
|
||||
}
|
||||
U.teleport(game, "PALLET_TOWN", 10, 8, "down")
|
||||
U.wait(30)
|
||||
|
||||
local link = LinkState.new(game)
|
||||
game.stack:push(link)
|
||||
U.wait(10)
|
||||
|
||||
-- the GAME SPEED forcing under test: a link session pins the logic clock
|
||||
-- to 1X no matter what the option or POKEPORT_SPEED says
|
||||
game.save.options.speed = 10
|
||||
U.wait(2)
|
||||
log("logicSpeed with GAME SPEED=10 during link:", game:logicSpeed())
|
||||
|
||||
-- top menu: LAN / ONLINE MATCH / TOURNAMENT
|
||||
U.tap(game, "down"); U.wait(3)
|
||||
U.tap(game, "a"); U.wait(5) -- ONLINE MATCH
|
||||
if link.stage ~= "onlineMenu" then
|
||||
log("FAIL expected onlineMenu, at", tostring(link.stage))
|
||||
-- a mods-enabled build lands on the vanilla-restart prompt instead
|
||||
U.wait(60)
|
||||
U.shot(game, DIR .. "/online_host_blocked.png")
|
||||
return
|
||||
end
|
||||
U.tap(game, "a"); U.wait(10) -- HOST ONLINE
|
||||
|
||||
-- wait for the relay to hand back a room code
|
||||
local code
|
||||
for _ = 1, 900 do
|
||||
if link.net and link.net.code then code = link.net.code break end
|
||||
U.wait(1)
|
||||
end
|
||||
if not code then
|
||||
log("FAIL no room code from the relay:", tostring(link.net and link.net.error))
|
||||
U.shot(game, DIR .. "/online_host_nocode.png")
|
||||
return
|
||||
end
|
||||
log("hosting code", code)
|
||||
U.shot(game, DIR .. "/online_host_1_code.png")
|
||||
local f = io.open(CODE_FILE, "w")
|
||||
if f then f:write(code) f:close() end
|
||||
|
||||
-- wait for the joiner
|
||||
for _ = 1, 1800 do
|
||||
if link.stage == "modeSelect" then break end
|
||||
U.wait(1)
|
||||
end
|
||||
if link.stage ~= "modeSelect" then
|
||||
log("FAIL nobody joined (stage " .. tostring(link.stage) .. ")")
|
||||
return
|
||||
end
|
||||
log("paired with", tostring(link.peerName))
|
||||
U.shot(game, DIR .. "/online_host_2_paired.png")
|
||||
|
||||
U.tap(game, "down"); U.wait(3) -- TRADE / BATTLE -> BATTLE
|
||||
U.tap(game, "a"); U.wait(10)
|
||||
-- host owns the level rule; ANY is the default row
|
||||
for _ = 1, 600 do
|
||||
if link.stage == "battleOptions" then break end
|
||||
U.wait(1)
|
||||
end
|
||||
if link.stage == "battleOptions" then
|
||||
U.shot(game, DIR .. "/online_host_3_options.png")
|
||||
U.tap(game, "a"); U.wait(5)
|
||||
end
|
||||
|
||||
-- the battle: mash A, exactly like a player who just wants it over with
|
||||
local battle
|
||||
for _ = 1, 1800 do
|
||||
local top = game.stack:top()
|
||||
if top and top.kind == "link" then battle = top break end
|
||||
U.wait(1)
|
||||
end
|
||||
if not battle then
|
||||
log("FAIL battle never started (stage " .. tostring(link.stage) .. ")")
|
||||
U.shot(game, DIR .. "/online_host_nobattle.png")
|
||||
return
|
||||
end
|
||||
log("battle started vs", tostring(battle.opponentName))
|
||||
U.shot(game, DIR .. "/online_host_4_battle.png")
|
||||
|
||||
local shots = 0
|
||||
for i = 1, 200000 do
|
||||
if battle.result then break end
|
||||
U.tap(game, "a")
|
||||
if i % 900 == 0 and shots < 3 then
|
||||
shots = shots + 1
|
||||
U.shot(game, DIR .. ("/online_host_5_turn%d.png"):format(shots))
|
||||
end
|
||||
end
|
||||
U.wait(60)
|
||||
U.shot(game, DIR .. "/online_host_6_result.png")
|
||||
log("result", tostring(battle.result), "turns", tostring(battle.turnCount))
|
||||
log("desyncs", #desyncs)
|
||||
log("DONE")
|
||||
end
|
||||
@@ -0,0 +1,129 @@
|
||||
-- Driver: play a real online match, joining side.
|
||||
-- See tests/drivers/online_match_host.lua for how to run the pair.
|
||||
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
local LinkState = require("src.link.LinkState")
|
||||
local CodeEntry = require("src.link.CodeEntry")
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
|
||||
local CODE_FILE = os.getenv("ONLINE_CODE_FILE") or "/tmp/poke_code.txt"
|
||||
local TAG = "ONLINE_MATCH_JOIN:"
|
||||
|
||||
local function log(...) U.log(TAG, ...) end
|
||||
|
||||
local desyncs = {}
|
||||
-- wrap emit rather than subscribing: with no mods loaded Runtime.events is
|
||||
-- the null sink, and a vanilla online run is exactly the case under test
|
||||
local realEmit = Runtime.emit
|
||||
Runtime.emit = function(name, p)
|
||||
if name == "link.desync" and p then
|
||||
desyncs[#desyncs + 1] = p
|
||||
log(("DESYNC turn=%s component=%s fatal=%s"):format(
|
||||
tostring(p.turn), tostring(p.component), tostring(p.fatal)))
|
||||
end
|
||||
return realEmit(name, p)
|
||||
end
|
||||
|
||||
game.save.player.name = "GUEST"
|
||||
game.save.party = {
|
||||
Pokemon.new(game.data, "BLASTOISE", 50),
|
||||
Pokemon.new(game.data, "GENGAR", 50),
|
||||
Pokemon.new(game.data, "DRAGONITE", 50),
|
||||
}
|
||||
U.teleport(game, "PALLET_TOWN", 10, 8, "down")
|
||||
U.wait(30)
|
||||
|
||||
-- the host writes its room code out once the relay assigns one
|
||||
local code
|
||||
for _ = 1, 3600 do
|
||||
local f = io.open(CODE_FILE, "r")
|
||||
if f then
|
||||
local text = (f:read("*a") or ""):gsub("%s+", "")
|
||||
f:close()
|
||||
if #text == CodeEntry.LENGTH then code = text break end
|
||||
end
|
||||
U.wait(1)
|
||||
end
|
||||
if not code then
|
||||
log("FAIL host never published a code")
|
||||
return
|
||||
end
|
||||
log("joining code", code)
|
||||
|
||||
local link = LinkState.new(game)
|
||||
game.stack:push(link)
|
||||
U.wait(10)
|
||||
game.save.options.speed = 20
|
||||
U.wait(2)
|
||||
log("logicSpeed with GAME SPEED=20 during link:", game:logicSpeed())
|
||||
|
||||
log("before nav: stage", tostring(link.stage), "index", tostring(link.index),
|
||||
"top", tostring(game.stack:top() == link), "mods", #require("src.link.Handshake").mods(game))
|
||||
U.tap(game, "down"); U.wait(3)
|
||||
log("after down: stage", tostring(link.stage), "index", tostring(link.index))
|
||||
U.tap(game, "a"); U.wait(5) -- ONLINE MATCH
|
||||
log("after a: stage", tostring(link.stage), "index", tostring(link.index))
|
||||
if link.stage ~= "onlineMenu" then
|
||||
log("FAIL expected onlineMenu, at", tostring(link.stage))
|
||||
U.wait(60)
|
||||
U.shot(game, DIR .. "/online_join_blocked.png")
|
||||
return
|
||||
end
|
||||
U.tap(game, "down"); U.wait(3)
|
||||
U.tap(game, "a"); U.wait(5) -- JOIN ONLINE -> code entry
|
||||
if link.stage ~= "codeEntry" then
|
||||
log("FAIL expected codeEntry, at", tostring(link.stage))
|
||||
return
|
||||
end
|
||||
-- set the six slots straight rather than scrubbing each one with UP
|
||||
-- presses; the scrub interaction has its own coverage (online_play_test)
|
||||
for i = 1, CodeEntry.LENGTH do
|
||||
local idx = CodeEntry.CHARSET:find(code:sub(i, i), 1, true)
|
||||
link.codeEntry.chars[i] = idx or 1
|
||||
end
|
||||
U.wait(2)
|
||||
U.shot(game, DIR .. "/online_join_1_code.png")
|
||||
U.tap(game, "a"); U.wait(10)
|
||||
|
||||
for _ = 1, 1800 do
|
||||
if link.stage == "waitMode" or link.stage == "battleWait" then break end
|
||||
if link.net and link.net.error then
|
||||
log("FAIL join error:", tostring(link.net.error))
|
||||
return
|
||||
end
|
||||
U.wait(1)
|
||||
end
|
||||
log("connected, stage", tostring(link.stage))
|
||||
U.shot(game, DIR .. "/online_join_2_connected.png")
|
||||
|
||||
local battle
|
||||
for _ = 1, 1800 do
|
||||
local top = game.stack:top()
|
||||
if top and top.kind == "link" then battle = top break end
|
||||
U.wait(1)
|
||||
end
|
||||
if not battle then
|
||||
log("FAIL battle never started (stage " .. tostring(link.stage) .. ")")
|
||||
U.shot(game, DIR .. "/online_join_nobattle.png")
|
||||
return
|
||||
end
|
||||
log("battle started vs", tostring(battle.opponentName))
|
||||
U.shot(game, DIR .. "/online_join_3_battle.png")
|
||||
|
||||
local shots = 0
|
||||
for i = 1, 200000 do
|
||||
if battle.result then break end
|
||||
U.tap(game, "a")
|
||||
if i % 900 == 0 and shots < 3 then
|
||||
shots = shots + 1
|
||||
U.shot(game, DIR .. ("/online_join_4_turn%d.png"):format(shots))
|
||||
end
|
||||
end
|
||||
U.wait(60)
|
||||
U.shot(game, DIR .. "/online_join_5_result.png")
|
||||
log("result", tostring(battle.result), "turns", tostring(battle.turnCount))
|
||||
log("desyncs", #desyncs)
|
||||
log("DONE")
|
||||
end
|
||||
@@ -1,29 +1,8 @@
|
||||
-- Driver: party menu cursor alignment (#278). A manual eye check, not a
|
||||
-- pass/fail run -- no assertion in this repo can judge where a triangle
|
||||
-- sits against a reference screenshot.
|
||||
--
|
||||
-- pokered evidence. home/pokemon.asm PartyMenuInit seeds the shared menu
|
||||
-- cursor coordinates:
|
||||
--
|
||||
-- ld hl, wTopMenuItemY
|
||||
-- inc a ; a = 1
|
||||
-- ld [hli], a ; top menu item Y
|
||||
-- xor a
|
||||
-- ld [hli], a ; top menu item X
|
||||
--
|
||||
-- and home/window.asm PlaceMenuCursor walks that many rows down from
|
||||
-- hlcoord 0, 0. Meanwhile party_menu.asm RedrawPartyMenu_ starts the name
|
||||
-- column at hlcoord 3, 0. So a party entry's name is on tile row 0 while
|
||||
-- its cursor belongs on tile row 1: the level/HP line, level with the
|
||||
-- middle of the two-row icon.
|
||||
--
|
||||
-- The bug: PartyMenu drew the cursor at entryY(i), the name row, putting it
|
||||
-- a full tile (8px) too high on every slot. The fix draws it at y + 8.
|
||||
--
|
||||
-- Do NOT run this under POKEPORT_SPEED. Fast-forward scales only the logic
|
||||
-- clock while rendering and audio run on their own real-time accumulators
|
||||
-- (src/core/Game.lua), so a sped-up run can capture a half-drawn frame.
|
||||
--
|
||||
-- Driver: party menu cursor alignment (#278). Eye check, not pass/fail.
|
||||
-- pokered home/pokemon.asm PartyMenuInit seeds wTopMenuItemY = 1 while
|
||||
-- party_menu.asm RedrawPartyMenu_ starts the name column at hlcoord 3, 0,
|
||||
-- so the cursor belongs on an entry's second row (level/HP), not its name
|
||||
-- row. No POKEPORT_SPEED: rendering runs on its own real-time clock.
|
||||
-- POKEPORT_DRIVER=tests/drivers/party_cursor_bug278_test.lua POKEPORT_IDENTITY=bug278 love .
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
@@ -36,12 +15,8 @@ return function(game)
|
||||
return ok
|
||||
end
|
||||
|
||||
-- ---- preconditions a human's eye cannot separate from the bug ----------
|
||||
-- A cursor drawn off-screen, or a party too short to show the stride,
|
||||
-- both look exactly like "the offset is wrong". Check them first.
|
||||
|
||||
-- the geometry contract the fix depends on: 16px stride, name row at the
|
||||
-- top of each entry. If entryY ever changes, the +8 has to be revisited.
|
||||
-- the geometry the +8 depends on: 16px stride, name row at the top of
|
||||
-- each entry. If entryY changes, the cursor offset has to be revisited.
|
||||
check("entryY stride is 16px", PartyMenu.entryY(2) - PartyMenu.entryY(1) == 16)
|
||||
check("slot 1 name row is y=0", PartyMenu.entryY(1) == 0)
|
||||
|
||||
@@ -54,7 +29,6 @@ return function(game)
|
||||
game.save.player.name = "bryan"
|
||||
check("party has enough slots to judge the stride", #game.save.party >= 3)
|
||||
|
||||
-- the window actually rendered: a black frame is not an offset bug
|
||||
check("renderer is up", game.renderer ~= nil)
|
||||
|
||||
U.teleport(game, "PALLET_TOWN", 10, 8, "down")
|
||||
@@ -66,8 +40,7 @@ return function(game)
|
||||
|
||||
U.shot(game, "bug278_party_cursor_slot1.png")
|
||||
|
||||
-- move the cursor down so the stride is visible too: a fix that is right
|
||||
-- on slot 1 and wrong further down would otherwise read as a pass
|
||||
-- step down as well, so a stride bug cannot hide behind a correct slot 1
|
||||
U.tap(game, "down")
|
||||
U.wait(15)
|
||||
U.shot(game, "bug278_party_cursor_slot2.png")
|
||||
@@ -75,20 +48,10 @@ return function(game)
|
||||
U.wait(15)
|
||||
U.shot(game, "bug278_party_cursor_slot3.png")
|
||||
|
||||
U.log("........................................................")
|
||||
U.log("LOOK NOW: the party menu is open. Screenshots were written to the")
|
||||
U.log(" LOVE save dir as bug278_party_cursor_slot1/2/3.png.")
|
||||
U.log(" RIGHT: the black triangle sits on the LOWER of each entry's two")
|
||||
U.log(" rows, level with the LEVEL/HP line and with the middle of")
|
||||
U.log(" the two-row party icon.")
|
||||
U.log(" BUG #278 looks like: the triangle riding up on the NAME row, its")
|
||||
U.log(" tip level with the first letter of the nickname.")
|
||||
U.log(" ALSO WRONG: correct on slot 1 but drifting on slots 2-4 (that is")
|
||||
U.log(" a stride bug, not an offset bug), or the triangle sliding")
|
||||
U.log(" a half tile so it straddles both rows.")
|
||||
U.log("Compare against the reference shot in issue #278. Input is yours")
|
||||
U.log("from here: up/down re-checks every slot, B closes the menu.")
|
||||
U.log("........................................................")
|
||||
U.log("Party menu is open; shots are in the LOVE save dir as")
|
||||
U.log("bug278_party_cursor_slot1/2/3.png. The cursor should sit on the")
|
||||
U.log("lower row of each entry, level with LEVEL/HP, not up on the name")
|
||||
U.log("row (#278). Up/down re-checks every slot, B closes the menu.")
|
||||
|
||||
while true do
|
||||
coroutine.yield()
|
||||
|
||||
@@ -0,0 +1,278 @@
|
||||
-- Driver: watch a POTION fill the party menu's HP bar (#252).
|
||||
-- pokered engine/items/item_effects.asm .doneHealing runs UpdateHPBar2 with
|
||||
-- the party menu still drawn; status cures branch off and never touch the bar.
|
||||
-- Not under POKEPORT_SPEED: SFX_HEAL_HP rides the real-time audio clock.
|
||||
-- POKEPORT_DRIVER=tests/drivers/party_heal_bug252_test.lua \
|
||||
-- POKEPORT_IDENTITY=bug252 POKEPORT_TOUCH=0 POKEPORT_VERSION=red love .
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
local Bag = require("src.inventory.Bag")
|
||||
local ItemEffects = require("src.inventory.ItemEffects")
|
||||
local PartyMenu = require("src.ui.PartyMenu")
|
||||
local TextBox = require("src.render.TextBox")
|
||||
|
||||
local pass, fail = 0, 0
|
||||
local function check(label, ok)
|
||||
if ok then pass = pass + 1 else fail = fail + 1 end
|
||||
U.log(ok and "PASS" or "FAIL", label)
|
||||
return ok
|
||||
end
|
||||
local function top() return game.stack:top() end
|
||||
local function isPicker(s)
|
||||
return s ~= nil and (s.screenId == "PartyMenu" or getmetatable(s) == PartyMenu)
|
||||
end
|
||||
local function isBox(s) return getmetatable(s) == TextBox end
|
||||
local function inStack(pred)
|
||||
for _, s in ipairs(game.stack.states or {}) do
|
||||
if pred(s) then return true end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
-- ---- preconditions ------------------------------------------------------
|
||||
-- A lost item id, a healsHP gate that dropped an item, or a use() that stops
|
||||
-- handing back the pre-heal HP all read as "the animation is broken".
|
||||
U.log("======== #252 party-menu HP fill: machine checks ========")
|
||||
check("ItemEffects.healsHP exists", type(ItemEffects.healsHP) == "function")
|
||||
check("PartyMenu:animateTo exists", type(PartyMenu.animateTo) == "function")
|
||||
check("PartyMenu:close exists", type(PartyMenu.close) == "function")
|
||||
if type(ItemEffects.healsHP) == "function" then
|
||||
-- .doneHealing is reached by exactly the .healHP items; FULL_HEAL and the
|
||||
-- single-status cures jump to the no-bar branch
|
||||
for _, id in ipairs({ "POTION", "SUPER_POTION", "HYPER_POTION",
|
||||
"MAX_POTION", "FULL_RESTORE", "REVIVE",
|
||||
"MAX_REVIVE" }) do
|
||||
check(id .. " takes the .healHP (animated) path",
|
||||
ItemEffects.healsHP(id) == true)
|
||||
end
|
||||
for _, id in ipairs({ "ANTIDOTE", "PARLYZ_HEAL", "AWAKENING", "BURN_HEAL",
|
||||
"ICE_HEAL", "FULL_HEAL", "ETHER" }) do
|
||||
check(id .. " does NOT animate the bar (status cure / PP)",
|
||||
ItemEffects.healsHP(id) ~= true)
|
||||
end
|
||||
end
|
||||
for _, id in ipairs({ "POTION", "MAX_POTION", "REVIVE", "ANTIDOTE" }) do
|
||||
check(id .. " resolves in the item table", game.data.items[id] ~= nil)
|
||||
end
|
||||
|
||||
-- use() must hand back the PRE-heal HP so the fill has a start (wHPBarOldHP)
|
||||
do
|
||||
local scratch = Pokemon.new(game.data, "BULBASAUR", 20)
|
||||
scratch.hp = 3
|
||||
local scratchSave = { party = { scratch }, inventory = {}, flags = {},
|
||||
pokedex = { seen = {}, owned = {} } }
|
||||
local result, msgs, extra = ItemEffects.use(game.data, scratchSave,
|
||||
"POTION", scratch)
|
||||
check("POTION on a hurt mon reports consumed", result == "consumed")
|
||||
check("...and hands back extra.healedFrom = the pre-heal HP",
|
||||
type(extra) == "table" and extra.healedFrom == 3)
|
||||
check("...with the restored-HP message",
|
||||
type(msgs) == "table" and type(msgs[1]) == "string"
|
||||
and msgs[1]:find("restored", 1, true) ~= nil)
|
||||
local _, _, cureExtra = ItemEffects.use(game.data, scratchSave,
|
||||
"ANTIDOTE", scratch)
|
||||
check("ANTIDOTE hands back no healedFrom (no fill)",
|
||||
cureExtra == nil or cureExtra.healedFrom == nil)
|
||||
end
|
||||
|
||||
-- ---- the fixture --------------------------------------------------------
|
||||
-- CHARIZARD L50 sits near 150 max HP, so a MAX_POTION from 1 HP is the full
|
||||
-- 96-frame fill across the whole 48-pixel bar.
|
||||
local lead = Pokemon.new(game.data, "CHARIZARD", 50)
|
||||
local fainted = Pokemon.new(game.data, "PIKACHU", 30)
|
||||
local poisoned = Pokemon.new(game.data, "SNORLAX", 40)
|
||||
lead.hp = 1
|
||||
fainted.hp = 0
|
||||
poisoned.status = "PSN"
|
||||
game.save.party = { lead, fainted, poisoned }
|
||||
game.save.player.name = "RED"
|
||||
for _, row in ipairs({ { "MAX_POTION", 9 }, { "POTION", 9 },
|
||||
{ "REVIVE", 9 }, { "ANTIDOTE", 9 } }) do
|
||||
Bag.add(game.save, row[1], row[2])
|
||||
end
|
||||
U.log(("lead: %s %d/%d HP"):format(lead.species, lead.hp, lead.stats.hp))
|
||||
|
||||
U.teleport(game, "PALLET_TOWN", 10, 8, "down")
|
||||
U.wait(10)
|
||||
|
||||
-- ---- menu navigation ---------------------------------------------------
|
||||
local function cursorTo(menu, want)
|
||||
for _ = 1, 40 do
|
||||
if not menu or menu.index == want then return menu and menu.index == want end
|
||||
U.tap(game, menu.index < want and "down" or "up")
|
||||
U.wait(3)
|
||||
end
|
||||
return menu.index == want
|
||||
end
|
||||
|
||||
-- START -> ITEM -> <id> -> USE, leaving the party picker open. Returns the
|
||||
-- picker, or nil plus a reason.
|
||||
local function openPickerFor(id)
|
||||
U.tap(game, "start")
|
||||
U.wait(10)
|
||||
local menu = top()
|
||||
if not (menu and menu.screenId == "StartMenu") then
|
||||
return nil, "start menu never opened"
|
||||
end
|
||||
-- the ITEM row shifts with POKéDEX / LINK / MODS, so never hardcode it
|
||||
local itemRow
|
||||
for i, it in ipairs(menu.items or {}) do
|
||||
if it.label == "ITEM" then itemRow = i break end
|
||||
end
|
||||
if not itemRow or not cursorTo(menu, itemRow) then return nil, "no ITEM row" end
|
||||
U.tap(game, "a")
|
||||
U.wait(10)
|
||||
|
||||
local bag = top()
|
||||
if not (bag and bag.screenId == "BagMenu") then return nil, "bag never opened" end
|
||||
local bagRow
|
||||
for i, r in ipairs(bag.items or {}) do
|
||||
if r.value == id then bagRow = i break end
|
||||
end
|
||||
if not bagRow or not cursorTo(bag, bagRow) then return nil, id .. " not in bag" end
|
||||
U.tap(game, "a")
|
||||
U.wait(10)
|
||||
|
||||
-- outside battle every usable item offers USE / TOSS first; USE is row 1
|
||||
local ut = top()
|
||||
if ut and ut.items and ut.items[1] and ut.items[1].label == "USE" then
|
||||
if not cursorTo(ut, 1) then return nil, "USE row unreachable" end
|
||||
U.tap(game, "a")
|
||||
U.wait(10)
|
||||
end
|
||||
local picker = top()
|
||||
if not isPicker(picker) then return nil, "party picker never opened" end
|
||||
return picker
|
||||
end
|
||||
|
||||
local function backToOverworld()
|
||||
for _ = 1, 30 do
|
||||
if top() == game.overworld then return true end
|
||||
U.tap(game, "b")
|
||||
U.wait(6)
|
||||
end
|
||||
return top() == game.overworld
|
||||
end
|
||||
|
||||
-- ======== scripted run: MAX_POTION on the 1 HP lead ======================
|
||||
U.log("======== #252 scripted run: MAX POTION on a 1 HP CHARIZARD ========")
|
||||
local picker, why = openPickerFor("MAX_POTION")
|
||||
check("party picker opened for MAX POTION" .. (why and (" (" .. why .. ")") or ""),
|
||||
picker ~= nil)
|
||||
|
||||
if picker then
|
||||
check("cursor sits on the hurt lead", cursorTo(picker, 1))
|
||||
U.shot(game, DIR .. "/bug252_picker_before.png")
|
||||
|
||||
local hpBefore = lead.hp
|
||||
U.tap(game, "a") -- choose the lead
|
||||
|
||||
-- THE DEFECT: the picker used to pop here, before the item had even run.
|
||||
check("the party menu is STILL the top state after the A press",
|
||||
isPicker(top()))
|
||||
check("the fill is running (picker.heal is set)",
|
||||
type(picker.heal) == "table")
|
||||
if type(picker.heal) == "table" then
|
||||
check("the fill starts from the pre-heal HP (wHPBarOldHP)",
|
||||
math.floor(picker.heal.shown + 0.5) == hpBefore)
|
||||
end
|
||||
|
||||
-- Sample the climb and prove input is ignored for its duration
|
||||
-- (UpdateHPBar2 blocks). U.frame() is the real yield count: the taps below
|
||||
-- each burn a frame, so an iteration counter would under-report.
|
||||
local startFrame, samples, blocked = U.frame(), {}, true
|
||||
local iter, shot1, shot2 = 0, false, false
|
||||
for _ = 1, 400 do
|
||||
if not picker.heal then break end
|
||||
local shown = picker.heal.shown
|
||||
samples[#samples + 1] = shown
|
||||
local frac = shown / math.max(1, lead.stats.hp)
|
||||
if not shot1 and frac > 0.33 then
|
||||
shot1 = true
|
||||
U.shot(game, DIR .. "/bug252_fill_third.png")
|
||||
elseif not shot2 and frac > 0.66 then
|
||||
shot2 = true
|
||||
U.shot(game, DIR .. "/bug252_fill_two_thirds.png")
|
||||
else
|
||||
-- mash B and A: neither may do anything while the bar is filling
|
||||
U.tap(game, (iter % 2 == 0) and "b" or "a")
|
||||
end
|
||||
if picker.heal and not isPicker(top()) then blocked = false end
|
||||
iter = iter + 1
|
||||
U.wait(1)
|
||||
end
|
||||
local frames = U.frame() - startFrame
|
||||
U.log(("fill ran ~%d frames (%.2f s at 60 Hz)"):format(frames, frames / 60))
|
||||
check("the fill took more than half a second (it animates, not snaps)",
|
||||
frames > 30)
|
||||
check("the fill is not absurdly long (< 3 s)", frames < 180)
|
||||
check("A and B did nothing while the bar filled", blocked)
|
||||
local rose = #samples >= 2 and samples[#samples] > samples[1]
|
||||
check("the drawn HP climbed over those frames", rose)
|
||||
if #samples >= 2 then
|
||||
U.log(("shown HP walked %.1f -> %.1f of %d")
|
||||
:format(samples[1], samples[#samples], lead.stats.hp))
|
||||
end
|
||||
check("the mon really is at full HP now", lead.hp == lead.stats.hp)
|
||||
|
||||
-- .showHealingItemMessage: the message prints with the menu still drawn
|
||||
for _ = 1, 60 do
|
||||
if isBox(top()) then break end
|
||||
U.wait(1)
|
||||
end
|
||||
check("the message box opened", isBox(top()))
|
||||
check("...over the STILL-drawn party menu", inStack(isPicker))
|
||||
U.wait(60) -- let the line type out, so the shot shows the text not an empty box
|
||||
U.shot(game, DIR .. "/bug252_message_over_party.png")
|
||||
|
||||
-- TextBox pops BEFORE it fires onDone, which is what makes
|
||||
-- PartyMenu:close's identity check land on the picker
|
||||
for _ = 1, 30 do
|
||||
if not inStack(isPicker) then break end
|
||||
U.tap(game, "a")
|
||||
U.wait(8)
|
||||
end
|
||||
check("the picker is gone once the message is dismissed", not inStack(isPicker))
|
||||
local back = top()
|
||||
check("and we are back on the ITEM list", back ~= nil and back.screenId == "BagMenu")
|
||||
U.shot(game, DIR .. "/bug252_back_on_bag.png")
|
||||
end
|
||||
backToOverworld()
|
||||
|
||||
-- ======== contrast: ANTIDOTE must NOT animate ===========================
|
||||
U.log("======== #252 contrast: ANTIDOTE (no bar fill at all) ========")
|
||||
local cure = openPickerFor("ANTIDOTE")
|
||||
if check("party picker opened for ANTIDOTE", cure ~= nil) then
|
||||
check("keepOpen is off for a status cure", cure.keepOpen ~= true)
|
||||
cursorTo(cure, 3) -- the poisoned SNORLAX
|
||||
U.tap(game, "a")
|
||||
U.wait(6)
|
||||
check("no fill was started for a status cure", cure.heal == nil)
|
||||
check("the picker popped itself, like every non-medicine item",
|
||||
not inStack(isPicker))
|
||||
U.shot(game, DIR .. "/bug252_antidote_message.png")
|
||||
check("PSN was cured", poisoned.status == nil)
|
||||
end
|
||||
backToOverworld()
|
||||
|
||||
-- ---- verdict, then re-arm and hand off ----------------------------------
|
||||
U.log(("======== machine checks: %d passed, %d failed ========"):format(pass, fail))
|
||||
|
||||
lead.hp = 1
|
||||
fainted.hp = 0
|
||||
poisoned.status = "PSN"
|
||||
local rearmed = openPickerFor("MAX_POTION")
|
||||
if rearmed then cursorTo(rearmed, 1) end
|
||||
|
||||
U.log("The bag, USE and the party picker are re-opened with the cursor on a")
|
||||
U.log("1 HP CHARIZARD and a MAX POTION chosen. Press A, watch slot 1's bar:")
|
||||
U.log("the list stays up, the bar lengthens over ~1.5s with the number, and")
|
||||
U.log("buttons do nothing until it lands. #252 was the list snapping shut.")
|
||||
U.log("Spare items are in the bag if you want to run it again.")
|
||||
|
||||
while true do
|
||||
coroutine.yield()
|
||||
end
|
||||
end
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user