mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-12 16:31:05 +02:00
Compare commits
39 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| 3b2e489325 | |||
| 10acfe0710 | |||
| 5113cfd951 | |||
| 43158d724b | |||
| f0a88ea473 | |||
| 31365d8dfd | |||
| 531a719794 | |||
| ff90062741 | |||
| 3448fae3f5 | |||
| 25df838b45 | |||
| be90e5b42c | |||
| 08e8dfc416 | |||
| 6a27be54de | |||
| d4055209e6 |
@@ -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,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
|
||||
@@ -31,48 +31,118 @@ The packaged app contains neither a ROM nor pre-extracted game data. Music,
|
||||
sound effects, and cries are synthesized while the game runs from compact
|
||||
audio channel programs copied out of the verified ROM.
|
||||
|
||||
## 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).
|
||||
|
||||
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.
|
||||
|
||||
After import, saves and the ROM-derived cache stay next to the game on the
|
||||
SD card (`portable.txt`).
|
||||
|
||||
## 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 +150,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 +158,16 @@ 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.
|
||||
|
||||
|
||||
|
||||
## Modding
|
||||
|
||||
@@ -103,36 +175,38 @@ 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
|
||||
|
||||
This project would not be possible without [pret](https://github.com/pret) >
|
||||
the pret band of decompiling maniacs > and their
|
||||
[pokered](https://github.com/pret/pokered) disassembly.
|
||||
[pokered](https://github.com/pret/pokered) disassembly.
|
||||
Executable
+321
@@ -0,0 +1,321 @@
|
||||
#!/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}"
|
||||
|
||||
$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).
|
||||
|
||||
### 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
|
||||
|
||||
@@ -1,13 +1,21 @@
|
||||
-- pokered/scripts/PewterNidoranHouse.asm
|
||||
-- PewterNidoranHouseNidoranText: text_far _PewterNidoranHouseNidoranText, then
|
||||
-- text_asm plays the NIDORAN_M cry (PlayCry/WaitForSoundToFinish) before ending.
|
||||
-- No sound-effect command exists in Commands.lua, so we port the dialogue line
|
||||
-- only; the cry SFX is cosmetic and has no gameplay effect.
|
||||
-- play_cry only stashes ctx.pendingCry for the very next show_text row, so it
|
||||
-- has to sit immediately before that row (src/script/Commands.lua); the cry
|
||||
-- then sounds once the box has typed out, matching the asm's print-then-cry
|
||||
-- order. Its waitForButton form keeps DisplayTextID's trailing
|
||||
-- WaitForTextScrollButtonPress -- PewterNidoranHouse_Script is
|
||||
-- `jp EnableAutoTextBoxDrawing`, which zeroes
|
||||
-- wDoNotWaitForButtonPressAfterDisplayingText -- so the box still holds until
|
||||
-- A/B. The cry row was missing, leaving the NIDORAN silent (#247).
|
||||
return {
|
||||
PEWTER_NIDORAN_HOUSE = {
|
||||
talk = {
|
||||
TEXT_PEWTERNIDORANHOUSE_NIDORAN = {
|
||||
{ "face_player" },
|
||||
-- text_asm: ld a, NIDORAN_M / call PlayCry
|
||||
{ "play_cry", "NIDORAN_M", true },
|
||||
{ "show_text", "_PewterNidoranHouseNidoranText" },
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -59,13 +59,17 @@ M.VIRIDIAN_CITY = {
|
||||
-- ViridianCityYoungster2CaterpieAndWeedleDescriptionText are
|
||||
-- defined without a leading underscore in pokered/text/ViridianCity.asm
|
||||
-- and aren't present in data/generated/text.lua, so we fall back to
|
||||
-- the literal strings from pokered.
|
||||
-- the literal strings from pokered. Those fallbacks have to carry the
|
||||
-- extractor's markers, not plain newlines: line -> \n, cont -> \v,
|
||||
-- para -> \f. Spelling cont/para as \n and \n\n put all six lines on
|
||||
-- one page with nothing to wait on, so the whole speech scrolled past
|
||||
-- without a button press (#250).
|
||||
TEXT_VIRIDIANCITY_YOUNGSTER2 = function(game, ow, npc, done)
|
||||
local t = text(game)
|
||||
ask(game, t._ViridianCityYoungster2YouWantToKnowAboutText
|
||||
or "You want to know\nabout the 2 kinds\nof caterpillar\nPOKéMON?", function(yes)
|
||||
or "You want to know\nabout the 2 kinds\vof caterpillar\vPOKéMON?", function(yes)
|
||||
if yes then
|
||||
push(game, "CATERPIE has no\npoison, but\nWEEDLE does.\n\nWatch out for its\nPOISON STING!", done)
|
||||
push(game, "CATERPIE has no\npoison, but\vWEEDLE does.\fWatch out for its\nPOISON STING!", done)
|
||||
else
|
||||
push(game, "Oh, OK then!", done)
|
||||
end
|
||||
|
||||
@@ -3,12 +3,16 @@
|
||||
return {
|
||||
VIRIDIAN_NICKNAME_HOUSE = {
|
||||
talk = {
|
||||
-- ViridianNicknameHouseSpearowText: text_asm that shows the text
|
||||
-- then plays a SPEAROW cry (PlayCry/WaitForSoundToFinish). The
|
||||
-- cry playback isn't in the talk-script command vocabulary, so
|
||||
-- only the flavor line is ported here.
|
||||
-- ViridianNicknameHouseSpearowText: text_asm that PrintTexts the
|
||||
-- line, then plays the SPEAROW cry (PlayCry/WaitForSoundToFinish)
|
||||
-- before TextScriptEnd. play_cry only arms the show_text row that
|
||||
-- follows it, so the cry sounds once the box has typed out; its
|
||||
-- waitForButton form keeps DisplayTextID's trailing
|
||||
-- WaitForTextScrollButtonPress, so the box then holds until A/B like
|
||||
-- any other NPC line rather than popping itself (#251).
|
||||
TEXT_VIRIDIANNICKNAMEHOUSE_SPEAROW = {
|
||||
{ "show_text", "_ViridianNicknameHouseSpearowText" },
|
||||
{ "play_cry", "SPEAROW", true }, -- 1 PlayCry
|
||||
{ "show_text", "_ViridianNicknameHouseSpearowText" }, -- 2 PrintText
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
+66
-16
@@ -16,6 +16,14 @@ M.VIRIDIAN_MART = {
|
||||
-- StartSimulatingJoypadStates walks the player to the counter
|
||||
-- (.PlayerMovement: PAD_LEFT 1, PAD_UP 2, door (3,7) -> counter (2,5))
|
||||
-- and ViridianMartOaksParcelScript hands the parcel over. The player
|
||||
-- never presses A.
|
||||
--
|
||||
-- The RLE list is consumed back to front: DecodeRLEList fills forward
|
||||
-- from wSimulatedJoypadStatesEnd, the script seeds wSimulatedJoypadStates-
|
||||
-- Index with the count, and home/overworld.asm decrements that index
|
||||
-- before each read. So PAD_LEFT 1, PAD_UP 2 actually plays up, up, left,
|
||||
-- and the walk ends facing the clerk. Replaying the list front to back
|
||||
-- put the player at the counter facing up instead (#235). The player
|
||||
-- never presses A. This matters beyond convenience: the parcel gates
|
||||
-- Oak's Pokedex and the old man clearing Route 2, so vanilla guarantees
|
||||
-- it on entry rather than letting you walk out without it.
|
||||
@@ -28,8 +36,8 @@ M.VIRIDIAN_MART = {
|
||||
if not f.EVENT_GOT_STARTER then return end
|
||||
ow:queueScript({
|
||||
{ "show_text", "_ViridianMartClerkYouCameFromPalletTownText" },
|
||||
{ "move_player", "left", 1 },
|
||||
{ "move_player", "up", 2 },
|
||||
{ "move_player", "left", 1 },
|
||||
-- the quest text's last page is "{PLAYER} got\nOAK's PARCEL!"
|
||||
{ "give_item", "OAKS_PARCEL", 1, "_ViridianMartClerkParcelQuestText" },
|
||||
{ "set_flag", "EVENT_GOT_OAKS_PARCEL" },
|
||||
@@ -86,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)
|
||||
@@ -711,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
|
||||
@@ -728,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
|
||||
@@ -751,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
|
||||
@@ -761,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)
|
||||
|
||||
@@ -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
|
||||
|
||||
+119
-7
@@ -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
|
||||
|
||||
@@ -161,8 +163,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 +184,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
|
||||
@@ -193,4 +226,83 @@ appears while no controller is being used: the first gamepad button or
|
||||
stick push hides it, the next screen touch brings it back, and unplugging
|
||||
the last controller restores it immediately. Layout re-derives from the
|
||||
window size on rotation. Desktop testing: `POKEPORT_TOUCH=1 love .` forces
|
||||
the overlay on and lets the mouse act as a finger (`=0` forces it off).
|
||||
the overlay on and lets the mouse act as a finger (`=0` forces it off).
|
||||
## Translation support
|
||||
|
||||
Every string the player can read is now reachable from a mod, so a
|
||||
translation is an ordinary content mod rather than a fork.
|
||||
|
||||
Two things had to change. Text layout stopped counting bytes: the dialogue
|
||||
box measures a line in glyphs (charmap sequences), so a 3-byte character
|
||||
costs one column, a cut never lands inside a character, and a page with a
|
||||
non-default `advance` re-measures instead of overflowing. That also fixed
|
||||
25 vanilla English lines that were wrapping early because `é` in POKéMON
|
||||
and POKéDEX costs two bytes ("I study POKéMON as" is 19 bytes and 18
|
||||
glyphs, and the box was breaking it).
|
||||
|
||||
Second, the text the engine writes itself - battle messages, item results,
|
||||
menu labels, the link-play screens - moved behind `src/core/Strings.lua`
|
||||
and the new `strings` registry. Extracted script text was already
|
||||
overridable through `text`; this covers the other half. Entries are keyed
|
||||
by the English source, so a translation that has not reached a string yet
|
||||
keeps rendering in English and a half-finished translation stays playable.
|
||||
|
||||
Authors generate the whole thing:
|
||||
|
||||
```sh
|
||||
python3 tools/modkit.py translation francais --language "Francais"
|
||||
```
|
||||
|
||||
That scaffolds a mod with every translatable string as an empty catalog,
|
||||
plus a glyph-page and charmap stub, a naming-grid stub, and a
|
||||
`francais-worksheet/` directory holding the English to translate from
|
||||
(deliberately outside the mod: extracted text is ROM content and must not
|
||||
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
|
||||
|
||||
+582
-189
File diff suppressed because it is too large
Load Diff
@@ -8,6 +8,7 @@
|
||||
local MoveEffects = require("src.battle.MoveEffects")
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
local StatusRegistry = require("src.battle.StatusRegistry")
|
||||
local Strings = require("src.core.Strings")
|
||||
|
||||
local EffectRegistry = {}
|
||||
|
||||
@@ -91,7 +92,7 @@ function EffectRegistry.runDamaging(battle, ctx, record)
|
||||
if target.invulnerable and not neverMiss then
|
||||
-- Explosion/Selfdestruct still animate on a miss (HandleIfPlayerMoveMissed)
|
||||
if not (record and record.explode) then battle:cancelMoveAnim() end
|
||||
battle:sayNext(("%s's\nattack missed!"):format(displayName(user)))
|
||||
battle:sayNext(Strings("%s's\nattack missed!", displayName(user)))
|
||||
return
|
||||
end
|
||||
|
||||
@@ -113,7 +114,7 @@ function EffectRegistry.runDamaging(battle, ctx, record)
|
||||
if not battle:accuracyRoll(move, user, target) then
|
||||
-- Explosion/Selfdestruct still animate on a miss (HandleIfPlayerMoveMissed)
|
||||
if not (record and record.explode) then battle:cancelMoveAnim() end
|
||||
battle:sayNext(("%s's\nattack missed!"):format(displayName(user)))
|
||||
battle:sayNext(Strings("%s's\nattack missed!", displayName(user)))
|
||||
-- Jump Kick crash, Explode self-destruct
|
||||
if record and record.onMiss then record.onMiss(ctx, "accuracy") end
|
||||
user.trappingTurns = nil
|
||||
@@ -140,7 +141,7 @@ function EffectRegistry.runDamaging(battle, ctx, record)
|
||||
end
|
||||
if not counterable or (battle.lastDamage or 0) == 0 then
|
||||
battle:cancelMoveAnim()
|
||||
battle:sayNext(("%s's\nattack missed!"):format(displayName(user)))
|
||||
battle:sayNext(Strings("%s's\nattack missed!", displayName(user)))
|
||||
return
|
||||
end
|
||||
dmg = math.min(65535, battle.lastDamage * 2)
|
||||
@@ -163,14 +164,14 @@ function EffectRegistry.runDamaging(battle, ctx, record)
|
||||
if info.typeMult == 0 then
|
||||
-- type immunity zeros damage and sets wMoveMissed in Gen 1, so no anim
|
||||
if not (record and record.explode) then battle:cancelMoveAnim() end
|
||||
battle:sayNext(("It doesn't affect\n%s!"):format(displayName(target)))
|
||||
battle:sayNext(Strings("It doesn't affect\n%s!", displayName(target)))
|
||||
if record and record.onMiss then record.onMiss(ctx, "immune") end
|
||||
return
|
||||
end
|
||||
if info.missed then
|
||||
-- 0.25x floored the damage to zero: the original registers a miss
|
||||
if not (record and record.explode) then battle:cancelMoveAnim() end
|
||||
battle:sayNext(("%s's\nattack missed!"):format(displayName(user)))
|
||||
battle:sayNext(Strings("%s's\nattack missed!", displayName(user)))
|
||||
if record and record.onMiss then record.onMiss(ctx, "floored") end
|
||||
return
|
||||
end
|
||||
@@ -214,12 +215,12 @@ function EffectRegistry.runDamaging(battle, ctx, record)
|
||||
-- multi-hit loop (core.asm .moveDidNotMiss before the jump back
|
||||
-- to GetPlayerAnimationType), so crit/effectiveness reprint on
|
||||
-- every strike -- damage was only rolled once
|
||||
if info.crit then battle:sayNext("Critical hit!") end
|
||||
if info.ohko then battle:sayNext("One-hit KO!") end
|
||||
if info.crit then battle:sayNext(Strings("Critical hit!")) end
|
||||
if info.ohko then battle:sayNext(Strings("One-hit KO!")) end
|
||||
if info.typeMult > 10 then
|
||||
battle:sayNext("It's super\neffective!")
|
||||
battle:sayNext(Strings("It's super\neffective!"))
|
||||
elseif info.typeMult < 10 then
|
||||
battle:sayNext("It's not very\neffective...")
|
||||
battle:sayNext(Strings("It's not very\neffective..."))
|
||||
end
|
||||
if Runtime.wants("battle.damage_dealt") then
|
||||
Runtime.emit("battle.damage_dealt", {
|
||||
@@ -237,9 +238,9 @@ function EffectRegistry.runDamaging(battle, ctx, record)
|
||||
if hits > 1 then
|
||||
-- player: _MultiHitText; enemy: _HitXTimesText (always plural)
|
||||
if user.isPlayer then
|
||||
battle:sayNext(("Hit the enemy\n%d times!"):format(hits))
|
||||
battle:sayNext(Strings("Hit the enemy\n%d times!", hits))
|
||||
else
|
||||
battle:sayNext(("Hit %d times!"):format(hits))
|
||||
battle:sayNext(Strings("Hit %d times!", hits))
|
||||
end
|
||||
end
|
||||
|
||||
@@ -251,7 +252,7 @@ function EffectRegistry.runDamaging(battle, ctx, record)
|
||||
elseif moveInst.struggle then
|
||||
-- struggle recoils even when its effect id resolves to no record
|
||||
local recoil = math.max(1, math.floor(dmg / 2))
|
||||
battle:sayNext(("%s's\nhit with recoil!"):format(displayName(user)))
|
||||
battle:sayNext(Strings("%s's\nhit with recoil!", displayName(user)))
|
||||
battle:applyDamage(user, recoil)
|
||||
end
|
||||
|
||||
|
||||
+59
-56
@@ -15,6 +15,7 @@ local Logger = require("src.core.Logger")
|
||||
local StatusRegistry = require("src.battle.StatusRegistry")
|
||||
local TurnOrder = require("src.battle.TurnOrder")
|
||||
local TypeChart = require("src.battle.TypeChart")
|
||||
local Strings = require("src.core.Strings")
|
||||
|
||||
local MoveEffects = {}
|
||||
|
||||
@@ -36,14 +37,14 @@ local STAT_LABEL = {
|
||||
local function changeStage(battle, who, stat, delta, fromEnemy)
|
||||
if fromEnemy and (who.substituteHP or who.mist) then
|
||||
if who.mist then
|
||||
return { displayName(who) .. " is\nprotected by MIST!" }
|
||||
return { Strings("%s is\nprotected by MIST!", displayName(who)) }
|
||||
end
|
||||
return { "But, it failed!" }
|
||||
return { Strings("But, it failed!") }
|
||||
end
|
||||
local cur = who.stages[stat] or 0
|
||||
local new = math.max(-6, math.min(6, cur + delta))
|
||||
if new == cur then
|
||||
return { ("Nothing happened!") }
|
||||
return { Strings("Nothing happened!") }
|
||||
end
|
||||
who.stages[stat] = new
|
||||
-- effects.asm:505-506: after any stat-stage change, modified stats are
|
||||
@@ -53,13 +54,13 @@ local function changeStage(battle, who, stat, delta, fromEnemy)
|
||||
-- _MonsStatsRoseText/_MonsStatsFellText: "X's / STAT rose!"; the
|
||||
-- two-stage variants scroll "greatly" onto a third line
|
||||
if delta >= 2 then
|
||||
return { ("%s's\n%s\ngreatly rose!"):format(displayName(who), STAT_LABEL[stat]) }
|
||||
return { Strings("%s's\n%s\ngreatly rose!", displayName(who), STAT_LABEL[stat]) }
|
||||
elseif delta == 1 then
|
||||
return { ("%s's\n%s rose!"):format(displayName(who), STAT_LABEL[stat]) }
|
||||
return { Strings("%s's\n%s rose!", displayName(who), STAT_LABEL[stat]) }
|
||||
elseif delta == -1 then
|
||||
return { ("%s's\n%s fell!"):format(displayName(who), STAT_LABEL[stat]) }
|
||||
return { Strings("%s's\n%s fell!", displayName(who), STAT_LABEL[stat]) }
|
||||
end
|
||||
return { ("%s's\n%s\ngreatly fell!"):format(displayName(who), STAT_LABEL[stat]) }
|
||||
return { Strings("%s's\n%s\ngreatly fell!", displayName(who), STAT_LABEL[stat]) }
|
||||
end
|
||||
MoveEffects.changeStage = changeStage
|
||||
|
||||
@@ -88,10 +89,10 @@ end
|
||||
local function statusMove(status)
|
||||
return function(battle, user, target, move)
|
||||
if target.mon.status then
|
||||
return { "But, it failed!" }
|
||||
return { Strings("But, it failed!") }
|
||||
end
|
||||
if status == "PSN" and target.substituteHP then
|
||||
return { "But, it failed!" }
|
||||
return { Strings("But, it failed!") }
|
||||
end
|
||||
local msgs = inflictStatus(battle, target, status, {
|
||||
toxic = move and move.id == "TOXIC",
|
||||
@@ -99,7 +100,7 @@ local function statusMove(status)
|
||||
source = move and move.id,
|
||||
})
|
||||
if #msgs == 0 then
|
||||
return { "But, it failed!" }
|
||||
return { Strings("But, it failed!") }
|
||||
end
|
||||
return msgs
|
||||
end
|
||||
@@ -111,7 +112,7 @@ local function statusSide(status, chance)
|
||||
-- target (regardless of the burn roll)
|
||||
if move and move.type == "FIRE" and target.mon.status == "FRZ" then
|
||||
target.mon.status = nil
|
||||
return { ("Fire defrosted\n%s!"):format(displayName(target)) }
|
||||
return { Strings("Fire defrosted\n%s!", displayName(target)) }
|
||||
end
|
||||
if battle.rng(0, 255) >= chance then return {} end
|
||||
return inflictStatus(battle, target, status, {
|
||||
@@ -144,10 +145,10 @@ end
|
||||
|
||||
local function confuse(battle, target, pierceSub)
|
||||
if target.confusedTurns or (target.substituteHP and not pierceSub) then
|
||||
return { "But, it failed!" }
|
||||
return { Strings("But, it failed!") }
|
||||
end
|
||||
target.confusedTurns = battle.rng(2, 5)
|
||||
return { ("%s\nbecame confused!"):format(displayName(target)) }
|
||||
return { Strings("%s\nbecame confused!", displayName(target)) }
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
@@ -181,53 +182,53 @@ MoveEffects.primary = {
|
||||
LEECH_SEED_EFFECT = function(battle, user, target)
|
||||
-- leech_seed.asm has no substitute check: seeding lands through one
|
||||
if target.leechSeeded then
|
||||
return { "But, it failed!" }
|
||||
return { Strings("But, it failed!") }
|
||||
end
|
||||
for _, t in ipairs(target.curTypes) do
|
||||
if t == "GRASS" then return { "But, it failed!" } end
|
||||
if t == "GRASS" then return { Strings("But, it failed!") } end
|
||||
end
|
||||
target.leechSeeded = true
|
||||
return { ("%s\nwas seeded!"):format(displayName(target)) }
|
||||
return { Strings("%s\nwas seeded!", displayName(target)) }
|
||||
end,
|
||||
|
||||
HEAL_EFFECT = function(battle, user, target, move)
|
||||
local mon = user.mon
|
||||
if move.id == "REST" then
|
||||
if mon.hp == mon.stats.hp then return { "But, it failed!" } end
|
||||
if mon.hp == mon.stats.hp then return { Strings("But, it failed!") } end
|
||||
mon.hp = mon.stats.hp
|
||||
mon.status = "SLP"
|
||||
user.sleepTurns = 2
|
||||
user.toxicCounter = nil
|
||||
return { ("%s\nstarted sleeping!"):format(displayName(user)) }
|
||||
return { Strings("%s\nstarted sleeping!", displayName(user)) }
|
||||
end
|
||||
if mon.hp == mon.stats.hp then return { "But, it failed!" } end
|
||||
if mon.hp == mon.stats.hp then return { Strings("But, it failed!") } end
|
||||
mon.hp = math.min(mon.stats.hp, mon.hp + math.floor(mon.stats.hp / 2))
|
||||
return { ("%s\nregained health!"):format(displayName(user)) }
|
||||
return { Strings("%s\nregained health!", displayName(user)) }
|
||||
end,
|
||||
|
||||
LIGHT_SCREEN_EFFECT = function(battle, user)
|
||||
if user.lightScreen then return { "But, it failed!" } end
|
||||
if user.lightScreen then return { Strings("But, it failed!") } end
|
||||
user.lightScreen = true
|
||||
return { ("%s's\nprotected against\nspecial attacks!"):format(displayName(user)) }
|
||||
return { Strings("%s's\nprotected against\nspecial attacks!", displayName(user)) }
|
||||
end,
|
||||
|
||||
REFLECT_EFFECT = function(battle, user)
|
||||
if user.reflect then return { "But, it failed!" } end
|
||||
if user.reflect then return { Strings("But, it failed!") } end
|
||||
user.reflect = true
|
||||
return { ("%s\ngained armor!"):format(displayName(user)) }
|
||||
return { Strings("%s\ngained armor!", displayName(user)) }
|
||||
end,
|
||||
|
||||
MIST_EFFECT = function(battle, user)
|
||||
if user.mist then return { "But, it failed!" } end
|
||||
if user.mist then return { Strings("But, it failed!") } end
|
||||
user.mist = true
|
||||
-- _ShroudedInMistText (lowercase "mist")
|
||||
return { ("%s's\nshrouded in mist!"):format(displayName(user)) }
|
||||
return { Strings("%s's\nshrouded in mist!", displayName(user)) }
|
||||
end,
|
||||
|
||||
FOCUS_ENERGY_EFFECT = function(battle, user)
|
||||
if user.focusEnergy then return { "But, it failed!" } end
|
||||
if user.focusEnergy then return { Strings("But, it failed!") } end
|
||||
user.focusEnergy = true
|
||||
return { ("%s's\ngetting pumped!"):format(displayName(user)) }
|
||||
return { Strings("%s's\ngetting pumped!", displayName(user)) }
|
||||
end,
|
||||
|
||||
HAZE_EFFECT = function(battle, user, target)
|
||||
@@ -254,33 +255,33 @@ MoveEffects.primary = {
|
||||
target.skipMove = true
|
||||
end
|
||||
target.mon.status = nil
|
||||
return { "All STATUS changes\nare eliminated!" }
|
||||
return { Strings("All STATUS changes\nare eliminated!") }
|
||||
end,
|
||||
|
||||
SUBSTITUTE_EFFECT = function(battle, user)
|
||||
if user.substituteHP then return { ("%s\nhas a SUBSTITUTE!"):format(displayName(user)) } end
|
||||
if user.substituteHP then return { Strings("%s\nhas a SUBSTITUTE!", displayName(user)) } end
|
||||
local cost = math.floor(user.mon.stats.hp / 4)
|
||||
-- substitute.asm only fails on subtraction underflow (current HP
|
||||
-- strictly below maxHP/4); at equality the substitute is built and
|
||||
-- the user is left standing on exactly 0 HP (it faints only when
|
||||
-- the engine next checks HP, not here)
|
||||
if user.mon.hp < cost then
|
||||
return { "Too weak to make\na SUBSTITUTE!" }
|
||||
return { Strings("Too weak to make\na SUBSTITUTE!") }
|
||||
end
|
||||
user.mon.hp = user.mon.hp - cost
|
||||
user.substituteHP = cost + 1
|
||||
-- _SubstituteText
|
||||
return { "It created a\nSUBSTITUTE!" }
|
||||
return { Strings("It created a\nSUBSTITUTE!") }
|
||||
end,
|
||||
|
||||
CONVERSION_EFFECT = function(battle, user, target)
|
||||
-- conversion.asm fails against a mid-Fly/Dig target (INVULNERABLE)
|
||||
if target.invulnerable then
|
||||
return { "But, it failed!" }
|
||||
return { Strings("But, it failed!") }
|
||||
end
|
||||
user.curTypes = { target.curTypes[1], target.curTypes[2] }
|
||||
-- _ConvertedTypeText
|
||||
return { ("Converted type to\n%s's!"):format(displayName(target)) }
|
||||
return { Strings("Converted type to\n%s's!", displayName(target)) }
|
||||
end,
|
||||
|
||||
-- MIMIC_EFFECT lives in BattleState:resolveMimic: MimicEffect
|
||||
@@ -311,27 +312,27 @@ MoveEffects.primary = {
|
||||
table.insert(user.curMoves, { id = mv.id, pp = 5, mimic = true })
|
||||
end
|
||||
-- _TransformedText: the copied name prints bare (wNameBuffer)
|
||||
return { ("%s\ntransformed into\n%s!"):format(displayName(user), target.name) }
|
||||
return { Strings("%s\ntransformed into\n%s!", displayName(user), target.name) }
|
||||
end,
|
||||
|
||||
DISABLE_EFFECT = function(battle, user, target)
|
||||
if target.disabledSlot then return { "But, it failed!" } end
|
||||
if target.disabledSlot then return { Strings("But, it failed!") } end
|
||||
local usable = {}
|
||||
for i, mv in ipairs(target.curMoves) do
|
||||
if mv.pp > 0 then table.insert(usable, i) end
|
||||
end
|
||||
if #usable == 0 then return { "But, it failed!" } end
|
||||
if #usable == 0 then return { Strings("But, it failed!") } end
|
||||
local slot = usable[battle.rng(1, #usable)]
|
||||
target.disabledSlot = slot
|
||||
target.disabledTurns = battle.rng(1, 8)
|
||||
local id = target.curMoves[slot].id
|
||||
-- _MoveWasDisabledText: "X's / MOVE was / disabled!"
|
||||
return { ("%s's\n%s was\ndisabled!"):format(displayName(target),
|
||||
return { Strings("%s's\n%s was\ndisabled!", displayName(target),
|
||||
battle.data.moves[id].name) }
|
||||
end,
|
||||
|
||||
SPLASH_EFFECT = function()
|
||||
return { "No effect!" }
|
||||
return { Strings("No effect!") }
|
||||
end,
|
||||
}
|
||||
|
||||
@@ -428,7 +429,9 @@ local function drainHalf(text)
|
||||
local mon = ctx.user.mon
|
||||
mon.hp = math.min(mon.stats.hp, mon.hp + heal)
|
||||
ctx.drain()
|
||||
ctx.say(text:format(displayName(ctx.target)))
|
||||
-- `text` arrives as a source string (Strings.source at the call
|
||||
-- site keeps it in the catalog); look it up here, at use time
|
||||
ctx.say(Strings(text, displayName(ctx.target)))
|
||||
end
|
||||
end
|
||||
|
||||
@@ -436,7 +439,7 @@ end
|
||||
-- flags the miss before the special-damage override)
|
||||
local function immuneMsg(ctx)
|
||||
if TypeChart.effectiveness(ctx.move.type, ctx.target.curTypes) == 0 then
|
||||
return ("It doesn't affect\n%s!"):format(displayName(ctx.target))
|
||||
return Strings("It doesn't affect\n%s!", displayName(ctx.target))
|
||||
end
|
||||
return nil
|
||||
end
|
||||
@@ -498,12 +501,12 @@ MoveEffects.full = {
|
||||
-- removed): overkill and substitute hits recoil at full strength
|
||||
local recoil = math.max(1, math.floor(ctx.rawDamage
|
||||
/ (ctx.moveInst.struggle and 2 or 4)))
|
||||
ctx.say(("%s's\nhit with recoil!"):format(displayName(ctx.user)))
|
||||
ctx.say(Strings("%s's\nhit with recoil!", displayName(ctx.user)))
|
||||
ctx.battle:applyDamage(ctx.user, recoil)
|
||||
end,
|
||||
},
|
||||
DRAIN_HP_EFFECT = {
|
||||
afterDamage = drainHalf("Sucked health from\n%s!"),
|
||||
afterDamage = drainHalf(Strings.source("Sucked health from\n%s!")),
|
||||
},
|
||||
DREAM_EATER_EFFECT = {
|
||||
-- only works on sleeping targets (checked before damage)
|
||||
@@ -511,7 +514,7 @@ MoveEffects.full = {
|
||||
if ctx.target.mon.status ~= "SLP" then return false, "But, it failed!" end
|
||||
return true
|
||||
end,
|
||||
afterDamage = drainHalf("%s's\ndream was eaten!"),
|
||||
afterDamage = drainHalf(Strings.source("%s's\ndream was eaten!")),
|
||||
},
|
||||
|
||||
-- charge moves: first turn just charges; Fly AND Dig go
|
||||
@@ -557,7 +560,7 @@ MoveEffects.full = {
|
||||
user.thrashTurns, user.thrashMove, user.thrashAnnounced = nil, nil, nil
|
||||
if not user.confusedTurns then
|
||||
user.confusedTurns = ctx.rng(2, 5)
|
||||
ctx.say(("%s\nbecame confused!"):format(displayName(user)))
|
||||
ctx.say(Strings("%s\nbecame confused!", displayName(user)))
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -566,7 +569,7 @@ MoveEffects.full = {
|
||||
JUMP_KICK_EFFECT = {
|
||||
onMiss = function(ctx, reason)
|
||||
if reason ~= "accuracy" then return end
|
||||
ctx.say(("%s\nkept going and\ncrashed!"):format(displayName(ctx.user)))
|
||||
ctx.say(Strings("%s\nkept going and\ncrashed!", displayName(ctx.user)))
|
||||
ctx.damage(ctx.user, 1)
|
||||
end,
|
||||
},
|
||||
@@ -595,7 +598,7 @@ MoveEffects.full = {
|
||||
afterDamage = function(ctx)
|
||||
local battle = ctx.battle
|
||||
battle.payDay = (battle.payDay or 0) + 2 * ctx.user.mon.level
|
||||
ctx.say("Coins scattered\neverywhere!")
|
||||
ctx.say(Strings("Coins scattered\neverywhere!"))
|
||||
end,
|
||||
},
|
||||
SWIFT_EFFECT = { neverMiss = true },
|
||||
@@ -610,7 +613,7 @@ MoveEffects.full = {
|
||||
local user = ctx.user
|
||||
user.bideTurns = ctx.rng(2, 3)
|
||||
user.bideDamage = 0
|
||||
ctx.say(("%s\nis storing energy!"):format(displayName(user)))
|
||||
ctx.say(Strings("%s\nis storing energy!", displayName(user)))
|
||||
end,
|
||||
},
|
||||
SWITCH_AND_TELEPORT_EFFECT = {
|
||||
@@ -631,27 +634,27 @@ MoveEffects.full = {
|
||||
end
|
||||
if ok then
|
||||
if move.id == "ROAR" then
|
||||
ctx.say(("%s\nran away scared!"):format(displayName(target)))
|
||||
ctx.say(Strings("%s\nran away scared!", displayName(target)))
|
||||
elseif move.id == "WHIRLWIND" then
|
||||
ctx.say(("%s\nwas blown away!"):format(displayName(target)))
|
||||
ctx.say(Strings("%s\nwas blown away!", displayName(target)))
|
||||
else
|
||||
ctx.say(("%s\nran from battle!"):format(displayName(user)))
|
||||
ctx.say(Strings("%s\nran from battle!", displayName(user)))
|
||||
end
|
||||
battle.result = "run"
|
||||
battle.afterQueue = "finish"
|
||||
elseif move.id == "TELEPORT" then
|
||||
battle:cancelMoveAnim()
|
||||
ctx.say("But, it failed!")
|
||||
ctx.say(Strings("But, it failed!"))
|
||||
else
|
||||
battle:cancelMoveAnim()
|
||||
ctx.say(("It didn't affect\n%s!"):format(displayName(target)))
|
||||
ctx.say(Strings("It didn't affect\n%s!", displayName(target)))
|
||||
end
|
||||
elseif move.id == "TELEPORT" then
|
||||
battle:cancelMoveAnim()
|
||||
ctx.say("But, it failed!")
|
||||
ctx.say(Strings("But, it failed!"))
|
||||
else
|
||||
battle:cancelMoveAnim()
|
||||
ctx.say(("%s\nis unaffected!"):format(displayName(target)))
|
||||
ctx.say(Strings("%s\nis unaffected!", displayName(target)))
|
||||
end
|
||||
end,
|
||||
},
|
||||
@@ -669,7 +672,7 @@ MoveEffects.full = {
|
||||
callsMove = function(ctx)
|
||||
local last = ctx.target.lastMove
|
||||
if not last then
|
||||
ctx.say("The MIRROR MOVE\nfailed!")
|
||||
ctx.say(Strings("The MIRROR MOVE\nfailed!"))
|
||||
return nil
|
||||
end
|
||||
return last
|
||||
|
||||
+31
-21
@@ -5,6 +5,8 @@
|
||||
-- and residual sweep. Callers without a battle (pure-module tests) fall
|
||||
-- back to the vanilla records, which is bit-identical behavior.
|
||||
|
||||
local Strings = require("src.core.Strings")
|
||||
|
||||
local Status = {}
|
||||
|
||||
-- pokered's <USER>/<TARGET> text macros print "Enemy " before the enemy
|
||||
@@ -28,8 +30,11 @@ local function hasType(battler, wanted)
|
||||
end
|
||||
|
||||
-- shared PSN/BRN residual: 1/16 max HP, multiplied (and advanced) by the
|
||||
-- Toxic counter (HandlePoisonBurnLeechSeed)
|
||||
local function damageOverTime(what)
|
||||
-- Toxic counter (HandlePoisonBurnLeechSeed). The caller passes the whole
|
||||
-- sentence rather than the noun: "hurt by poison" and "hurt by the burn"
|
||||
-- decline differently once translated, so a shared fragment cannot be the
|
||||
-- translatable unit.
|
||||
local function damageOverTime(template)
|
||||
return function(battler)
|
||||
local mon = battler.mon
|
||||
local base = math.max(1, math.floor(mon.stats.hp / 16))
|
||||
@@ -39,10 +44,15 @@ local function damageOverTime(what)
|
||||
battler.toxicCounter = battler.toxicCounter + 1
|
||||
end
|
||||
mon.hp = math.max(0, mon.hp - dmg)
|
||||
return { ("%s's\nhurt by %s!"):format(name(battler), what) }
|
||||
return { Strings(template, name(battler)) }
|
||||
end
|
||||
end
|
||||
|
||||
-- Labels stay plain literals on purpose: this table is built at require
|
||||
-- time, before Strings.load has a catalog, so a Strings() here would
|
||||
-- freeze the English. They are already translatable through the
|
||||
-- statuses registry (mod.content.statuses:patch(id, { label = ... })).
|
||||
--
|
||||
-- The five persistent conditions as records: the beforeMove gauntlet, the
|
||||
-- residual sweep, the inflict text/immunities (StatusRegistry.inflict),
|
||||
-- the catch/wobble bonuses (Catching.attempt), the HUD label, and the
|
||||
@@ -57,13 +67,13 @@ Status.RECORDS = {
|
||||
battler.sleepTurns = (battler.sleepTurns or 1) - 1
|
||||
if battler.sleepTurns <= 0 then
|
||||
battler.mon.status = nil
|
||||
return false, { name(battler) .. "\nwoke up!" } -- wakes but loses the turn
|
||||
return false, { Strings("%s\nwoke up!", name(battler)) } -- wakes, loses the turn
|
||||
end
|
||||
return false, { name(battler) .. "\nis fast asleep!" }
|
||||
return false, { Strings("%s\nis fast asleep!", name(battler)) }
|
||||
end,
|
||||
onInflict = function(battle, target, opts, display)
|
||||
target.sleepTurns = battle.rng(1, 7)
|
||||
return { ("%s\nfell asleep!"):format(display) }
|
||||
return { Strings("%s\nfell asleep!", display) }
|
||||
end,
|
||||
},
|
||||
FRZ = {
|
||||
@@ -71,35 +81,35 @@ Status.RECORDS = {
|
||||
catchBonus = 25, shakeBonus = 10,
|
||||
beforeMovePriority = 30,
|
||||
beforeMove = function(battler)
|
||||
return false, { name(battler) .. "\nis frozen solid!" }
|
||||
return false, { Strings("%s\nis frozen solid!", name(battler)) }
|
||||
end,
|
||||
canInflict = function(target) return not hasType(target, "ICE") end,
|
||||
onInflict = function(_, _, _, display)
|
||||
return { ("%s\nwas frozen solid!"):format(display) }
|
||||
return { Strings("%s\nwas frozen solid!", display) }
|
||||
end,
|
||||
},
|
||||
PSN = {
|
||||
id = "PSN", label = "PSN", hudLabel = "PSN",
|
||||
catchBonus = 12, shakeBonus = 5,
|
||||
residual = damageOverTime("poison"),
|
||||
residual = damageOverTime(Strings.source("%s's\nhurt by poison!")),
|
||||
canInflict = function(target) return not hasType(target, "POISON") end,
|
||||
onInflict = function(_, target, opts, display)
|
||||
if opts.toxic then
|
||||
target.toxicCounter = 1
|
||||
-- _BadlyPoisonedText
|
||||
return { ("%s's\nbadly poisoned!"):format(display) }
|
||||
return { Strings("%s's\nbadly poisoned!", display) }
|
||||
end
|
||||
return { ("%s\nwas poisoned!"):format(display) }
|
||||
return { Strings("%s\nwas poisoned!", display) }
|
||||
end,
|
||||
},
|
||||
BRN = {
|
||||
id = "BRN", label = "BRN", hudLabel = "BRN",
|
||||
catchBonus = 12, shakeBonus = 5,
|
||||
statPenalty = { stat = "attack", div = 2 },
|
||||
residual = damageOverTime("the burn"),
|
||||
residual = damageOverTime(Strings.source("%s's\nhurt by the burn!")),
|
||||
canInflict = function(target) return not hasType(target, "FIRE") end,
|
||||
onInflict = function(_, _, _, display)
|
||||
return { ("%s\nwas burned!"):format(display) }
|
||||
return { Strings("%s\nwas burned!", display) }
|
||||
end,
|
||||
},
|
||||
PAR = {
|
||||
@@ -110,7 +120,7 @@ Status.RECORDS = {
|
||||
beforeMove = function(battler, rng)
|
||||
-- cp 25 percent / jr nc: fully paralyzed on rand < 63 (63/256)
|
||||
if rng(0, 255) < 63 then
|
||||
return false, { name(battler) .. "'s\nfully paralyzed!" }
|
||||
return false, { Strings("%s's\nfully paralyzed!", name(battler)) }
|
||||
end
|
||||
return true, {}
|
||||
end,
|
||||
@@ -120,7 +130,7 @@ Status.RECORDS = {
|
||||
end,
|
||||
onInflict = function(_, _, _, display)
|
||||
-- _ParalyzedMayNotAttackText (primary and secondary paralysis)
|
||||
return { ("%s's\nparalyzed! It may\nnot attack!"):format(display) }
|
||||
return { Strings("%s's\nparalyzed! It may\nnot attack!", display) }
|
||||
end,
|
||||
},
|
||||
}
|
||||
@@ -156,7 +166,7 @@ function Status.beforeMove(battler, rng, battle)
|
||||
end
|
||||
if battler.flinched then
|
||||
battler.flinched = false
|
||||
return false, { name(battler) .. "\nflinched!" }
|
||||
return false, { Strings("%s\nflinched!", name(battler)) }
|
||||
end
|
||||
local record = Status.recordFor(battleStatuses(battle), mon.status)
|
||||
local handler = record and record.beforeMove
|
||||
@@ -174,23 +184,23 @@ function Status.beforeMove(battler, rng, battle)
|
||||
end
|
||||
if battler.boundTurns and battler.boundTurns > 0 then
|
||||
battler.boundTurns = battler.boundTurns - 1
|
||||
msgs[#msgs + 1] = name(battler) .. "\ncan't move!"
|
||||
msgs[#msgs + 1] = Strings("%s\ncan't move!", name(battler))
|
||||
return false, msgs
|
||||
end
|
||||
if battler.disabledTurns then
|
||||
battler.disabledTurns = battler.disabledTurns - 1
|
||||
if battler.disabledTurns <= 0 then
|
||||
battler.disabledTurns, battler.disabledSlot = nil, nil
|
||||
table.insert(msgs, name(battler) .. "'s\ndisabled no more!")
|
||||
table.insert(msgs, Strings("%s's\ndisabled no more!", name(battler)))
|
||||
end
|
||||
end
|
||||
if battler.confusedTurns then
|
||||
battler.confusedTurns = battler.confusedTurns - 1
|
||||
if battler.confusedTurns <= 0 then
|
||||
battler.confusedTurns = nil
|
||||
table.insert(msgs, name(battler) .. "\nsnapped out of\nconfusion!")
|
||||
table.insert(msgs, Strings("%s\nsnapped out of\nconfusion!", name(battler)))
|
||||
else
|
||||
table.insert(msgs, name(battler) .. "\nis confused!")
|
||||
table.insert(msgs, Strings("%s\nis confused!", name(battler)))
|
||||
-- cp 50 percent + 1 / jr c: hurt itself on rand >= 128 (128/256)
|
||||
if rng(0, 255) < 128 then
|
||||
return false, msgs, true -- hurt itself
|
||||
@@ -231,7 +241,7 @@ function Status.residual(battler, opponent, battle)
|
||||
dmg = math.min(dmg, mon.hp)
|
||||
mon.hp = mon.hp - dmg
|
||||
opponent.mon.hp = math.min(opponent.mon.stats.hp, opponent.mon.hp + dmg)
|
||||
table.insert(msgs, ("LEECH SEED saps\n%s!"):format(name(battler)))
|
||||
table.insert(msgs, Strings("LEECH SEED saps\n%s!", name(battler)))
|
||||
end
|
||||
return msgs
|
||||
end
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
local Status = require("src.battle.Status")
|
||||
local Strings = require("src.core.Strings")
|
||||
|
||||
local StatusRegistry = {}
|
||||
|
||||
@@ -45,7 +46,7 @@ function StatusRegistry.inflict(battle, target, status, opts)
|
||||
if record and record.onInflict then
|
||||
msgs = record.onInflict(battle, target, opts, display)
|
||||
else
|
||||
msgs = { ("%s\nwas afflicted\nby %s!"):format(display,
|
||||
msgs = { Strings("%s\nwas afflicted\nby %s!", display,
|
||||
record and record.label or tostring(status)) }
|
||||
end
|
||||
Runtime.emit("battle.status_inflicted", {
|
||||
|
||||
@@ -16,9 +16,10 @@
|
||||
-- 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")
|
||||
|
||||
local TrainerAI = {}
|
||||
|
||||
@@ -99,7 +100,7 @@ function TrainerAI.useItem(battle, item)
|
||||
local enemy = battle.enemy
|
||||
local trainerName = battle.trainer.name
|
||||
local itemName = battle.data.items[item] and battle.data.items[item].name or item
|
||||
local msgs = { ("%s\nused %s!"):format(trainerName, itemName) }
|
||||
local msgs = { Strings("%s\nused %s!", trainerName, itemName) }
|
||||
if item == "FULL_HEAL" then
|
||||
enemy.mon.status = nil
|
||||
enemy.toxicCounter = nil
|
||||
@@ -112,10 +113,10 @@ function TrainerAI.useItem(battle, item)
|
||||
elseif X_STAT[item] then
|
||||
local stat = X_STAT[item]
|
||||
enemy.stages[stat] = math.min(6, (enemy.stages[stat] or 0) + 1)
|
||||
table.insert(msgs, ("%s's\n%s rose!"):format(enemy.name, stat:upper()))
|
||||
table.insert(msgs, Strings("%s's\n%s rose!", enemy.name, stat:upper()))
|
||||
elseif item == "GUARD_SPEC" then
|
||||
enemy.mist = true
|
||||
table.insert(msgs, ("%s's\nprotected against\nstat changes!"):format(enemy.name))
|
||||
table.insert(msgs, Strings("%s's\nprotected against\nstat changes!", enemy.name))
|
||||
end
|
||||
return msgs
|
||||
end
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
local Json = require("src.link.Json")
|
||||
local Logger = require("src.core.Logger")
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
local Strings = require("src.core.Strings")
|
||||
|
||||
local DiscordPresence = {
|
||||
APP_ID = "1529183141267374262",
|
||||
@@ -436,7 +437,7 @@ end
|
||||
local function buildActivity()
|
||||
local details, activityState
|
||||
if state.activity == "battle" then
|
||||
details = state.battleLabel or "In battle"
|
||||
details = state.battleLabel or Strings("In battle")
|
||||
activityState = state.location and (state.location) or "Kanto"
|
||||
elseif state.activity == "exploring" and state.location then
|
||||
details = state.location
|
||||
@@ -550,20 +551,20 @@ local function subscribe(game)
|
||||
|
||||
track("battle.started", function(ev)
|
||||
local battle = ev and ev.battle
|
||||
local label = "In battle"
|
||||
local label = Strings("In battle")
|
||||
if ev and ev.kind == "wild" then
|
||||
local name = speciesName(game, ev.species)
|
||||
if name then
|
||||
label = "Battling wild " .. name
|
||||
if ev.level then label = label .. " Lv" .. tostring(ev.level) end
|
||||
else
|
||||
label = "Wild battle"
|
||||
label = Strings("Wild battle")
|
||||
end
|
||||
elseif ev and ev.kind == "trainer" then
|
||||
local tname = battle and battle.trainer and battle.trainer.name
|
||||
label = tname and ("Battling " .. tname) or "Trainer battle"
|
||||
label = tname and ("Battling " .. tname) or Strings("Trainer battle")
|
||||
elseif ev and ev.kind == "link" then
|
||||
label = "Link battle"
|
||||
label = Strings("Link battle")
|
||||
end
|
||||
local mapId = state.mapId
|
||||
or (game.save and game.save.player and game.save.player.map)
|
||||
@@ -594,7 +595,7 @@ local function subscribe(game)
|
||||
state.mapId = nil
|
||||
setPresence({
|
||||
activity = "menu",
|
||||
location = id == "IntroMovie" and "Watching the intro" or "Title screen",
|
||||
location = id == "IntroMovie" and "Watching the intro" or Strings("Title screen"),
|
||||
clearBattle = true,
|
||||
})
|
||||
end
|
||||
|
||||
@@ -53,6 +53,9 @@ function Game:load()
|
||||
require("src.render.Font").load(Data)
|
||||
-- menu cursor/border/geometry constants; field.theme restyles them
|
||||
require("src.ui.Theme").load(Data)
|
||||
-- the engine's own text, after the merge so a translation mod's catalog
|
||||
-- is already in Data.strings; empty on a mod-free boot and skipped
|
||||
require("src.core.Strings").load(Data)
|
||||
|
||||
self.stack = StateStack
|
||||
StateStack:init()
|
||||
@@ -235,6 +238,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
|
||||
|
||||
+101
-17
@@ -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
|
||||
@@ -1155,9 +1232,16 @@ function SaveData.defaultHeal(boot)
|
||||
end
|
||||
|
||||
-- Post-credits home (issue #103). pokered left the player in HALL_OF_FAME
|
||||
-- after jp Init; this port places CONTINUE at the NewGameWarp bedroom and
|
||||
-- retargets LAST_MAP exits (Red's house mats) at the heal-point town so
|
||||
-- leaving the house does not dump the player back at Indigo Plateau.
|
||||
-- after jp Init; this port relocates CONTINUE instead, and retargets
|
||||
-- LAST_MAP exits (Red's house mats) at the heal-point town so leaving the
|
||||
-- house does not dump the player back at Indigo Plateau.
|
||||
--
|
||||
-- The landing spot is the blackout point, not the NewGameWarp bedroom:
|
||||
-- HallOfFameResetEventsAndSaveScript sets wLastBlackoutMap := PALLET_TOWN,
|
||||
-- so resuming puts the player outside the front door. Spawning them back
|
||||
-- in the upstairs bedroom was a port-only detour (#253). Non-vanilla
|
||||
-- spawns are unaffected -- defaultHeal returns boot's own start map for
|
||||
-- those, which is what the bedroom branch resolved to anyway.
|
||||
-- Marks postGameHomeOk so a later intentional HoF save is not relocated.
|
||||
function SaveData.applyPostGameHome(save, boot)
|
||||
boot = type(boot) == "table" and boot or {}
|
||||
@@ -1165,9 +1249,9 @@ function SaveData.applyPostGameHome(save, boot)
|
||||
save.lastHeal = { map = heal.map, x = heal.x, y = heal.y }
|
||||
save.lastOutdoor = { id = heal.map, x = heal.x, y = heal.y }
|
||||
save.player = save.player or {}
|
||||
save.player.map = boot.startMap or "REDS_HOUSE_2F"
|
||||
save.player.x = boot.startX or 3
|
||||
save.player.y = boot.startY or 6
|
||||
save.player.map = heal.map
|
||||
save.player.x = heal.x
|
||||
save.player.y = heal.y
|
||||
save.player.facing = boot.startFacing or "down"
|
||||
save.postGameHomeOk = true
|
||||
return heal
|
||||
@@ -1209,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 = {},
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
-- The engine's own player-facing text, made overridable.
|
||||
--
|
||||
-- Extracted dialogue already had a home: `Data.text`, keyed by pokered's
|
||||
-- labels, which a mod reaches through `mod.content.text`. The strings the
|
||||
-- engine *authors* had none. Battle messages, item results, menu labels
|
||||
-- and the link-play screens were literals in Lua, so a translator could
|
||||
-- reach two thirds of the game and no more (#186, #245).
|
||||
--
|
||||
-- Those literals stay where they are, wrapped in `S(...)`, and the English
|
||||
-- source doubles as the catalog key:
|
||||
--
|
||||
-- self:say(S("But, it failed!"))
|
||||
-- self:say(S("Wild %s\nappeared!", self.enemy.name))
|
||||
--
|
||||
-- Keying on the source rather than on an invented id is deliberate. It
|
||||
-- keeps the English readable at the point it is used (the alternative
|
||||
-- scatters a thousand `battle.it_failed` ids that have to be looked up to
|
||||
-- review a diff), it needs no id registry to stay in sync, and an entry a
|
||||
-- translation has not covered yet falls through to English instead of
|
||||
-- rendering a raw id at the player. The cost is that editing an English
|
||||
-- string orphans its translations; `tools/modkit.py translation --refresh`
|
||||
-- reports those as changed keys rather than silently dropping them.
|
||||
--
|
||||
-- Same-source-different-meaning is the one case source keys cannot hold on
|
||||
-- their own ("OFF" as a filter setting vs "OFF" as a toggle, which some
|
||||
-- languages render differently). Those sites pass a context:
|
||||
--
|
||||
-- S("OFF", "options.musicFilter") -- key: "options.musicFilter|OFF"
|
||||
--
|
||||
-- A mod supplies the catalog through the `strings` registry:
|
||||
--
|
||||
-- mod.content.strings:override("But, it failed!", "Echec !")
|
||||
--
|
||||
-- With no mod loaded the catalog is empty and `S` is an identity function
|
||||
-- guarded by one boolean, so a vanilla boot draws byte-identical text.
|
||||
|
||||
local Strings = {}
|
||||
|
||||
local catalog = nil -- Data.strings once a mod has put something in it
|
||||
local missing = {} -- format-arity complaints, reported once each
|
||||
|
||||
-- Called from Game after the mod merge, and again on dev-mode hot reload.
|
||||
-- Holding the table (not a copy) means a mod that registers late still
|
||||
-- takes effect without a second load.
|
||||
function Strings.load(data)
|
||||
local t = data and data.strings
|
||||
catalog = nil
|
||||
if type(t) ~= "table" then return end
|
||||
for _ in pairs(t) do
|
||||
catalog = t
|
||||
return
|
||||
end
|
||||
end
|
||||
|
||||
function Strings.active()
|
||||
return catalog ~= nil
|
||||
end
|
||||
|
||||
-- The lookup itself. `context` is optional and only disambiguates sources
|
||||
-- that collide; the plain key is tried after it, so a translation that does
|
||||
-- not care about the distinction can supply one entry for both.
|
||||
function Strings.lookup(source, context)
|
||||
if not catalog then return source end
|
||||
if context then
|
||||
local hit = catalog[context .. "|" .. source]
|
||||
if type(hit) == "string" then return hit end
|
||||
end
|
||||
local hit = catalog[source]
|
||||
if type(hit) == "string" then return hit end
|
||||
return source
|
||||
end
|
||||
|
||||
-- Count `%`-directives so a translation that drops or adds one is caught
|
||||
-- here rather than as a mid-battle `string.format` error.
|
||||
local function specifiers(s)
|
||||
local n = 0
|
||||
for spec in s:gmatch("%%(.)") do
|
||||
if spec ~= "%" then n = n + 1 end
|
||||
end
|
||||
return n
|
||||
end
|
||||
|
||||
-- S(source) -> translated source
|
||||
-- S(source, ...) -> translated source, string.format'ed
|
||||
-- S(source, context) -> context-disambiguated lookup, no formatting
|
||||
--
|
||||
-- The two-argument forms are told apart by whether the source carries any
|
||||
-- format directives: a source with no `%s` cannot be formatting, so a lone
|
||||
-- string second argument is a context.
|
||||
function Strings.get(source, ...)
|
||||
local argc = select("#", ...)
|
||||
if argc == 0 then return Strings.lookup(source) end
|
||||
|
||||
local wants = specifiers(source)
|
||||
if wants == 0 and argc == 1 and type((...)) == "string" then
|
||||
return Strings.lookup(source, (...))
|
||||
end
|
||||
|
||||
local text = Strings.lookup(source)
|
||||
-- A translation with the wrong arity would raise inside string.format,
|
||||
-- which in a battle means a crash the player cannot escape. Fall back to
|
||||
-- the English source, which is known to match, and say so once.
|
||||
if specifiers(text) ~= wants then
|
||||
if not missing[source] then
|
||||
missing[source] = true
|
||||
require("src.core.Logger").warn(
|
||||
"strings: translation of %q has %d format directives, source has %d"
|
||||
.. " -- using the source", source, specifiers(text), wants)
|
||||
end
|
||||
text = source
|
||||
end
|
||||
local ok, out = pcall(string.format, text, ...)
|
||||
if not ok then return source end
|
||||
return out
|
||||
end
|
||||
|
||||
-- A marker, not a lookup: returns its argument untouched.
|
||||
--
|
||||
-- Some templates are declared in a module-level table and formatted much
|
||||
-- later (BattleState's charge-move lines, for one). Translating at the
|
||||
-- declaration would freeze the English, because those tables are built at
|
||||
-- require time and Strings.load has no catalog yet; the use site therefore
|
||||
-- calls Strings(template, ...) and looks the source up then. That works at
|
||||
-- runtime but leaves the literal invisible to the catalog generator, which
|
||||
-- only sees what is spelled out at a call site. Wrapping the declaration in
|
||||
-- Strings.source puts it back in the harvest while changing nothing at all
|
||||
-- about when the lookup happens.
|
||||
function Strings.source(text)
|
||||
return text
|
||||
end
|
||||
|
||||
setmetatable(Strings, { __call = function(_, ...) return Strings.get(...) end })
|
||||
|
||||
return Strings
|
||||
@@ -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
|
||||
|
||||
+441
-38
@@ -1,4 +1,5 @@
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
local Strings = require("src.core.Strings")
|
||||
|
||||
local RomImporter = {}
|
||||
RomImporter.__index = RomImporter
|
||||
@@ -254,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")
|
||||
@@ -337,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 .. '"')
|
||||
@@ -356,7 +389,7 @@ end
|
||||
-- Returns the chosen absolute path or nil. Android uses love.system.pickFile
|
||||
-- ("mod") instead -- see RomImporter:chooseMod.
|
||||
local function chooseZip()
|
||||
local prompt = "Choose a mod .zip"
|
||||
local prompt = Strings("Choose a mod .zip")
|
||||
local platform = love.system.getOS()
|
||||
if platform == "OS X" then
|
||||
return commandOutput(
|
||||
@@ -368,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 .. '"')
|
||||
@@ -387,7 +429,7 @@ end
|
||||
-- dialogs). Returns the chosen absolute path or nil. Android uses
|
||||
-- love.system.pickFile("sav") instead -- see RomImporter:chooseSaveImport.
|
||||
local function chooseSav()
|
||||
local prompt = "Choose a .sav save file"
|
||||
local prompt = Strings("Choose a .sav save file")
|
||||
local platform = love.system.getOS()
|
||||
if platform == "OS X" then
|
||||
return commandOutput(
|
||||
@@ -399,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 .. '"')
|
||||
@@ -431,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"
|
||||
@@ -440,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"),
|
||||
@@ -472,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
|
||||
@@ -524,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
|
||||
|
||||
@@ -927,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
|
||||
@@ -952,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
|
||||
@@ -991,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.
|
||||
@@ -1146,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
|
||||
@@ -1159,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.
|
||||
@@ -1426,7 +1662,7 @@ function RomImporter:draw()
|
||||
self.updateButton = { x = rect.x, y = rect.y, width = rect.width,
|
||||
height = rect.height, action = "download" }
|
||||
message(upLatest and ("Update v" .. upLatest .. " available")
|
||||
or "An update is available", rect.width)
|
||||
or Strings("An update is available"), rect.width)
|
||||
elseif upStatus == "needs_full" then
|
||||
local rect = actionButton("Open releases")
|
||||
self.updateButton = { x = rect.x, y = rect.y, width = rect.width,
|
||||
@@ -1508,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)
|
||||
@@ -1517,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)
|
||||
@@ -1524,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)
|
||||
@@ -1576,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
|
||||
@@ -1628,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
|
||||
@@ -1718,7 +2058,7 @@ function RomImporter:_playButton(x, y, w, h, gameName, ready, locked)
|
||||
col(PAL.disabledInk, 0.3)
|
||||
love.graphics.rectangle("line", x, y, w, h, r, r)
|
||||
col(PAL.disabledInk)
|
||||
local label = locked and "Coming soon" or "Import a ROM to play"
|
||||
local label = locked and "Coming soon" or Strings("Import a ROM to play")
|
||||
printfB(label, x, y + (h - self.playFont:getHeight()) / 2, w, "center")
|
||||
self.playButtonRect = nil
|
||||
end
|
||||
@@ -1730,13 +2070,13 @@ function RomImporter:_drawTabBar(x, y, w, h, chip)
|
||||
local s, pulse = self._s, self.pulse
|
||||
local tabs = {
|
||||
{ id = "red", letter = "R", top = PAL.chipRedTop, bot = PAL.chipRedBot,
|
||||
under = PAL.red, label = "RED", ink = PAL.white },
|
||||
under = PAL.red, label = Strings("RED"), ink = PAL.white },
|
||||
{ id = "blue", letter = "B", top = PAL.chipBlueTop, bot = PAL.chipBlueBot,
|
||||
under = PAL.blue, label = "BLUE", ink = PAL.white },
|
||||
under = PAL.blue, label = Strings("BLUE"), ink = PAL.white },
|
||||
{ id = "yellow", letter = "Y", top = PAL.chipGoldTop, bot = PAL.chipGoldBot,
|
||||
under = PAL.gold, label = "YELLOW", ink = PAL.chipInkGold },
|
||||
under = PAL.gold, label = Strings("YELLOW"), ink = PAL.chipInkGold },
|
||||
{ id = "mods", mods = true, top = PAL.chipModTop, bot = PAL.chipModBot,
|
||||
under = PAL.modDot, label = "MODS" },
|
||||
under = PAL.modDot, label = Strings("MODS") },
|
||||
}
|
||||
local gap = 10 * s
|
||||
local r = 12 * s
|
||||
@@ -1795,7 +2135,7 @@ function RomImporter:_drawTabBar(x, y, w, h, chip)
|
||||
local ready = 0
|
||||
for _, v in ipairs(GameVersion.ORDER) do if self.ready[v] then ready = ready + 1 end end
|
||||
love.graphics.setFont(self.readyFont)
|
||||
local label = ("%d of 3 ready"):format(ready)
|
||||
local label = Strings("%d of 3 ready", ready)
|
||||
local lw = self.readyFont:getWidth(label)
|
||||
if x + w - lw > cursorX + 8 * s then
|
||||
col(PAL.labelGray)
|
||||
@@ -1851,7 +2191,7 @@ function RomImporter:_drawGamePanel(version, x, y, w, h)
|
||||
|
||||
-- ROM card contents by state (rehomes the existing import flow)
|
||||
local dropHint = self.android and "Copy the .gb via USB."
|
||||
or "Or drop the .gb file here."
|
||||
or Strings("Or drop the .gb file here.")
|
||||
local accent = locked and PAL.gold or (version == "red" and PAL.red or PAL.blue)
|
||||
local romState, romDetail, romBtnLabel, romBtnEnabled, romProgress
|
||||
if locked then
|
||||
@@ -1866,12 +2206,12 @@ function RomImporter:_drawGamePanel(version, x, y, w, h)
|
||||
romDetail = self.detail or ""
|
||||
romProgress = self.progress or 0
|
||||
elseif ready then
|
||||
romState = self.romName[version] or "ROM imported"
|
||||
romState = self.romName[version] or Strings("ROM imported")
|
||||
romDetail = "Verified."
|
||||
romBtnLabel, romBtnEnabled = "Re-import ROM", true
|
||||
elseif erroring then
|
||||
romState = "Import failed"
|
||||
romDetail = self.detail or "That ROM could not be imported."
|
||||
romDetail = self.detail or Strings("That ROM could not be imported.")
|
||||
romBtnLabel, romBtnEnabled = "Import ROM", true
|
||||
elseif notice then
|
||||
romState = "No ROM imported"
|
||||
@@ -2000,7 +2340,7 @@ function RomImporter:_drawGamePanel(version, x, y, w, h)
|
||||
if sfNotice and sfNotice.dir then
|
||||
iy = iy + 4 * s
|
||||
love.graphics.setFont(self.hintFont)
|
||||
local label = "Open folder"
|
||||
local label = Strings("Open folder")
|
||||
local lw = self.hintFont:getWidth(label)
|
||||
local frect = { x = ix, y = iy, width = lw, height = self.hintFont:getHeight(),
|
||||
dir = sfNotice.dir }
|
||||
@@ -2048,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)
|
||||
@@ -2055,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)
|
||||
@@ -2165,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()
|
||||
@@ -2184,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
|
||||
@@ -2210,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
|
||||
@@ -2228,14 +2622,14 @@ function RomImporter:_drawSaveSlotPanel(version, x, y, w, h)
|
||||
|
||||
love.graphics.setFont(self.slotNameFont)
|
||||
col(PAL.white)
|
||||
local name = slot.name or "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)
|
||||
|
||||
local metaTxt
|
||||
if slot.exists and slot.meta then
|
||||
metaTxt = ("%d badges - %s - %d caught"):format(
|
||||
slot.meta.badges or 0, slot.meta.timeText or "0:00",
|
||||
metaTxt = Strings("%d badges - %s - %d caught", slot.meta.badges or 0, slot.meta.timeText or "0:00",
|
||||
slot.meta.dexCount or 0)
|
||||
else
|
||||
metaTxt = "empty slot"
|
||||
@@ -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()
|
||||
@@ -2339,7 +2742,7 @@ function RomImporter:_drawModsPanel(x, y, w, h)
|
||||
for _, m in ipairs(mods) do if m.enabled then enabledCount = enabledCount + 1 end end
|
||||
love.graphics.setFont(self.hintFont)
|
||||
col(PAL.warning)
|
||||
love.graphics.print(("%d of %d enabled"):format(enabledCount, #mods),
|
||||
love.graphics.print(Strings("%d of %d enabled", enabledCount, #mods),
|
||||
x + nameW + 14 * s, y + (headerH - self.hintFont:getHeight()) / 2)
|
||||
|
||||
local btnLabel = "Import mod .zip"
|
||||
@@ -2360,7 +2763,7 @@ function RomImporter:_drawModsPanel(x, y, w, h)
|
||||
else
|
||||
col(PAL.warning)
|
||||
love.graphics.printf(self.android and "Or copy a mod .zip via USB."
|
||||
or "Or drop a mod .zip onto the window.", x, top, w, "left")
|
||||
or Strings("Or drop a mod .zip onto the window."), x, top, w, "left")
|
||||
end
|
||||
top = top + self.hintFont:getHeight() + 12 * s
|
||||
|
||||
@@ -2376,7 +2779,7 @@ function RomImporter:_drawModsPanel(x, y, w, h)
|
||||
col(PAL.warning)
|
||||
local emptyHint = self.android
|
||||
and "No mods installed - tap Import mod .zip to add one."
|
||||
or "No mods installed - drop a mod .zip here to add one."
|
||||
or Strings("No mods installed - drop a mod .zip here to add one.")
|
||||
love.graphics.printf(emptyHint,
|
||||
x + 16 * s, top + boxH / 2 - self.hintFont:getHeight() / 2, w - 32 * s, "center")
|
||||
self.modRects = {}
|
||||
@@ -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)
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
local Flags = require("src.script.Flags")
|
||||
local Strings = require("src.core.Strings")
|
||||
|
||||
local ItemEffects = {}
|
||||
|
||||
@@ -46,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"
|
||||
@@ -119,8 +131,8 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
|
||||
if battle and (VITAMINS[itemId] or STONES[itemId] or itemId == "PP_UP"
|
||||
or itemId == "RARE_CANDY" or itemId == "COIN_CASE"
|
||||
or (itemDef and itemDef.machine)) then
|
||||
return "failed", { "OAK: " .. save.player.name
|
||||
.. "!\nThis isn't the\ntime to use that!" }
|
||||
return "failed", { Strings("OAK: %s!\nThis isn't the\ntime to use that!",
|
||||
save.player.name) }
|
||||
end
|
||||
|
||||
if BALLS[itemId] then
|
||||
@@ -138,11 +150,12 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
|
||||
local mapId, npc = adjacentSleepingSnorlax(save, ow)
|
||||
if npc then
|
||||
return "flute_wake", { data.text._PlayedFluteHadEffectText
|
||||
or "{PLAYER} played the\nPOKé FLUTE." }, { mapId = mapId, npc = npc }
|
||||
or Strings("{PLAYER} played the\nPOKé FLUTE.") },
|
||||
{ mapId = mapId, npc = npc }
|
||||
end
|
||||
-- otherwise: play the tune, nothing happens (ItemUsePokeFlute's
|
||||
-- PlayedFluteNoEffectText branch)
|
||||
return "flute_field", { "Played the POKé\nFLUTE.\fNow, that's a\ncatchy tune!" }
|
||||
return "flute_field", { Strings("Played the POKé\nFLUTE.\fNow, that's a\ncatchy tune!") }
|
||||
end
|
||||
local woke = false
|
||||
local function wake(mon)
|
||||
@@ -157,24 +170,24 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
|
||||
-- WakeUpEntireParty runs on the enemy's bench too
|
||||
for _, mon in ipairs(battle.enemyParty or {}) do wake(mon) end
|
||||
if not woke then
|
||||
return "failed", { "Played the POKé\nFLUTE.\fNow, that's a\ncatchy tune!" }
|
||||
return "failed", { Strings("Played the POKé\nFLUTE.\fNow, that's a\ncatchy tune!") }
|
||||
end
|
||||
return "flute", { ("%s played the\nPOKé FLUTE."):format(save.player.name),
|
||||
"All sleeping\nPOKéMON woke up!" }
|
||||
return "flute", { Strings("%s played the\nPOKé FLUTE.", save.player.name),
|
||||
Strings("All sleeping\nPOKéMON woke up!") }
|
||||
end
|
||||
|
||||
-- battle-only items
|
||||
if X_ITEMS[itemId] or itemId == "DIRE_HIT" or itemId == "GUARD_SPEC"
|
||||
or itemId == "POKE_DOLL" then
|
||||
if not battle then
|
||||
return "failed", { "OAK: " .. save.player.name .. "!\nThis isn't the\ntime to use that!" }
|
||||
return "failed", { Strings("OAK: %s!\nThis isn't the\ntime to use that!", save.player.name) }
|
||||
end
|
||||
local b = battle.player
|
||||
if itemId == "X_ACCURACY" then
|
||||
-- ItemUseXAccuracy sets USING_X_ACCURACY: moves never miss
|
||||
-- (not an accuracy stage)
|
||||
b.xAccuracy = true
|
||||
return "consumed", { ("%s's\nhits will never\nmiss!"):format(b.name) }
|
||||
return "consumed", { Strings("%s's\nhits will never\nmiss!", b.name) }
|
||||
end
|
||||
if X_ITEMS[itemId] then
|
||||
local stat = X_ITEMS[itemId]
|
||||
@@ -183,28 +196,28 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
|
||||
-- effect, so at +6 it is still consumed and StatModifierUpEffect
|
||||
-- just prints "Nothing happened!"
|
||||
if cur >= 6 then
|
||||
return "consumed", { "Nothing happened!" }
|
||||
return "consumed", { Strings("Nothing happened!") }
|
||||
end
|
||||
b.stages[stat] = cur + 1
|
||||
return "consumed", { ("%s's\n%s rose!"):format(b.name, stat:upper()) }
|
||||
return "consumed", { Strings("%s's\n%s rose!", b.name, stat:upper()) }
|
||||
end
|
||||
-- ItemUseDireHit/ItemUseGuardSpec always set the bit and consume
|
||||
-- the item, even when it is already active
|
||||
if itemId == "DIRE_HIT" then
|
||||
b.focusEnergy = true
|
||||
return "consumed", { ("%s's\ngetting pumped!"):format(b.name) }
|
||||
return "consumed", { Strings("%s's\ngetting pumped!", b.name) }
|
||||
end
|
||||
if itemId == "GUARD_SPEC" then
|
||||
b.mist = true
|
||||
return "consumed", { ("%s's\nprotected against\nstat changes!"):format(b.name) }
|
||||
return "consumed", { Strings("%s's\nprotected against\nstat changes!", b.name) }
|
||||
end
|
||||
if itemId == "POKE_DOLL" then
|
||||
if battle.kind ~= "wild" then
|
||||
-- ItemUsePokeDoll jumps to ItemUseNotTime in trainer battles
|
||||
return "failed", { "OAK: " .. save.player.name
|
||||
.. "!\nThis isn't the\ntime to use that!" }
|
||||
return "failed", { Strings(
|
||||
"OAK: %s!\nThis isn't the\ntime to use that!", save.player.name) }
|
||||
end
|
||||
return "consumed_escape", { "The wild POKéMON\nran away!" }
|
||||
return "consumed_escape", { Strings("The wild POKéMON\nran away!") }
|
||||
end
|
||||
end
|
||||
|
||||
@@ -213,7 +226,7 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
|
||||
-- restore every move with no menu.
|
||||
if itemId == "ETHER" or itemId == "MAX_ETHER"
|
||||
or itemId == "ELIXER" or itemId == "MAX_ELIXER" then
|
||||
if not target then return "failed", { "It won't have\nany effect." } end
|
||||
if not target then return "failed", { Strings("It won't have\nany effect.") } end
|
||||
local restored = false
|
||||
local full = itemId == "MAX_ETHER" or itemId == "MAX_ELIXER"
|
||||
local allMoves = itemId == "ELIXER" or itemId == "MAX_ELIXER"
|
||||
@@ -235,9 +248,9 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
|
||||
restored = mv and restore(mv) or false
|
||||
end
|
||||
if not restored then
|
||||
return "failed", { "It won't have\nany effect." }
|
||||
return "failed", { Strings("It won't have\nany effect.") }
|
||||
end
|
||||
return "consumed", { ("%s's PP\nwas restored!"):format(monName(data, target)) }
|
||||
return "consumed", { Strings("%s's PP\nwas restored!", monName(data, target)) }
|
||||
end
|
||||
|
||||
local heal = HEAL_AMOUNT[itemId]
|
||||
@@ -250,49 +263,57 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
|
||||
target.status = nil
|
||||
cureActiveToxic(battle, target)
|
||||
require("src.core.Sound").play(data, "Heal_Ailment")
|
||||
return "consumed", { ("%s's\nstatus returned\nto normal!"):format(monName(data, target)) }
|
||||
return "consumed", { Strings("%s's\nstatus returned\nto normal!", monName(data, target)) }
|
||||
end
|
||||
if not target or target.hp <= 0 or target.hp >= target.stats.hp then
|
||||
return "failed", { "It won't have\nany effect." }
|
||||
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
|
||||
target.hp = math.min(target.stats.hp, target.hp + heal)
|
||||
end
|
||||
local msgs = { ("%s's HP\nwas restored!"):format(monName(data, target)) }
|
||||
local msgs = { Strings("%s's HP\nwas restored!", monName(data, target)) }
|
||||
if itemId == "FULL_RESTORE" then
|
||||
target.status = nil
|
||||
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]
|
||||
if cures then
|
||||
if not target or not target.status or not cures[target.status] then
|
||||
return "failed", { "It won't have\nany effect." }
|
||||
return "failed", { Strings("It won't have\nany effect.") }
|
||||
end
|
||||
target.status = nil
|
||||
cureActiveToxic(battle, target)
|
||||
require("src.core.Sound").play(data, "Heal_Ailment")
|
||||
return "consumed", { ("%s's\nstatus returned\nto normal!"):format(monName(data, target)) }
|
||||
return "consumed", { Strings("%s's\nstatus returned\nto normal!", monName(data, target)) }
|
||||
end
|
||||
|
||||
if itemId == "REVIVE" or itemId == "MAX_REVIVE" then
|
||||
if not target or target.hp > 0 then
|
||||
return "failed", { "It won't have\nany effect." }
|
||||
return "failed", { Strings("It won't have\nany effect.") }
|
||||
end
|
||||
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", { ("%s\nis revitalized!"):format(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
|
||||
if not target or target.level >= 100 then
|
||||
return "failed", { "It won't have\nany effect." }
|
||||
return "failed", { Strings("It won't have\nany effect.") }
|
||||
end
|
||||
local Growth = require("src.pokemon.Growth")
|
||||
local Stats = require("src.pokemon.Stats")
|
||||
@@ -302,56 +323,56 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
|
||||
local old = target.stats
|
||||
target.stats = Stats.calc(speciesDef, target.level, target.dvs, target.statExp)
|
||||
target.hp = math.min(target.stats.hp, target.hp + (target.stats.hp - old.hp))
|
||||
return "consumed", { ("%s grew\nto level %d!"):format(monName(data, target), target.level) },
|
||||
return "consumed", { Strings("%s grew\nto level %d!", monName(data, target), target.level) },
|
||||
{ leveledTo = target.level }
|
||||
end
|
||||
|
||||
if STONES[itemId] then
|
||||
if not target then return "failed", { "It won't have\nany effect." } end
|
||||
if not target then return "failed", { Strings("It won't have\nany effect.") } end
|
||||
local speciesDef = data.pokemon[target.species]
|
||||
for _, evo in ipairs(speciesDef.evolutions) do
|
||||
if evo.method == "ITEM" and evo.item == itemId then
|
||||
return "consumed", nil, { evolveTo = evo.species }
|
||||
end
|
||||
end
|
||||
return "failed", { "It won't have\nany effect." }
|
||||
return "failed", { Strings("It won't have\nany effect.") }
|
||||
end
|
||||
|
||||
-- vitamins: +2560 stat exp, refused at 25600+ (ItemUseVitamin,
|
||||
-- engine/items/item_effects.asm)
|
||||
local vitaminStat = VITAMINS[itemId]
|
||||
if vitaminStat then
|
||||
if not target then return "failed", { "It won't have\nany effect." } end
|
||||
if not target then return "failed", { Strings("It won't have\nany effect.") } end
|
||||
target.statExp = target.statExp or {}
|
||||
local cur = target.statExp[vitaminStat] or 0
|
||||
if cur >= 25600 then
|
||||
return "failed", { "It won't have\nany effect." }
|
||||
return "failed", { Strings("It won't have\nany effect.") }
|
||||
end
|
||||
target.statExp[vitaminStat] = math.min(65535, cur + 2560)
|
||||
local Stats = require("src.pokemon.Stats")
|
||||
target.stats = Stats.calc(data.pokemon[target.species], target.level,
|
||||
target.dvs, target.statExp)
|
||||
target.hp = math.min(target.hp, target.stats.hp)
|
||||
return "consumed", { ("%s's %s\nrose!"):format(monName(data, target),
|
||||
return "consumed", { Strings("%s's %s\nrose!", monName(data, target),
|
||||
vitaminStat == "hp" and "HP" or vitaminStat:upper()) }
|
||||
end
|
||||
|
||||
-- PP UP boosts the move the player picked (ItemUsePPUp's move menu)
|
||||
if itemId == "PP_UP" then
|
||||
if not target then return "failed", { "It won't have\nany effect." } end
|
||||
if not target then return "failed", { Strings("It won't have\nany effect.") } end
|
||||
local mv = target.moves[moveIndex or 1]
|
||||
local mdef = mv and data.moves[mv.id]
|
||||
if mdef and (mv.ppUps or 0) < 3 then
|
||||
mv.ppUps = (mv.ppUps or 0) + 1
|
||||
-- each PP UP adds maxPP/5 uses on top of the base maximum
|
||||
mv.pp = mv.pp + math.floor(mdef.pp / 5)
|
||||
return "consumed", { ("%s's PP\nincreased!"):format(mdef.name) }
|
||||
return "consumed", { Strings("%s's PP\nincreased!", mdef.name) }
|
||||
end
|
||||
return "failed", { "It won't have\nany effect." }
|
||||
return "failed", { Strings("It won't have\nany effect.") }
|
||||
end
|
||||
|
||||
if itemDef and itemDef.machine then
|
||||
if not target then return "failed", { "It won't have\nany effect." } end
|
||||
if not target then return "failed", { Strings("It won't have\nany effect.") } end
|
||||
local speciesDef = data.pokemon[target.species]
|
||||
local ok = false
|
||||
for _, m in ipairs(speciesDef.tmhm) do
|
||||
@@ -362,11 +383,11 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
|
||||
-- plays SFX_DENIED before MonCannotLearnMachineMoveText (the generic
|
||||
-- ItemUseNotTime/NoCyclingAllowedHere paths are silent)
|
||||
require("src.core.Sound").play(data, "Denied")
|
||||
return "failed", { ("%s can't\nlearn that move!"):format(monName(data, target)) }
|
||||
return "failed", { Strings("%s can't\nlearn that move!", monName(data, target)) }
|
||||
end
|
||||
for _, mv in ipairs(target.moves) do
|
||||
if mv.id == itemDef.machine.move then
|
||||
return "failed", { "It knows that\nmove already!" }
|
||||
return "failed", { Strings("It knows that\nmove already!") }
|
||||
end
|
||||
end
|
||||
-- HMs are never consumed; TMs are single-use
|
||||
@@ -375,14 +396,14 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
|
||||
|
||||
if itemId == "OLD_ROD" or itemId == "GOOD_ROD" or itemId == "SUPER_ROD" then
|
||||
if battle then
|
||||
return "failed", { "OAK: " .. save.player.name .. "!\nThis isn't the\ntime to use that!" }
|
||||
return "failed", { Strings("OAK: %s!\nThis isn't the\ntime to use that!", save.player.name) }
|
||||
end
|
||||
return "fish", itemId
|
||||
end
|
||||
|
||||
if itemId == "BICYCLE" then
|
||||
if battle then
|
||||
return "failed", { "OAK: " .. save.player.name .. "!\nThis isn't the\ntime to use that!" }
|
||||
return "failed", { Strings("OAK: %s!\nThis isn't the\ntime to use that!", save.player.name) }
|
||||
end
|
||||
return "bicycle"
|
||||
end
|
||||
@@ -392,26 +413,26 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
|
||||
end
|
||||
if itemId == "TOWN_MAP" then
|
||||
if battle then
|
||||
return "failed", { "OAK: " .. save.player.name .. "!\nThis isn't the\ntime to use that!" }
|
||||
return "failed", { Strings("OAK: %s!\nThis isn't the\ntime to use that!", save.player.name) }
|
||||
end
|
||||
return "townmap"
|
||||
end
|
||||
if itemId == "ITEMFINDER" then
|
||||
if battle then
|
||||
return "failed", { "OAK: " .. save.player.name .. "!\nThis isn't the\ntime to use that!" }
|
||||
return "failed", { Strings("OAK: %s!\nThis isn't the\ntime to use that!", save.player.name) }
|
||||
end
|
||||
return "itemfinder"
|
||||
end
|
||||
if itemId == "COIN_CASE" then
|
||||
return "failed", { ("Coin count:\n%d"):format(save.coins or 0) }
|
||||
return "failed", { Strings("Coin count:\n%d", save.coins or 0) }
|
||||
end
|
||||
if itemId == "REPEL" or itemId == "SUPER_REPEL" or itemId == "MAX_REPEL" then
|
||||
local steps = itemId == "REPEL" and 100 or itemId == "SUPER_REPEL" and 200 or 250
|
||||
save.repelSteps = steps
|
||||
return "consumed", { ("%s used\n%s!"):format(save.player.name, name) }
|
||||
return "consumed", { Strings("%s used\n%s!", save.player.name, name) }
|
||||
end
|
||||
|
||||
return "failed", { "OAK: " .. save.player.name .. "!\nThis isn't the\ntime to use that!" }
|
||||
return "failed", { Strings("OAK: %s!\nThis isn't the\ntime to use that!", save.player.name) }
|
||||
end
|
||||
|
||||
return ItemEffects
|
||||
|
||||
+25
-24
@@ -21,6 +21,7 @@ local Logger = require("src.core.Logger")
|
||||
local Protocol = require("src.link.Protocol")
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
local TurnOrder = require("src.battle.TurnOrder")
|
||||
local Strings = require("src.core.Strings")
|
||||
|
||||
local LinkBattle = {}
|
||||
|
||||
@@ -177,18 +178,18 @@ function LinkBattle.new(game, net, opts)
|
||||
local theirName = opts.theirName or "FOE"
|
||||
|
||||
if not Handshake.battleAllowed(opts.verdict) then
|
||||
return nil, "Link battle needs\nthe same mods on\nboth games."
|
||||
return nil, Strings("Link battle needs\nthe same mods on\nboth games.")
|
||||
end
|
||||
|
||||
-- both parties pass through the same pack->unpack clamp on both
|
||||
-- machines, so the copies are identical everywhere
|
||||
local unpackOpts = { strict = opts.strict or false, forceLevel = opts.forceLevel }
|
||||
local myParty, myErr = unpackParty(game, opts.myParty, unpackOpts, function(p)
|
||||
return ("Your %s can't\nbattle on the\nother game."):format(tostring(p.species))
|
||||
return Strings("Your %s can't\nbattle on the\nother game.", tostring(p.species))
|
||||
end)
|
||||
if not myParty then return nil, myErr end
|
||||
local theirParty, theirErr = unpackParty(game, opts.theirParty, unpackOpts, function(p, why)
|
||||
return ("Their %s isn't\nin this game.\n(%s)"):format(tostring(p.species), tostring(why))
|
||||
return Strings("Their %s isn't\nin this game.\n(%s)", tostring(p.species), tostring(why))
|
||||
end)
|
||||
if not theirParty then return nil, theirErr end
|
||||
if #myParty == 0 or #theirParty == 0 then
|
||||
@@ -232,7 +233,7 @@ function LinkBattle.new(game, net, opts)
|
||||
self.enemyParty = theirParty
|
||||
self.playerParty = myParty -- intro ball row uses the clamped copies
|
||||
self.opponentName = theirName
|
||||
self.introText = ("%s wants\nto battle!"):format(theirName)
|
||||
self.introText = Strings("%s wants\nto battle!", theirName)
|
||||
self.remoteHashes = {}
|
||||
self.localHashes = {}
|
||||
self.remoteParts = {}
|
||||
@@ -292,7 +293,7 @@ function LinkBattle.new(game, net, opts)
|
||||
battle = s, side = s.sides[2], battler = s.enemy, previous = previous,
|
||||
})
|
||||
s.enemySendingOut = true
|
||||
s:sayNext(("%s sent\nout %s!"):format(theirName, s.enemy.name))
|
||||
s:sayNext(Strings("%s sent\nout %s!", theirName, s.enemy.name))
|
||||
s:actNext(function()
|
||||
s.enemySendingOut = false
|
||||
s:startGrowIn(s.enemy)
|
||||
@@ -315,8 +316,9 @@ function LinkBattle.new(game, net, opts)
|
||||
tostring(turn), component, tostring(localH), tostring(remoteH))
|
||||
Runtime.emit("link.desync", { turn = turn, component = component,
|
||||
localHash = localH, remoteHash = remoteH })
|
||||
endAsDraw(s, ("Link desync!\n%s differs.\fAre both games\nrunning the same\nmods?")
|
||||
:format(component))
|
||||
endAsDraw(s, Strings(
|
||||
"Link desync!\n%s differs.\fAre both games\nrunning the same\nmods?",
|
||||
component))
|
||||
end
|
||||
|
||||
-- a verified turn stays recorded: consuming it here left a finished
|
||||
@@ -349,7 +351,7 @@ function LinkBattle.new(game, net, opts)
|
||||
local function resolveLockstep(s, myMsg, theirMsg)
|
||||
if myMsg.kind == "run" or theirMsg.kind == "run" then
|
||||
local who = myMsg.kind == "run" and game.save.player.name or theirName
|
||||
endAsDraw(s, ("%s ran from\nthe battle!"):format(who))
|
||||
endAsDraw(s, Strings("%s ran from\nthe battle!", who))
|
||||
return
|
||||
end
|
||||
s.phase = "messages"
|
||||
@@ -479,9 +481,9 @@ function LinkBattle.new(game, net, opts)
|
||||
party = myParty,
|
||||
onSwitch = function(mon)
|
||||
if mon == s.player.mon then
|
||||
s:say(("%s is\nalready out!"):format(s.player.name))
|
||||
s:say(Strings("%s is\nalready out!", s.player.name))
|
||||
elseif mon.hp <= 0 then
|
||||
s:say("There's no will\nto fight!")
|
||||
s:say(Strings("There's no will\nto fight!"))
|
||||
else
|
||||
s:resolveSwitch(mon)
|
||||
end
|
||||
@@ -491,7 +493,7 @@ function LinkBattle.new(game, net, opts)
|
||||
end
|
||||
|
||||
self.openItems = function(s)
|
||||
s:say("Items can't be\nused in a link\nbattle!")
|
||||
s:say(Strings("Items can't be\nused in a link\nbattle!"))
|
||||
s.phase = "messages"
|
||||
s.afterQueue = "menu"
|
||||
end
|
||||
@@ -509,7 +511,7 @@ function LinkBattle.new(game, net, opts)
|
||||
return
|
||||
end
|
||||
end
|
||||
s:sayNext(("%s is out of\nPOKéMON!\f%s wins!"):format(game.save.player.name,
|
||||
s:sayNext(Strings("%s is out of\nPOKéMON!\f%s wins!", game.save.player.name,
|
||||
theirName))
|
||||
s.result = "lose"
|
||||
s.afterQueue = "finish"
|
||||
@@ -522,7 +524,7 @@ function LinkBattle.new(game, net, opts)
|
||||
return
|
||||
end
|
||||
end
|
||||
s:sayNext(("%s is out of\nPOKéMON!\f%s wins!"):format(theirName,
|
||||
s:sayNext(Strings("%s is out of\nPOKéMON!\f%s wins!", theirName,
|
||||
game.save.player.name))
|
||||
s.result = "win"
|
||||
s.afterQueue = "finish"
|
||||
@@ -543,13 +545,13 @@ function LinkBattle.new(game, net, opts)
|
||||
-- only a draw if our own simulation hasn't already decided
|
||||
-- (the winner's bye can arrive while we're still animating)
|
||||
if not s.result then
|
||||
endAsDraw(s, ("%s left the\nbattle."):format(theirName))
|
||||
endAsDraw(s, Strings("%s left the\nbattle.", theirName))
|
||||
end
|
||||
elseif msg.type == "forfeit" then
|
||||
-- the peer's own shot clock ran out; unlike a mutual RUN/desync
|
||||
-- draw, this has a definite winner (us)
|
||||
if not s.result then
|
||||
endWithResult(s, "win", ("%s ran out of\ntime!"):format(theirName))
|
||||
endWithResult(s, "win", Strings("%s ran out of\ntime!", theirName))
|
||||
end
|
||||
else
|
||||
-- a tournament control message (bracket_update, the next
|
||||
@@ -582,7 +584,7 @@ function LinkBattle.new(game, net, opts)
|
||||
if s.turnClock <= 0 then
|
||||
s.turnClockActive = false
|
||||
send({ type = "forfeit" })
|
||||
endWithResult(s, "lose", "Time's up! You\nforfeit the match.")
|
||||
endWithResult(s, "lose", Strings("Time's up! You\nforfeit the match."))
|
||||
end
|
||||
elseif opts.turnLimit then
|
||||
s.turnClockActive = false
|
||||
@@ -643,17 +645,16 @@ function LinkBattle.newSpectator(game, net, opts)
|
||||
local guestName = opts.guestName or "GUEST"
|
||||
|
||||
if not Handshake.battleAllowed(opts.verdict) then
|
||||
return nil, "Link battle needs\nthe same mods on\nboth games."
|
||||
return nil, Strings("Link battle needs\nthe same mods on\nboth games.")
|
||||
end
|
||||
|
||||
local unpackOpts = { strict = opts.strict or false, forceLevel = opts.forceLevel }
|
||||
local hostParty, hostErr = unpackParty(game, opts.hostParty, unpackOpts, function(p)
|
||||
return ("%s's %s can't\nbattle on this\ngame."):format(hostName, tostring(p.species))
|
||||
return Strings("%s's %s can't\nbattle on this\ngame.", hostName, tostring(p.species))
|
||||
end)
|
||||
if not hostParty then return nil, hostErr end
|
||||
local guestParty, guestErr = unpackParty(game, opts.guestParty, unpackOpts, function(p, why)
|
||||
return ("%s's %s can't\nbattle on this\ngame.\n(%s)"):format(
|
||||
guestName, tostring(p.species), tostring(why))
|
||||
return Strings("%s's %s can't\nbattle on this\ngame.\n(%s)", guestName, tostring(p.species), tostring(why))
|
||||
end)
|
||||
if not guestParty then return nil, guestErr end
|
||||
if #hostParty == 0 or #guestParty == 0 then
|
||||
@@ -672,7 +673,7 @@ function LinkBattle.newSpectator(game, net, opts)
|
||||
self.enemyParty = guestParty
|
||||
self.playerParty = hostParty
|
||||
self.opponentName = guestName
|
||||
self.introText = ("%s vs %s!"):format(hostName, guestName)
|
||||
self.introText = Strings("%s vs %s!", hostName, guestName)
|
||||
|
||||
local function orderMove(action)
|
||||
if action and action.id then return game.data.moves[action.id] end
|
||||
@@ -712,7 +713,7 @@ function LinkBattle.newSpectator(game, net, opts)
|
||||
battle = s, side = s.sides[2], battler = s.enemy, previous = previous,
|
||||
})
|
||||
s.enemySendingOut = true
|
||||
s:sayNext(("%s sent\nout %s!"):format(guestName, s.enemy.name))
|
||||
s:sayNext(Strings("%s sent\nout %s!", guestName, s.enemy.name))
|
||||
s:actNext(function()
|
||||
s.enemySendingOut = false
|
||||
s:startGrowIn(s.enemy)
|
||||
@@ -789,7 +790,7 @@ function LinkBattle.newSpectator(game, net, opts)
|
||||
return
|
||||
end
|
||||
end
|
||||
s:sayNext(("%s is out of\nPOKéMON!\f%s wins!"):format(hostName, guestName))
|
||||
s:sayNext(Strings("%s is out of\nPOKéMON!\f%s wins!", hostName, guestName))
|
||||
s.result = "guestWin"
|
||||
s.afterQueue = "finish"
|
||||
end
|
||||
@@ -801,7 +802,7 @@ function LinkBattle.newSpectator(game, net, opts)
|
||||
return
|
||||
end
|
||||
end
|
||||
s:sayNext(("%s is out of\nPOKéMON!\f%s wins!"):format(guestName, hostName))
|
||||
s:sayNext(Strings("%s is out of\nPOKéMON!\f%s wins!", guestName, hostName))
|
||||
s.result = "hostWin"
|
||||
s.afterQueue = "finish"
|
||||
end
|
||||
|
||||
+46
-45
@@ -11,6 +11,7 @@ local Protocol = require("src.link.Protocol")
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
local Screens = require("src.ui.Screens")
|
||||
local TextBox = require("src.render.TextBox")
|
||||
local Strings = require("src.core.Strings")
|
||||
|
||||
local LinkState = {}
|
||||
LinkState.__index = LinkState
|
||||
@@ -90,7 +91,7 @@ function LinkState.newJoinOnline(game, code)
|
||||
self.stage = "onlineJoining"
|
||||
else
|
||||
self.stage = "menu" -- exitWith below needs a real stage to unwind from
|
||||
self:exitWith("Link error:\n" .. (self.net.error or "?"))
|
||||
self:exitWith(Strings("Link error:\n%s", self.net.error or "?"))
|
||||
end
|
||||
return self
|
||||
end
|
||||
@@ -174,7 +175,7 @@ function LinkState:update(dt)
|
||||
if self.net then
|
||||
self.net:update()
|
||||
if self.net.error and not PRE_CONNECT_STAGES[self.stage] then
|
||||
self:exitWith("Link error:\n" .. self.net.error:sub(1, 60))
|
||||
self:exitWith(Strings("Link error:\n%s", self.net.error:sub(1, 60)))
|
||||
return
|
||||
end
|
||||
-- the peer vanished without a bye (only once the inbox is drained,
|
||||
@@ -183,7 +184,7 @@ function LinkState:update(dt)
|
||||
and not PRE_CONNECT_STAGES[self.stage] and self.stage ~= "addrEntry"
|
||||
and self.stage ~= "codeEntry" and self.stage ~= "notice"
|
||||
and self.stage ~= "battleRunning" then
|
||||
self:exitWith("The link was\nbroken.")
|
||||
self:exitWith(Strings("The link was\nbroken."))
|
||||
return
|
||||
end
|
||||
end
|
||||
@@ -201,7 +202,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("Online play needs\nno mods enabled.\fDisable them in\nSTART > MODS.")
|
||||
self:exitWith(Strings("Online play needs\nno mods enabled.\fDisable them in\nSTART > MODS."))
|
||||
return
|
||||
end
|
||||
if self.index == 2 then
|
||||
@@ -227,7 +228,7 @@ function LinkState:update(dt)
|
||||
if self.net:host() then
|
||||
self.stage = "hosting"
|
||||
else
|
||||
self:exitWith("Link error:\n" .. (self.net.error or "?"))
|
||||
self:exitWith(Strings("Link error:\n%s", self.net.error or "?"))
|
||||
end
|
||||
else
|
||||
self.stage = "addrEntry"
|
||||
@@ -246,7 +247,7 @@ function LinkState:update(dt)
|
||||
if self.net:hostOnline() then
|
||||
self.stage = "onlineHosting"
|
||||
else
|
||||
self:exitWith("Link error:\n" .. (self.net.error or "?"))
|
||||
self:exitWith(Strings("Link error:\n%s", self.net.error or "?"))
|
||||
end
|
||||
else
|
||||
self.stage = "codeEntry"
|
||||
@@ -284,7 +285,7 @@ function LinkState:update(dt)
|
||||
if self.net:joinOnline(nil, code) then
|
||||
self.stage = "onlineJoining"
|
||||
else
|
||||
self:exitWith("Link error:\n" .. (self.net.error or "?"))
|
||||
self:exitWith(Strings("Link error:\n%s", self.net.error or "?"))
|
||||
end
|
||||
end
|
||||
|
||||
@@ -323,7 +324,7 @@ function LinkState:update(dt)
|
||||
if self.net:join(table.concat(octets, ".")) then
|
||||
self.stage = "joining"
|
||||
else
|
||||
self:exitWith("Link error:\n" .. (self.net.error or "?"))
|
||||
self:exitWith(Strings("Link error:\n%s", self.net.error or "?"))
|
||||
end
|
||||
end
|
||||
|
||||
@@ -433,7 +434,7 @@ function LinkState:update(dt)
|
||||
end
|
||||
if not battle then
|
||||
self.net:send({ type = "bye" })
|
||||
self:exitWith(why or "Link battle\ncan't start.", "error")
|
||||
self:exitWith(why or Strings("Link battle\ncan't start."), "error")
|
||||
return
|
||||
end
|
||||
self.game.stack:push(battle)
|
||||
@@ -491,8 +492,8 @@ function LinkState:updateTrade(input)
|
||||
local t = self.trade
|
||||
|
||||
if t.stage == "cancelled" then
|
||||
self:exitWith(t.error and ("The trade stopped:\n%s."):format(t.error)
|
||||
or "The trade was\ncancelled.")
|
||||
self:exitWith(t.error and Strings("The trade stopped:\n%s.", t.error)
|
||||
or Strings("The trade was\ncancelled."))
|
||||
return
|
||||
end
|
||||
if t.stage == "done" then
|
||||
@@ -520,7 +521,7 @@ function LinkState:updateTrade(input)
|
||||
enemyOtId = received.otId,
|
||||
onDone = function()
|
||||
game.stack:push(TextBox.new(game,
|
||||
("Trade completed!\f%s received\n%s!"):format(game.save.player.name, name),
|
||||
Strings("Trade completed!\f%s received\n%s!", game.save.player.name, name),
|
||||
function()
|
||||
if evoTo then
|
||||
-- via="TRADE": a trade evolution cannot be B-cancelled
|
||||
@@ -547,7 +548,7 @@ function LinkState:updateTrade(input)
|
||||
-- the trade) -- B is dead after that, matching the A branch's own
|
||||
-- self.confirmed == nil guard
|
||||
self.net:send({ type = "bye" })
|
||||
self:exitWith("The trade was\ncancelled.")
|
||||
self:exitWith(Strings("The trade was\ncancelled."))
|
||||
elseif t.stage == "picking" and input:wasPressed("a") then
|
||||
if t:canPick(self.index) then
|
||||
self.net:send(t:pick(self.index))
|
||||
@@ -574,30 +575,30 @@ end
|
||||
function LinkState:draw()
|
||||
if self.stage == "menu" then
|
||||
drawTitle("BOIS CLUB LIVE")
|
||||
Font.draw("LINK CABLE (LAN)", 32, 44)
|
||||
Font.draw("ONLINE MATCH", 32, 60)
|
||||
Font.draw("TOURNAMENT", 32, 76)
|
||||
Font.draw(Strings("LINK CABLE (LAN)"), 32, 44)
|
||||
Font.draw(Strings("ONLINE MATCH"), 32, 60)
|
||||
Font.draw(Strings("TOURNAMENT"), 32, 76)
|
||||
Font.drawCode(CURSOR, 24, 44 + (self.index - 1) * 16)
|
||||
|
||||
elseif self.stage == "lanMenu" then
|
||||
drawTitle("LINK CABLE (LAN)")
|
||||
Font.draw("HOST A GAME", 32, 48)
|
||||
Font.draw("JOIN A GAME", 32, 68)
|
||||
Font.draw(Strings("HOST A GAME"), 32, 48)
|
||||
Font.draw(Strings("JOIN A GAME"), 32, 68)
|
||||
Font.drawCode(CURSOR, 24, self.index == 1 and 48 or 68)
|
||||
Font.draw("UDP port " .. Net.defaultPort(), 8, 128)
|
||||
Font.draw(Strings("UDP port %s", Net.defaultPort()), 8, 128)
|
||||
|
||||
elseif self.stage == "onlineMenu" then
|
||||
drawTitle("ONLINE MATCH")
|
||||
Font.draw("HOST ONLINE", 32, 48)
|
||||
Font.draw("JOIN ONLINE", 32, 68)
|
||||
Font.draw(Strings("HOST ONLINE"), 32, 48)
|
||||
Font.draw(Strings("JOIN ONLINE"), 32, 68)
|
||||
Font.drawCode(CURSOR, 24, self.index == 1 and 48 or 68)
|
||||
|
||||
elseif self.stage == "onlineHosting" then
|
||||
drawTitle("HOSTING ONLINE")
|
||||
Font.draw("Tell your friend", 16, 40)
|
||||
Font.draw("the code:", 16, 52)
|
||||
Font.draw(Strings("Tell your friend"), 16, 40)
|
||||
Font.draw(Strings("the code:"), 16, 52)
|
||||
Font.draw(self.net.code or "??????", 32, 68)
|
||||
Font.draw("Waiting for join...", 8, 96)
|
||||
Font.draw(Strings("Waiting for join..."), 8, 96)
|
||||
|
||||
elseif self.stage == "codeEntry" then
|
||||
drawTitle("ENTER CODE")
|
||||
@@ -609,18 +610,18 @@ function LinkState:draw()
|
||||
Font.drawCode(0xEE, x, 76) -- ▼ under the active slot
|
||||
end
|
||||
end
|
||||
Font.draw("A: connect B: back", 8, 128)
|
||||
Font.draw(Strings("A: connect B: back"), 8, 128)
|
||||
|
||||
elseif self.stage == "onlineJoining" then
|
||||
drawTitle("CONNECTING...")
|
||||
Font.draw("Calling...", 8, 56)
|
||||
Font.draw(Strings("Calling..."), 8, 56)
|
||||
Font.draw(self.net.target or "", 8, 72)
|
||||
|
||||
elseif self.stage == "hosting" then
|
||||
drawTitle("HOSTING")
|
||||
Font.draw("Friend joins at:", 16, 48)
|
||||
Font.draw(Strings("Friend joins at:"), 16, 48)
|
||||
Font.draw(self.net.address or "?", 16, 64)
|
||||
Font.draw("Waiting for join...", 8, 96)
|
||||
Font.draw(Strings("Waiting for join..."), 8, 96)
|
||||
|
||||
elseif self.stage == "addrEntry" then
|
||||
drawTitle("ENTER HOST ADDRESS")
|
||||
@@ -635,34 +636,34 @@ function LinkState:draw()
|
||||
for octet = 1, 3 do
|
||||
Font.draw(".", 16 + octet * 32 - 8, 64)
|
||||
end
|
||||
Font.draw("Port: " .. Net.defaultPort(), 16, 96)
|
||||
Font.draw("A: connect B: back", 8, 128)
|
||||
Font.draw(Strings("Port: %s", Net.defaultPort()), 16, 96)
|
||||
Font.draw(Strings("A: connect B: back"), 8, 128)
|
||||
|
||||
elseif self.stage == "joining" then
|
||||
drawTitle("JOINING...")
|
||||
Font.draw("Calling...", 8, 56)
|
||||
Font.draw(Strings("Calling..."), 8, 56)
|
||||
Font.draw(self.net.target or "", 8, 72)
|
||||
|
||||
elseif self.stage == "modeSelect" then
|
||||
drawTitle("CONNECTED!")
|
||||
Font.draw("TRADE", 32, 48)
|
||||
Font.draw("BATTLE", 32, 68)
|
||||
Font.draw(Strings("TRADE"), 32, 48)
|
||||
Font.draw(Strings("BATTLE"), 32, 68)
|
||||
Font.drawCode(CURSOR, 24, self.index == 1 and 48 or 68)
|
||||
|
||||
elseif self.stage == "battleOptions" then
|
||||
drawTitle("BATTLE OPTIONS")
|
||||
Font.draw("LEVELS:", 16, 56)
|
||||
Font.draw(Strings("LEVELS:"), 16, 56)
|
||||
Font.draw(forceLevelLabel(self.levelChoice or ANY), 88, 56)
|
||||
Font.draw("A: continue B: back", 8, 128)
|
||||
Font.draw(Strings("A: continue B: back"), 8, 128)
|
||||
|
||||
elseif self.stage == "waitMode" or self.stage == "waitHello" then
|
||||
drawTitle("CONNECTED!")
|
||||
if self.stage == "waitHello" then
|
||||
Font.draw("Checking the", 16, 56)
|
||||
Font.draw("other game...", 16, 72)
|
||||
Font.draw(Strings("Checking the"), 16, 56)
|
||||
Font.draw(Strings("other game..."), 16, 72)
|
||||
else
|
||||
Font.draw("Waiting for the", 16, 56)
|
||||
Font.draw("host to choose...", 16, 72)
|
||||
Font.draw(Strings("Waiting for the"), 16, 56)
|
||||
Font.draw(Strings("host to choose..."), 16, 72)
|
||||
end
|
||||
|
||||
elseif self.stage == "notice" then
|
||||
@@ -671,12 +672,12 @@ function LinkState:draw()
|
||||
if i > 8 then break end -- what fits above the prompt row
|
||||
Font.draw(line, 8, 24 + (i - 1) * 12)
|
||||
end
|
||||
Font.draw(self.noticeExits and "A: back" or "A: trade anyway", 8, 128)
|
||||
Font.draw(self.noticeExits and "A: back" or Strings("A: trade anyway"), 8, 128)
|
||||
|
||||
elseif self.stage == "trade" then
|
||||
drawTitle("TRADE")
|
||||
local t = self.trade
|
||||
Font.draw("YOURS", 8, 20)
|
||||
Font.draw(Strings("YOURS"), 8, 20)
|
||||
for i, mon in ipairs(self.game.save.party) do
|
||||
local def = self.game.data.pokemon[mon.species]
|
||||
local label = (mon.nickname or def.name):sub(1, 8)
|
||||
@@ -684,7 +685,7 @@ function LinkState:draw()
|
||||
Font.draw(label, 16, 20 + i * 12)
|
||||
if i == self.index then Font.drawCode(CURSOR, 8, 20 + i * 12) end
|
||||
end
|
||||
Font.draw("THEIRS", 84, 20)
|
||||
Font.draw(Strings("THEIRS"), 84, 20)
|
||||
for i, mon in ipairs(t.theirParty or {}) do
|
||||
local def = self.game.data.pokemon[mon.species]
|
||||
Font.draw((mon.nickname or def.name):sub(1, 8), 92, 20 + i * 12)
|
||||
@@ -695,16 +696,16 @@ function LinkState:draw()
|
||||
elseif t.stage == "waitParty" then hint = "Exchanging data..."
|
||||
elseif t.stage == "picking" then
|
||||
hint = t:canPick(self.index) and "Pick one to trade"
|
||||
or "X: not on theirs"
|
||||
or Strings("X: not on theirs")
|
||||
elseif t.stage == "waitPick" then hint = "Waiting for them..."
|
||||
elseif t.stage == "confirming" then
|
||||
hint = self.confirmed and "Waiting..." or "A: trade B: cancel"
|
||||
hint = self.confirmed and "Waiting..." or Strings("A: trade B: cancel")
|
||||
end
|
||||
Font.draw(hint or "", 8, 132)
|
||||
|
||||
elseif self.stage == "battleWait" or self.stage == "battleRunning" then
|
||||
drawTitle("LINK BATTLE")
|
||||
Font.draw("Exchanging data...", 16, 64)
|
||||
Font.draw(Strings("Exchanging data..."), 16, 64)
|
||||
end
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
end
|
||||
|
||||
+13
-9
@@ -26,6 +26,7 @@
|
||||
|
||||
local Json = require("src.link.Json")
|
||||
local Logger = require("src.core.Logger")
|
||||
local Strings = require("src.core.Strings")
|
||||
|
||||
local hasEnet, enet = pcall(require, "enet")
|
||||
if not hasEnet then enet = nil end
|
||||
@@ -173,7 +174,7 @@ function Net:connectTCP(addr)
|
||||
tcp:settimeout(5)
|
||||
local ok, err = tcp:connect(host, port)
|
||||
if not ok then
|
||||
self.error = ("can't reach relay %s:%d\n(%s)"):format(host, port, tostring(err))
|
||||
self.error = Strings("can't reach relay %s:%d\n(%s)", host, port, tostring(err))
|
||||
return false
|
||||
end
|
||||
tcp:settimeout(0)
|
||||
@@ -236,11 +237,14 @@ local function handleGenericRelayControl(self, msg)
|
||||
self.paired = true
|
||||
return true
|
||||
elseif msg.type == "join_error" then
|
||||
self.error = ({
|
||||
not_found = "That code wasn't\nfound.",
|
||||
full = "That game already\nhas two players.",
|
||||
expired = "That code has\nexpired.",
|
||||
})[msg.reason] or ("Couldn't join:\n%s"):format(tostring(msg.reason))
|
||||
self.error = Strings(({
|
||||
not_found = Strings.source("That code wasn't\nfound."),
|
||||
full = Strings.source("That game already\nhas two players."),
|
||||
expired = Strings.source("That code has\nexpired."),
|
||||
})[msg.reason] or "")
|
||||
if self.error == "" then
|
||||
self.error = Strings("Couldn't join:\n%s", tostring(msg.reason))
|
||||
end
|
||||
self.closed = true
|
||||
return true
|
||||
elseif msg.type == "peer_gone" then
|
||||
@@ -328,7 +332,7 @@ function Net:update()
|
||||
-- an unreachable join target surfaces as a service error
|
||||
-- (ICMP unreachable on the connected UDP socket)
|
||||
if self.mode == "joining" and not self.paired then
|
||||
self.error = ("no answer from\n%s"):format(self.target or "the host")
|
||||
self.error = Strings("no answer from\n%s", self.target or "the host")
|
||||
else
|
||||
self.error = tostring(event)
|
||||
end
|
||||
@@ -358,14 +362,14 @@ function Net:update()
|
||||
self.closed = true
|
||||
if not self.paired then
|
||||
self.error = self.error or
|
||||
("no answer from\n%s"):format(self.target or "the host")
|
||||
Strings("no answer from\n%s", self.target or "the host")
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
if self.mode == "joining" and not self.paired
|
||||
and self.joinDeadline and now() > self.joinDeadline then
|
||||
self.error = ("no answer from\n%s"):format(self.target or "the host")
|
||||
self.error = Strings("no answer from\n%s", self.target or "the host")
|
||||
self.closed = true
|
||||
pcall(function() self.peer:disconnect_now() end)
|
||||
end
|
||||
|
||||
+35
-35
@@ -18,6 +18,7 @@ local Protocol = require("src.link.Protocol")
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
local Sound = require("src.core.Sound")
|
||||
local TextBox = require("src.render.TextBox")
|
||||
local Strings = require("src.core.Strings")
|
||||
|
||||
local Tournament = {}
|
||||
Tournament.__index = Tournament
|
||||
@@ -128,7 +129,7 @@ end
|
||||
function Tournament:startHosting()
|
||||
self.net = Net.new()
|
||||
if not self.net:connectTCP(Net.defaultRelayAddress()) then
|
||||
self:exitWith("Link error:\n" .. (self.net.error or "?"))
|
||||
self:exitWith(Strings("Link error:\n%s", self.net.error or "?"))
|
||||
return
|
||||
end
|
||||
local size, minL, maxL = partyStats(self.game.save.party)
|
||||
@@ -151,7 +152,7 @@ end
|
||||
function Tournament:startJoining(code)
|
||||
self.net = Net.new()
|
||||
if not self.net:connectTCP(Net.defaultRelayAddress()) then
|
||||
self:exitWith("Link error:\n" .. (self.net.error or "?"))
|
||||
self:exitWith(Strings("Link error:\n%s", self.net.error or "?"))
|
||||
return
|
||||
end
|
||||
local size, minL, maxL = partyStats(self.game.save.party)
|
||||
@@ -171,9 +172,9 @@ end
|
||||
-- -------------------------------------------------------------------
|
||||
|
||||
local JOIN_ERROR_TEXT = {
|
||||
not_found = "That code wasn't\nfound.",
|
||||
already_started = "That tournament\nhas already begun.",
|
||||
expired = "That code has\nexpired.",
|
||||
not_found = Strings.source("That code wasn't\nfound."),
|
||||
already_started = Strings.source("That tournament\nhas already begun."),
|
||||
expired = Strings.source("That code has\nexpired."),
|
||||
}
|
||||
|
||||
function Tournament:handleMessage(msg)
|
||||
@@ -198,17 +199,17 @@ function Tournament:handleMessage(msg)
|
||||
end
|
||||
elseif msg.type == "tournament_host_error" then
|
||||
if msg.reason == "party_ineligible" then
|
||||
self:exitWith(("Can't host:\nneed %d Pokemon\nLv %s-%s."):format(
|
||||
msg.requiredPartySize, levelLabel(msg.minLevel), levelLabel(msg.maxLevel)))
|
||||
self:exitWith(Strings("Can't host:\nneed %d Pokemon\nLv %s-%s.", msg.requiredPartySize, levelLabel(msg.minLevel), levelLabel(msg.maxLevel)))
|
||||
else
|
||||
self:exitWith("Couldn't host\nthat tournament.")
|
||||
self:exitWith(Strings("Couldn't host\nthat tournament."))
|
||||
end
|
||||
elseif msg.type == "tournament_join_error" then
|
||||
if msg.reason == "party_ineligible" then
|
||||
self:exitWith(("Your party needs\n%d Pokemon, Lv\n%s-%s."):format(
|
||||
msg.requiredPartySize, levelLabel(msg.minLevel), levelLabel(msg.maxLevel)))
|
||||
self:exitWith(Strings("Your party needs\n%d Pokemon, Lv\n%s-%s.", msg.requiredPartySize, levelLabel(msg.minLevel), levelLabel(msg.maxLevel)))
|
||||
else
|
||||
self:exitWith(JOIN_ERROR_TEXT[msg.reason] or "Couldn't join\nthat tournament.")
|
||||
self:exitWith(JOIN_ERROR_TEXT[msg.reason]
|
||||
and Strings(JOIN_ERROR_TEXT[msg.reason])
|
||||
or Strings("Couldn't join\nthat tournament."))
|
||||
end
|
||||
elseif msg.type == "tournament_roster" then
|
||||
self.roster = msg.players
|
||||
@@ -281,7 +282,7 @@ function Tournament:beginMatchBattle()
|
||||
-- shouldn't happen (both sides already passed the online-play mods
|
||||
-- gate), but a mismatched engine/build is still possible -- bail out
|
||||
-- of just this match rather than crash the tournament
|
||||
self:exitWith("Link error:\nversion mismatch\nwith opponent.")
|
||||
self:exitWith(Strings("Link error:\nversion mismatch\nwith opponent."))
|
||||
return
|
||||
end
|
||||
self.linkSeed = self.isHost and love.math.random(1, 2 ^ 30) or nil
|
||||
@@ -351,11 +352,11 @@ function Tournament:update(dt)
|
||||
self.net:update()
|
||||
if self.net.error and self.stage ~= "menu" and self.stage ~= "hostSettings"
|
||||
and self.stage ~= "codeEntry" then
|
||||
self:exitWith("Link error:\n" .. self.net.error:sub(1, 60))
|
||||
self:exitWith(Strings("Link error:\n%s", self.net.error:sub(1, 60)))
|
||||
return
|
||||
end
|
||||
if self.net.closed and self.stage ~= "done" then
|
||||
self:exitWith("The tournament\nconnection was\nlost.")
|
||||
self:exitWith(Strings("The tournament\nconnection was\nlost."))
|
||||
return
|
||||
end
|
||||
end
|
||||
@@ -381,7 +382,7 @@ function Tournament:update(dt)
|
||||
and LinkBattle.newHost(self.game, self.net, self.pendingBattleOpts)
|
||||
or LinkBattle.newGuest(self.game, self.net, self.pendingBattleOpts)
|
||||
if not battle then
|
||||
self:exitWith(why or "Link battle\ncan't start.")
|
||||
self:exitWith(why or Strings("Link battle\ncan't start."))
|
||||
return
|
||||
end
|
||||
-- anything after `party` in this same batch belongs to the
|
||||
@@ -418,7 +419,7 @@ function Tournament:update(dt)
|
||||
forceLevel = levelForWire(self.settings.forceLevel),
|
||||
})
|
||||
if not battle then
|
||||
self:exitWith(why or "Can't watch this\nmatch.")
|
||||
self:exitWith(why or Strings("Can't watch this\nmatch."))
|
||||
return
|
||||
end
|
||||
for j = #msgs, i + 1, -1 do
|
||||
@@ -541,8 +542,8 @@ local SETTINGS_LABELS = { "POKEMON", "MIN LV", "MAX LV", "TIMER", "LEVELS", "PLA
|
||||
function Tournament:draw()
|
||||
if self.stage == "menu" then
|
||||
drawTitle("TOURNAMENT")
|
||||
Font.draw("HOST", 32, 48)
|
||||
Font.draw("JOIN", 32, 68)
|
||||
Font.draw(Strings("HOST"), 32, 48)
|
||||
Font.draw(Strings("JOIN"), 32, 68)
|
||||
Font.drawCode(CURSOR, 24, self.index == 1 and 48 or 68)
|
||||
|
||||
elseif self.stage == "hostSettings" then
|
||||
@@ -561,7 +562,7 @@ function Tournament:draw()
|
||||
Font.draw(values[i], 96, y)
|
||||
if i == self.settingsIndex then Font.drawCode(CURSOR, 8, y) end
|
||||
end
|
||||
Font.draw("START: create", 8, 128)
|
||||
Font.draw(Strings("START: create"), 8, 128)
|
||||
|
||||
elseif self.stage == "codeEntry" then
|
||||
drawTitle("ENTER CODE")
|
||||
@@ -573,28 +574,27 @@ function Tournament:draw()
|
||||
Font.drawCode(0xEE, x, 76)
|
||||
end
|
||||
end
|
||||
Font.draw("A: join B: back", 8, 128)
|
||||
Font.draw(Strings("A: join B: back"), 8, 128)
|
||||
|
||||
elseif self.stage == "registering" then
|
||||
drawTitle("CONNECTING...")
|
||||
Font.draw("B: cancel", 8, 128)
|
||||
Font.draw(Strings("B: cancel"), 8, 128)
|
||||
|
||||
elseif self.stage == "bracket" or self.stage == "matchHello"
|
||||
or self.stage == "matchWaitParty" or self.stage == "spectateWait" then
|
||||
drawTitle(("TOURNAMENT %s"):format(self.code or "??????"))
|
||||
drawTitle(Strings("TOURNAMENT %s", self.code or "??????"))
|
||||
if self.bracket then
|
||||
local y = 20
|
||||
for _, round in ipairs(self.bracket.rounds) do
|
||||
Font.draw(("ROUND %d"):format(round.round), 8, y)
|
||||
Font.draw(Strings("ROUND %d", round.round), 8, y)
|
||||
y = y + 10
|
||||
for _, m in ipairs(round.matches) do
|
||||
local line
|
||||
if m.bye then
|
||||
line = ("%s (bye)"):format(m.a or m.b or "?")
|
||||
line = Strings("%s (bye)", m.a or m.b or "?")
|
||||
else
|
||||
local mark = m.state == "live" and "*" or (m.winner and "" or "")
|
||||
line = ("%s%s vs %s%s"):format(
|
||||
m.winner == m.a and ">" or " ", m.a or "?",
|
||||
line = Strings("%s%s vs %s%s", m.winner == m.a and ">" or " ", m.a or "?",
|
||||
m.b or "?", m.winner == m.b and "<" or (mark == "*" and " *" or ""))
|
||||
end
|
||||
Font.draw(line, 12, y)
|
||||
@@ -604,11 +604,11 @@ function Tournament:draw()
|
||||
end
|
||||
else
|
||||
if self.participating == false then
|
||||
Font.draw("(organizing --", 16, 32)
|
||||
Font.draw("not playing)", 16, 42)
|
||||
Font.draw(Strings("(organizing --"), 16, 32)
|
||||
Font.draw(Strings("not playing)"), 16, 42)
|
||||
end
|
||||
Font.draw("Waiting for", 16, 48)
|
||||
Font.draw("players to join:", 16, 60)
|
||||
Font.draw(Strings("Waiting for"), 16, 48)
|
||||
Font.draw(Strings("players to join:"), 16, 60)
|
||||
local y = 60
|
||||
for i, name in ipairs(self.roster) do
|
||||
y = 60 + i * 10
|
||||
@@ -620,18 +620,18 @@ function Tournament:draw()
|
||||
end
|
||||
end
|
||||
if self.isCreator and #self.roster >= 2 and not self.bracket then
|
||||
Font.draw("A: START B: cancel", 8, 132)
|
||||
Font.draw(Strings("A: START B: cancel"), 8, 132)
|
||||
else
|
||||
Font.draw("B: cancel", 8, 132)
|
||||
Font.draw(Strings("B: cancel"), 8, 132)
|
||||
end
|
||||
|
||||
elseif self.stage == "done" then
|
||||
drawTitle("TOURNAMENT OVER")
|
||||
if self.champion then
|
||||
Font.draw(("%s is the"):format(self.champion), 16, 56)
|
||||
Font.draw("champion!", 16, 68)
|
||||
Font.draw(Strings("%s is the", self.champion), 16, 56)
|
||||
Font.draw(Strings("champion!"), 16, 68)
|
||||
end
|
||||
Font.draw("A: continue", 8, 128)
|
||||
Font.draw(Strings("A: continue"), 8, 128)
|
||||
end
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
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
|
||||
|
||||
+21
-20
@@ -10,6 +10,7 @@ local Semver = require("src.mods.Semver")
|
||||
local Version = require("src.core.Version")
|
||||
local Theme = require("src.ui.Theme")
|
||||
local OptionRows = require("src.ui.OptionRows")
|
||||
local Strings = require("src.core.Strings")
|
||||
|
||||
local ManagerState = {}
|
||||
ManagerState.__index = ManagerState
|
||||
@@ -269,7 +270,7 @@ function ManagerState:modRows()
|
||||
end
|
||||
end
|
||||
if #rows == 0 then
|
||||
rows[1] = { header = true, label = "NO MODS INSTALLED" }
|
||||
rows[1] = { header = true, label = Strings("NO MODS INSTALLED") }
|
||||
end
|
||||
return rows
|
||||
end
|
||||
@@ -281,7 +282,7 @@ function ManagerState:profileRows()
|
||||
rows[#rows + 1] = { profile = p, label = p.name,
|
||||
glyph = opts.activeProfile == p.name and GLYPH.errored or " " }
|
||||
end
|
||||
rows[#rows + 1] = { saveAs = true, label = "SAVE CURRENT AS.." }
|
||||
rows[#rows + 1] = { saveAs = true, label = Strings("SAVE CURRENT AS..") }
|
||||
rows[#rows + 1] = { adhoc = true,
|
||||
label = opts.activeProfile and "[AD-HOC]" or "[AD-HOC] (LIVE)" }
|
||||
return rows
|
||||
@@ -316,30 +317,30 @@ function ManagerState:detailRows(m)
|
||||
rows[#rows + 1] = { label = m.enabled and "DISABLE" or "ENABLE",
|
||||
action = function() self:beginToggle(m) end }
|
||||
if self:schemaFor(m) then
|
||||
rows[#rows + 1] = { label = "OPTIONS..",
|
||||
rows[#rows + 1] = { label = Strings("OPTIONS.."),
|
||||
action = function() self:openOptions(m) end }
|
||||
end
|
||||
if m.permissions and #m.permissions > 0 then
|
||||
rows[#rows + 1] = { label = "PERMISSIONS..",
|
||||
rows[#rows + 1] = { label = Strings("PERMISSIONS.."),
|
||||
action = function() self:goTo("permissions") end }
|
||||
end
|
||||
if m.error then
|
||||
rows[#rows + 1] = { label = "VIEW ERROR..",
|
||||
rows[#rows + 1] = { label = Strings("VIEW ERROR.."),
|
||||
action = function() self:goTo("errors") end }
|
||||
end
|
||||
rows[#rows + 1] = { label = "BACK", action = function() self:goBack() end }
|
||||
rows[#rows + 1] = { label = Strings("BACK"), action = function() self:goBack() end }
|
||||
return rows
|
||||
end
|
||||
|
||||
function ManagerState:applyRows()
|
||||
local rows = {}
|
||||
rows[#rows + 1] = { label = "APPLY & RESTART", action = function()
|
||||
rows[#rows + 1] = { label = Strings("APPLY & RESTART"), action = function()
|
||||
self:openConfirm({ "RESTART NOW?" }, function() self:restartGame() end)
|
||||
end }
|
||||
rows[#rows + 1] = { label = "DISCARD CHANGES", action = function()
|
||||
rows[#rows + 1] = { label = Strings("DISCARD CHANGES"), action = function()
|
||||
self:discardChanges()
|
||||
end }
|
||||
rows[#rows + 1] = { label = "BACK", action = function() self:goBack() end }
|
||||
rows[#rows + 1] = { label = Strings("BACK"), action = function() self:goBack() end }
|
||||
return rows
|
||||
end
|
||||
|
||||
@@ -352,7 +353,7 @@ function ManagerState:permissionRows(m)
|
||||
label = info and info.text or name }
|
||||
end
|
||||
if #rows == 0 then
|
||||
rows[1] = { inert = true, label = "DATA & API ONLY" }
|
||||
rows[1] = { inert = true, label = Strings("DATA & API ONLY") }
|
||||
end
|
||||
return rows
|
||||
end
|
||||
@@ -564,7 +565,7 @@ function ManagerState:openCascade(r, m, want)
|
||||
else
|
||||
local dep = self.byId[r.alsoDisable[1]]
|
||||
lines[#lines + 1] = (dep and dep.name or r.alsoDisable[1]) .. " NEEDS THIS."
|
||||
lines[#lines + 1] = #r.alsoDisable > 1 and "DISABLE ALL?" or "DISABLE BOTH?"
|
||||
lines[#lines + 1] = #r.alsoDisable > 1 and "DISABLE ALL?" or Strings("DISABLE BOTH?")
|
||||
end
|
||||
self.overlay = { kind = "confirm", lines = lines, index = 1,
|
||||
onYes = function() self:commitToggle(r.apply) end }
|
||||
@@ -700,7 +701,7 @@ end
|
||||
function ManagerState:saveCurrentAs()
|
||||
local NamingScreen = require("src.ui.NamingScreen")
|
||||
self.game.stack:push(NamingScreen.new(self.game, {
|
||||
title = "PROFILE NAME?",
|
||||
title = Strings("PROFILE NAME?"),
|
||||
maxLen = 10,
|
||||
onDone = function(name)
|
||||
local opts = self:optionsTable()
|
||||
@@ -726,7 +727,7 @@ end
|
||||
function ManagerState:renameProfile(p)
|
||||
local NamingScreen = require("src.ui.NamingScreen")
|
||||
self.game.stack:push(NamingScreen.new(self.game, {
|
||||
title = "RENAME?",
|
||||
title = Strings("RENAME?"),
|
||||
maxLen = 10,
|
||||
default = p.name,
|
||||
onDone = function(name)
|
||||
@@ -886,7 +887,7 @@ function ManagerState:buildOptionRows(m, schema)
|
||||
end }
|
||||
end
|
||||
end
|
||||
rows[#rows + 1] = { id = "__reset", label = "RESET DEFAULTS",
|
||||
rows[#rows + 1] = { id = "__reset", label = Strings("RESET DEFAULTS"),
|
||||
value = function() return "" end,
|
||||
activate = function()
|
||||
for _, row in ipairs(schema) do
|
||||
@@ -1047,7 +1048,7 @@ function ManagerState:drawApply()
|
||||
y = y + 1
|
||||
end
|
||||
if #staged == 0 then
|
||||
Font.draw(Runtime.safeMode and "SAFE MODE" or "NO CHANGES", 16, y * 8)
|
||||
Font.draw(Runtime.safeMode and "SAFE MODE" or Strings("NO CHANGES"), 16, y * 8)
|
||||
y = y + 1
|
||||
end
|
||||
local rows = self:rowsForScreen()
|
||||
@@ -1076,12 +1077,12 @@ function ManagerState:drawOverlay()
|
||||
end
|
||||
if overlay.kind == "confirm" then
|
||||
local yesY = ty + #lines + 1
|
||||
Font.draw("YES", 5 * 8, yesY * 8)
|
||||
Font.draw("NO", 5 * 8, (yesY + 1) * 8)
|
||||
Font.draw(Strings("YES"), 5 * 8, yesY * 8)
|
||||
Font.draw(Strings("NO"), 5 * 8, (yesY + 1) * 8)
|
||||
Font.drawCode(Theme.cursor, 4 * 8,
|
||||
(overlay.index == 1 and yesY or yesY + 1) * 8)
|
||||
else
|
||||
Font.draw("A:OK", 5 * 8, (ty + #lines + 1) * 8)
|
||||
Font.draw(Strings("A:OK"), 5 * 8, (ty + #lines + 1) * 8)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1090,7 +1091,7 @@ function ManagerState:draw()
|
||||
OptionRows.draw(self.game, self.optionRows or {}, self.cursor,
|
||||
self.scroll or 0)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
Font.draw(self.notice or "B:DONE (NO RESTART)", 8, 136)
|
||||
Font.draw(self.notice or Strings("B:DONE (NO RESTART)"), 8, 136)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
if self.overlay then self:drawOverlay() end
|
||||
return
|
||||
@@ -1099,7 +1100,7 @@ function ManagerState:draw()
|
||||
love.graphics.rectangle("fill", 0, 0, 160, 144)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
Font.drawBox(0, 0, 20, 18)
|
||||
Font.draw(self.banner or "MOD MANAGER", 16, 8)
|
||||
Font.draw(self.banner or Strings("MOD MANAGER"), 16, 8)
|
||||
if self.screen == "list" then
|
||||
self:drawList()
|
||||
elseif self.screen == "detail" then
|
||||
|
||||
+31
-2
@@ -584,6 +584,17 @@ R.text = {
|
||||
example = 'mod.content.text:override("_PalletTownText1", "HELLO!")',
|
||||
}
|
||||
|
||||
-- The engine's own authored text, the half of the game `text` does not
|
||||
-- cover (src/core/Strings.lua). The id is the English source string, so a
|
||||
-- translation supplies one entry per literal the engine draws and anything
|
||||
-- it has not reached yet keeps rendering in English. Ids that collide in
|
||||
-- English but not elsewhere carry a "context|source" prefix.
|
||||
R.strings = {
|
||||
semantics = "record", target = "strings",
|
||||
value = f.str,
|
||||
example = 'mod.content.strings:override("But, it failed!", "Echec !")',
|
||||
}
|
||||
|
||||
-- the self-contained bytecode blob ChipAsm.song/sfx emit (13.1 shape 2);
|
||||
-- shared by every namespace that plays a chip program
|
||||
local chipProgram = f.rec{
|
||||
@@ -918,8 +929,20 @@ R.battle_sprite_scales = {
|
||||
-- 1 = native pixels; the drawn size relative to the pic's own pixels
|
||||
scale = f.numRange(0.25, 4.0),
|
||||
},
|
||||
example = 'mod.content.battle_sprite_scales:register("abra_back", ' ..
|
||||
'{ path = "assets/generated/battle/back/abrab.png", scale = 1.5 })',
|
||||
example = [[
|
||||
mod.content.battle_sprite_scales:register("abra_back", {
|
||||
path = "assets/generated/battle/back/abrab.png",
|
||||
scale = 1.5,
|
||||
})]],
|
||||
notes = [[
|
||||
Scales one battle pic by its asset path, overriding the species-level
|
||||
`battleScaleFront`/`battleScaleBack` (see [pokemon](#pokemon)) for that
|
||||
image. The only way to scale a pic that is not species-keyed, like the
|
||||
player's trainer back sprite. Resolution order at draw time:
|
||||
image-level, then species-level, then the defaults (1x front, 2x back).
|
||||
The pic stays grounded at any scale: player feet stay flush on the
|
||||
text-box top, the enemy pic stays bottom-pinned in its slot, and the
|
||||
scale composes with the send-out grow animation.]],
|
||||
}
|
||||
|
||||
-- ------- progression
|
||||
@@ -1146,6 +1169,12 @@ R.field = {
|
||||
name = f.opt(f.str) })),
|
||||
nest = f.opt(f.any) },
|
||||
flyOrder = f.list(f.str),
|
||||
-- the player's own trainer art (FieldDefaults.PLAYER_PICS): the battle
|
||||
-- back pic, the catch tutorial's old man, and the front pic the intro,
|
||||
-- trainer card and Hall of Fame share. Every key is optional so a
|
||||
-- conversion can replace one pic and inherit the rest.
|
||||
playerPics = f.rec{
|
||||
back = f.opt(f.str), demoBack = f.opt(f.str), front = f.opt(f.str) },
|
||||
-- the new-game and boot config a total conversion replaces
|
||||
boot = f.rec{
|
||||
startMap = f.opt(f.str), startX = f.opt(f.int(0)), startY = f.opt(f.int(0)),
|
||||
|
||||
@@ -14,6 +14,7 @@ local Runtime = require("src.mods.Runtime")
|
||||
local Screens = require("src.ui.Screens")
|
||||
local Stats = require("src.pokemon.Stats")
|
||||
local TextBox = require("src.render.TextBox")
|
||||
local Strings = require("src.core.Strings")
|
||||
|
||||
local Evolution = {}
|
||||
|
||||
@@ -23,7 +24,7 @@ Evolution.METHODS = {
|
||||
return trigger.kind == "levelup" and mon.level >= (evo.level or 0)
|
||||
end,
|
||||
describe = function(evo)
|
||||
return ("Level %d"):format(evo.level or 0)
|
||||
return Strings("Level %d", evo.level or 0)
|
||||
end,
|
||||
},
|
||||
ITEM = {
|
||||
@@ -139,7 +140,7 @@ function Evolution.learnEvolutionMoves(game, mon, onDone)
|
||||
table.insert(mon.moves, { id = moveId, pp = mdef.pp })
|
||||
Runtime.emit("pokemon.move_learned", { mon = mon, moveId = moveId })
|
||||
game.stack:push(TextBox.new(game,
|
||||
("%s learned\n%s!"):format(name, mdef.name), nextStep))
|
||||
Strings("%s learned\n%s!", name, mdef.name), nextStep))
|
||||
else
|
||||
-- LearnMoveFromLevelUp with a full moveset: the forget UI
|
||||
Screens.push(game, "MoveLearnMenu", mon, moveId, nextStep)
|
||||
@@ -160,8 +161,8 @@ function Evolution.evolve(game, mon, newSpecies, onDone, via)
|
||||
Music.play(game.data, Music.special(game.data, "evolution"))
|
||||
local oldName = mon.nickname or game.data.pokemon[mon.species].name
|
||||
Evolution.apply(game, mon, newSpecies, via)
|
||||
local msg = ("What?\n%s is\nevolving!\fCongratulations!\nYour %s\nevolved into\n%s!")
|
||||
:format(oldName, oldName, game.data.pokemon[newSpecies].name)
|
||||
local msg = Strings("What?\n%s is\nevolving!\fCongratulations!\nYour %s\nevolved into\n%s!",
|
||||
oldName, oldName, game.data.pokemon[newSpecies].name)
|
||||
game.stack:push(TextBox.new(game, msg, function()
|
||||
Music.restoreMap(game.data)
|
||||
-- re-run the evolved species' level-up learn check before onDone
|
||||
@@ -186,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,
|
||||
}
|
||||
|
||||
@@ -3,8 +3,12 @@
|
||||
-- patch pokemon.spriteFront / icons.bySpecies. These helpers are the
|
||||
-- sanctioned seam: every battle pic and party icon load goes through
|
||||
-- pokemon.sprite / pokemon.icon, which stay live for the whole process.
|
||||
--
|
||||
-- playerPath is the same seam for the player's own trainer art, whose
|
||||
-- vanilla paths are data (field.playerPics) rather than a species record.
|
||||
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
local FieldDefaults = require("src.world.FieldDefaults")
|
||||
|
||||
local Sprites = {}
|
||||
|
||||
@@ -36,6 +40,36 @@ function Sprites.path(data, species, side, opts)
|
||||
return path, ctx.trueColor and true or false
|
||||
end
|
||||
|
||||
-- Resolve the player's own trainer pic path.
|
||||
-- side: "back" (the battle intro pic) | "front" (intro / card / Hall of Fame)
|
||||
-- opts.kind: "battle" | "intro" | "trainer_card" | "hof"
|
||||
-- opts.demo: the catch tutorial, where the old man fights in the player's
|
||||
-- place and stands in for the back pic
|
||||
-- opts.battle: the live battle, for kind == "battle"
|
||||
-- Returns path, trueColor.
|
||||
function Sprites.playerPath(data, side, opts)
|
||||
opts = opts or {}
|
||||
side = side == "back" and "back" or "front"
|
||||
-- one key per pic, so a conversion can replace the back and inherit the
|
||||
-- rest; fieldValue folds data.field over FieldDefaults per key
|
||||
local key = side == "front" and "front"
|
||||
or (opts.demo and "demoBack" or "back")
|
||||
local path = FieldDefaults.fieldValue(data, "playerPics", key)
|
||||
local ctx = {
|
||||
side = side,
|
||||
kind = opts.kind or "battle",
|
||||
demo = opts.demo and true or false,
|
||||
battle = opts.battle,
|
||||
trueColor = false,
|
||||
data = data,
|
||||
}
|
||||
if path and Runtime.wantsHook("player.sprite") then
|
||||
local hooked = Runtime.call("player.sprite", samePath, path, ctx)
|
||||
if type(hooked) == "string" and hooked ~= "" then path = hooked end
|
||||
end
|
||||
return path, ctx.trueColor and true or false
|
||||
end
|
||||
|
||||
-- Resolve a party-menu icon image path for `mon`.
|
||||
-- vanillaPath is the path PartyMenu already picked from icons.bySpecies /
|
||||
-- def.icon / icons.byDex; the hook may replace it.
|
||||
|
||||
@@ -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
|
||||
|
||||
+69
-20
@@ -102,35 +102,84 @@ local function pageFor(code)
|
||||
return nil
|
||||
end
|
||||
|
||||
local SPACE = 0x7F
|
||||
|
||||
-- Segment text into glyph spans: `{ from, to, code }` byte ranges, one per
|
||||
-- drawn glyph, code nil when the charmap has nothing. A span is a whole
|
||||
-- charmap sequence, so a multi-byte char ("é", "♂") and an ASCII ligature
|
||||
-- ("<PK>", "'d") are each one glyph.
|
||||
--
|
||||
-- Every caller that *measures* or *cuts* text walks these instead of bytes.
|
||||
-- "POKéMON" is 8 bytes and 7 glyphs: measuring it as 8 wraps lines that fit
|
||||
-- (25 vanilla lines did), and cutting at byte 8 splits the é into two
|
||||
-- invalid bytes that both draw as spaces. That distinction is the whole
|
||||
-- reason a non-English font can be shipped as a mod (#186, #245).
|
||||
--
|
||||
-- Safe before Font.load: with no charmap it falls back to UTF-8 lead-byte
|
||||
-- boundaries, which is all a headless paginate needs.
|
||||
function Font.split(text)
|
||||
local spans = {}
|
||||
local i, n = 1, #text
|
||||
while i <= n do
|
||||
local span
|
||||
local candidates = state and state.byFirstByte[text:byte(i)]
|
||||
if candidates then
|
||||
for _, entry in ipairs(candidates) do
|
||||
local len = #entry.seq
|
||||
if text:sub(i, i + len - 1) == entry.seq then
|
||||
span = { from = i, to = i + len - 1, code = entry.code }
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
if not span then
|
||||
-- Nothing matched. Still keep a UTF-8 sequence whole, so a cut never
|
||||
-- lands mid-character even for a glyph we cannot draw.
|
||||
local last = i
|
||||
if text:byte(i) >= 0xC0 then
|
||||
local k = i + 1
|
||||
while k <= n do
|
||||
local b = text:byte(k)
|
||||
if b < 0x80 or b > 0xBF then break end
|
||||
last, k = k, k + 1
|
||||
end
|
||||
end
|
||||
span = { from = i, to = last }
|
||||
end
|
||||
spans[#spans + 1] = span
|
||||
i = span.to + 1
|
||||
end
|
||||
return spans
|
||||
end
|
||||
|
||||
-- How many leading spans fit in `budget` pixels. Advances come from each
|
||||
-- glyph's own page, so a variable-width page measures correctly.
|
||||
function Font.spansFitting(spans, budget)
|
||||
local used, fit = 0, 0
|
||||
for _, span in ipairs(spans) do
|
||||
used = used + Font.advanceOf(span.code or SPACE)
|
||||
if used > budget then break end
|
||||
fit = fit + 1
|
||||
end
|
||||
return fit
|
||||
end
|
||||
|
||||
-- Convert a text string into a list of glyph codes. Unknown characters
|
||||
-- render as space (and are reported once).
|
||||
local reported = {}
|
||||
function Font.encode(text)
|
||||
local codes = {}
|
||||
local i = 1
|
||||
while i <= #text do
|
||||
local candidates = state.byFirstByte[text:byte(i)]
|
||||
local matched = false
|
||||
if candidates then
|
||||
for _, entry in ipairs(candidates) do
|
||||
local n = #entry.seq
|
||||
if text:sub(i, i + n - 1) == entry.seq then
|
||||
codes[#codes + 1] = entry.code
|
||||
i = i + n
|
||||
matched = true
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
if not matched then
|
||||
local ch = text:sub(i, i)
|
||||
if not reported[ch] and ch:byte() >= 32 then
|
||||
for _, span in ipairs(Font.split(text)) do
|
||||
local code = span.code
|
||||
if not code then
|
||||
local ch = text:sub(span.from, span.to)
|
||||
if not reported[ch] and text:byte(span.from) >= 32 then
|
||||
reported[ch] = true
|
||||
require("src.core.Logger").warn("font: no glyph for %q", ch)
|
||||
end
|
||||
codes[#codes + 1] = 0x7F -- space
|
||||
i = i + 1
|
||||
code = SPACE
|
||||
end
|
||||
codes[#codes + 1] = code
|
||||
end
|
||||
return codes
|
||||
end
|
||||
|
||||
+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,
|
||||
|
||||
+51
-5
@@ -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
|
||||
@@ -92,13 +97,28 @@ end
|
||||
-- pokered ContText waits for A/B + ▼ before scrolling that line in.
|
||||
function TextBox.paginate(text, maxCols)
|
||||
maxCols = maxCols or (Theme.textBox and Theme.textBox.maxCols) or MAX_COLS
|
||||
-- maxCols is a column count, so the budget is that many vanilla 8px
|
||||
-- cells. Measuring in pixels rather than columns is what lets a mod's
|
||||
-- variable-advance page wrap correctly (#186).
|
||||
local budget = maxCols * 8
|
||||
local pages = {}
|
||||
local contBefore = {}
|
||||
-- Soft-wrap on glyph boundaries, never byte boundaries: a line is over
|
||||
-- budget by what it *draws*, and the cut falls between glyphs so a
|
||||
-- multi-byte char is never torn in half.
|
||||
local function pushLine(lines, conts, line, wait)
|
||||
while #line > maxCols do
|
||||
local cut = maxCols
|
||||
for i = maxCols, 1, -1 do
|
||||
if line:sub(i, i) == " " then cut = i break end
|
||||
while true do
|
||||
local spans = Font.split(line)
|
||||
local fit = Font.spansFitting(spans, budget)
|
||||
if fit >= #spans then break end
|
||||
-- a glyph wider than the whole box still has to advance by one
|
||||
fit = math.max(fit, 1)
|
||||
local cut = spans[fit].to
|
||||
for i = fit, 1, -1 do
|
||||
if line:sub(spans[i].from, spans[i].to) == " " then
|
||||
cut = spans[i].to
|
||||
break
|
||||
end
|
||||
end
|
||||
table.insert(lines, line:sub(1, cut))
|
||||
table.insert(conts, wait)
|
||||
@@ -164,9 +184,28 @@ 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
|
||||
-- auto.wait: the pet-NPC cries (PewterNidoranHouseNidoranText,
|
||||
-- ViridianNicknameHouseSpearowText) have nothing queued behind the
|
||||
-- cry, so DisplayTextID's trailing WaitForTextScrollButtonPress still
|
||||
-- runs once it is over -- their maps enable auto text box drawing,
|
||||
-- which zeroes wDoNotWaitForButtonPressAfterDisplayingText
|
||||
-- (home/window.asm AutoTextBoxDrawingCommon). Drop the auto gate and
|
||||
-- hand the box to the plain A/B path, which also starts the blinking
|
||||
-- arrow, instead of popping it (#247, #251).
|
||||
if self.auto.wait then
|
||||
self.auto = nil
|
||||
return
|
||||
end
|
||||
self.autoTimer = self.autoTimer + 1
|
||||
local delay = self.auto.delay or 3
|
||||
-- auto.onOverlap: fired once when the delay elapses but before the
|
||||
@@ -267,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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = {},
|
||||
|
||||
+29
-14
@@ -10,6 +10,7 @@ local Flags = require("src.script.Flags")
|
||||
local Logger = require("src.core.Logger")
|
||||
local Screens = require("src.ui.Screens")
|
||||
local TextBox = require("src.render.TextBox")
|
||||
local Strings = require("src.core.Strings")
|
||||
|
||||
local Commands = {}
|
||||
|
||||
@@ -74,6 +75,9 @@ end
|
||||
-- <PROMPT> of its own; the box only closes once WaitForSoundToFinish's poll
|
||||
-- loop sees the cry channel go quiet, so it's a no-button-wait auto text
|
||||
-- gated on the cry, not a button-wait one -- see TextBox's opts.auto.
|
||||
-- play_cry's waitForButton form keeps that cry gate but hands the box back
|
||||
-- to the A/B path once the cry is over -- see TextBox's opts.auto.wait
|
||||
-- (#247, #251).
|
||||
function Commands.show_text(ctx, textId, subs)
|
||||
local text = ctx.game.data.text[textId]
|
||||
if not text and ctx.overworld then
|
||||
@@ -100,10 +104,12 @@ function Commands.show_text(ctx, textId, subs)
|
||||
local opts
|
||||
if ctx.pendingCry then
|
||||
local species = ctx.pendingCry
|
||||
ctx.pendingCry = nil
|
||||
local waitForButton = ctx.pendingCryWait
|
||||
ctx.pendingCry, ctx.pendingCryWait = nil, nil
|
||||
-- delay 0: WaitForSoundToFinish has no trailing Delay3 of its own
|
||||
opts = { auto = { sound = function()
|
||||
return require("src.core.Sound").playCry(ctx.game.data, species)
|
||||
end, delay = 0 } } -- WaitForSoundToFinish has no trailing Delay3 of its own
|
||||
end, delay = 0, wait = waitForButton } }
|
||||
end
|
||||
-- text_opts armed the next box: auto = true is the plain no-button-wait
|
||||
-- form, overlap folds under auto, everything else passes through
|
||||
@@ -189,7 +195,7 @@ function Commands.give_item(ctx, itemId, count, gotText)
|
||||
-- skips the received text entirely when AddItemToInventory refuses)
|
||||
if not require("src.inventory.Bag").add(ctx.save, itemId, count or 1) then
|
||||
Commands.show_text(ctx, ctx.game.data.text
|
||||
and ctx.game.data.text._BagFullText or "You can't carry\nany more items!")
|
||||
and ctx.game.data.text._BagFullText or Strings("You can't carry\nany more items!"))
|
||||
return math.huge
|
||||
end
|
||||
local def = ctx.game.data.items[itemId]
|
||||
@@ -202,7 +208,7 @@ function Commands.give_item(ctx, itemId, count, gotText)
|
||||
(def and def.keyItem) and "Get_Key_Item" or "Get_Item1")
|
||||
if gotText ~= false then
|
||||
Commands.show_text(ctx, gotText
|
||||
or "{PLAYER} got\n" .. ctx.game.stringBuffer .. "!")
|
||||
or Strings("{PLAYER} got\n%s!", ctx.game.stringBuffer))
|
||||
end
|
||||
end
|
||||
|
||||
@@ -469,18 +475,26 @@ function Commands.play_once(ctx, songId)
|
||||
runner:yield()
|
||||
end
|
||||
|
||||
-- play_cry <species>: PlayCry (home/audio.asm). The text_asm bodies that
|
||||
-- use it run text_far (a no-button-wait "...@" string) -> PlayCry ->
|
||||
-- WaitForSoundToFinish, all within the same text ID -- the cry only starts
|
||||
-- once the box has finished typing, and the box then auto-closes (no A
|
||||
-- press) the instant the cry finishes, rather than firing immediately
|
||||
-- play_cry <species> [waitForButton]: PlayCry (home/audio.asm). The
|
||||
-- text_asm bodies that use it run text_far (a no-button-wait "...@" string)
|
||||
-- -> PlayCry -> WaitForSoundToFinish, all within the same text ID -- the cry
|
||||
-- only starts once the box has finished typing, and the box then auto-closes
|
||||
-- (no A press) the instant the cry finishes, rather than firing immediately
|
||||
-- alongside the typewriter effect. Script rows run strictly in order, so
|
||||
-- this stashes the species on ctx for the show_text row that always
|
||||
-- immediately follows it (Power Plant Zapdos, Seafoam Articuno, Victory
|
||||
-- Road Moltres, Cerulean Cave Mewtwo battle text) to play once its box is
|
||||
-- done typing (see show_text's opts.auto). Headless-safe no-op there.
|
||||
function Commands.play_cry(ctx, species)
|
||||
--
|
||||
-- waitForButton is the pet-NPC form (scripts/PewterNidoranHouse.asm and
|
||||
-- scripts/ViridianNicknameHouse.asm: the text prints, then PlayCry,
|
||||
-- WaitForSoundToFinish, TextScriptEnd). Nothing is queued behind the cry
|
||||
-- there, so DisplayTextID's trailing WaitForTextScrollButtonPress still
|
||||
-- runs and the box holds until A/B like any other NPC line instead of
|
||||
-- popping itself into a static battle (#247, #251).
|
||||
function Commands.play_cry(ctx, species, waitForButton)
|
||||
ctx.pendingCry = species
|
||||
ctx.pendingCryWait = waitForButton or nil
|
||||
end
|
||||
|
||||
-- check_battle_result <r1> [r2 ...]: lastCheck = the last scripted
|
||||
@@ -517,7 +531,7 @@ local function askNickname(ctx, mon)
|
||||
if ctx.game.data.text and ctx.game.data.text._DoYouWantToNicknameText then
|
||||
Commands.show_text(ctx, "_DoYouWantToNicknameText", { RAM = name })
|
||||
else
|
||||
Commands.show_text(ctx, ("Do you want to\ngive a nickname\nto %s?"):format(name))
|
||||
Commands.show_text(ctx, Strings("Do you want to\ngive a nickname\nto %s?", name))
|
||||
end
|
||||
local ChoiceBox = require("src.ui.ChoiceBox")
|
||||
ctx.game.stack:push(ChoiceBox.new(ctx.game, function(yes)
|
||||
@@ -527,7 +541,7 @@ local function askNickname(ctx, mon)
|
||||
return
|
||||
end
|
||||
Screens.push(ctx.game, "NamingScreen", {
|
||||
title = "NICKNAME?", maxLen = 10,
|
||||
title = Strings("NICKNAME?"), maxLen = 10,
|
||||
onDone = function(nick)
|
||||
if nick and #nick > 0 then mon.nickname = nick end
|
||||
ctx.lastCheck = success
|
||||
@@ -598,8 +612,9 @@ function Commands.give_pokemon(ctx, species, level)
|
||||
local TextBox = require("src.render.TextBox")
|
||||
local raw = ctx.game.data.text and ctx.game.data.text._SentToBoxText
|
||||
ctx.game.stack:push(TextBox.new(ctx.game, raw or (
|
||||
"There's no more\nroom for POKéMON!\v" .. name
|
||||
.. " was\vsent to POKéMON\vBOX " .. boxNum .. " on PC!"),
|
||||
Strings(
|
||||
"There's no more\nroom for POKéMON!\v%s was\vsent to POKéMON\vBOX %s on PC!",
|
||||
name, boxNum)),
|
||||
function() end))
|
||||
end
|
||||
end
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
local Data = require("src.core.Data")
|
||||
local Logger = require("src.core.Logger")
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
local Strings = require("src.core.Strings")
|
||||
|
||||
local MapScripts = {}
|
||||
|
||||
@@ -268,7 +269,7 @@ function MapScripts.validateContribution(contribution, lookup)
|
||||
end
|
||||
end
|
||||
if type(contribution) ~= "table" then
|
||||
return { "contribution is not a table" }
|
||||
return { Strings("contribution is not a table") }
|
||||
end
|
||||
for textConst, script in pairs(contribution.talk or {}) do
|
||||
if type(script) == "table" then collect("talk." .. textConst, script) end
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
local Commands = require("src.script.Commands")
|
||||
local Logger = require("src.core.Logger")
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
local Strings = require("src.core.Strings")
|
||||
|
||||
local unpack = table.unpack or unpack -- LuaJIT (LÖVE) compatibility
|
||||
|
||||
@@ -195,7 +196,7 @@ function ScriptRunner:resume(...)
|
||||
if not ok then
|
||||
local source = self.ctx and self.ctx.source
|
||||
local where = source
|
||||
and (" [%s %s.%s]"):format(tostring(source.modId or "engine"),
|
||||
and Strings(" [%s %s.%s]", tostring(source.modId or "engine"),
|
||||
tostring(source.mapId or "?"), tostring(source.hook or "?"))
|
||||
or ""
|
||||
Logger.error("script error%s: %s", where, tostring(err))
|
||||
|
||||
+61
-26
@@ -9,6 +9,7 @@ local TextBox = require("src.render.TextBox")
|
||||
local BagMenu = {}
|
||||
|
||||
local Bag = require("src.inventory.Bag")
|
||||
local Strings = require("src.core.Strings")
|
||||
|
||||
-- acquisition order like wBagItems (Bag.order), not alphabetical
|
||||
local function buildItems(game)
|
||||
@@ -40,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
|
||||
@@ -108,13 +116,13 @@ local function useOn(game, battle, id, target, list, moveIndex)
|
||||
if game.save.onBike then
|
||||
game.save.onBike = false
|
||||
Music.playMap(game.data, ow and ow.map.id, false)
|
||||
showMessages(game, { save_name(game) .. " got off\nthe BICYCLE." })
|
||||
showMessages(game, { Strings("%s got off\nthe BICYCLE.", save_name(game)) })
|
||||
elseif bikeAllowed() then
|
||||
game.save.onBike = true
|
||||
Music.playMap(game.data, ow.map.id, true)
|
||||
showMessages(game, { save_name(game) .. " got on\nthe BICYCLE!" })
|
||||
showMessages(game, { Strings("%s got on\nthe BICYCLE!", save_name(game)) })
|
||||
else
|
||||
showMessages(game, { "No cycling\nallowed here." })
|
||||
showMessages(game, { Strings("No cycling\nallowed here.") })
|
||||
end
|
||||
return
|
||||
end
|
||||
@@ -130,13 +138,14 @@ local function useOn(game, battle, id, target, list, moveIndex)
|
||||
return
|
||||
end
|
||||
end
|
||||
showMessages(game, { "No good! It's not\neven near water." })
|
||||
showMessages(game, { Strings("No good! It's not\neven near water.") })
|
||||
return
|
||||
end
|
||||
|
||||
if result == "ball" then
|
||||
if not battle then
|
||||
showMessages(game, { "OAK: " .. game.save.player.name .. "!\nThis isn't the\ntime to use that!" })
|
||||
showMessages(game, { Strings("OAK: %s!\nThis isn't the\ntime to use that!",
|
||||
game.save.player.name) })
|
||||
return
|
||||
end
|
||||
consume(game, id)
|
||||
@@ -151,7 +160,7 @@ local function useOn(game, battle, id, target, list, moveIndex)
|
||||
local function teach()
|
||||
if #target.moves < 4 then
|
||||
table.insert(target.moves, { id = moveId, pp = mdef.pp })
|
||||
showMessages(game, { ("%s learned\n%s!"):format(target.nickname or
|
||||
showMessages(game, { Strings("%s learned\n%s!", target.nickname or
|
||||
game.data.pokemon[target.species].name, mdef.name) })
|
||||
if result == "learn" then consume(game, id) end
|
||||
else
|
||||
@@ -172,7 +181,7 @@ local function useOn(game, battle, id, target, list, moveIndex)
|
||||
require("src.ui.Screens").push(game, "TownMap")
|
||||
end)
|
||||
if not ok then
|
||||
showMessages(game, { "The TOWN MAP is\nunreadable here." })
|
||||
showMessages(game, { Strings("The TOWN MAP is\nunreadable here.") })
|
||||
end
|
||||
return
|
||||
end
|
||||
@@ -184,10 +193,10 @@ local function useOn(game, battle, id, target, list, moveIndex)
|
||||
local t = game.data.text
|
||||
if ow and ow:hasHiddenItemLeft() then
|
||||
showMessages(game, { t._ItemfinderFoundItemText
|
||||
or "Yes! ITEMFINDER\nindicates there's\nan item nearby." })
|
||||
or Strings("Yes! ITEMFINDER\nindicates there's\nan item nearby.") })
|
||||
else
|
||||
showMessages(game, { t._ItemfinderFoundNothingText
|
||||
or "Nope! ITEMFINDER\nisn't responding." })
|
||||
or Strings("Nope! ITEMFINDER\nisn't responding.") })
|
||||
end
|
||||
return
|
||||
end
|
||||
@@ -219,8 +228,9 @@ local function useOn(game, battle, id, target, list, moveIndex)
|
||||
-- departure helper -- the same path Dig/Teleport take from the party menu
|
||||
ow:beginTeleportOut()
|
||||
else
|
||||
showMessages(game, { "OAK: " .. game.save.player.name
|
||||
.. "!\nThis isn't the\ntime to use that!" })
|
||||
showMessages(game, { Strings(
|
||||
"OAK: %s!\nThis isn't the\ntime to use that!",
|
||||
game.save.player.name) })
|
||||
end
|
||||
return
|
||||
end
|
||||
@@ -264,7 +274,7 @@ local function useOn(game, battle, id, target, list, moveIndex)
|
||||
if #target.moves < 4 then
|
||||
table.insert(target.moves, { id = moveId, pp = mdef.pp })
|
||||
local name = target.nickname or def.name
|
||||
showMessages(game, { ("%s learned\n%s!"):format(name, mdef.name) },
|
||||
showMessages(game, { Strings("%s learned\n%s!", name, mdef.name) },
|
||||
nextStep)
|
||||
else
|
||||
require("src.ui.Screens").push(game, "MoveLearnMenu",
|
||||
@@ -285,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)
|
||||
@@ -305,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 = {}
|
||||
@@ -354,8 +381,8 @@ local function useItem(game, battle, id, list)
|
||||
local moveDef = game.data.moves[def.machine.move]
|
||||
local moveName = moveDef and moveDef.name or def.machine.move
|
||||
local booted = def.machine.kind == "HM"
|
||||
and "Booted up an HM!" or "Booted up a TM!"
|
||||
showMessages(game, { booted, ("It contained\n%s!"):format(moveName) },
|
||||
and "Booted up an HM!" or Strings("Booted up a TM!")
|
||||
showMessages(game, { booted, Strings("It contained\n%s!", moveName) },
|
||||
function() pickTargetAndUse(game, battle, id, list) end)
|
||||
return
|
||||
end
|
||||
@@ -372,6 +399,8 @@ function BagMenu.new(game, opts)
|
||||
list = ListMenu.new(game, "ITEMS", buildItems(game), {
|
||||
kind = "bag",
|
||||
footer = ("¥%d"):format(game.save.money),
|
||||
-- B returns to the start menu when the bag was opened from it
|
||||
onCancel = opts.onCancel,
|
||||
-- SELECT reorders items like the original bag (swap_items.asm)
|
||||
onSelectKey = function(item, l)
|
||||
if not item then return end
|
||||
@@ -400,17 +429,23 @@ function BagMenu.new(game, opts)
|
||||
useItem(game, battle, id, list)
|
||||
return
|
||||
end
|
||||
-- USE / TOSS submenu (the original's item options)
|
||||
-- USE / TOSS submenu (the original's item options).
|
||||
-- data/text_boxes.asm USE_TOSS_MENU_TEMPLATE: box (13,10)-(19,14),
|
||||
-- text at (15,11); start_sub_menus.asm then sets wTopMenuItemY/X to
|
||||
-- 11/14 for the cursor. Menu's own geometry reproduces all of that
|
||||
-- from the box alone, so this needs opts rather than a change to the
|
||||
-- shared Menu. The old 12/10/8/6 box was a column too wide and a row
|
||||
-- too tall, which left the labels stranded near its top edge (#284).
|
||||
local Menu = require("src.ui.Menu")
|
||||
game.stack:push(Menu.new(game, {
|
||||
{ label = "USE", onSelect = function()
|
||||
{ label = Strings("USE"), onSelect = function()
|
||||
useItem(game, battle, id, list)
|
||||
end },
|
||||
{ label = "TOSS", onSelect = function()
|
||||
{ label = Strings("TOSS"), onSelect = function()
|
||||
-- KeyItemFlags + HMs decide tossability (not price:
|
||||
-- MOON STONE is price 0 but tossable)
|
||||
if not def or def.keyItem or id:find("^HM_") then
|
||||
showMessages(game, { "That's too impor-\ntant to toss!" })
|
||||
showMessages(game, { Strings("That's too impor-\ntant to toss!") })
|
||||
return
|
||||
end
|
||||
local QuantityBox = require("src.ui.QuantityBox")
|
||||
@@ -424,12 +459,12 @@ function BagMenu.new(game, opts)
|
||||
Bag.remove(game.save, id, qty)
|
||||
list.items = buildItems(game)
|
||||
list.index = math.min(list.index, math.max(1, #list.items))
|
||||
showMessages(game, { ("Threw away\n%s."):format(def and def.name or id) })
|
||||
showMessages(game, { Strings("Threw away\n%s.", def and def.name or id) })
|
||||
end))
|
||||
end,
|
||||
}))
|
||||
end },
|
||||
}, { tx = 12, ty = 10, tw = 8, th = 6 }))
|
||||
}, { tx = 13, ty = 10, tw = 7, th = 5 }))
|
||||
end,
|
||||
})
|
||||
return list
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
local Font = require("src.render.Font")
|
||||
local ListMenu = require("src.ui.ListMenu")
|
||||
local Input = require("src.core.Input")
|
||||
local Strings = require("src.core.Strings")
|
||||
|
||||
local BindingsMenu = setmetatable({}, { __index = ListMenu })
|
||||
BindingsMenu.__index = BindingsMenu
|
||||
@@ -55,7 +56,9 @@ function BindingsMenu.new(game)
|
||||
and game.save.options.bindings
|
||||
local items = {}
|
||||
for i, def in ipairs(BUTTONS) do
|
||||
items[i] = { label = def.label,
|
||||
-- translated here, not in ROWS: that table is built at require
|
||||
-- time, before Strings.load has a catalog to look in
|
||||
items[i] = { label = Strings(def.label),
|
||||
right = boundRight(overlay, def), button = def }
|
||||
end
|
||||
local self = setmetatable(ListMenu.new(game, "CONTROLS", items, {}),
|
||||
@@ -111,7 +114,7 @@ function BindingsMenu:draw()
|
||||
if self.capture then
|
||||
Font.drawBox(1, 6, 18, 4)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
Font.draw("PRESS A BUTTON", 24, 60)
|
||||
Font.draw(Strings("PRESS A BUTTON"), 24, 60)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
end
|
||||
end
|
||||
|
||||
+35
-25
@@ -7,13 +7,15 @@ 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")
|
||||
|
||||
local BoxMenu = {}
|
||||
|
||||
local function monLabel(game, mon)
|
||||
local def = game.data.pokemon[mon.species]
|
||||
return ("%s :L%d"):format(mon.nickname or def.name, mon.level)
|
||||
return Strings("%s :L%d", mon.nickname or def.name, mon.level)
|
||||
end
|
||||
|
||||
local function monName(game, mon)
|
||||
@@ -28,13 +30,13 @@ local function monSubmenu(game, action, mon, onAction)
|
||||
game.stack:push(Menu.new(game, {
|
||||
{ label = action, onSelect = onAction },
|
||||
{
|
||||
label = "STATS",
|
||||
label = Strings("STATS"),
|
||||
keepOpen = true,
|
||||
onSelect = function()
|
||||
require("src.ui.Screens").push(game, "SummaryMenu", mon)
|
||||
end,
|
||||
},
|
||||
{ label = "CANCEL" },
|
||||
{ label = Strings("CANCEL") },
|
||||
}, { tx = 9, ty = 10, tw = 11, th = 8, noSound = true }))
|
||||
end
|
||||
|
||||
@@ -50,12 +52,12 @@ local function withdraw(game)
|
||||
local t = game.data.text
|
||||
if #box == 0 then
|
||||
game.stack:push(TextBox.new(game, t._NoMonText
|
||||
or "What? There are\nno POKéMON here!"))
|
||||
or Strings("What? There are\nno POKéMON here!")))
|
||||
return
|
||||
end
|
||||
if #game.save.party >= Party.MAX then
|
||||
game.stack:push(TextBox.new(game, t._CantTakeMonText
|
||||
or "You can't take\nany more POKéMON.\fDeposit POKéMON\nfirst."))
|
||||
or Strings("You can't take\nany more POKéMON.\fDeposit POKéMON\nfirst.")))
|
||||
return
|
||||
end
|
||||
local items = {}
|
||||
@@ -63,7 +65,7 @@ local function withdraw(game)
|
||||
table.insert(items, { label = monLabel(game, mon), value = i })
|
||||
end
|
||||
game.stack:push(ListMenu.new(game,
|
||||
("BOX %d (WITHDRAW)"):format(game.save.currentBox), items, {
|
||||
Strings("BOX %d (WITHDRAW)", game.save.currentBox), items, {
|
||||
onChoose = function(item, list)
|
||||
local mon = box[item.value]
|
||||
if not mon then return end
|
||||
@@ -72,13 +74,21 @@ 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)
|
||||
game.stringBuffer = name
|
||||
require("src.core.Sound").playCry(game.data, mon.species)
|
||||
afterTransfer(game, list, t._MonIsTakenOutText
|
||||
or (name .. " is\ntaken out.\vGot " .. name .. "."))
|
||||
or Strings("%s is\ntaken out.\vGot %s.", name, name))
|
||||
end)
|
||||
end,
|
||||
}))
|
||||
@@ -88,13 +98,13 @@ local function deposit(game)
|
||||
local t = game.data.text
|
||||
if #game.save.party <= 1 then
|
||||
game.stack:push(TextBox.new(game, t._CantDepositLastMonText
|
||||
or "You can't deposit\nthe last POKéMON!"))
|
||||
or Strings("You can't deposit\nthe last POKéMON!")))
|
||||
return
|
||||
end
|
||||
local box = Boxes.active(game.save)
|
||||
if #box >= Boxes.CAPACITY then
|
||||
game.stack:push(TextBox.new(game, t._BoxFullText
|
||||
or "Oops! This Box is\nfull of POKéMON."))
|
||||
or Strings("Oops! This Box is\nfull of POKéMON.")))
|
||||
return
|
||||
end
|
||||
local items = {}
|
||||
@@ -107,12 +117,12 @@ local function deposit(game)
|
||||
if not mon then return end
|
||||
monSubmenu(game, "DEPOSIT", mon, function()
|
||||
if #game.save.party <= 1 then
|
||||
list.footer = "You need at least\none POKéMON!"
|
||||
list.footer = Strings("You need at least\none POKéMON!")
|
||||
return
|
||||
end
|
||||
local active = Boxes.active(game.save)
|
||||
if #active >= Boxes.CAPACITY then
|
||||
list.footer = ("BOX %d is full!"):format(game.save.currentBox)
|
||||
list.footer = Strings("BOX %d is full!", game.save.currentBox)
|
||||
return
|
||||
end
|
||||
table.remove(game.save.party, item.value)
|
||||
@@ -122,7 +132,7 @@ local function deposit(game)
|
||||
game.boxNumString = tostring(game.save.currentBox)
|
||||
require("src.core.Sound").playCry(game.data, mon.species)
|
||||
afterTransfer(game, list, t._MonWasStoredText
|
||||
or (name .. " was\nstored in Box " .. game.boxNumString .. "."))
|
||||
or Strings("%s was\nstored in Box %s.", name, game.boxNumString))
|
||||
end)
|
||||
end,
|
||||
}))
|
||||
@@ -135,7 +145,7 @@ local function release(game)
|
||||
local t = game.data.text
|
||||
if #box == 0 then
|
||||
game.stack:push(TextBox.new(game, t._NoMonText
|
||||
or "What? There are\nno POKéMON here!"))
|
||||
or Strings("What? There are\nno POKéMON here!")))
|
||||
return
|
||||
end
|
||||
local items = {}
|
||||
@@ -143,20 +153,20 @@ local function release(game)
|
||||
table.insert(items, { label = monLabel(game, mon), value = i })
|
||||
end
|
||||
game.stack:push(ListMenu.new(game,
|
||||
("BOX %d (RELEASE)"):format(game.save.currentBox), items, {
|
||||
Strings("BOX %d (RELEASE)", game.save.currentBox), items, {
|
||||
onChoose = function(_, list)
|
||||
local mon = box[list.index]
|
||||
if not mon then return end
|
||||
local name = monName(game, mon)
|
||||
local ChoiceBox = require("src.ui.ChoiceBox")
|
||||
game.stack:push(TextBox.new(game,
|
||||
"Once released,\n" .. name .. " is\ngone forever. OK?", function()
|
||||
Strings("Once released,\n%s is\ngone forever. OK?", name), function()
|
||||
game.stack:push(ChoiceBox.new(game, function(yes)
|
||||
if not yes then return end
|
||||
table.remove(box, list.index)
|
||||
require("src.core.Sound").playCry(game.data, mon.species)
|
||||
game.stack:push(TextBox.new(game,
|
||||
("%s was\nreleased outside.\fBye %s!"):format(name, name)))
|
||||
Strings("%s was\nreleased outside.\fBye %s!", name, name)))
|
||||
list:removeCurrent()
|
||||
end, { defaultNo = true, noSound = true }))
|
||||
end))
|
||||
@@ -170,7 +180,7 @@ local function changeBox(game)
|
||||
for i = 1, Boxes.COUNT do
|
||||
local mark = i == game.save.currentBox and "*" or " "
|
||||
table.insert(items, {
|
||||
label = ("%sBOX %2d"):format(mark, i),
|
||||
label = Strings("%sBOX %2d", mark, i),
|
||||
right = ("%d/%d"):format(#boxes[i], Boxes.CAPACITY),
|
||||
value = i,
|
||||
})
|
||||
@@ -181,7 +191,7 @@ local function changeBox(game)
|
||||
-- BOX, data will be saved. OK?"); declining aborts the change
|
||||
local ChoiceBox = require("src.ui.ChoiceBox")
|
||||
game.stack:push(TextBox.new(game,
|
||||
"When you change a\nPOKéMON BOX, data\nwill be saved. OK?", function()
|
||||
Strings("When you change a\nPOKéMON BOX, data\nwill be saved. OK?"), function()
|
||||
game.stack:push(ChoiceBox.new(game, function(yes)
|
||||
if not yes then return end
|
||||
game.save.currentBox = item.value
|
||||
@@ -197,10 +207,10 @@ end
|
||||
local function drawChrome(game)
|
||||
Font.drawBox(0, 12, 20, 6)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
Font.draw("What?", 8, 112)
|
||||
Font.draw(Strings("What?"), 8, 112)
|
||||
Font.drawBox(9, 14, 11, 4)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
Font.draw("BOX No.", 80, 128)
|
||||
Font.draw(Strings("BOX No."), 80, 128)
|
||||
local n = game.save.currentBox or 1
|
||||
if n >= 10 then
|
||||
Font.draw(tostring(n), 136, 128)
|
||||
@@ -217,15 +227,15 @@ function BoxMenu.new(game)
|
||||
-- full interior (cursor col + label). keepOpen so WITHDRAW/DEPOSIT/
|
||||
-- RELEASE/CHANGE BOX leave this menu underneath (jp BillsPCMenu).
|
||||
local menu = Menu.new(game, {
|
||||
{ label = "WITHDRAW <PK><MN>", keepOpen = true,
|
||||
{ label = Strings("WITHDRAW <PK><MN>"), keepOpen = true,
|
||||
onSelect = function() withdraw(game) end },
|
||||
{ label = "DEPOSIT <PK><MN>", keepOpen = true,
|
||||
{ label = Strings("DEPOSIT <PK><MN>"), keepOpen = true,
|
||||
onSelect = function() deposit(game) end },
|
||||
{ label = "RELEASE <PK><MN>", keepOpen = true,
|
||||
{ label = Strings("RELEASE <PK><MN>"), keepOpen = true,
|
||||
onSelect = function() release(game) end },
|
||||
{ label = "CHANGE BOX", keepOpen = true,
|
||||
{ label = Strings("CHANGE BOX"), keepOpen = true,
|
||||
onSelect = function() changeBox(game) end },
|
||||
{ label = "SEE YA!" },
|
||||
{ label = Strings("SEE YA!") },
|
||||
-- Bill's PC runs silent end to end (BIT_NO_MENU_BUTTON_SOUND,
|
||||
-- engine/menus/pokemon_pc.asm)
|
||||
}, { tx = 0, ty = 0, tw = 14, th = 12, noSound = true })
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
local Font = require("src.render.Font")
|
||||
local Theme = require("src.ui.Theme")
|
||||
local Strings = require("src.core.Strings")
|
||||
|
||||
local ChoiceBox = {}
|
||||
ChoiceBox.__index = ChoiceBox
|
||||
@@ -46,8 +47,8 @@ function ChoiceBox:draw()
|
||||
local tx, ty, tw, th = self.tx, self.ty, self.tw, self.th
|
||||
Font.drawBox(tx, ty, tw, th)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
Font.draw("YES", (tx + 2) * 8, (ty + 1) * 8)
|
||||
Font.draw("NO", (tx + 2) * 8, (ty + 3) * 8)
|
||||
Font.draw(Strings("YES"), (tx + 2) * 8, (ty + 1) * 8)
|
||||
Font.draw(Strings("NO"), (tx + 2) * 8, (ty + 3) * 8)
|
||||
Font.drawCode(Theme.cursor, (tx + 1) * 8,
|
||||
(ty + (self.index == 1 and 1 or 3)) * 8)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
|
||||
+6
-5
@@ -31,6 +31,7 @@
|
||||
|
||||
local Font = require("src.render.Font")
|
||||
local Music = require("src.core.Music")
|
||||
local Strings = require("src.core.Strings")
|
||||
|
||||
local Credits = {}
|
||||
Credits.__index = Credits
|
||||
@@ -261,14 +262,14 @@ function Credits:drawCopyright(xoff)
|
||||
love.graphics.draw(self.gfImg, xoff + 80, 88)
|
||||
else
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
Font.draw("GAME FREAK", xoff + 80, 88)
|
||||
Font.draw(Strings("GAME FREAK"), xoff + 80, 88)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
end
|
||||
else
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
Font.draw("Nintendo", xoff + 80, 56)
|
||||
Font.draw("Creatures inc.", xoff + 80, 72)
|
||||
Font.draw("GAME FREAK inc.", xoff + 16, 88)
|
||||
Font.draw(Strings("Nintendo"), xoff + 80, 56)
|
||||
Font.draw(Strings("Creatures inc."), xoff + 80, 72)
|
||||
Font.draw(Strings("GAME FREAK inc."), xoff + 16, 88)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
end
|
||||
end
|
||||
@@ -298,7 +299,7 @@ function Credits:drawTheEnd()
|
||||
end
|
||||
else
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
Font.draw((te and te.display) or "T H E E N D", 32, 64)
|
||||
Font.draw((te and te.display) or Strings("T H E E N D"), 32, 64)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
-- without permanently marking the mon owned.
|
||||
|
||||
local Font = require("src.render.Font")
|
||||
local Strings = require("src.core.Strings")
|
||||
|
||||
local DexEntryMenu = {}
|
||||
DexEntryMenu.__index = DexEntryMenu
|
||||
@@ -36,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
|
||||
@@ -76,8 +81,8 @@ function DexEntryMenu:draw()
|
||||
-- feet/inches use the dex screen's ′/″ glyphs ("HT ?′??″" in
|
||||
-- pokedex.asm; the tiles come from gfx/pokedex/pokedex.png via
|
||||
-- engine/gfx/load_pokedex_tiles.asm)
|
||||
Font.draw(("HT %d′%02d″"):format(e.heightFt, e.heightIn or 0), 72, 44)
|
||||
Font.draw(("WT %.1flb"):format((e.weight or 0) / 10), 72, 54)
|
||||
Font.draw(Strings("HT %d′%02d″", e.heightFt, e.heightIn or 0), 72, 44)
|
||||
Font.draw(Strings("WT %.1flb", (e.weight or 0) / 10), 72, 54)
|
||||
end
|
||||
local text = owned and e.text and self.game.data.text[e.text] or nil
|
||||
local y = 72
|
||||
@@ -88,7 +93,7 @@ function DexEntryMenu:draw()
|
||||
y = y + 10
|
||||
end
|
||||
else
|
||||
Font.draw("Data unknown.", 8, y)
|
||||
Font.draw(Strings("Data unknown."), 8, y)
|
||||
end
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
end
|
||||
|
||||
+35
-13
@@ -1,14 +1,17 @@
|
||||
-- The evolution movie (engine/movie/evolution.asm): the mon's pic
|
||||
-- flashes back and forth with the evolved form, speeding up, then the
|
||||
-- new form appears with its cry and the congratulations text.
|
||||
-- pokered engine/pokemon/evos_moves.asm (EvolveMon) polls hJoyHeld during
|
||||
-- the flash: holding B aborts the evolution -- the mon keeps its species
|
||||
-- and _StoppedEvolvingText ("Huh? MON stopped evolving!") prints. The
|
||||
-- lone exception is trade evolutions (wLinkState == LINK_STATE_TRADING),
|
||||
-- which skip that poll and cannot be cancelled (#213).
|
||||
-- pokered engine/movie/evolution.asm (Evolution_CheckForCancel) polls the
|
||||
-- joypad during the flash: holding B aborts the evolution -- the mon keeps
|
||||
-- its species and _StoppedEvolvingText ("Huh? MON stopped evolving!")
|
||||
-- prints. Two kinds are exempt: trade evolutions, which evos_moves.asm
|
||||
-- routes past the poll entirely (wLinkState == LINK_STATE_TRADING, #213),
|
||||
-- and stone evolutions, where the B press is read but thrown away because
|
||||
-- ItemUseEvoStone left wForceEvolution set (#290).
|
||||
|
||||
local Font = require("src.render.Font")
|
||||
local Music = require("src.core.Music")
|
||||
local Strings = require("src.core.Strings")
|
||||
|
||||
local EvolutionState = {}
|
||||
EvolutionState.__index = EvolutionState
|
||||
@@ -17,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
|
||||
@@ -43,9 +62,12 @@ function EvolutionState.new(game, mon, newSpecies, onDone, via)
|
||||
self.newSpecies = newSpecies
|
||||
self.onDone = onDone
|
||||
self.via = via
|
||||
-- evos_moves.asm: only trade evolutions (LINK_STATE_TRADING) skip the
|
||||
-- B-cancel poll; level-up, stone and rare-candy evos are all cancelable.
|
||||
self.cancelable = (via ~= "TRADE")
|
||||
-- evolution.asm Evolution_CheckForCancel: a B press is discarded when
|
||||
-- wForceEvolution is set, and ItemUseEvoStone sets it before calling
|
||||
-- TryEvolvingMon, so a stone evolution (via == "ITEM") cannot be
|
||||
-- cancelled either. Only level-up and rare-candy evolutions run with
|
||||
-- wForceEvolution clear and so honour B (#290, #213).
|
||||
self.cancelable = (via ~= "TRADE" and via ~= "ITEM")
|
||||
self.oldName = mon.nickname or game.data.pokemon[mon.species].name
|
||||
self.oldSprite = frontSprite(game, mon.species, mon)
|
||||
self.newSprite = frontSprite(game, newSpecies, mon)
|
||||
@@ -69,7 +91,7 @@ function EvolutionState:update(dt)
|
||||
local TextBox = require("src.render.TextBox")
|
||||
-- mirrors data/generated/text.lua _StoppedEvolvingText
|
||||
game.stack:push(TextBox.new(game,
|
||||
("Huh? %s\nstopped evolving!"):format(self.oldName),
|
||||
Strings("Huh? %s\nstopped evolving!", self.oldName),
|
||||
function()
|
||||
Music.restoreMap(game.data)
|
||||
game.stack:pop() -- the evolution screen itself
|
||||
@@ -85,8 +107,8 @@ function EvolutionState:update(dt)
|
||||
local TextBox = require("src.render.TextBox")
|
||||
local newName = game.data.pokemon[self.newSpecies].name
|
||||
game.stack:push(TextBox.new(game,
|
||||
("Congratulations!\nYour %s\nevolved into\n%s!")
|
||||
:format(self.oldName, newName),
|
||||
Strings("Congratulations!\nYour %s\nevolved into\n%s!",
|
||||
self.oldName, newName),
|
||||
function()
|
||||
Music.restoreMap(game.data)
|
||||
game.stack:pop() -- the evolution screen itself
|
||||
@@ -121,9 +143,9 @@ function EvolutionState:draw()
|
||||
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
if not self.done then
|
||||
Font.draw("What?", 8, 104)
|
||||
Font.draw(Strings("What?"), 8, 104)
|
||||
Font.draw(self.oldName .. " is", 8, 114)
|
||||
Font.draw("evolving!", 8, 124)
|
||||
Font.draw(Strings("evolving!"), 8, 124)
|
||||
end
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
end
|
||||
|
||||
+55
-62
@@ -7,10 +7,13 @@
|
||||
-- name/time/money boxes plus the dex rating. Plays Music_HallOfFame
|
||||
-- when the audio data has it. Calls onDone() after popping itself.
|
||||
|
||||
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")
|
||||
local Strings = require("src.core.Strings")
|
||||
|
||||
local HallOfFame = {}
|
||||
HallOfFame.__index = HallOfFame
|
||||
@@ -47,9 +50,11 @@ local FADE_FRAMES = 20
|
||||
-- HoFPrintTextAndDelay after each dex line
|
||||
local DEX_HOLD = 120
|
||||
|
||||
-- through Assets.resolve so an enabled mod's overrides/ shadows these the
|
||||
-- same way it shadows every other generated asset
|
||||
local function tryImage(path)
|
||||
if not path then return nil end
|
||||
local ok, img = pcall(love.graphics.newImage, path)
|
||||
local ok, img = pcall(love.graphics.newImage, Assets.resolve(path))
|
||||
return ok and img or nil
|
||||
end
|
||||
|
||||
@@ -60,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
|
||||
@@ -79,7 +73,8 @@ function HallOfFame.new(game, onDone)
|
||||
self.timer = 0
|
||||
self.phase = "mons"
|
||||
self.sprites = {} -- species -> image or false
|
||||
self.playerPic = tryImage("assets/generated/trainer_card/red.png")
|
||||
self.playerPic = tryImage(require("src.pokemon.Sprites").playerPath(
|
||||
game.data, "front", { kind = "hof" }))
|
||||
self.scrollX = PIC_X
|
||||
self.showHofBanner = false
|
||||
self.fade = 0
|
||||
@@ -177,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
|
||||
@@ -206,13 +225,13 @@ function HallOfFame:drawMonInfo(mon)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
local name = mon.nickname or (def and def.name) or mon.species
|
||||
Font.draw(name, 1 * 8, 4 * 8)
|
||||
Font.draw("LEVEL/", 2 * 8, 6 * 8)
|
||||
Font.draw("TYPE1/", 2 * 8, 7 * 8)
|
||||
Font.draw(Strings("LEVEL/"), 2 * 8, 6 * 8)
|
||||
Font.draw(Strings("TYPE1/"), 2 * 8, 7 * 8)
|
||||
local t1 = def and def.types and def.types[1]
|
||||
local t2 = def and def.types and def.types[2]
|
||||
local dual = t2 and t2 ~= t1
|
||||
if dual then
|
||||
Font.draw("TYPE2/", 2 * 8, 8 * 8)
|
||||
Font.draw(Strings("TYPE2/"), 2 * 8, 8 * 8)
|
||||
end
|
||||
-- PrintLevelCommon at (8,7): bare level digits (no <LV> tile here)
|
||||
Font.draw(tostring(mon.level), 8 * 8, 7 * 8)
|
||||
@@ -229,7 +248,7 @@ end
|
||||
function HallOfFame:drawHofBanner()
|
||||
Font.drawBox(2, 13, 16, 5)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
Font.draw("HALL OF FAME", 4 * 8, 15 * 8)
|
||||
Font.draw(Strings("HALL OF FAME"), 4 * 8, 15 * 8)
|
||||
end
|
||||
|
||||
function HallOfFame:drawPic(img)
|
||||
@@ -249,37 +268,15 @@ function HallOfFame:drawPlayerStats()
|
||||
-- play time / money box: TextBoxBorder (0,4) b=6,c=10 → drawBox(0,4,12,8)
|
||||
Font.drawBox(0, 4, 12, 8)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
Font.draw("PLAY TIME", 1 * 8, 6 * 8)
|
||||
Font.draw(Strings("PLAY TIME"), 1 * 8, 6 * 8)
|
||||
local t = math.floor(save.playTime or 0)
|
||||
Font.draw(("%3d:%02d"):format(math.floor(t / 3600), math.floor(t / 60) % 60),
|
||||
5 * 8, 7 * 8)
|
||||
Font.draw("MONEY", 1 * 8, 9 * 8)
|
||||
Font.draw(Strings("MONEY"), 1 * 8, 9 * 8)
|
||||
-- PrintBCDNumber with MONEY_SIGN; port uses ¥ like TrainerCard
|
||||
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 "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 "POKéDEX Rating{COLON}"):gsub("{COLON}", ":")
|
||||
Font.draw(ratingHeader, 1 * 8, 14 * 8)
|
||||
local rating = text[dexRatingKey(owned)] or "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)
|
||||
@@ -304,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)
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
local Font = require("src.render.Font")
|
||||
local Music = require("src.core.Music")
|
||||
local Sound = require("src.core.Sound")
|
||||
local Strings = require("src.core.Strings")
|
||||
|
||||
local IntroMovie = {}
|
||||
IntroMovie.__index = IntroMovie
|
||||
@@ -287,7 +288,7 @@ function IntroMovie:drawSplash()
|
||||
end
|
||||
-- custom studio name (replaces the GAME FREAK splash text)
|
||||
love.graphics.setColor(0, 0, 0, dim and 0.35 or 1)
|
||||
local card = self.studio.card or "bois club games"
|
||||
local card = self.studio.card or Strings("bois club games")
|
||||
Font.draw(card, (160 - #card * 8) / 2, TEXT_Y)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
end
|
||||
@@ -348,7 +349,7 @@ function IntroMovie:drawFight()
|
||||
|
||||
if not gengar and not nido then
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
Font.draw("GENGAR VS NIDORINO", (160 - 18 * 8) / 2, 64)
|
||||
Font.draw(Strings("GENGAR VS NIDORINO"), (160 - 18 * 8) / 2, 64)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
end
|
||||
drawBars()
|
||||
@@ -366,7 +367,7 @@ function IntroMovie:draw()
|
||||
-- custom boot card (replaces the Nintendo / GAME FREAK copyright
|
||||
-- card; no (c) glyph in the charmap, keep it ASCII-safe)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
local credit = self.studio.credit or "bois club"
|
||||
local credit = self.studio.credit or Strings("bois club")
|
||||
Font.draw("2026", (160 - 4 * 8) / 2, 48)
|
||||
Font.draw(credit, (160 - #credit * 8) / 2, 64)
|
||||
Font.draw("bryanthaboi", (160 - 11 * 8) / 2, 80)
|
||||
|
||||
+7
-3
@@ -5,6 +5,7 @@
|
||||
local Font = require("src.render.Font")
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
local Theme = require("src.ui.Theme")
|
||||
local Strings = require("src.core.Strings")
|
||||
|
||||
local ListMenu = {}
|
||||
ListMenu.__index = ListMenu
|
||||
@@ -74,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)
|
||||
@@ -192,7 +193,7 @@ function ListMenu:draw()
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
Font.draw(self.title, 8, 4)
|
||||
if #self.items == 0 then
|
||||
Font.draw("Nothing here.", 16, 64)
|
||||
Font.draw(Strings("Nothing here."), 16, 64)
|
||||
end
|
||||
for row = 1, self.rows do
|
||||
local i = self.scroll + row
|
||||
@@ -201,7 +202,10 @@ function ListMenu:draw()
|
||||
local y = 8 + row * 16
|
||||
Font.draw(item.label, 16, y)
|
||||
if item.ball then -- the Pokédex owned-ball marker tile
|
||||
local bx = 16 + (#item.label + 1) * 8 + 3
|
||||
-- one blank glyph after the name, measured in glyph advances rather
|
||||
-- than bytes: NIDORAN♂/♀ carry a multi-byte charmap entry, so
|
||||
-- `#item.label` overcounted by 2 and pushed their ball 16px right (#285)
|
||||
local bx = 16 + Font.width(item.label) + 8 + 3
|
||||
local by = y + 3
|
||||
love.graphics.circle("fill", bx, by, 3.5)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
|
||||
+12
-11
@@ -4,6 +4,7 @@
|
||||
-- can't be forgotten; B / CANCEL gives up on the new move.
|
||||
|
||||
local Font = require("src.render.Font")
|
||||
local Strings = require("src.core.Strings")
|
||||
|
||||
local MoveLearnMenu = {}
|
||||
MoveLearnMenu.__index = MoveLearnMenu
|
||||
@@ -43,9 +44,9 @@ function MoveLearnMenu:enter()
|
||||
local name = self:monName()
|
||||
self.selecting = false
|
||||
game.stack:push(TextBox.new(game,
|
||||
("%s is\ntrying to learn\v%s!\fBut, %s\ncan't learn more\vthan 4 moves!\f")
|
||||
:format(name, mdef.name, name) ..
|
||||
("Delete an older\nmove to make room\vfor %s?"):format(mdef.name),
|
||||
Strings("%s is\ntrying to learn\v%s!\fBut, %s\ncan't learn more\vthan 4 moves!\f",
|
||||
name, mdef.name, name) ..
|
||||
Strings("Delete an older\nmove to make room\vfor %s?", mdef.name),
|
||||
nil, {
|
||||
choice = function(yes)
|
||||
if yes then
|
||||
@@ -76,7 +77,7 @@ function MoveLearnMenu:update(dt)
|
||||
-- HMCantDeleteText, then back to the forget list
|
||||
local TextBox = require("src.render.TextBox")
|
||||
self.game.stack:push(TextBox.new(self.game,
|
||||
"HM techniques\ncan't be deleted!"))
|
||||
Strings("HM techniques\ncan't be deleted!")))
|
||||
return
|
||||
end
|
||||
local mdef = self.game.data.moves[self.newMoveId]
|
||||
@@ -96,7 +97,7 @@ function MoveLearnMenu:confirmAbandon()
|
||||
local mdef = game.data.moves[self.newMoveId]
|
||||
self.selecting = false
|
||||
game.stack:push(TextBox.new(game,
|
||||
("Abandon learning\n%s?"):format(mdef.name), nil, {
|
||||
Strings("Abandon learning\n%s?", mdef.name), nil, {
|
||||
choice = function(yes)
|
||||
if yes then self:finish(false) else self:enter() end
|
||||
end,
|
||||
@@ -113,11 +114,11 @@ function MoveLearnMenu:finish(learned)
|
||||
local msg
|
||||
if learned then
|
||||
-- OneTwoAndText/PoofText/ForgotAndText
|
||||
msg = ("1, 2 and... Poof!\f%s forgot\n%s!\fAnd...\f%s learned\n%s!")
|
||||
:format(name, self.forgot, name, mdef.name)
|
||||
msg = Strings("1, 2 and... Poof!\f%s forgot\n%s!\fAnd...\f%s learned\n%s!",
|
||||
name, self.forgot, name, mdef.name)
|
||||
else
|
||||
-- DidNotLearnText
|
||||
msg = ("%s\ndid not learn\v%s!"):format(name, mdef.name)
|
||||
msg = Strings("%s\ndid not learn\v%s!", name, mdef.name)
|
||||
end
|
||||
game.stack:push(TextBox.new(game, msg, function()
|
||||
if self.onDone then self.onDone(learned) end
|
||||
@@ -133,12 +134,12 @@ function MoveLearnMenu:draw()
|
||||
for i, mv in ipairs(self.mon.moves) do
|
||||
Font.draw(self.game.data.moves[mv.id].name, 48, (5 + i) * 8)
|
||||
end
|
||||
Font.draw("CANCEL", 48, (6 + #self.mon.moves) * 8)
|
||||
Font.draw(Strings("CANCEL"), 48, (6 + #self.mon.moves) * 8)
|
||||
Font.drawCode(CURSOR, 40, (5 + self.index) * 8)
|
||||
-- WhichMoveToForgetText in the bottom dialogue box
|
||||
Font.drawBox(0, 12, 20, 6)
|
||||
Font.draw("Which move should", 8, 14 * 8)
|
||||
Font.draw("be forgotten?", 8, 16 * 8)
|
||||
Font.draw(Strings("Which move should"), 8, 14 * 8)
|
||||
Font.draw(Strings("be forgotten?"), 8, 16 * 8)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
end
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ local Font = require("src.render.Font")
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
local Sound = require("src.core.Sound")
|
||||
local Theme = require("src.ui.Theme")
|
||||
local Strings = require("src.core.Strings")
|
||||
|
||||
local NamingScreen = {}
|
||||
NamingScreen.__index = NamingScreen
|
||||
@@ -64,7 +65,7 @@ function NamingScreen.new(game, opts)
|
||||
opts = opts or {}
|
||||
local self = setmetatable({}, NamingScreen)
|
||||
self.game = game
|
||||
self.title = opts.title or "YOUR NAME?"
|
||||
self.title = opts.title or Strings("YOUR NAME?")
|
||||
self.presets = opts.presets
|
||||
self.maxLen = opts.maxLen or 7
|
||||
self.default = opts.default
|
||||
@@ -78,7 +79,7 @@ end
|
||||
function NamingScreen:enter()
|
||||
if self.presets and #self.presets > 0 then
|
||||
local Menu = require("src.ui.Menu")
|
||||
local items = { { label = "NEW NAME" } }
|
||||
local items = { { label = Strings("NEW NAME") } }
|
||||
for _, preset in ipairs(self.presets) do
|
||||
table.insert(items, {
|
||||
label = preset,
|
||||
|
||||
+24
-13
@@ -7,12 +7,14 @@
|
||||
-- mod can insertBefore("name_player", ...) without counting indices.
|
||||
-- Calls onDone() after popping itself.
|
||||
|
||||
local Assets = require("src.render.Assets")
|
||||
local Sound = require("src.core.Sound")
|
||||
local Music = require("src.core.Music")
|
||||
local Logger = require("src.core.Logger")
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
local TextBox = require("src.render.TextBox")
|
||||
local Font = require("src.render.Font")
|
||||
local Strings = require("src.core.Strings")
|
||||
|
||||
local OakSpeech = {}
|
||||
OakSpeech.__index = OakSpeech
|
||||
@@ -38,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)
|
||||
@@ -51,9 +53,11 @@ local function textOr(game, key)
|
||||
return (t and t[key]) or FALLBACKS[key]
|
||||
end
|
||||
|
||||
-- through Assets.resolve so an enabled mod's overrides/ shadows these the
|
||||
-- same way it shadows every other generated asset
|
||||
local function tryImage(path)
|
||||
if not path then return nil end
|
||||
local ok, img = pcall(love.graphics.newImage, path)
|
||||
local ok, img = pcall(love.graphics.newImage, Assets.resolve(path))
|
||||
return ok and img or nil
|
||||
end
|
||||
|
||||
@@ -101,7 +105,9 @@ function OakSpeech.resolvePic(game, desc, speech)
|
||||
if speech and speech.playerPic and not desc.path then
|
||||
return speech.playerPic, false
|
||||
end
|
||||
return tryImage(desc.path or "assets/generated/trainer_card/red.png"), false
|
||||
if desc.path then return tryImage(desc.path), false end
|
||||
return tryImage(require("src.pokemon.Sprites").playerPath(
|
||||
game.data, "front", { kind = "intro" })), false
|
||||
elseif t == "image" then
|
||||
return tryImage(desc.path), desc.flip and true or false
|
||||
elseif t == "sprite" then
|
||||
@@ -140,7 +146,7 @@ function OakSpeech.defaultSteps(speech)
|
||||
id = "name_player",
|
||||
kind = "name",
|
||||
who = "player",
|
||||
title = "YOUR NAME?",
|
||||
title = Strings("YOUR NAME?"),
|
||||
presetsWho = "player",
|
||||
presetsFallback = { "RED", "ASH", "JACK" },
|
||||
},
|
||||
@@ -154,7 +160,7 @@ function OakSpeech.defaultSteps(speech)
|
||||
id = "name_rival",
|
||||
kind = "name",
|
||||
who = "rival",
|
||||
title = "HIS NAME?",
|
||||
title = Strings("HIS NAME?"),
|
||||
presetsWho = "rival",
|
||||
presetsFallback = { "BLUE", "GARY", "JOHN" },
|
||||
},
|
||||
@@ -220,7 +226,8 @@ function OakSpeech.new(game, onDone)
|
||||
self.nameLen = constants.playerNameLength or 7
|
||||
-- RedPicFront (gfx/player/red.png, shared with the trainer card) and
|
||||
-- the ShrinkPic1/ShrinkPic2 frames (gfx/player/shrink{1,2}.png)
|
||||
self.playerPic = tryImage("assets/generated/trainer_card/red.png")
|
||||
self.playerPic = tryImage(require("src.pokemon.Sprites").playerPath(
|
||||
game.data, "front", { kind = "intro" }))
|
||||
self.shrinkPic1 = tryImage(oakGfx.shrink1
|
||||
or "assets/generated/intro/shrink1.png")
|
||||
self.shrinkPic2 = tryImage(oakGfx.shrink2
|
||||
@@ -337,7 +344,7 @@ function OakSpeech:runStep(step)
|
||||
self.picFlip = true
|
||||
self:revealPic("wipe", function()
|
||||
Sound.playCry(self.game.data, self.demoSpecies)
|
||||
self:say("_OakSpeechText2A", function() self:advance() end)
|
||||
self:say(Strings("_OakSpeechText2A"), function() self:advance() end)
|
||||
end)
|
||||
elseif kind == "name" then
|
||||
local who = step.who or "player"
|
||||
@@ -345,7 +352,7 @@ function OakSpeech:runStep(step)
|
||||
or namePresets(self.game, step.presetsWho or who,
|
||||
step.presetsFallback or { "RED" })
|
||||
require("src.ui.Screens").push(self.game, "NamingScreen", {
|
||||
title = step.title or (who == "rival" and "HIS NAME?" or "YOUR NAME?"),
|
||||
title = step.title or (who == "rival" and "HIS NAME?" or Strings("YOUR NAME?")),
|
||||
presets = presets,
|
||||
maxLen = step.maxLen or self.nameLen,
|
||||
onDone = function(name)
|
||||
@@ -542,6 +549,10 @@ function OakSpeech:update(dt)
|
||||
elseif s.frame >= 79 and s.frame <= 102 then
|
||||
self.fadeLevel = math.floor((s.frame - 79) / 8) + 1
|
||||
elseif s.frame > 102 then
|
||||
-- clear before finish(): a finished-listener that pushes a state gets
|
||||
-- ITS state popped in the speech's place, and a live shrink would call
|
||||
-- finish() again next frame, re-firing the event every frame (#308)
|
||||
self.shrink = nil
|
||||
self:finish()
|
||||
end
|
||||
end
|
||||
|
||||
+36
-21
@@ -22,6 +22,7 @@ local Logger = require("src.core.Logger")
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
local OptionRows = require("src.ui.OptionRows")
|
||||
local Renderer = require("src.render.Renderer")
|
||||
local Strings = require("src.core.Strings")
|
||||
|
||||
local OptionsMenu = {}
|
||||
OptionsMenu.__index = OptionsMenu
|
||||
@@ -116,14 +117,14 @@ local function sameRows(_, rows) return rows end
|
||||
-- ladder's, so the save.options mutations are unchanged
|
||||
local function buildRows(game)
|
||||
local rows = {
|
||||
{ id = "textSpeed", label = "TEXT SPEED",
|
||||
{ id = "textSpeed", label = Strings("TEXT SPEED"),
|
||||
value = function(g) return SPEEDS[speedIndex(g)][2] end,
|
||||
step = function(g)
|
||||
local i = speedIndex(g) % #SPEEDS + 1
|
||||
g.save.options.textSpeed = SPEEDS[i][1]
|
||||
return true
|
||||
end },
|
||||
{ id = "animations", label = "BATTLE ANIMATION",
|
||||
{ id = "animations", label = Strings("BATTLE ANIMATION"),
|
||||
value = function(g)
|
||||
return g.save.options.animations == false and "OFF" or "ON"
|
||||
end,
|
||||
@@ -132,7 +133,7 @@ local function buildRows(game)
|
||||
o.animations = o.animations == false and true or false
|
||||
return true
|
||||
end },
|
||||
{ id = "battleStyle", label = "BATTLE STYLE",
|
||||
{ id = "battleStyle", label = Strings("BATTLE STYLE"),
|
||||
value = function(g)
|
||||
return g.save.options.battleStyle == "set" and "SET" or "SHIFT"
|
||||
end,
|
||||
@@ -141,7 +142,18 @@ local function buildRows(game)
|
||||
o.battleStyle = o.battleStyle == "set" and "shift" or "set"
|
||||
return true
|
||||
end },
|
||||
{ id = "ruleset", label = "RULESET",
|
||||
-- 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)
|
||||
local ids = rulesetIds(g)
|
||||
@@ -150,7 +162,7 @@ local function buildRows(game)
|
||||
g.save.options.ruleset = ids[wrapIndex(i - 1 + dir, #ids) + 1]
|
||||
return true
|
||||
end },
|
||||
{ id = "musicVol", label = "MUSIC VOL",
|
||||
{ id = "musicVol", label = Strings("MUSIC VOL"),
|
||||
value = function(g) return volLabel(g.save.options.musicVol) end,
|
||||
step = function(g, dir)
|
||||
local o = g.save.options
|
||||
@@ -158,7 +170,7 @@ local function buildRows(game)
|
||||
require("src.core.Music").setVolumeLevel(o.musicVol)
|
||||
return true
|
||||
end },
|
||||
{ id = "sfxVol", label = "SFX VOL",
|
||||
{ id = "sfxVol", label = Strings("SFX VOL"),
|
||||
value = function(g) return volLabel(g.save.options.sfxVol) end,
|
||||
step = function(g, dir)
|
||||
local o = g.save.options
|
||||
@@ -166,7 +178,7 @@ local function buildRows(game)
|
||||
require("src.core.Sound").setVolumeLevel(o.sfxVol)
|
||||
return true
|
||||
end },
|
||||
{ id = "musicFilter", label = "MUSIC FILTER",
|
||||
{ id = "musicFilter", label = Strings("MUSIC FILTER"),
|
||||
value = function(g)
|
||||
return FILTERS[(g.save.options.musicFilter or 0) + 1]
|
||||
end,
|
||||
@@ -176,7 +188,7 @@ local function buildRows(game)
|
||||
require("src.core.Music").setFilterLevel(o.musicFilter)
|
||||
return true
|
||||
end },
|
||||
{ id = "colors", label = "COLORS",
|
||||
{ id = "colors", label = Strings("COLORS"),
|
||||
value = function(g)
|
||||
return PaletteFX.modeLabel(g.save.options.colors or "gbc")
|
||||
end,
|
||||
@@ -188,7 +200,7 @@ local function buildRows(game)
|
||||
PaletteFX.setMode(o.colors)
|
||||
return true
|
||||
end },
|
||||
{ id = "tilt", label = "TILT",
|
||||
{ id = "tilt", label = Strings("TILT"),
|
||||
value = function(g) return Tilt.levelLabel(g.save.options.tilt or 0) end,
|
||||
step = function(g, dir)
|
||||
local o = g.save.options
|
||||
@@ -205,7 +217,7 @@ local function buildRows(game)
|
||||
end
|
||||
return true
|
||||
end },
|
||||
{ id = "gbcfx", label = "GBC FX",
|
||||
{ id = "gbcfx", label = Strings("GBC FX"),
|
||||
value = function(g)
|
||||
return GBCFX.levelLabel(g.save.options.gbcfx or 0)
|
||||
end,
|
||||
@@ -215,7 +227,7 @@ local function buildRows(game)
|
||||
GBCFX.setLevel(o.gbcfx)
|
||||
return true
|
||||
end },
|
||||
{ id = "zoom", label = "ZOOM",
|
||||
{ id = "zoom", label = Strings("ZOOM"),
|
||||
value = function(g)
|
||||
return Zoom.offsetLabel(g.save.options.zoom or 0)
|
||||
end,
|
||||
@@ -230,7 +242,7 @@ local function buildRows(game)
|
||||
Zoom.offset = off
|
||||
return true
|
||||
end },
|
||||
{ id = "voidFill", label = "VOID FILL",
|
||||
{ id = "voidFill", label = Strings("VOID FILL"),
|
||||
value = function(g)
|
||||
return TileRenderer.voidFillLabel(g.save.options.voidFill)
|
||||
end,
|
||||
@@ -246,7 +258,7 @@ local function buildRows(game)
|
||||
TileRenderer.setVoidFill(o.voidFill)
|
||||
return true
|
||||
end },
|
||||
{ id = "videoMode", label = "VIDEO MODE",
|
||||
{ id = "videoMode", label = Strings("VIDEO MODE"),
|
||||
value = function(g)
|
||||
return VideoMode.modeLabel(g.save.options.videoMode)
|
||||
end,
|
||||
@@ -259,7 +271,7 @@ local function buildRows(game)
|
||||
-- hard render cap (issue #88): bounds the present rate so a
|
||||
-- driver-forced vsync-off run cannot spin at thousands of FPS. Logic
|
||||
-- is fixed-step off dt, so this touches presentation only.
|
||||
{ id = "fpsCap", label = "MAX FPS",
|
||||
{ id = "fpsCap", label = Strings("MAX FPS"),
|
||||
value = function(g)
|
||||
return FrameCap.label(g.save.options.fpsCap)
|
||||
end,
|
||||
@@ -271,7 +283,7 @@ local function buildRows(game)
|
||||
end },
|
||||
-- fast-forward the logic clock only; music and sfx keep their tempo
|
||||
-- (src/core/GameSpeed.lua), so this is safe to leave on
|
||||
{ id = "speed", label = "GAME SPEED",
|
||||
{ id = "speed", label = Strings("GAME SPEED"),
|
||||
value = function(g)
|
||||
return GameSpeed.levelLabel(g.save.options.speed)
|
||||
end,
|
||||
@@ -282,17 +294,17 @@ local function buildRows(game)
|
||||
end },
|
||||
-- the manager's discoverable home (18-mod-manager-ux); inert until
|
||||
-- opened, so the row costs a vanilla install nothing
|
||||
{ id = "mods", label = "MODS",
|
||||
{ id = "mods", label = Strings("MODS"),
|
||||
value = function(g)
|
||||
local status = g.modStatus or {}
|
||||
return ("%d INSTALLED"):format(#(status.available or {}))
|
||||
return Strings("%d INSTALLED", #(status.available or {}))
|
||||
end,
|
||||
activate = function(g)
|
||||
require("src.ui.Screens").push(g, "ManagerState")
|
||||
end },
|
||||
-- rebinding UI (gap C2, 12-ui-extensibility 4.4); captured inputs
|
||||
-- live in options.bindings, so the row costs a vanilla install nothing
|
||||
{ id = "controls", label = "CONTROLS",
|
||||
{ id = "controls", label = Strings("CONTROLS"),
|
||||
activate = function(g)
|
||||
require("src.ui.Screens").push(g, "BindingsMenu")
|
||||
end },
|
||||
@@ -328,7 +340,8 @@ local function buildRows(game)
|
||||
return rows
|
||||
end
|
||||
|
||||
function OptionsMenu.new(game)
|
||||
function OptionsMenu.new(game, opts)
|
||||
opts = opts or {}
|
||||
local rows = buildRows(game)
|
||||
local hooked = Runtime.call("ui.options.rows", sameRows, game, rows)
|
||||
if type(hooked) == "table" then
|
||||
@@ -337,8 +350,8 @@ function OptionsMenu.new(game)
|
||||
Logger.error("ui.options.rows returned %s; keeping the vanilla rows",
|
||||
type(hooked))
|
||||
end
|
||||
return setmetatable({ game = game, rows = rows, index = 1, scroll = 0 },
|
||||
OptionsMenu)
|
||||
return setmetatable({ game = game, rows = rows, index = 1, scroll = 0,
|
||||
onCancel = opts.onCancel }, OptionsMenu)
|
||||
end
|
||||
|
||||
function OptionsMenu:update(dt)
|
||||
@@ -361,9 +374,11 @@ function OptionsMenu:update(dt)
|
||||
changed = row.step(self.game, dir) and true or false
|
||||
elseif input:wasPressed("a") then -- CANCEL
|
||||
self.game.stack:pop()
|
||||
if self.onCancel then self.onCancel() end
|
||||
end
|
||||
elseif input:wasPressed("b") or input:wasPressed("start") then
|
||||
self.game.stack:pop()
|
||||
if self.onCancel then self.onCancel() end
|
||||
end
|
||||
if changed and self.game.writeOptions then
|
||||
self.game:writeOptions()
|
||||
|
||||
+242
-51
@@ -17,14 +17,57 @@ local Screens = require("src.ui.Screens")
|
||||
local Theme = require("src.ui.Theme")
|
||||
local FieldDefaults = require("src.world.FieldDefaults")
|
||||
local Map = require("src.world.Map")
|
||||
local Strings = require("src.core.Strings")
|
||||
|
||||
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
|
||||
@@ -49,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
|
||||
@@ -70,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
|
||||
@@ -97,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
|
||||
@@ -117,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
|
||||
@@ -133,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
|
||||
@@ -147,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
|
||||
|
||||
@@ -204,7 +360,7 @@ function PartyMenu:update(dt)
|
||||
self.game.save.flashLit = true
|
||||
self.game.stack:push(TextBox.new(self.game,
|
||||
self.game.data.text._FlashLightsAreaText
|
||||
or "A blinding FLASH\nlights the area!", function()
|
||||
or Strings("A blinding FLASH\nlights the area!"), function()
|
||||
self.game.stack:push(Transition.whiteFlash(self.game))
|
||||
end))
|
||||
return
|
||||
@@ -247,7 +403,7 @@ function PartyMenu:update(dt)
|
||||
current = "_CurrentTooFastText",
|
||||
no_place = "_SurfingNoPlaceToGetOffText" })[reason]
|
||||
or "_NoSurfingHereText"
|
||||
local txt = (self.game.data.text[key] or "No SURFing here!")
|
||||
local txt = (self.game.data.text[key] or Strings("No SURFing here!"))
|
||||
:gsub("{RAM:wNameBuffer}", mon.nickname or def.name)
|
||||
if reason == "no_place" then
|
||||
-- .cannotStopSurfing prints _SurfingNoPlaceToGetOffText but
|
||||
@@ -278,7 +434,7 @@ function PartyMenu:update(dt)
|
||||
local def = self.game.data.pokemon[mon.species]
|
||||
local key = (reason == "no_badge") and "_NewBadgeRequiredText"
|
||||
or "_NothingToCutText"
|
||||
local txt = (self.game.data.text[key] or "Nothing to CUT!")
|
||||
local txt = (self.game.data.text[key] or Strings("Nothing to CUT!"))
|
||||
:gsub("{RAM:wNameBuffer}", mon.nickname or def.name)
|
||||
self.game.stack:push(TextBox.new(self.game, txt))
|
||||
return -- .loop: submenu stays open behind the message
|
||||
@@ -300,13 +456,15 @@ function PartyMenu:update(dt)
|
||||
self.game.stack:pop() -- close the party menu (jp .goBackToMap)
|
||||
ow.strengthActive = true
|
||||
local t1 = (self.game.data.text._UsedStrengthText
|
||||
or "{RAM:wNameBuffer} used\nSTRENGTH."):gsub("{RAM:wNameBuffer}", name)
|
||||
or Strings("{RAM:wNameBuffer} used\nSTRENGTH.")):gsub("{RAM:wNameBuffer}", name)
|
||||
local t2 = (self.game.data.text._CanMoveBouldersText
|
||||
or "{RAM:wNameBuffer} can\nmove boulders."):gsub("{RAM:wNameBuffer}", name)
|
||||
or Strings("{RAM:wNameBuffer} can\nmove boulders.")):gsub("{RAM:wNameBuffer}", name)
|
||||
-- like surf (#320): the blink belongs UNDER the texts, not as a
|
||||
-- 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 } }))
|
||||
@@ -348,7 +506,7 @@ function PartyMenu:update(dt)
|
||||
or user.hp <= heal then
|
||||
self.softboiledFrom = nil
|
||||
local TextBox = require("src.render.TextBox")
|
||||
self.game.stack:push(TextBox.new(self.game, "It won't have\nany effect."))
|
||||
self.game.stack:push(TextBox.new(self.game, Strings("It won't have\nany effect.")))
|
||||
else
|
||||
user.hp = user.hp - heal
|
||||
mon.hp = math.min(mon.stats.hp, mon.hp + heal)
|
||||
@@ -357,7 +515,7 @@ function PartyMenu:update(dt)
|
||||
local def = self.game.data.pokemon[mon.species]
|
||||
local TextBox = require("src.render.TextBox")
|
||||
self.game.stack:push(TextBox.new(self.game,
|
||||
("%s's HP\nwas restored!"):format(mon.nickname or def.name)))
|
||||
Strings("%s's HP\nwas restored!", mon.nickname or def.name)))
|
||||
end
|
||||
elseif self.swapFrom then
|
||||
if self.swapFrom ~= self.index then
|
||||
@@ -366,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
|
||||
@@ -375,55 +537,55 @@ function PartyMenu:update(dt)
|
||||
local ow = self.game.overworld
|
||||
if self.battle and self.onSwitch then
|
||||
-- SwitchStatsCancelText (core.asm PartyMenuOrRockOrRun)
|
||||
items = { { label = "SWITCH", action = "battle_switch" },
|
||||
{ label = "STATS", action = "stats" },
|
||||
{ label = "CANCEL", action = "cancel" } }
|
||||
items = { { label = Strings("SWITCH"), action = "battle_switch" },
|
||||
{ label = Strings("STATS"), action = "stats" },
|
||||
{ label = Strings("CANCEL"), action = "cancel" } }
|
||||
else
|
||||
-- STATS/SWITCH plus this mon's field moves (start_sub_menus.asm
|
||||
-- builds the same dynamic list)
|
||||
items = { { label = "STATS", action = "stats" },
|
||||
{ label = "SWITCH", action = "switch" } }
|
||||
items = { { label = Strings("STATS"), action = "stats" },
|
||||
{ label = Strings("SWITCH"), action = "switch" } }
|
||||
-- Field moves (HMs/TMs) are usable out of battle even when the mon
|
||||
-- is fainted -- Gen 1 does not require HP for Cut/Fly/Surf/etc.
|
||||
-- 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"))
|
||||
for _, mv in ipairs(mon.moves) do
|
||||
if mv.id == "FLY" and outside
|
||||
and self.game.save.inventory.THUNDERBADGE then
|
||||
table.insert(items, { label = "FLY", action = "fly" })
|
||||
table.insert(items, { label = Strings("FLY"), action = "fly" })
|
||||
elseif mv.id == "FLASH" and ow.dark
|
||||
and self.game.save.inventory.BOULDERBADGE then
|
||||
table.insert(items, { label = "FLASH", action = "flash" })
|
||||
table.insert(items, { label = Strings("FLASH"), action = "flash" })
|
||||
elseif mv.id == "CUT" and self.game.save.inventory.CASCADEBADGE then
|
||||
-- CUT/SURF/STRENGTH are party-menu field moves too
|
||||
-- (start_sub_menus.asm .outOfBattleMovePointers); listed here
|
||||
-- with the same list-time badge filter this file already uses
|
||||
-- for FLY/FLASH. The facing-tile/activation check happens on
|
||||
-- selection (useCutFieldMove/useSurfFieldMove).
|
||||
table.insert(items, { label = "CUT", action = "cut" })
|
||||
table.insert(items, { label = Strings("CUT"), action = "cut" })
|
||||
elseif mv.id == "SURF" and self.game.save.inventory.SOULBADGE then
|
||||
table.insert(items, { label = "SURF", action = "surf" })
|
||||
table.insert(items, { label = Strings("SURF"), action = "surf" })
|
||||
elseif mv.id == "STRENGTH" and self.game.save.inventory.RAINBOWBADGE then
|
||||
table.insert(items, { label = "STRENGTH", action = "strength" })
|
||||
table.insert(items, { label = Strings("STRENGTH"), action = "strength" })
|
||||
elseif mv.id == "SOFTBOILED" then
|
||||
table.insert(items, { label = "SOFTBOILED", action = "softboiled" })
|
||||
table.insert(items, { label = Strings("SOFTBOILED"), action = "softboiled" })
|
||||
elseif mv.id == "TELEPORT" and outside then
|
||||
-- TELEPORT works only OUTDOORS (start_sub_menus.asm
|
||||
-- .teleport -> CheckIfInOutsideMap); dark maps don't
|
||||
-- block it
|
||||
table.insert(items, { label = "TELEPORT", action = "escape" })
|
||||
table.insert(items, { label = Strings("TELEPORT"), action = "escape" })
|
||||
elseif mv.id == "DIG" and DIG_TILESETS[ow.map.def.tileset]
|
||||
and ow.map.id ~= "AGATHAS_ROOM" then
|
||||
-- DIG runs ItemUseEscapeRope (.dig sets wCurItem =
|
||||
-- ESCAPE_ROPE): usable in the dungeon tilesets of
|
||||
-- escape_rope_tilesets.asm minus Agatha's room, even in
|
||||
-- the dark (Rock Tunnel)
|
||||
table.insert(items, { label = "DIG", action = "escape" })
|
||||
table.insert(items, { label = Strings("DIG"), action = "escape" })
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -456,13 +618,13 @@ function PartyMenu:bottomMessage()
|
||||
return "Use on which one?"
|
||||
elseif self.tmhm then
|
||||
return self.game.data.text._PartyMenuUseTMText
|
||||
or "Use TM on which\nPOKéMON?"
|
||||
or Strings("Use TM on which\nPOKéMON?")
|
||||
elseif self.battle then
|
||||
return self.game.data.text._PartyMenuBattleText
|
||||
or "Bring out which\nPOKéMON?"
|
||||
or Strings("Bring out which\nPOKéMON?")
|
||||
else
|
||||
return self.game.data.text._PartyMenuNormalText
|
||||
or "Choose a POKéMON."
|
||||
or Strings("Choose a POKéMON.")
|
||||
end
|
||||
end
|
||||
|
||||
@@ -480,9 +642,19 @@ function PartyMenu:draw()
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
local party = self.party or self.game.save.party
|
||||
if #party == 0 then
|
||||
Font.draw("No POKéMON!", 16, 64)
|
||||
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)
|
||||
@@ -512,33 +684,52 @@ function PartyMenu:draw()
|
||||
end
|
||||
-- right-aligned so the shorter "ABLE" shares "NOT ABLE"'s right edge
|
||||
if can then
|
||||
Font.draw("ABLE", 120, y + 8)
|
||||
Font.draw(Strings("ABLE"), 120, y + 8)
|
||||
else
|
||||
Font.draw("NOT ABLE", 88, y + 8)
|
||||
Font.draw(Strings("NOT ABLE"), 88, y + 8)
|
||||
end
|
||||
else
|
||||
if mon.hp <= 0 then
|
||||
Font.draw("FNT", 136, y)
|
||||
Font.draw(Strings("FNT"), 136, y)
|
||||
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),
|
||||
-- level with the middle of the two-row icon -- not on the name row that
|
||||
-- entryY returns. Drawing it at y put it a tile too high (#278).
|
||||
local cursorY = y + 8
|
||||
if i == self.index then
|
||||
Font.drawCode(Theme.cursor, 0, y)
|
||||
Font.drawCode(Theme.cursor, 0, cursorY)
|
||||
end
|
||||
if i == self.swapFrom or i == self.softboiledFrom then
|
||||
Font.drawCode(Theme.cursorHollow, 0, y) -- the unfilled swap arrow
|
||||
Font.drawCode(Theme.cursorHollow, 0, cursorY) -- the unfilled swap arrow
|
||||
end
|
||||
end
|
||||
if self.swapFrom then
|
||||
Font.draw("Move to where?", 8, 136)
|
||||
Font.draw(Strings("Move to where?"), 8, 136)
|
||||
elseif self.softboiledFrom then
|
||||
Font.draw("Use on which one?", 8, 136)
|
||||
Font.draw(Strings("Use on which one?"), 8, 136)
|
||||
elseif self.tmhm then
|
||||
-- "Use TM on which\nPOKeMON?" in the standard bottom text box
|
||||
-- (party_menu.asm keeps the message box for the TM/HM menu); box + line
|
||||
@@ -546,14 +737,14 @@ function PartyMenu:draw()
|
||||
Font.drawBox(0, 12, 20, 6)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
local prompt = self.game.data.text._PartyMenuUseTMText
|
||||
or "Use TM on which\nPOKéMON?"
|
||||
or Strings("Use TM on which\nPOKéMON?")
|
||||
local ly = 112
|
||||
for line in (prompt .. "\n"):gmatch("([^\n]*)\n") do
|
||||
Font.draw(line, 8, ly)
|
||||
ly = ly + 16
|
||||
end
|
||||
elseif self.pickOnly then
|
||||
Font.draw("Use on which one?", 8, 136)
|
||||
Font.draw(Strings("Use on which one?"), 8, 136)
|
||||
else
|
||||
-- default field party menu (StartMenu) and the battle voluntary-switch
|
||||
-- (BattleState:openParty): Gen1 prints PartyMenuNormalText / PartyMenuBattleText
|
||||
|
||||
+15
-11
@@ -10,6 +10,7 @@ local ChoiceBox = require("src.ui.ChoiceBox")
|
||||
local ListMenu = require("src.ui.ListMenu")
|
||||
local Menu = require("src.ui.Menu")
|
||||
local Sound = require("src.core.Sound")
|
||||
local Strings = require("src.core.Strings")
|
||||
|
||||
local PlayerPC = {}
|
||||
|
||||
@@ -76,14 +77,14 @@ local function withdraw(game)
|
||||
askQuantity(game, list, pc[item.value] or 1, item.value, function(qty)
|
||||
local Bag = require("src.inventory.Bag")
|
||||
if not Bag.add(game.save, item.value, qty) then
|
||||
list.footer = "You can't carry\nany more items."
|
||||
list.footer = Strings("You can't carry\nany more items.")
|
||||
return
|
||||
end
|
||||
pc[item.value] = pc[item.value] - qty
|
||||
if pc[item.value] <= 0 then pc[item.value] = nil end
|
||||
refreshRow(list, pc, item.value)
|
||||
Sound.play(game.data, "Withdraw_Deposit")
|
||||
list.footer = ("Withdrew\n%s."):format(itemName(game, item.value))
|
||||
list.footer = Strings("Withdrew\n%s.", itemName(game, item.value))
|
||||
end)
|
||||
end,
|
||||
}))
|
||||
@@ -112,14 +113,14 @@ local function deposit(game)
|
||||
onChoose = function(item, list)
|
||||
askQuantity(game, list, inv[item.value] or 1, item.value, function(qty)
|
||||
if pcFull(game, pc, item.value) then
|
||||
list.footer = "No room left to\nstore items."
|
||||
list.footer = Strings("No room left to\nstore items.")
|
||||
return
|
||||
end
|
||||
require("src.inventory.Bag").remove(game.save, item.value, qty)
|
||||
pc[item.value] = (pc[item.value] or 0) + qty
|
||||
refreshRow(list, inv, item.value)
|
||||
Sound.play(game.data, "Withdraw_Deposit")
|
||||
list.footer = ("%s was\nstored via PC."):format(itemName(game, item.value))
|
||||
list.footer = Strings("%s was\nstored via PC.", itemName(game, item.value))
|
||||
end)
|
||||
end,
|
||||
}))
|
||||
@@ -132,7 +133,7 @@ local function toss(game)
|
||||
onChoose = function(item, list)
|
||||
local def = game.data.items[item.value]
|
||||
if (def and def.keyItem) or item.value:find("^HM_") then
|
||||
list.footer = "That's too impor-\ntant to toss!"
|
||||
list.footer = Strings("That's too impor-\ntant to toss!")
|
||||
return
|
||||
end
|
||||
local QuantityBox = require("src.ui.QuantityBox")
|
||||
@@ -140,13 +141,13 @@ local function toss(game)
|
||||
max = pc[item.value] or 1,
|
||||
onDone = function(qty)
|
||||
if not qty then return end
|
||||
list.footer = ("Toss %s?"):format(itemName(game, item.value))
|
||||
list.footer = Strings("Toss %s?", itemName(game, item.value))
|
||||
game.stack:push(ChoiceBox.new(game, function(yes)
|
||||
if yes then
|
||||
pc[item.value] = pc[item.value] - qty
|
||||
if pc[item.value] <= 0 then pc[item.value] = nil end
|
||||
refreshRow(list, pc, item.value)
|
||||
list.footer = ("Threw away %s."):format(itemName(game, item.value))
|
||||
list.footer = Strings("Threw away %s.", itemName(game, item.value))
|
||||
else
|
||||
list.footer = nil
|
||||
end
|
||||
@@ -160,10 +161,13 @@ end
|
||||
function PlayerPC.new(game)
|
||||
game.save.pcItems = game.save.pcItems or {}
|
||||
return Menu.new(game, {
|
||||
{ label = "WITHDRAW ITEM", onSelect = function() withdraw(game) end },
|
||||
{ label = "DEPOSIT ITEM", onSelect = function() deposit(game) end },
|
||||
{ label = "TOSS ITEM", onSelect = function() toss(game) end },
|
||||
{ label = "LOG OFF" },
|
||||
-- keepOpen so B in the item lists returns here instead of dropping the
|
||||
-- whole PC session (players_pc.asm re-shows the PC menu); same pattern
|
||||
-- as BoxMenu's rows
|
||||
{ label = Strings("WITHDRAW ITEM"), keepOpen = true, onSelect = function() withdraw(game) end },
|
||||
{ label = Strings("DEPOSIT ITEM"), keepOpen = true, onSelect = function() deposit(game) end },
|
||||
{ label = Strings("TOSS ITEM"), keepOpen = true, onSelect = function() toss(game) end },
|
||||
{ label = Strings("LOG OFF") },
|
||||
-- silent PC session (BIT_NO_MENU_BUTTON_SOUND); players_pc.asm
|
||||
-- PlayersPCMenu TextBoxBorder (0,0) b=8 c=14 → 16x10
|
||||
}, { tx = 0, ty = 0, tw = 16, th = 10, noSound = true })
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
-- Minimal Pokédex: dex-ordered list with seen/owned markers.
|
||||
|
||||
local ListMenu = require("src.ui.ListMenu")
|
||||
local Strings = require("src.core.Strings")
|
||||
|
||||
local PokedexMenu = {}
|
||||
|
||||
@@ -9,7 +10,8 @@ function PokedexMenu:sgbPalettes(game)
|
||||
return require("src.render.PaletteFX").wholeNamed(game.data, "BROWNMON")
|
||||
end
|
||||
|
||||
function PokedexMenu.new(game)
|
||||
function PokedexMenu.new(game, opts)
|
||||
opts = opts or {}
|
||||
local dex = game.save.pokedex or { seen = {}, owned = {} }
|
||||
local byDex = {}
|
||||
for species, def in pairs(game.data.pokemon) do
|
||||
@@ -45,8 +47,9 @@ function PokedexMenu.new(game)
|
||||
end
|
||||
end
|
||||
local list = ListMenu.new(game, "POKéDEX", items, {
|
||||
footer = ("SEEN %d OWNED %d"):format(seen, owned),
|
||||
footer = Strings("SEEN %d OWNED %d", seen, owned),
|
||||
pageJump = true, -- Left/Right page jumps like the original
|
||||
onCancel = opts.onCancel, -- B returns to the start menu when opened from it
|
||||
onChoose = function(item)
|
||||
if not item.value then return end
|
||||
-- the DATA / CRY / AREA / QUIT choice (engine/menus/pokedex.asm
|
||||
@@ -55,16 +58,16 @@ function PokedexMenu.new(game)
|
||||
local Menu = require("src.ui.Menu")
|
||||
local Screens = require("src.ui.Screens")
|
||||
game.stack:push(Menu.new(game, {
|
||||
{ label = "DATA", onSelect = function()
|
||||
{ label = Strings("DATA"), onSelect = function()
|
||||
Screens.push(game, "DexEntryMenu", item.value)
|
||||
end },
|
||||
{ label = "CRY", keepOpen = true, onSelect = function()
|
||||
{ label = Strings("CRY"), keepOpen = true, onSelect = function()
|
||||
require("src.core.Sound").playCry(game.data, item.value)
|
||||
end },
|
||||
{ label = "AREA", onSelect = function()
|
||||
{ label = Strings("AREA"), onSelect = function()
|
||||
Screens.push(game, "TownMap", { nestSpecies = item.value })
|
||||
end },
|
||||
{ label = "QUIT" },
|
||||
{ label = Strings("QUIT") },
|
||||
}, { tx = 12, ty = 8, tw = 8, th = 10 }))
|
||||
end,
|
||||
})
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
local Font = require("src.render.Font")
|
||||
local SaveData = require("src.core.SaveData")
|
||||
local Strings = require("src.core.Strings")
|
||||
|
||||
local QuarantineReport = {}
|
||||
QuarantineReport.__index = QuarantineReport
|
||||
@@ -38,12 +39,12 @@ local function buildLines(report, meta)
|
||||
end
|
||||
local rows = {}
|
||||
for _, mon in ipairs(report.lostMons or {}) do
|
||||
rows[#rows + 1] = ("%s (%s)"):format(mon.species or "?", mon.from or "?")
|
||||
rows[#rows + 1] = Strings("%s (%s)", mon.species or "?", mon.from or "?")
|
||||
end
|
||||
section(lines, "Moved to LOST box:", rows)
|
||||
rows = {}
|
||||
for _, item in ipairs(report.lostItems or {}) do
|
||||
rows[#rows + 1] = ("%s x%d"):format(item.id or "?", item.count or 1)
|
||||
rows[#rows + 1] = Strings("%s x%d", item.id or "?", item.count or 1)
|
||||
end
|
||||
section(lines, "Items removed:", rows)
|
||||
rows = {}
|
||||
@@ -51,16 +52,16 @@ local function buildLines(report, meta)
|
||||
if map.to then
|
||||
rows[#rows + 1] = ("%s>%s"):format(map.id or "?", map.to)
|
||||
else
|
||||
rows[#rows + 1] = ("%s (%s)"):format(map.id or "?", map.field or "?")
|
||||
rows[#rows + 1] = Strings("%s (%s)", map.id or "?", map.field or "?")
|
||||
end
|
||||
end
|
||||
section(lines, "Location reset:", rows)
|
||||
rows = {}
|
||||
for _, mon in ipairs(report.restoredMons or {}) do
|
||||
rows[#rows + 1] = ("%s to box %d"):format(mon.species or "?", mon.box or 0)
|
||||
rows[#rows + 1] = Strings("%s to box %d", mon.species or "?", mon.box or 0)
|
||||
end
|
||||
for _, item in ipairs(report.restoredItems or {}) do
|
||||
rows[#rows + 1] = ("%s x%d"):format(item.id or "?", item.count or 1)
|
||||
rows[#rows + 1] = Strings("%s x%d", item.id or "?", item.count or 1)
|
||||
end
|
||||
section(lines, "Restored:", rows)
|
||||
local notice = SaveData.modsDiffNotice(report.modsDiff, meta)
|
||||
@@ -113,7 +114,7 @@ function QuarantineReport:draw()
|
||||
love.graphics.rectangle("fill", 0, 0, 160, 144)
|
||||
Font.drawBox(0, 0, 20, 18)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
Font.draw("LOAD REPORT", 8, 8)
|
||||
Font.draw(Strings("LOAD REPORT"), 8, 8)
|
||||
for row = 1, VISIBLE do
|
||||
local line = self.lines[self.offset + row]
|
||||
if line then Font.draw(line, 8, 12 + row * 8) end
|
||||
@@ -121,7 +122,7 @@ function QuarantineReport:draw()
|
||||
if self.offset < self:maxOffset() then
|
||||
Font.drawCode(require("src.ui.Theme").moreArrow, 144, 124)
|
||||
end
|
||||
Font.draw("A:CONTINUE", 8, 130)
|
||||
Font.draw(Strings("A:CONTINUE"), 8, 130)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
end
|
||||
|
||||
|
||||
+10
-9
@@ -12,6 +12,7 @@ local ChoiceBox = require("src.ui.ChoiceBox")
|
||||
local ListMenu = require("src.ui.ListMenu")
|
||||
local Menu = require("src.ui.Menu")
|
||||
local QuantityBox = require("src.ui.QuantityBox")
|
||||
local Strings = require("src.core.Strings")
|
||||
|
||||
local ShopMenu = {}
|
||||
|
||||
@@ -33,7 +34,7 @@ local function buy(game, stock)
|
||||
end
|
||||
local greet = txt(game, "_PokemartBuyingGreetingText", "Take your time.")
|
||||
local notEnough = txt(game, "_PokemartNotEnoughMoneyText",
|
||||
"You don't have\nenough money.")
|
||||
Strings("You don't have\nenough money."))
|
||||
local list
|
||||
list = ListMenu.new(game, "BUY", items, {
|
||||
dialogue = true,
|
||||
@@ -56,7 +57,7 @@ local function buy(game, stock)
|
||||
end
|
||||
local cost = qty * def.price
|
||||
-- _PokemartTellBuyPriceText + yes/no confirm
|
||||
list.footer = ("%s?\nThat will be\n¥%d. OK?"):format(def.name, cost)
|
||||
list.footer = Strings("%s?\nThat will be\n¥%d. OK?", def.name, cost)
|
||||
game.stack:push(ChoiceBox.new(game, function(yes)
|
||||
if not yes then
|
||||
list.footer = greet
|
||||
@@ -68,13 +69,13 @@ local function buy(game, stock)
|
||||
end
|
||||
if not Bag.add(game.save, item.value, qty) then
|
||||
list.footer = txt(game, "_PokemartItemBagFullText",
|
||||
"You can't carry\nany more items.")
|
||||
Strings("You can't carry\nany more items."))
|
||||
return
|
||||
end
|
||||
require("src.core.Sound").play(game.data, "Purchase")
|
||||
game.save.money = game.save.money - cost
|
||||
list.footer = txt(game, "_PokemartBoughtItemText",
|
||||
"Here you are!\nThank you!")
|
||||
Strings("Here you are!\nThank you!"))
|
||||
end))
|
||||
end,
|
||||
}))
|
||||
@@ -112,7 +113,7 @@ local function sell(game)
|
||||
-- ITEM_NONE "0" from Blue's House before that pickup was fixed (#11).
|
||||
if not def or def.keyItem or item.value:find("^HM_") then
|
||||
list.footer = txt(game, "_PokemartUnsellableItemText",
|
||||
"I can't put a\nprice on that.")
|
||||
Strings("I can't put a\nprice on that."))
|
||||
return
|
||||
end
|
||||
local unit = math.floor(def.price / 2)
|
||||
@@ -125,7 +126,7 @@ local function sell(game)
|
||||
return
|
||||
end
|
||||
-- _PokemartTellSellPriceText + yes/no confirm
|
||||
list.footer = ("I can pay you\n¥%d for that."):format(unit * qty)
|
||||
list.footer = Strings("I can pay you\n¥%d for that.", unit * qty)
|
||||
game.stack:push(ChoiceBox.new(game, function(yes)
|
||||
if not yes then
|
||||
list.footer = greet
|
||||
@@ -152,9 +153,9 @@ function ShopMenu.new(game, stock, onQuit)
|
||||
-- keepOpen: the mart menu stays underneath its list so closing the
|
||||
-- list lands back here; only QUIT (or B) leaves and fires onQuit
|
||||
local menu = Menu.new(game, {
|
||||
{ label = "BUY", keepOpen = true, onSelect = function() buy(game, stock) end },
|
||||
{ label = "SELL", keepOpen = true, onSelect = function() sell(game) end },
|
||||
{ label = "QUIT", onSelect = onQuit },
|
||||
{ label = Strings("BUY"), keepOpen = true, onSelect = function() buy(game, stock) end },
|
||||
{ label = Strings("SELL"), keepOpen = true, onSelect = function() sell(game) end },
|
||||
{ label = Strings("QUIT"), onSelect = onQuit },
|
||||
}, { tx = 0, ty = 0, tw = 8, th = 8 })
|
||||
menu.onCancel = onQuit
|
||||
return menu
|
||||
|
||||
@@ -45,6 +45,7 @@
|
||||
|
||||
local Font = require("src.render.Font")
|
||||
local Sound = require("src.core.Sound")
|
||||
local Strings = require("src.core.Strings")
|
||||
|
||||
local SlotMachine = {}
|
||||
SlotMachine.__index = SlotMachine
|
||||
@@ -356,7 +357,7 @@ function SlotMachine:resolveWin(win)
|
||||
-- SlotReward300Func prints "Yeah!" (text_pause) before the flash; the port
|
||||
-- shows it in the box while the screen flashes. LinedUpText follows.
|
||||
self.yeah = (sym == "7")
|
||||
self.message = ("%s lined up!\nScored %d coins!"):format(sym, pay)
|
||||
self.message = Strings("%s lined up!\nScored %d coins!", sym, pay)
|
||||
-- .flashScreenLoop: flip rBGP, wait 5 frames, b times. The coins are not
|
||||
-- credited until the player dismisses the "lined up" text (see startPayout).
|
||||
self.stage = "flash"
|
||||
@@ -394,7 +395,7 @@ end
|
||||
-- delay then CloseTextDisplay), otherwise ask "One more go?".
|
||||
function SlotMachine:afterSpin()
|
||||
if coins(self) == 0 then
|
||||
self.message = "Darn!\nRan out of coins!"
|
||||
self.message = Strings("Darn!\nRan out of coins!")
|
||||
self.stage = "message"
|
||||
self.afterMessage = nil
|
||||
self.exitTimer = 60
|
||||
@@ -515,7 +516,7 @@ function SlotMachine:update(dt)
|
||||
self.bet = 3 - self.betIndex
|
||||
if input:wasPressed("a") then
|
||||
if coins(self) < self.bet then
|
||||
self.message = "Not enough\ncoins!"
|
||||
self.message = Strings("Not enough\ncoins!")
|
||||
self.afterMessage = "bet"
|
||||
self.stage = "message"
|
||||
return
|
||||
@@ -671,8 +672,8 @@ function SlotMachine:drawBottom()
|
||||
local by = self.stage == "intro" and 6 or 11
|
||||
Font.drawBox(13, by, 6, 5)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
Font.draw("YES", 15 * 8, (by + 1) * 8)
|
||||
Font.draw("NO", 15 * 8, (by + 2) * 8)
|
||||
Font.draw(Strings("YES"), 15 * 8, (by + 1) * 8)
|
||||
Font.draw(Strings("NO"), 15 * 8, (by + 2) * 8)
|
||||
Font.drawCode(0xED, 14 * 8, (by + 1 + (self.yesno == 1 and 0 or 1)) * 8)
|
||||
end
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
@@ -723,8 +724,8 @@ function SlotMachine:drawPlain(art)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
love.graphics.rectangle("fill", 0, 0, 160, 144)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
Font.draw("SLOT MACHINE", 32, 4)
|
||||
Font.draw(("COINS %4d"):format(coins(self)), 8, 16)
|
||||
Font.draw(Strings("SLOT MACHINE"), 32, 4)
|
||||
Font.draw(Strings("COINS %4d", coins(self)), 8, 16)
|
||||
if art then self:drawReels(art) end
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
Font.draw(">", 12, 56)
|
||||
|
||||
+27
-21
@@ -10,6 +10,7 @@ local Menu = require("src.ui.Menu")
|
||||
local Renderer = require("src.render.Renderer")
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
local Screens = require("src.ui.Screens")
|
||||
local Strings = require("src.core.Strings")
|
||||
|
||||
local StartMenu = {}
|
||||
|
||||
@@ -19,33 +20,38 @@ function StartMenu.new(game)
|
||||
local flags = game.save.flags or {}
|
||||
local items = {}
|
||||
|
||||
-- vanilla start submenus return here on B (RedisplayStartMenu): the
|
||||
-- generic Menu pops the start menu when a row is selected, so each
|
||||
-- submenu gets an onCancel that re-opens it
|
||||
local function reopen() Screens.push(game, "StartMenu") end
|
||||
|
||||
-- POKéDEX: only after Oak hands it over
|
||||
if flags.EVENT_GOT_POKEDEX then
|
||||
table.insert(items, { label = "POKéDEX", onSelect = function()
|
||||
Screens.push(game, "PokedexMenu")
|
||||
table.insert(items, { label = Strings("POKéDEX"), onSelect = function()
|
||||
Screens.push(game, "PokedexMenu", { onCancel = reopen })
|
||||
end })
|
||||
end
|
||||
|
||||
-- POKéMON is always listed (draw_start_menu.asm prints it even with
|
||||
-- an empty party; selecting it then just no-ops)
|
||||
table.insert(items, { label = "POKéMON", onSelect = function()
|
||||
table.insert(items, { label = Strings("POKéMON"), onSelect = function()
|
||||
if #game.save.party == 0 then return end
|
||||
Screens.push(game, "PartyMenu")
|
||||
Screens.push(game, "PartyMenu", { onCancel = reopen })
|
||||
end })
|
||||
|
||||
table.insert(items, { label = "ITEM", onSelect = function()
|
||||
Screens.push(game, "BagMenu")
|
||||
table.insert(items, { label = Strings("ITEM"), onSelect = function()
|
||||
Screens.push(game, "BagMenu", { onCancel = reopen })
|
||||
end })
|
||||
|
||||
-- the player's name opens the trainer card (StartMenu_TrainerInfo)
|
||||
table.insert(items, { label = game.save.player.name or "RED",
|
||||
onSelect = function()
|
||||
Screens.push(game, "TrainerCard")
|
||||
Screens.push(game, "TrainerCard", { onCancel = reopen })
|
||||
end })
|
||||
|
||||
-- SAVE shows the player/badges/dex/time panel then asks to confirm
|
||||
-- (PrintSaveScreenText)
|
||||
table.insert(items, { label = "SAVE", onSelect = function()
|
||||
table.insert(items, { label = Strings("SAVE"), onSelect = function()
|
||||
local TextBox = require("src.render.TextBox")
|
||||
local ChoiceBox = require("src.ui.ChoiceBox")
|
||||
local badges = require("src.inventory.Badges").count(game.data, game.save)
|
||||
@@ -54,32 +60,32 @@ function StartMenu.new(game)
|
||||
owned = owned + 1
|
||||
end
|
||||
local t = math.floor(game.save.playTime or 0)
|
||||
local panel = ("PLAYER %s\nBADGES %d\nPOKéDEX %3d\nTIME %6d:%02d")
|
||||
:format(game.save.player.name or "RED", badges, owned,
|
||||
math.floor(t / 3600), math.floor(t / 60) % 60)
|
||||
local panel = Strings("PLAYER %s\nBADGES %d\nPOKéDEX %3d\nTIME %6d:%02d",
|
||||
game.save.player.name or "RED", badges, owned,
|
||||
math.floor(t / 3600), math.floor(t / 60) % 60)
|
||||
game.stack:push(TextBox.new(game,
|
||||
panel .. "\fWould you like to\nSAVE the game?", function()
|
||||
panel .. Strings("\fWould you like to\nSAVE the game?"), function()
|
||||
game.stack:push(ChoiceBox.new(game, function(yes)
|
||||
if not yes then return end
|
||||
-- "Now saving..." beat before the write (save.asm
|
||||
-- NowSavingString), then GameSavedText + SFX_SAVE
|
||||
game.stack:push(TextBox.new(game, "Now saving...", function()
|
||||
game.stack:push(TextBox.new(game, Strings("Now saving..."), function()
|
||||
game:writeSave()
|
||||
require("src.core.Sound").play(game.data, "Save")
|
||||
game.stack:push(TextBox.new(game,
|
||||
(game.save.player.name or "RED") .. " saved\nthe game!"))
|
||||
Strings("%s saved\nthe game!", game.save.player.name or "RED")))
|
||||
end))
|
||||
end))
|
||||
end))
|
||||
end })
|
||||
|
||||
table.insert(items, { label = "OPTION", onSelect = function()
|
||||
Screens.push(game, "OptionsMenu")
|
||||
table.insert(items, { label = Strings("OPTION"), onSelect = function()
|
||||
Screens.push(game, "OptionsMenu", { onCancel = reopen })
|
||||
end })
|
||||
|
||||
-- LINK needs a party
|
||||
if #game.save.party > 0 then
|
||||
table.insert(items, { label = "LINK", onSelect = function()
|
||||
table.insert(items, { label = Strings("LINK"), onSelect = function()
|
||||
local LinkState = require("src.link.LinkState")
|
||||
game.stack:push(LinkState.new(game))
|
||||
end })
|
||||
@@ -89,7 +95,7 @@ function StartMenu.new(game)
|
||||
-- one discovered mod so a vanilla install's menu is unchanged
|
||||
local status = game.modStatus
|
||||
if status and #(status.available or {}) > 0 then
|
||||
table.insert(items, { label = "MODS", onSelect = function()
|
||||
table.insert(items, { label = Strings("MODS"), onSelect = function()
|
||||
Screens.push(game, "ManagerState")
|
||||
end })
|
||||
end
|
||||
@@ -97,10 +103,10 @@ function StartMenu.new(game)
|
||||
-- the original's EXIT just closed the menu (CloseStartMenu); with a
|
||||
-- window close button covering that, QUIT instead power-cycles back
|
||||
-- to the title after a confirm (defaultNo guards accidental quits)
|
||||
table.insert(items, { label = "QUIT", onSelect = function()
|
||||
table.insert(items, { label = Strings("QUIT"), onSelect = function()
|
||||
local TextBox = require("src.render.TextBox")
|
||||
local ChoiceBox = require("src.ui.ChoiceBox")
|
||||
game.stack:push(TextBox.new(game, "RETURN TO MAIN\nMENU?", function()
|
||||
game.stack:push(TextBox.new(game, Strings("RETURN TO MAIN\nMENU?"), function()
|
||||
game.stack:push(ChoiceBox.new(game, function(yes)
|
||||
if yes then game:returnToTitle() end
|
||||
end, { defaultNo = true }))
|
||||
@@ -150,7 +156,7 @@ function StartMenu.new(game)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
Font.draw(("%3d"):format(math.floor(safari.steps or 0)), 8, 8)
|
||||
Font.draw("/500", 32, 8)
|
||||
Font.draw("BALL", 8, 24)
|
||||
Font.draw(Strings("BALL"), 8, 24)
|
||||
Font.draw(("%2d"):format(math.floor(safari.balls or 0)), 48, 24)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
end
|
||||
|
||||
+79
-25
@@ -12,6 +12,8 @@ local Font = require("src.render.Font")
|
||||
-- TypeChart.displayName maps it back to "PSYCHIC", like HallOfFame and the
|
||||
-- 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
|
||||
@@ -29,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",
|
||||
@@ -58,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()
|
||||
@@ -72,25 +105,37 @@ 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)
|
||||
Font.draw("STATUS/", 72, 48)
|
||||
Font.draw(Strings("STATUS/"), 72, 48)
|
||||
Font.draw(mon.status or "OK", 128, 48)
|
||||
|
||||
-- stats box (0,8) 10x10: names rows 9/11/13/15, values indented
|
||||
@@ -107,33 +152,42 @@ function SummaryMenu:draw()
|
||||
|
||||
-- TYPE1/TYPE2/IDNo/OT column (10,9) with values indented (11,10)
|
||||
drawLineBox(19, 9, 8, 6)
|
||||
Font.draw("TYPE1/", 80, 72)
|
||||
Font.draw(Strings("TYPE1/"), 80, 72)
|
||||
Font.draw(def.types[1] and TypeChart.displayName(def.types[1]) or "", 88, 80)
|
||||
if def.types[2] then
|
||||
Font.draw("TYPE2/", 80, 88)
|
||||
Font.draw(Strings("TYPE2/"), 80, 88)
|
||||
Font.draw(TypeChart.displayName(def.types[2]), 88, 96)
|
||||
end
|
||||
Font.draw("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)
|
||||
Font.draw("OT/", 80, 120)
|
||||
Font.draw(Strings("OT/"), 80, 120)
|
||||
Font.draw(mon.ot or game.save.player.name or "RED", 96, 128)
|
||||
else
|
||||
-- page 2: EXP + the moves with PP (StatusScreen2)
|
||||
drawLineBox(19, 1, 6, 10)
|
||||
Font.draw("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.
|
||||
Font.draw("LEVEL UP", 72, 40)
|
||||
Font.draw(Strings("EXP POINTS"), 72, 24)
|
||||
-- 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]
|
||||
@@ -141,7 +195,7 @@ function SummaryMenu:draw()
|
||||
if mv then
|
||||
local mdef = data.moves[mv.id]
|
||||
Font.draw(mdef.name, 16, y)
|
||||
Font.draw("PP", 88, y + 8)
|
||||
Font.draw(Strings("PP"), 88, y + 8)
|
||||
Font.draw(("%2d/%2d"):format(mv.pp, mdef.pp), 112, y + 8)
|
||||
else
|
||||
Font.draw("-", 16, y)
|
||||
|
||||
+11
-10
@@ -6,6 +6,7 @@
|
||||
local Font = require("src.render.Font")
|
||||
local Music = require("src.core.Music")
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
local Strings = require("src.core.Strings")
|
||||
|
||||
local TitleState = {}
|
||||
TitleState.__index = TitleState
|
||||
@@ -165,19 +166,19 @@ function ContinueInfo:draw()
|
||||
-- box at (4,7), 8x14 content; labels double-spaced from (5,9)
|
||||
Font.drawBox(4, 7, 16, 10)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
Font.draw("PLAYER", 40, 72)
|
||||
Font.draw(Strings("PLAYER"), 40, 72)
|
||||
Font.draw((save.player and save.player.name) or "RED", 96, 72)
|
||||
local badges = require("src.inventory.Badges").count(self.game.data, save)
|
||||
Font.draw("BADGES", 40, 88)
|
||||
Font.draw(Strings("BADGES"), 40, 88)
|
||||
Font.draw(("%2d"):format(badges), 128, 88)
|
||||
local owned = 0
|
||||
for _ in pairs(save.pokedex and save.pokedex.owned or {}) do
|
||||
owned = owned + 1
|
||||
end
|
||||
Font.draw("POKéDEX", 40, 104)
|
||||
Font.draw(Strings("POKéDEX"), 40, 104)
|
||||
Font.draw(("%3d"):format(owned), 120, 104)
|
||||
local t = math.floor(save.playTime or 0)
|
||||
Font.draw("TIME", 40, 120)
|
||||
Font.draw(Strings("TIME"), 40, 120)
|
||||
Font.draw(("%3d:%02d"):format(math.floor(t / 3600),
|
||||
math.floor(t / 60) % 60), 104, 120)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
@@ -188,7 +189,7 @@ function TitleState:openMenu()
|
||||
local game = self.game
|
||||
local items = {}
|
||||
if hasSave() then
|
||||
table.insert(items, { label = "CONTINUE", onSelect = function()
|
||||
table.insert(items, { label = Strings("CONTINUE"), onSelect = function()
|
||||
-- peek at the save for the info window; fall through if the
|
||||
-- file can't be read
|
||||
local ok, loaded = pcall(require("src.core.SaveData").load)
|
||||
@@ -199,13 +200,13 @@ function TitleState:openMenu()
|
||||
end
|
||||
end })
|
||||
end
|
||||
table.insert(items, { label = "NEW GAME", onSelect = function()
|
||||
table.insert(items, { label = Strings("NEW GAME"), onSelect = function()
|
||||
if self.onNewGame then self.onNewGame() end
|
||||
end })
|
||||
table.insert(items, { label = "OPTION", onSelect = function()
|
||||
table.insert(items, { label = Strings("OPTION"), onSelect = function()
|
||||
require("src.ui.Screens").push(game, "OptionsMenu")
|
||||
end })
|
||||
table.insert(items, { label = "EXIT GAME", onSelect = function()
|
||||
table.insert(items, { label = Strings("EXIT GAME"), onSelect = function()
|
||||
if love.event and love.event.quit then
|
||||
love.event.quit()
|
||||
end
|
||||
@@ -254,7 +255,7 @@ function TitleState:draw()
|
||||
love.graphics.draw(self.logo, 16, 8)
|
||||
else
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
Font.draw(self.blue and "POKéMON BLUE" or "POKéMON RED",
|
||||
Font.draw(self.blue and "POKéMON BLUE" or Strings("POKéMON RED"),
|
||||
(160 - 12 * 8) / 2, 24)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
end
|
||||
@@ -290,7 +291,7 @@ function TitleState:draw()
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
-- the copyright row (tile 2,17); copyrightText because field.title's
|
||||
-- copyright key already names the extracted image strip
|
||||
Font.draw(self.title.copyrightText or "2026 bois club games", 1, 136)
|
||||
Font.draw(self.title.copyrightText or Strings("2026 bois club games"), 1, 136)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
end
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
local Font = require("src.render.Font")
|
||||
local Sound = require("src.core.Sound")
|
||||
local TextBox = require("src.render.TextBox")
|
||||
local Strings = require("src.core.Strings")
|
||||
|
||||
local TradeAnim = {}
|
||||
TradeAnim.__index = TradeAnim
|
||||
@@ -343,7 +344,7 @@ function TradeAnim:drawMonInfo(mon, ot, otId, boxTy)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
Font.draw(no, 56, y0)
|
||||
Font.draw(speciesName(self.game, mon), 40, y0 + 16)
|
||||
Font.draw("OT/" .. (ot or "????"), 40, y0 + 32)
|
||||
Font.draw(Strings("OT/%s", ot or "????"), 40, y0 + 32)
|
||||
Font.draw(("IDNo.%05d"):format(otId or 0), 40, y0 + 48)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
end
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user