mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-15 07:41:21 +02:00
Compare commits
48 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f1950348d4 | |||
| 5603477787 | |||
| 0ca1defb66 | |||
| 370ddea0b8 | |||
| 6a9e77cde0 | |||
| a03f69926e | |||
| 04ece522d7 | |||
| 4943d3e5f4 | |||
| 5a48a61f2e | |||
| fd66a04686 | |||
| b69f8c27c9 | |||
| 66f0326492 | |||
| dbab4a2116 | |||
| 3e8da9b232 | |||
| 223202f230 | |||
| 3078c20e1d | |||
| 617a87a47b | |||
| 8539a6b268 | |||
| f9f38d161f | |||
| 8b85cda84c | |||
| 1deba07106 | |||
| c24c7cbcb7 | |||
| c2334f2f91 | |||
| bfac5fe327 | |||
| 3b1032cc63 | |||
| 72f83760b7 | |||
| 63a179b77a | |||
| 37ba5c3879 | |||
| 00f86ca192 | |||
| b0868e7571 | |||
| 9ec3ff26d8 | |||
| 726e1d4ec5 | |||
| 5b3c5f5da8 | |||
| c45f6be8f2 | |||
| 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
|
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,
|
could ship — running shoes, alternate sprites, day/night, shiny indicators,
|
||||||
Gen 2-like battle toggles, soundtrack packs — open a
|
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.
|
instead.
|
||||||
|
|
||||||
"Can we add X" on its own is hard to act on. Say what you want, why you want it,
|
"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.
|
soundtrack packs, Gen 2-like battle toggles, map cosmetics, bag QoL, etc.
|
||||||
|
|
||||||
The engine already exposes a lot of this through registries and hooks
|
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 /
|
Use a **Feature request** instead for launcher / ports / video options /
|
||||||
networking / save tooling / new API seams.
|
networking / save tooling / new API seams.
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
name: Release
|
name: Release
|
||||||
|
|
||||||
# Builds the macOS, Windows, and Linux desktop apps plus an Android APK
|
# Builds the macOS, Windows, and Linux desktop apps, an Android APK, and the
|
||||||
# on the self-hosted Mac runner, and publishes them as a GitHub Release.
|
# Anbernic RG34XXSP (Stock OS 64-bit MOD / PortMaster) port on the self-hosted
|
||||||
|
# Mac runner, and publishes them as a GitHub Release.
|
||||||
#
|
#
|
||||||
# Versioning:
|
# Versioning:
|
||||||
# - First ever release is 0.1.0.
|
# - First ever release is 0.1.0.
|
||||||
@@ -169,6 +170,13 @@ jobs:
|
|||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
scripts/build_android.sh --version "${{ steps.ver.outputs.version }}"
|
scripts/build_android.sh --version "${{ steps.ver.outputs.version }}"
|
||||||
|
|
||||||
|
- name: Build Anbernic RG34XXSP port
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
# Self-contained aarch64 PortMaster-style pack; pulls the LÖVE 11.5
|
||||||
|
# runtime from PortMaster-GUI, so it needs no signing/notarization.
|
||||||
|
./build-rg34xxsp.sh --version "${{ steps.ver.outputs.version }}"
|
||||||
|
|
||||||
- name: Notarize & staple macOS app
|
- name: Notarize & staple macOS app
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
@@ -218,6 +226,12 @@ jobs:
|
|||||||
[ -n "$apk" ] || { echo "::error::no Android APK found under dist/android/debug"; exit 1; }
|
[ -n "$apk" ] || { echo "::error::no Android APK found under dist/android/debug"; exit 1; }
|
||||||
cp "$apk" "$outdir/gen1recomp-${v}-android.apk"
|
cp "$apk" "$outdir/gen1recomp-${v}-android.apk"
|
||||||
|
|
||||||
|
# Anbernic handheld port (suffix names the CFW it targets, so a
|
||||||
|
# future RG35XX/other-CFW pack can ship alongside it).
|
||||||
|
rg34="dist/rg34xxsp/gen1recomp-rg34xxsp-stockos64-mod.zip"
|
||||||
|
[ -f "$rg34" ] || { echo "::error::$rg34 not found (expected from ./build-rg34xxsp.sh)"; exit 1; }
|
||||||
|
cp "$rg34" "$outdir/gen1recomp-${v}-rg34xxsp-stockos64-mod.zip"
|
||||||
|
|
||||||
# Platform-independent update payload, built alongside the desktop
|
# Platform-independent update payload, built alongside the desktop
|
||||||
# apps above (same game.love that gets fused into each of them).
|
# apps above (same game.love that gets fused into each of them).
|
||||||
love_file=".bazinga/work/game.love"
|
love_file=".bazinga/work/game.love"
|
||||||
@@ -283,6 +297,7 @@ jobs:
|
|||||||
"dist/release/gen1recomp-${v}-windows.zip" \
|
"dist/release/gen1recomp-${v}-windows.zip" \
|
||||||
"dist/release/gen1recomp-${v}-linux.zip" \
|
"dist/release/gen1recomp-${v}-linux.zip" \
|
||||||
"dist/release/gen1recomp-${v}-android.apk" \
|
"dist/release/gen1recomp-${v}-android.apk" \
|
||||||
|
"dist/release/gen1recomp-${v}-rg34xxsp-stockos64-mod.zip" \
|
||||||
"dist/release/gen1recomp-${v}.love" \
|
"dist/release/gen1recomp-${v}.love" \
|
||||||
"dist/release/sha256sums.txt"
|
"dist/release/sha256sums.txt"
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
# Pokemon Gen 1 Recompilation Project
|
# Gen1Recomp
|
||||||
|
|
||||||
A native LÖVE2D recreation of Pokemon Red and Blue. The engine and map
|
A native LÖVE2D recreation of Poke Red and Blue. The engine and map
|
||||||
behavior are hand-written Lua; game data and graphics are decoded from a ROM
|
behavior are hand-written Lua; game data and graphics are decoded from a ROM
|
||||||
supplied by the player.
|
supplied by the player.
|
||||||
|
|
||||||
SUPPORT AND ANNOUNCEMENTS: [Discord](https://bois.icu)
|
SUPPORT AND ANNOUNCEMENTS: [Discord](https://bois.icu)
|
||||||
|
|
||||||
This project does not include a ROM, emulate the Game Boy, transpile assembly,
|
This project does not include a ROM, emulate the Game Boy, transpile assembly,
|
||||||
or download a disassembly. A canonical US 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.
|
game content input.
|
||||||
|
|
||||||
The ROM is verified, used during import, and then released from memory. It is
|
The ROM is verified, used during import, and then released from memory. It is
|
||||||
@@ -33,46 +33,82 @@ audio channel programs copied out of the verified ROM.
|
|||||||
|
|
||||||
## Controls
|
## Controls
|
||||||
|
|
||||||
| Action | Keyboard | Controller |
|
|
||||||
|--------|----------|------------|
|
| Action | Keyboard | Controller |
|
||||||
| Move | Arrow keys / WASD | D-pad / left stick |
|
| ------ | ----------------- | ------------------ |
|
||||||
| A | Z / Enter / Space | A |
|
| Move | Arrow keys / WASD | D-pad / left stick |
|
||||||
| B | X / Backspace | B |
|
| A | Z / Enter / Space | A |
|
||||||
| Start | Escape | Start |
|
| B | X / Backspace | B |
|
||||||
| Select | Tab / Shift | Back / Select |
|
| Start | Escape | Start |
|
||||||
|
| Select | Tab / Shift | Back / Select |
|
||||||
|
|
||||||
|
|
||||||
Rebind any of these in-game under **OPTIONS → CONTROLS**. Controllers are
|
Rebind any of these in-game under **OPTIONS → CONTROLS**. Controllers are
|
||||||
supported out of the box.
|
supported out of the box.
|
||||||
|
|
||||||
### Hotkeys
|
### Hotkeys
|
||||||
|
|
||||||
| Key | What it does |
|
|
||||||
|-----|----------------|
|
| Key | What it does |
|
||||||
| `-` / `=` | Zoom out / in (overworld; also mouse wheel) |
|
| --------- | ---------------------------------------------------- |
|
||||||
| `2` | Cycle COLORS |
|
| `-` / `=` | Zoom out / in (overworld; also mouse wheel) |
|
||||||
| `3` | Cycle TILT (free-roam overworld) |
|
| `2` | Cycle COLORS |
|
||||||
| `4` | Cycle ZOOM through every level (free-roam overworld) |
|
| `3` | Cycle TILT (free-roam overworld) |
|
||||||
| `5` | Cycle GBC FX |
|
| `4` | Cycle ZOOM through every level (free-roam overworld) |
|
||||||
| `F1` | Save |
|
| `5` | Cycle GBC FX |
|
||||||
| `F2` | Load |
|
| `F1` | Save |
|
||||||
| `F10` | Open / close the mod manager |
|
| `F2` | Load |
|
||||||
|
| `F10` | Open / close the mod manager |
|
||||||
|
|
||||||
|
|
||||||
COLORS, TILT, ZOOM, GBC FX, and VOID FILL are also in the Options menu
|
COLORS, TILT, ZOOM, GBC FX, and VOID FILL are also in the Options menu
|
||||||
and persist in `options.lua`.
|
and persist in `options.lua`.
|
||||||
|
|
||||||
|
### 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
|
## Running From Source
|
||||||
|
|
||||||
Requires LÖVE 11.x. Place a Red or Blue ROM in the project folder and
|
Requires LÖVE 11.x. Place a Red or Blue ROM in the project folder and
|
||||||
double-click `Play-Mac.command` or `Play-Windows.bat`, or run:
|
double-click `Play-Mac.command` or `Play-Windows.bat`, or run:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
scripts/setup.sh --rom "/path/to/Pokemon Red.gb" # or Pokemon Blue.gb
|
scripts/setup.sh --rom "/path/to/Poke Red.gb" # or Poke Blue.gb
|
||||||
scripts/run.sh
|
scripts/run.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
then `love .` for later launches. Windows PowerShell scripts, the optional
|
then `love .` for later launches. Windows PowerShell scripts, the optional
|
||||||
developer data build, test suites, and cache management are covered in
|
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
|
## Portable Mode
|
||||||
|
|
||||||
@@ -80,7 +116,7 @@ By default the game keeps your save, options, and the private ROM-derived
|
|||||||
data cache in your OS's normal per-user app data folder. To keep everything
|
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
|
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
|
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
|
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
|
(Windows, Linux, macOS); it has no effect on Android or iOS, where the app
|
||||||
runs from a read-only package.
|
runs from a read-only package.
|
||||||
@@ -88,14 +124,21 @@ runs from a read-only package.
|
|||||||
With `portable.txt` present:
|
With `portable.txt` present:
|
||||||
|
|
||||||
- `save.lua`, `save.lua.bak`, and `options.lua` are read from and written to
|
- `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`
|
- A ROM import writes the generated `data/generated` and `assets/generated`
|
||||||
cache straight into that folder too (nothing is left in the OS save
|
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
|
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.
|
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
|
- Deleting `portable.txt` switches back to the normal OS save directory; nothing
|
||||||
already written to either location is touched automatically, so copy files
|
already written to either location is touched automatically, so copy files
|
||||||
over yourself if you want to carry existing progress across the switch.
|
over yourself if you want to carry existing progress across the switch.
|
||||||
|
|
||||||
|
## Handhelds
|
||||||
|
|
||||||
|
A PortMaster-style port for the **Anbernic RG34XXSP** on Stock OS 64-bit MOD
|
||||||
|
ships with every release as `gen1recomp-*-rg34xxsp-stockos64-mod.zip`.
|
||||||
|
Install steps, controls, and troubleshooting live in
|
||||||
|
[docs/anbernic-rg34xxsp.md](docs/anbernic-rg34xxsp.md).
|
||||||
|
|
||||||
## Modding
|
## Modding
|
||||||
|
|
||||||
@@ -103,36 +146,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 —
|
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
|
getting started, a twelve-rung tutorial ladder, a cookbook, and the generated
|
||||||
reference — lives on the
|
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
|
## Bugs and Ideas
|
||||||
|
|
||||||
Found a bug? A warp dropping you somewhere it shouldn't, a battle doing math
|
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
|
that looks wrong, text in the wrong box, anything that does not match the
|
||||||
original game.
|
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
|
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.
|
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
|
Thought of a feature that could be good, or a way to improve one that already
|
||||||
exists?
|
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
|
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.
|
request with real detail is one that can actually get built.
|
||||||
|
|
||||||
## More
|
## More
|
||||||
|
|
||||||
- [Link play](https://github.com/bryanthaboi/pokemon-gen1-recomp-project/wiki/Guide-Link-Play)
|
- [Link play](https://github.com/bryanthaboi/gen1recomp/wiki/Guide-Link-Play)
|
||||||
— START > LINK connects two copies directly over UDP.
|
— START > LINK connects two copies directly over UDP.
|
||||||
- [Save editor](https://github.com/bryanthaboi/pokemon-gen1-recomp-project/wiki/Guide-Save-Editor)
|
- [Save editor](https://github.com/bryanthaboi/gen1recomp/wiki/Guide-Save-Editor)
|
||||||
— edit party, boxes, items, events, and Pokédex flags outside the game.
|
— edit party, boxes, items, events, and Pokédex flags outside the game.
|
||||||
- `docs/architecture.md` — runtime details;
|
- `docs/architecture.md` — runtime details;
|
||||||
`docs/behavior-porting-notes.md` — formula provenance.
|
`docs/behavior-porting-notes.md` — formula provenance.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
## Special Thanks
|
## Special Thanks
|
||||||
|
|
||||||
This project would not be possible without [pret](https://github.com/pret) >
|
This project would not be possible without [pret](https://github.com/pret) >
|
||||||
the pret band of decompiling maniacs > and their
|
the pret band of decompiling maniacs > and their
|
||||||
[pokered](https://github.com/pret/pokered) disassembly.
|
[pokered](https://github.com/pret/pokered) disassembly.
|
||||||
Executable
+333
@@ -0,0 +1,333 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Build a PortMaster-style aarch64 port of gen1recomp for Anbernic RG34XXSP
|
||||||
|
# on Stock OS 64-bit MOD (H700; cbepx-me StockOS MOD / official V1.0.6 base
|
||||||
|
# + PortMaster).
|
||||||
|
#
|
||||||
|
# The stock Anbernic firmware resolves ports relative to the launch script
|
||||||
|
# (roms/PORTS/...), not PortMaster's /$directory/ports/... path. This pack
|
||||||
|
# uses SHDIR-relative paths and bundles the LÖVE 11.5 aarch64 runtime so the
|
||||||
|
# device does not need a separate love_11.5 runtime download on first launch.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# ./build-rg34xxsp.sh [--version X.Y.Z]
|
||||||
|
#
|
||||||
|
# Output:
|
||||||
|
# dist/rg34xxsp/gen1recomp-rg34xxsp-stockos64-mod.zip
|
||||||
|
#
|
||||||
|
# Install on device:
|
||||||
|
# 1. Flash / run Stock OS 64-bit MOD with PortMaster installed.
|
||||||
|
# 2. Unzip into the SD card's roms/PORTS/ folder so you have:
|
||||||
|
# roms/PORTS/Gen1recomp.sh
|
||||||
|
# roms/PORTS/gen1recomp/...
|
||||||
|
# 3. Copy a legal US Red or Blue .gb into roms/PORTS/gen1recomp/lovegame/
|
||||||
|
# 4. Launch "Gen1recomp" from the Ports list; press Choose ROM (scans that
|
||||||
|
# folder when zenity is missing).
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
HERE="$ROOT/.bazinga"
|
||||||
|
CACHE="$HERE/cache/rg34xxsp"
|
||||||
|
WORK="$HERE/work/rg34xxsp"
|
||||||
|
DIST="$ROOT/dist/rg34xxsp"
|
||||||
|
|
||||||
|
APP_NAME="gen1recomp"
|
||||||
|
# Artifact suffix matches the CFW this port targets (Anbernic H700 Stock OS
|
||||||
|
# 64-bit MOD for RG34XXSP). Release uploads stage it as
|
||||||
|
# gen1recomp-<ver>-rg34xxsp-stockos64-mod.zip.
|
||||||
|
ARTIFACT_SUFFIX="rg34xxsp-stockos64-mod"
|
||||||
|
PORT_DIR_NAME="gen1recomp"
|
||||||
|
LAUNCHER_NAME="Gen1recomp.sh"
|
||||||
|
LOVE_VERSION="11.5"
|
||||||
|
VERSION="$(git -C "$ROOT" rev-parse --short HEAD 2>/dev/null || echo dev)"
|
||||||
|
|
||||||
|
# Official PortMaster LÖVE 11.5 aarch64 runtime (small love stub + liblove).
|
||||||
|
PM_RUNTIME_BASE="https://raw.githubusercontent.com/PortsMaster/PortMaster-GUI/main/PortMaster/runtimes/love_${LOVE_VERSION}"
|
||||||
|
|
||||||
|
say() { printf '\033[1;32m==>\033[0m %s\n' "$*"; }
|
||||||
|
warn() { printf '\033[1;33mwarn:\033[0m %s\n' "$*" >&2; }
|
||||||
|
fail() { printf '\033[1;31merror:\033[0m %s\n' "$*" >&2; exit 1; }
|
||||||
|
|
||||||
|
while [ $# -gt 0 ]; do
|
||||||
|
case "$1" in
|
||||||
|
--version) VERSION="$2"; shift ;;
|
||||||
|
-h|--help)
|
||||||
|
sed -n '2,24p' "$0"
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
*) fail "unknown argument: $1" ;;
|
||||||
|
esac
|
||||||
|
shift
|
||||||
|
done
|
||||||
|
|
||||||
|
command -v curl >/dev/null || fail "curl is required"
|
||||||
|
command -v zip >/dev/null || fail "zip is required"
|
||||||
|
command -v unzip >/dev/null || fail "unzip is required"
|
||||||
|
|
||||||
|
mkdir -p "$CACHE" "$WORK" "$DIST"
|
||||||
|
|
||||||
|
download() {
|
||||||
|
local url="$1" dest="$2"
|
||||||
|
if [ -f "$dest" ] && [ -s "$dest" ]; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
say "downloading $(basename "$dest")"
|
||||||
|
curl -fL --progress-bar "$url" -o "$dest.tmp" \
|
||||||
|
|| fail "download failed: $url"
|
||||||
|
mv "$dest.tmp" "$dest"
|
||||||
|
}
|
||||||
|
|
||||||
|
# --------------------------------------------------------------- game tree
|
||||||
|
# Unpacked directory (not a .love zip) so the player can drop a .gb next to
|
||||||
|
# main.lua and RomImporter can scan it without zenity/kdialog.
|
||||||
|
say "staging lovegame/"
|
||||||
|
GAME_SRC="$WORK/lovegame"
|
||||||
|
rm -rf "$GAME_SRC"
|
||||||
|
mkdir -p "$GAME_SRC"
|
||||||
|
|
||||||
|
# Same payload as scripts/build.sh's game.love — never ship ROM-derived cache.
|
||||||
|
# tools/save-editor is part of that payload: the launcher's Edit button on a
|
||||||
|
# save row opens it in-process (main.lua).
|
||||||
|
(cd "$ROOT" && zip -q -9 -r "$WORK/game-payload.zip" \
|
||||||
|
main.lua conf.lua src data assets tools/save-editor \
|
||||||
|
tools/rom_manifest.json tools/rom_manifest_blue.json \
|
||||||
|
-x '*.DS_Store' 'data/generated/*' 'assets/generated/*')
|
||||||
|
if unzip -Z1 "$WORK/game-payload.zip" \
|
||||||
|
| grep -Eq '^(data|assets)/generated/[^/]+|^(data|assets)/generated/.+/'; then
|
||||||
|
fail "payload unexpectedly contains generated ROM data"
|
||||||
|
fi
|
||||||
|
unzip -q "$WORK/game-payload.zip" -d "$GAME_SRC"
|
||||||
|
rm -f "$WORK/game-payload.zip"
|
||||||
|
|
||||||
|
# Stamp release version into the staged tree only (never the working tree).
|
||||||
|
if printf '%s' "$VERSION" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$'; then
|
||||||
|
say "stamping engine version $VERSION"
|
||||||
|
sed -E "s/(engine[[:space:]]*=[[:space:]]*\")[^\"]*(\")/\1$VERSION\2/" \
|
||||||
|
"$ROOT/src/core/Version.lua" > "$GAME_SRC/src/core/Version.lua"
|
||||||
|
version_re="$(printf '%s' "$VERSION" | sed 's/\./\\./g')"
|
||||||
|
grep -Eq "engine[[:space:]]*=[[:space:]]*\"$version_re\"" \
|
||||||
|
"$GAME_SRC/src/core/Version.lua" \
|
||||||
|
|| fail "version stamp failed"
|
||||||
|
else
|
||||||
|
say "version '$VERSION' is not X.Y.Z — shipping default engine (no stamp)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Portable marker: saves + ROM cache live next to the game on the SD card.
|
||||||
|
: > "$GAME_SRC/portable.txt"
|
||||||
|
|
||||||
|
# --------------------------------------------------------------- love runtime
|
||||||
|
say "fetching LÖVE $LOVE_VERSION aarch64 runtime"
|
||||||
|
LOVE_BIN="$CACHE/love.aarch64"
|
||||||
|
LOVE_LIB="$CACHE/liblove-11.5.so"
|
||||||
|
LUAJIT_LIB="$CACHE/libluajit-5.1.so.2"
|
||||||
|
MODPLUG_LIB="$CACHE/libmodplug.so.1"
|
||||||
|
OGG_LIB="$CACHE/libogg.so.0"
|
||||||
|
|
||||||
|
download "$PM_RUNTIME_BASE/love.aarch64" "$LOVE_BIN"
|
||||||
|
download "$PM_RUNTIME_BASE/libs.aarch64/liblove-11.5.so" "$LOVE_LIB"
|
||||||
|
download "$PM_RUNTIME_BASE/libs.aarch64/libluajit-5.1.so.2" "$LUAJIT_LIB"
|
||||||
|
download "$PM_RUNTIME_BASE/libs.aarch64/libmodplug.so.1" "$MODPLUG_LIB"
|
||||||
|
download "$PM_RUNTIME_BASE/libs.aarch64/libogg.so.0" "$OGG_LIB"
|
||||||
|
|
||||||
|
# Sanity: love stub must be an aarch64 ELF.
|
||||||
|
file "$LOVE_BIN" | grep -qi 'aarch64\|ARM aarch64' \
|
||||||
|
|| fail "love.aarch64 does not look like an aarch64 ELF (got: $(file "$LOVE_BIN"))"
|
||||||
|
|
||||||
|
# --------------------------------------------------------------- port tree
|
||||||
|
say "assembling port package"
|
||||||
|
PORT_ROOT="$WORK/port"
|
||||||
|
rm -rf "$PORT_ROOT"
|
||||||
|
mkdir -p "$PORT_ROOT/$PORT_DIR_NAME/bin" \
|
||||||
|
"$PORT_ROOT/$PORT_DIR_NAME/libs.aarch64" \
|
||||||
|
"$PORT_ROOT/$PORT_DIR_NAME/licenses" \
|
||||||
|
"$PORT_ROOT/$PORT_DIR_NAME/conf"
|
||||||
|
|
||||||
|
cp -R "$GAME_SRC" "$PORT_ROOT/$PORT_DIR_NAME/lovegame"
|
||||||
|
cp "$LOVE_BIN" "$PORT_ROOT/$PORT_DIR_NAME/bin/love.aarch64"
|
||||||
|
chmod +x "$PORT_ROOT/$PORT_DIR_NAME/bin/love.aarch64"
|
||||||
|
cp "$LOVE_LIB" "$LUAJIT_LIB" "$MODPLUG_LIB" "$OGG_LIB" \
|
||||||
|
"$PORT_ROOT/$PORT_DIR_NAME/libs.aarch64/"
|
||||||
|
|
||||||
|
# Drop a short license pointer for the bundled LÖVE bits.
|
||||||
|
cat > "$PORT_ROOT/$PORT_DIR_NAME/licenses/LICENSE.love2d.txt" <<'EOF'
|
||||||
|
This port bundles the LÖVE 11.5 aarch64 runtime from PortMaster
|
||||||
|
(https://github.com/PortsMaster/PortMaster-GUI). LÖVE is zlib-licensed;
|
||||||
|
see https://love2d.org/ for full terms.
|
||||||
|
EOF
|
||||||
|
|
||||||
|
# --------------------------------------------------------------- launcher
|
||||||
|
# Stock OS fix: resolve GAMEDIR from this script's directory (SHDIR), not
|
||||||
|
# PortMaster's $directory variable — Anbernic uses roms/PORTS with different
|
||||||
|
# casing and mount points (mmc / sdcard / TF1 / TF2).
|
||||||
|
cat > "$PORT_ROOT/$LAUNCHER_NAME" <<'EOF'
|
||||||
|
#!/bin/bash
|
||||||
|
# gen1recomp — Anbernic RG34XXSP stock OS / PortMaster launcher
|
||||||
|
# Uses SHDIR-relative paths so stock firmware finds the game folder.
|
||||||
|
|
||||||
|
export HOME="${HOME:-/root}"
|
||||||
|
XDG_DATA_HOME="${XDG_DATA_HOME:-$HOME/.local/share}"
|
||||||
|
|
||||||
|
if [ -d "/opt/system/Tools/PortMaster/" ]; then
|
||||||
|
controlfolder="/opt/system/Tools/PortMaster"
|
||||||
|
elif [ -d "/opt/tools/PortMaster/" ]; then
|
||||||
|
controlfolder="/opt/tools/PortMaster"
|
||||||
|
elif [ -d "$XDG_DATA_HOME/PortMaster/" ]; then
|
||||||
|
controlfolder="$XDG_DATA_HOME/PortMaster"
|
||||||
|
elif [ -d "/mnt/mmc/Roms/PORTS/PortMaster" ]; then
|
||||||
|
controlfolder="/mnt/mmc/Roms/PORTS/PortMaster"
|
||||||
|
elif [ -d "/mnt/sdcard/Roms/PORTS/PortMaster" ]; then
|
||||||
|
controlfolder="/mnt/sdcard/Roms/PORTS/PortMaster"
|
||||||
|
elif [ -d "/roms/ports/PortMaster" ]; then
|
||||||
|
controlfolder="/roms/ports/PortMaster"
|
||||||
|
else
|
||||||
|
controlfolder="/roms/PORTS/PortMaster"
|
||||||
|
fi
|
||||||
|
|
||||||
|
SHDIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
# shellcheck disable=SC1090
|
||||||
|
source "$controlfolder/control.txt"
|
||||||
|
get_controls
|
||||||
|
[ -f "${controlfolder}/mod_${CFW_NAME}.txt" ] && source "${controlfolder}/mod_${CFW_NAME}.txt"
|
||||||
|
|
||||||
|
GAMEDIR="$SHDIR/gen1recomp"
|
||||||
|
CONFDIR="$GAMEDIR/conf"
|
||||||
|
mkdir -p "$CONFDIR"
|
||||||
|
|
||||||
|
cd "$GAMEDIR" || exit 1
|
||||||
|
> "$GAMEDIR/log.txt" && exec > >(tee "$GAMEDIR/log.txt") 2>&1
|
||||||
|
|
||||||
|
export XDG_DATA_HOME="$CONFDIR"
|
||||||
|
export XDG_CONFIG_HOME="$CONFDIR"
|
||||||
|
export LD_LIBRARY_PATH="$GAMEDIR/libs.aarch64:${LD_LIBRARY_PATH:-}"
|
||||||
|
export SDL_GAMECONTROLLERCONFIG="${sdl_controllerconfig:-}"
|
||||||
|
# Mali / H700: prefer GLES where available
|
||||||
|
export LOVE_GRAPHICS_USE_OPENGLES="${LOVE_GRAPHICS_USE_OPENGLES:-1}"
|
||||||
|
# Same GPU class as a phone, but getOS() here says "Linux", so the Android
|
||||||
|
# gate (issue #136) would not fire on its own: refuse GBC FX explicitly.
|
||||||
|
# Hides the OPTIONS row, pins the level to OFF, and heals a level already
|
||||||
|
# persisted in options.lua.
|
||||||
|
export POKEPORT_GBCFX="${POKEPORT_GBCFX:-0}"
|
||||||
|
|
||||||
|
$ESUDO chmod a+x ./bin/love.aarch64 2>/dev/null || chmod a+x ./bin/love.aarch64
|
||||||
|
$ESUDO chmod 666 /dev/uinput 2>/dev/null || true
|
||||||
|
|
||||||
|
if [ -n "${GPTOKEYB:-}" ]; then
|
||||||
|
$GPTOKEYB "love.aarch64" &
|
||||||
|
fi
|
||||||
|
if type pm_platform_helper >/dev/null 2>&1; then
|
||||||
|
pm_platform_helper "$GAMEDIR/bin/love.aarch64"
|
||||||
|
fi
|
||||||
|
|
||||||
|
./bin/love.aarch64 "$GAMEDIR/lovegame"
|
||||||
|
|
||||||
|
if type pm_finish >/dev/null 2>&1; then
|
||||||
|
pm_finish
|
||||||
|
else
|
||||||
|
if [ -n "${ESUDO:-}" ]; then
|
||||||
|
$ESUDO kill -9 $(pidof gptokeyb) 2>/dev/null || true
|
||||||
|
else
|
||||||
|
kill -9 $(pidof gptokeyb) 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
EOF
|
||||||
|
chmod +x "$PORT_ROOT/$LAUNCHER_NAME"
|
||||||
|
|
||||||
|
# --------------------------------------------------------------- metadata
|
||||||
|
cat > "$PORT_ROOT/port.json" <<EOF
|
||||||
|
{
|
||||||
|
"version": 2,
|
||||||
|
"name": "gen1recomp.zip",
|
||||||
|
"items": [
|
||||||
|
"$LAUNCHER_NAME",
|
||||||
|
"$PORT_DIR_NAME"
|
||||||
|
],
|
||||||
|
"items_opt": null,
|
||||||
|
"attr": {
|
||||||
|
"title": "gen1recomp",
|
||||||
|
"desc": "Native LÖVE2D recreation of Pokemon Red and Blue. Supply your own legal US Red or Blue ROM.",
|
||||||
|
"inst": "Requires Anbernic RG34XXSP Stock OS 64-bit MOD with PortMaster. Copy a canonical US Red or Blue .gb into gen1recomp/lovegame/, then launch and press Choose ROM.",
|
||||||
|
"genres": ["adventure", "rpg"],
|
||||||
|
"porter": ["gen1recomp"],
|
||||||
|
"image": {},
|
||||||
|
"rtr": true,
|
||||||
|
"runtime": null,
|
||||||
|
"reqs": [],
|
||||||
|
"arch": ["aarch64"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
|
||||||
|
cat > "$PORT_ROOT/gameinfo.xml" <<EOF
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<gameList>
|
||||||
|
<game>
|
||||||
|
<path>./$LAUNCHER_NAME</path>
|
||||||
|
<name>gen1recomp</name>
|
||||||
|
<desc>Native LÖVE2D recreation of Pokemon Red and Blue. Requires your own legal US Red or Blue ROM.</desc>
|
||||||
|
<releasedate>20250101T000000</releasedate>
|
||||||
|
<developer>the bois club</developer>
|
||||||
|
<publisher>the bois club</publisher>
|
||||||
|
<genre>RPG</genre>
|
||||||
|
</game>
|
||||||
|
</gameList>
|
||||||
|
EOF
|
||||||
|
|
||||||
|
cat > "$PORT_ROOT/README.md" <<'EOF'
|
||||||
|
## gen1recomp (RG34XXSP / Stock OS 64-bit MOD)
|
||||||
|
|
||||||
|
Native LÖVE 11.5 port of gen1recomp for Anbernic RG34XXSP on
|
||||||
|
**Stock OS 64-bit MOD** (H700, PortMaster).
|
||||||
|
|
||||||
|
### Install
|
||||||
|
|
||||||
|
1. Use **Stock OS 64-bit MOD** with PortMaster installed (TF1).
|
||||||
|
2. Unzip so `Gen1recomp.sh` and the `gen1recomp/` folder sit in `roms/PORTS/`.
|
||||||
|
3. Copy a legal US Pokemon Red or Blue `.gb` into `gen1recomp/lovegame/`.
|
||||||
|
4. Refresh Ports / restart EmulationStation and launch **Gen1recomp**.
|
||||||
|
|
||||||
|
### Controls (launcher)
|
||||||
|
|
||||||
|
| Input | Action |
|
||||||
|
|--|--|
|
||||||
|
| D-pad / left stick | Move cursor |
|
||||||
|
| A | Click |
|
||||||
|
| L1 / R1 | Switch tabs |
|
||||||
|
| Right stick | Scroll lists |
|
||||||
|
| Start / Select | Play or Choose ROM |
|
||||||
|
|
||||||
|
In-game controls use the normal PortMaster / SDL pad map (rebind under OPTIONS → CONTROLS).
|
||||||
|
|
||||||
|
### Display options
|
||||||
|
|
||||||
|
GBC FX is disabled on this device (the H700's Mali GPU compiles that present
|
||||||
|
pass and then shows a black frame), so the OPTIONS row is hidden. COLORS,
|
||||||
|
TILT, ZOOM, VOID FILL and MAX FPS all work. To try it anyway, launch with
|
||||||
|
`POKEPORT_GBCFX=1`.
|
||||||
|
|
||||||
|
### First run
|
||||||
|
|
||||||
|
Stock OS has no zenity file picker. Put the `.gb` in `lovegame/`, then press
|
||||||
|
**Choose ROM** — the game scans that folder. After import, the ROM-derived
|
||||||
|
cache and saves stay beside the game (`portable.txt`).
|
||||||
|
|
||||||
|
Canonical SHA-1 (1 MiB US carts only):
|
||||||
|
|
||||||
|
- Red: `ea9bcae617fdf159b045185467ae58b2e4a48b9a`
|
||||||
|
- Blue: `d7037c83e1ae5b39bde3c30787637ba1d4c48ce2`
|
||||||
|
|
||||||
|
### Thanks
|
||||||
|
|
||||||
|
LÖVE runtime binaries from [PortMaster](https://portmaster.games/).
|
||||||
|
Stock OS 64-bit MOD: [cbepx-me](https://github.com/cbepx-me/Anbernic-H700-RG-xx-StockOS-Modification).
|
||||||
|
EOF
|
||||||
|
|
||||||
|
# --------------------------------------------------------------- zip
|
||||||
|
ZIP_OUT="$DIST/$APP_NAME-$ARTIFACT_SUFFIX.zip"
|
||||||
|
rm -f "$ZIP_OUT" "$DIST/$APP_NAME-rg34xxsp.zip"
|
||||||
|
say "packing $ZIP_OUT"
|
||||||
|
(cd "$PORT_ROOT" && zip -q -9 -r "$ZIP_OUT" \
|
||||||
|
"$LAUNCHER_NAME" "$PORT_DIR_NAME" port.json gameinfo.xml README.md)
|
||||||
|
|
||||||
|
say "done."
|
||||||
|
say "artifact: $ZIP_OUT ($(du -h "$ZIP_OUT" | cut -f1))"
|
||||||
|
say "copy onto the RG34XXSP SD card under roms/PORTS/ (Stock OS 64-bit MOD), then drop your .gb into gen1recomp/lovegame/"
|
||||||
@@ -13,7 +13,12 @@ function love.conf(t)
|
|||||||
_G.POKEPORT_DEV_MODE = developer
|
_G.POKEPORT_DEV_MODE = developer
|
||||||
|
|
||||||
if editor then
|
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.title = "Pokemon Save Editor"
|
||||||
t.window.width = 1280
|
t.window.width = 1280
|
||||||
t.window.height = 800
|
t.window.height = 800
|
||||||
|
|||||||
@@ -1,13 +1,21 @@
|
|||||||
-- pokered/scripts/PewterNidoranHouse.asm
|
-- pokered/scripts/PewterNidoranHouse.asm
|
||||||
-- PewterNidoranHouseNidoranText: text_far _PewterNidoranHouseNidoranText, then
|
-- PewterNidoranHouseNidoranText: text_far _PewterNidoranHouseNidoranText, then
|
||||||
-- text_asm plays the NIDORAN_M cry (PlayCry/WaitForSoundToFinish) before ending.
|
-- 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
|
-- play_cry only stashes ctx.pendingCry for the very next show_text row, so it
|
||||||
-- only; the cry SFX is cosmetic and has no gameplay effect.
|
-- 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 {
|
return {
|
||||||
PEWTER_NIDORAN_HOUSE = {
|
PEWTER_NIDORAN_HOUSE = {
|
||||||
talk = {
|
talk = {
|
||||||
TEXT_PEWTERNIDORANHOUSE_NIDORAN = {
|
TEXT_PEWTERNIDORANHOUSE_NIDORAN = {
|
||||||
{ "face_player" },
|
{ "face_player" },
|
||||||
|
-- text_asm: ld a, NIDORAN_M / call PlayCry
|
||||||
|
{ "play_cry", "NIDORAN_M", true },
|
||||||
{ "show_text", "_PewterNidoranHouseNidoranText" },
|
{ "show_text", "_PewterNidoranHouseNidoranText" },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -28,7 +28,29 @@ local function badgeGuard(badge, passFlag)
|
|||||||
}
|
}
|
||||||
end
|
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 = {
|
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 = {
|
talk = {
|
||||||
-- Route23Guard1Text: EventFlagBit ..., EVENT_PASSED_EARTHBADGE_CHECK
|
-- Route23Guard1Text: EventFlagBit ..., EVENT_PASSED_EARTHBADGE_CHECK
|
||||||
-- -> wWhichBadge = EARTHBADGE
|
-- -> wWhichBadge = EARTHBADGE
|
||||||
|
|||||||
@@ -59,13 +59,17 @@ M.VIRIDIAN_CITY = {
|
|||||||
-- ViridianCityYoungster2CaterpieAndWeedleDescriptionText are
|
-- ViridianCityYoungster2CaterpieAndWeedleDescriptionText are
|
||||||
-- defined without a leading underscore in pokered/text/ViridianCity.asm
|
-- defined without a leading underscore in pokered/text/ViridianCity.asm
|
||||||
-- and aren't present in data/generated/text.lua, so we fall back to
|
-- 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)
|
TEXT_VIRIDIANCITY_YOUNGSTER2 = function(game, ow, npc, done)
|
||||||
local t = text(game)
|
local t = text(game)
|
||||||
ask(game, t._ViridianCityYoungster2YouWantToKnowAboutText
|
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
|
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
|
else
|
||||||
push(game, "Oh, OK then!", done)
|
push(game, "Oh, OK then!", done)
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -3,12 +3,16 @@
|
|||||||
return {
|
return {
|
||||||
VIRIDIAN_NICKNAME_HOUSE = {
|
VIRIDIAN_NICKNAME_HOUSE = {
|
||||||
talk = {
|
talk = {
|
||||||
-- ViridianNicknameHouseSpearowText: text_asm that shows the text
|
-- ViridianNicknameHouseSpearowText: text_asm that PrintTexts the
|
||||||
-- then plays a SPEAROW cry (PlayCry/WaitForSoundToFinish). The
|
-- line, then plays the SPEAROW cry (PlayCry/WaitForSoundToFinish)
|
||||||
-- cry playback isn't in the talk-script command vocabulary, so
|
-- before TextScriptEnd. play_cry only arms the show_text row that
|
||||||
-- only the flavor line is ported here.
|
-- 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 = {
|
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
|
-- StartSimulatingJoypadStates walks the player to the counter
|
||||||
-- (.PlayerMovement: PAD_LEFT 1, PAD_UP 2, door (3,7) -> counter (2,5))
|
-- (.PlayerMovement: PAD_LEFT 1, PAD_UP 2, door (3,7) -> counter (2,5))
|
||||||
-- and ViridianMartOaksParcelScript hands the parcel over. The player
|
-- 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
|
-- never presses A. This matters beyond convenience: the parcel gates
|
||||||
-- Oak's Pokedex and the old man clearing Route 2, so vanilla guarantees
|
-- Oak's Pokedex and the old man clearing Route 2, so vanilla guarantees
|
||||||
-- it on entry rather than letting you walk out without it.
|
-- 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
|
if not f.EVENT_GOT_STARTER then return end
|
||||||
ow:queueScript({
|
ow:queueScript({
|
||||||
{ "show_text", "_ViridianMartClerkYouCameFromPalletTownText" },
|
{ "show_text", "_ViridianMartClerkYouCameFromPalletTownText" },
|
||||||
{ "move_player", "left", 1 },
|
|
||||||
{ "move_player", "up", 2 },
|
{ "move_player", "up", 2 },
|
||||||
|
{ "move_player", "left", 1 },
|
||||||
-- the quest text's last page is "{PLAYER} got\nOAK's PARCEL!"
|
-- the quest text's last page is "{PLAYER} got\nOAK's PARCEL!"
|
||||||
{ "give_item", "OAKS_PARCEL", 1, "_ViridianMartClerkParcelQuestText" },
|
{ "give_item", "OAKS_PARCEL", 1, "_ViridianMartClerkParcelQuestText" },
|
||||||
{ "set_flag", "EVENT_GOT_OAKS_PARCEL" },
|
{ "set_flag", "EVENT_GOT_OAKS_PARCEL" },
|
||||||
@@ -86,6 +94,27 @@ M.VIRIDIAN_CITY = {
|
|||||||
{ "show_text", "_ViridianCityOldManTimeIsMoneyText" }, -- 8 (9 = end)
|
{ "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
|
-- ViridianCityCheckGotPokedexScript: the north corridor is gated on
|
||||||
-- EVENT_GOT_POKEDEX, NOT on the sleeper being hidden, and it triggers
|
-- 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)
|
-- 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
|
return npc and npc.def.sprite == "SPRITE_BOULDER" and npc or nil
|
||||||
end
|
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 = {
|
M.VICTORY_ROAD_1F = {
|
||||||
onEnter = function(game, ow)
|
onEnter = function(game, ow)
|
||||||
if game.save.flags.EVENT_VICTORY_ROAD_1_BOULDER_ON_SWITCH then
|
ow:replaceBlock(4, 6,
|
||||||
ow:replaceBlock(4, 6, 0x1D)
|
game.save.flags.EVENT_VICTORY_ROAD_1_BOULDER_ON_SWITCH and 0x1D or 0x25)
|
||||||
end
|
|
||||||
end,
|
end,
|
||||||
onBoulderMoved = function(game, ow, npc)
|
onBoulderMoved = function(game, ow, npc)
|
||||||
if npc.cellX == 17 and npc.cellY == 13
|
if npc.cellX == 17 and npc.cellY == 13
|
||||||
@@ -728,12 +766,14 @@ M.VICTORY_ROAD_1F = {
|
|||||||
|
|
||||||
M.VICTORY_ROAD_2F = {
|
M.VICTORY_ROAD_2F = {
|
||||||
onEnter = function(game, ow)
|
onEnter = function(game, ow)
|
||||||
if game.save.flags.EVENT_VICTORY_ROAD_2_BOULDER_ON_SWITCH1 then
|
-- VictoryRoad2FResetBoulderEventScript (scripts/VictoryRoad2F.asm:19):
|
||||||
ow:replaceBlock(3, 4, 0x15)
|
-- entering 2F clears the 1F switch event, so 1F's barrier is closed
|
||||||
end
|
-- again the next time you climb down (#258)
|
||||||
if game.save.flags.EVENT_VICTORY_ROAD_2_BOULDER_ON_SWITCH2 then
|
game.save.flags.EVENT_VICTORY_ROAD_1_BOULDER_ON_SWITCH = nil
|
||||||
ow:replaceBlock(11, 7, 0x1D)
|
ow:replaceBlock(3, 4,
|
||||||
end
|
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,
|
end,
|
||||||
onBoulderMoved = function(game, ow, npc)
|
onBoulderMoved = function(game, ow, npc)
|
||||||
if npc.cellX == 1 and npc.cellY == 16
|
if npc.cellX == 1 and npc.cellY == 16
|
||||||
@@ -751,9 +791,8 @@ M.VICTORY_ROAD_2F = {
|
|||||||
|
|
||||||
M.VICTORY_ROAD_3F = {
|
M.VICTORY_ROAD_3F = {
|
||||||
onEnter = function(game, ow)
|
onEnter = function(game, ow)
|
||||||
if game.save.flags.EVENT_VICTORY_ROAD_3_BOULDER_ON_SWITCH1 then
|
ow:replaceBlock(3, 5,
|
||||||
ow:replaceBlock(3, 5, 0x1D)
|
game.save.flags.EVENT_VICTORY_ROAD_3_BOULDER_ON_SWITCH1 and 0x1D or 0x25)
|
||||||
end
|
|
||||||
end,
|
end,
|
||||||
onBoulderMoved = function(game, ow, npc)
|
onBoulderMoved = function(game, ow, npc)
|
||||||
if npc.cellX == 3 and npc.cellY == 5
|
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
|
game.save.flags.EVENT_VICTORY_ROAD_3_BOULDER_ON_SWITCH1 = true
|
||||||
ow:replaceBlock(3, 5, 0x1D)
|
ow:replaceBlock(3, 5, 0x1D)
|
||||||
end
|
end
|
||||||
-- the hole at (23,15): the boulder drops to 2F next to switch 2
|
-- the hole at (23,15): the boulder drops to 2F next to switch 2.
|
||||||
if npc.cellX == 23 and npc.cellY == 15 then
|
-- 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 Commands = require("src.script.Commands")
|
||||||
local ctx = { save = game.save, overworld = ow, game = game }
|
local ctx = { save = game.save, overworld = ow, game = game }
|
||||||
Commands.hide_object(ctx, "VICTORY_ROAD_3F", npc.def.name)
|
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
|
||||||
end,
|
end,
|
||||||
-- scripts/VictoryRoad3F.asm VictoryRoad3FDefaultScript: the same hole
|
-- 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
|
-- #118: do not raise mon.level until a paid retrieve (pokered reverts
|
||||||
-- wDayCareMonBoxLevel on .leaveMonInDayCare). Fold pending steps into
|
-- wDayCareMonBoxLevel on .leaveMonInDayCare). Fold pending steps into
|
||||||
-- mon.exp once and clear them so a second talk cannot re-apply the same
|
-- 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.
|
-- 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-
|
-- Rocket Hideout: without LIFT_KEY the panel only prints the need-
|
||||||
-- a-key line (scripts/RocketHideoutElevator.asm). Exit warps are
|
-- a-key line (scripts/RocketHideoutElevator.asm). Exit warps are
|
||||||
-- still seeded above so walking out returns to the entry floor
|
-- 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
|
if keyGate and not game.save.inventory[keyGate.item] then
|
||||||
local TextBox = require("src.render.TextBox")
|
local TextBox = require("src.render.TextBox")
|
||||||
game.stack:push(TextBox.new(game,
|
game.stack:push(TextBox.new(game,
|
||||||
|
|||||||
+147
-19
@@ -293,7 +293,11 @@ pewterEscort.guySteps = {
|
|||||||
"right", "right", "right",
|
"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
|
do
|
||||||
local opp = { up = "down", down = "up", left = "right", right = "left" }
|
local opp = { up = "down", down = "up", left = "right", right = "left" }
|
||||||
local ret = {}
|
local ret = {}
|
||||||
@@ -371,20 +375,44 @@ local function pewterGymEscort(game, ow)
|
|||||||
local head = plan.guyHeadStart
|
local head = plan.guyHeadStart
|
||||||
|
|
||||||
-- After the walk: face the player, restore map music, "Go take on
|
-- After the walk: face the player, restore map music, "Go take on
|
||||||
-- BROCK", then retrace RLEList_PewterGymGuy back to his spawn (35,16).
|
-- BROCK", then MovementData_PewterGymGuyExit -- five steps RIGHT out of
|
||||||
-- (pokered teleports him via MovementData_PewterGymGuyExit; we walk
|
-- (12,18), the cell PewterCityYoungsterShowsPlayerGymScript pins him to
|
||||||
-- the same route home instead. Brock victory still HideObject's him.)
|
-- 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()
|
local function walkHome()
|
||||||
if not guy then return end
|
if not guy then return end
|
||||||
local ret = pewterEscort.guyReturnSteps
|
|
||||||
local i = 0
|
local i = 0
|
||||||
local function tick()
|
local function tick()
|
||||||
i = i + 1
|
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"
|
guy.facing = "down"
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
ow:scriptMove(guy, ret[i], 1, tick)
|
ow:scriptMove(guy, "right", 1, tick)
|
||||||
end
|
end
|
||||||
tick()
|
tick()
|
||||||
end
|
end
|
||||||
@@ -468,23 +496,48 @@ end
|
|||||||
-- Gym) and again in Viridian Gym; we derive the same windows from the
|
-- Gym) and again in Viridian Gym; we derive the same windows from the
|
||||||
-- surrounding story flags so old saves work too.
|
-- surrounding story flags so old saves work too.
|
||||||
-- Rival1Exit → Viridian (right/down); Rival2Exit → League (left).
|
-- 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)
|
local function route22ExitDirs(n, py)
|
||||||
if n == 2 then
|
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" }
|
return { "left", "left", "left" }
|
||||||
end
|
end
|
||||||
if py == 5 then
|
-- Route22Rival1ExitMovementData1: (29,5) -> (31,5) -> (31,10)
|
||||||
|
if py == 4 then
|
||||||
return { "right", "right", "down", "down", "down", "down", "down" }
|
return { "right", "right", "down", "down", "down", "down", "down" }
|
||||||
end
|
end
|
||||||
|
-- Route22Rival1ExitMovementData2: (28,5) -> (28,4) -> (31,4) -> (31,10)
|
||||||
return { "up", "right", "right", "right",
|
return { "up", "right", "right", "right",
|
||||||
"down", "down", "down", "down", "down", "down" }
|
"down", "down", "down", "down", "down", "down" }
|
||||||
end
|
end
|
||||||
|
|
||||||
local function route22Scene(n, objIndex, objName, oppClass, baseParty, beatFlag, py)
|
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 {
|
return {
|
||||||
{ "show_object", "ROUTE_22", objName }, -- 1
|
{ "show_object", "ROUTE_22", objName }, -- 1
|
||||||
{ "move_npc_to", objIndex, 28, py }, -- 2
|
{ "move_npc_to", objIndex, rx, 5 }, -- 2
|
||||||
{ "face_object", objIndex, "right" }, -- 3
|
{ "face_object", objIndex, rivalFacing }, -- 3
|
||||||
{ "show_text", "_Route22RivalBeforeBattleText" .. n }, -- 4
|
{ "show_text", "_Route22RivalBeforeBattleText" .. n }, -- 4
|
||||||
{ "rival_battle", oppClass, baseParty }, -- 5
|
{ "rival_battle", oppClass, baseParty }, -- 5
|
||||||
{ "jump_if_false", 11 }, -- 6
|
{ "jump_if_false", 11 }, -- 6
|
||||||
@@ -496,20 +549,25 @@ local function route22Scene(n, objIndex, objName, oppClass, baseParty, beatFlag,
|
|||||||
}
|
}
|
||||||
end
|
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 = {
|
M.ROUTE_22 = {
|
||||||
onStep = function(game, ow, x, y)
|
onStep = function(game, ow, x, y)
|
||||||
if not inCoords({ { 29, 4 }, { 29, 5 } }, x, y) then return false end
|
if not inCoords({ { 29, 4 }, { 29, 5 } }, x, y) then return false end
|
||||||
local f = game.save.flags
|
local f = game.save.flags
|
||||||
|
local playerFacing = (y == 4) and "down" or "left"
|
||||||
if f.EVENT_GOT_POKEDEX and not f.EVENT_BEAT_BROCK
|
if f.EVENT_GOT_POKEDEX and not f.EVENT_BEAT_BROCK
|
||||||
and not f.EVENT_BEAT_ROUTE22_RIVAL_1ST_BATTLE then
|
and not f.EVENT_BEAT_ROUTE22_RIVAL_1ST_BATTLE then
|
||||||
return runAmbush(game, ow,
|
return runAmbush(game, ow,
|
||||||
route22Scene(1, 1, "ROUTE22_RIVAL1", "OPP_RIVAL1", 4,
|
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
|
end
|
||||||
if f.EVENT_BEAT_GIOVANNI and not f.EVENT_BEAT_ROUTE22_RIVAL_2ND_BATTLE then
|
if f.EVENT_BEAT_GIOVANNI and not f.EVENT_BEAT_ROUTE22_RIVAL_2ND_BATTLE then
|
||||||
return runAmbush(game, ow,
|
return runAmbush(game, ow,
|
||||||
route22Scene(2, 2, "ROUTE22_RIVAL2", "OPP_RIVAL2", 10,
|
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
|
end
|
||||||
return false
|
return false
|
||||||
end,
|
end,
|
||||||
@@ -545,7 +603,7 @@ end
|
|||||||
-- scripts/CeruleanCity.asm CeruleanCityRocketText: fight the thief,
|
-- scripts/CeruleanCity.asm CeruleanCityRocketText: fight the thief,
|
||||||
-- then he returns TM28 (DIG) and hurries off. CeruleanHideRocket
|
-- then he returns TM28 (DIG) and hurries off. CeruleanHideRocket
|
||||||
-- (CeruleanCity_2.asm) is GBFadeOutToBlack → Show GUARD1 / Hide GUARD2 /
|
-- (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 = {
|
local rocketRows = {
|
||||||
{ "face_player" }, -- 1
|
{ "face_player" }, -- 1
|
||||||
{ "check_flag", "EVENT_GOT_TM28" }, -- 2
|
{ "check_flag", "EVENT_GOT_TM28" }, -- 2
|
||||||
@@ -564,7 +622,7 @@ local rocketRows = {
|
|||||||
{ "fade", "out" }, -- 15 GBFadeOutToBlack
|
{ "fade", "out" }, -- 15 GBFadeOutToBlack
|
||||||
-- CeruleanHideRocket while black: GUARD1 (28,12) appears, GUARD2
|
-- CeruleanHideRocket while black: GUARD1 (28,12) appears, GUARD2
|
||||||
-- (27,12) and the ROCKET go. GUARD2 blocks the trashed-house south
|
-- (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).
|
-- the same in story.lua; either route is enough).
|
||||||
{ "show_object", "CERULEAN_CITY", "CERULEANCITY_GUARD1" }, -- 16
|
{ "show_object", "CERULEAN_CITY", "CERULEANCITY_GUARD1" }, -- 16
|
||||||
{ "hide_object", "CERULEAN_CITY", "CERULEANCITY_GUARD2" }, -- 17
|
{ "hide_object", "CERULEAN_CITY", "CERULEANCITY_GUARD2" }, -- 17
|
||||||
@@ -628,13 +686,83 @@ M.MUSEUM_1F = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
-- The Pewter Center's singing JIGGLYPUFF (scripts/PewterPokecenter.asm
|
-- 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 = {
|
M.PEWTER_POKECENTER = {
|
||||||
talk = {
|
talk = {
|
||||||
TEXT_PEWTERPOKECENTER_JIGGLYPUFF = function(game, ow, npc, done)
|
TEXT_PEWTERPOKECENTER_JIGGLYPUFF = function(game, ow, npc, done)
|
||||||
require("src.core.Music").playOnce(game.data, "Music_JigglypuffSong")
|
-- not the local push() helper: this box needs opts.auto, which is
|
||||||
push(game, text(game)._PewterPokecenterJigglypuffText
|
-- also what suppresses the blinking arrow and the A dismissal
|
||||||
or "JIGGLYPUFF: Puu\npupuu!", done)
|
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,
|
end,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
-- `dialogue` is the end-battle + post-battle text chain each gym leader
|
-- `dialogue` is the end-battle + post-battle text chain each gym leader
|
||||||
-- runs (SaveEndBattleTextPointers then the map's *PostBattle / ReceiveTM
|
-- runs (SaveEndBattleTextPointers then the map's *PostBattle / ReceiveTM
|
||||||
-- script). Leaders are not def_trainers entries, so engageTrainer has
|
-- 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.
|
-- synthetic "received badge/TM" stub.
|
||||||
|
|
||||||
local function range(prefix, first, last)
|
local function range(prefix, first, last)
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
# Anbernic RG34XXSP (Stock OS 64-bit MOD)
|
||||||
|
|
||||||
|
Download `gen1recomp-*-rg34xxsp-stockos64-mod.zip` from
|
||||||
|
[Releases](https://github.com/bryanthaboi/gen1recomp/releases). This build
|
||||||
|
targets **Stock OS 64-bit MOD** on the RG34XXSP with PortMaster installed
|
||||||
|
(TF1).
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
1. Unzip the release on your computer. You get `Gen1recomp.sh` and a
|
||||||
|
`gen1recomp/` folder.
|
||||||
|
2. Copy **both** onto the SD card under **`Roms/PORTS/`** so the layout is:
|
||||||
|
|
||||||
|
```
|
||||||
|
Roms/PORTS/Gen1recomp.sh
|
||||||
|
Roms/PORTS/gen1recomp/
|
||||||
|
```
|
||||||
|
|
||||||
|
On the device that path is `/mnt/mmc/Roms/PORTS/`. Keep the launcher and
|
||||||
|
the `gen1recomp/` folder as siblings — do not nest the `.sh` inside the
|
||||||
|
folder.
|
||||||
|
3. Put your legal US Red and/or Blue `.gb` files inside the game folder:
|
||||||
|
|
||||||
|
```
|
||||||
|
Roms/PORTS/gen1recomp/lovegame/
|
||||||
|
```
|
||||||
|
|
||||||
|
Example: `Roms/PORTS/gen1recomp/lovegame/Pokemon - Red Version.gb`
|
||||||
|
4. Eject the card, boot the handheld, open **Ports → Gen1recomp**.
|
||||||
|
5. On the launcher, move the cursor with the D-pad or left stick, press **A**
|
||||||
|
to click. Choose the Red or Blue tab, then **Choose ROM** — with no file
|
||||||
|
picker on stock OS, that scans `lovegame/` for the `.gb` you dropped in.
|
||||||
|
|
||||||
|
The port ships with `portable.txt` already in place, so after import the
|
||||||
|
saves and ROM-derived cache stay next to the game on the SD card. See
|
||||||
|
[Portable Mode](../README.md#portable-mode) for what that means.
|
||||||
|
|
||||||
|
Only the canonical 1 MiB US carts import:
|
||||||
|
|
||||||
|
- Red: `ea9bcae617fdf159b045185467ae58b2e4a48b9a`
|
||||||
|
- Blue: `d7037c83e1ae5b39bde3c30787637ba1d4c48ce2`
|
||||||
|
|
||||||
|
## Launcher controls
|
||||||
|
|
||||||
|
| Input | Action |
|
||||||
|
| ------------------ | ------------------ |
|
||||||
|
| D-pad / left stick | Move cursor |
|
||||||
|
| A | Click |
|
||||||
|
| L1 / R1 | Switch tabs |
|
||||||
|
| Right stick | Scroll lists |
|
||||||
|
| Start / Select | Play or Choose ROM |
|
||||||
|
|
||||||
|
In-game controls use the normal PortMaster / SDL pad map, rebindable under
|
||||||
|
**OPTIONS → CONTROLS**.
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
**GBC FX is off on this device.** The launcher exports `POKEPORT_GBCFX=0`,
|
||||||
|
which hides the GBC FX row from OPTIONS, pins the level to OFF, and clears a
|
||||||
|
level carried over in an `options.lua` from another machine. The H700's Mali
|
||||||
|
GPU is in the same class as the phone GPUs that compile that present pass and
|
||||||
|
then show a black frame (issue #136), and `love.system.getOS()` reports
|
||||||
|
`"Linux"` here, so the Android gate would not have caught it. Every other
|
||||||
|
display option — COLORS, TILT, ZOOM, VOID FILL, MAX FPS — works normally. If
|
||||||
|
your device turns out to handle the pass, launch with `POKEPORT_GBCFX=1` to
|
||||||
|
put the row back.
|
||||||
|
|
||||||
|
The pack bundles the LÖVE 11.5 aarch64 runtime from
|
||||||
|
[PortMaster](https://portmaster.games/), so the device does not need a
|
||||||
|
separate `love_11.5` runtime download on first launch. The launcher resolves
|
||||||
|
paths relative to its own directory rather than PortMaster's `$directory`,
|
||||||
|
because stock Anbernic firmware runs ports out of `roms/PORTS/` with its own
|
||||||
|
casing and mount points.
|
||||||
|
|
||||||
|
If a launch fails, `Roms/PORTS/gen1recomp/log.txt` holds the output of the
|
||||||
|
last run.
|
||||||
|
|
||||||
|
## Building the port
|
||||||
|
|
||||||
|
Release runs build this automatically (see `.github/workflows/release.yml`).
|
||||||
|
To build it by hand:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
./build-rg34xxsp.sh --version 0.1.0 # -> dist/rg34xxsp/gen1recomp-rg34xxsp-stockos64-mod.zip
|
||||||
|
```
|
||||||
|
|
||||||
|
`install-rg34xxsp.sh` copies that pack straight onto a mounted SD card for
|
||||||
|
local testing.
|
||||||
|
|
||||||
|
Stock OS 64-bit MOD comes from
|
||||||
|
[cbepx-me](https://github.com/cbepx-me/Anbernic-H700-RG-xx-StockOS-Modification).
|
||||||
@@ -59,7 +59,7 @@ the same core data and graphics into the source tree for verification.
|
|||||||
| | `src/battle/Experience.lua`, `Catching.lua`, `TrainerAI.lua` | exp/levels, Gen 1 catch algorithm, AI |
|
| | `src/battle/Experience.lua`, `Catching.lua`, `TrainerAI.lua` | exp/levels, Gen 1 catch algorithm, AI |
|
||||||
| | `src/battle/rulesets/` | `gen1_faithful` (default) vs `modern_clean` |
|
| | `src/battle/rulesets/` | `gen1_faithful` (default) vs `modern_clean` |
|
||||||
| ui | `src/ui/*` | start menu, generic menu, yes/no box, party/bag lists |
|
| 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
|
## Map scripts
|
||||||
|
|
||||||
|
|||||||
@@ -69,6 +69,15 @@ keeps working unchanged.
|
|||||||
- **Registry.** The ordered slot list and which one is active persist in
|
- **Registry.** The ordered slot list and which one is active persist in
|
||||||
`options.lua` (via the existing `SaveData.loadOptions`/`saveOptions`):
|
`options.lua` (via the existing `SaveData.loadOptions`/`saveOptions`):
|
||||||
`options.saveSlots = { [version] = { list = {"slot1", ...}, active = "slot1" } }`.
|
`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
|
- **Active slot resolution.** `saveNames(version)`, the function every
|
||||||
existing caller (`TitleState` hasSave/load/save, recovery order) already
|
existing caller (`TitleState` hasSave/load/save, recovery order) already
|
||||||
goes through, now resolves the *active* slot instead of a fixed flat name.
|
goes through, now resolves the *active* slot instead of a fixed flat name.
|
||||||
|
|||||||
+6
-6
@@ -1,21 +1,21 @@
|
|||||||
# Native modding
|
# Native modding
|
||||||
|
|
||||||
The modding book lives on the
|
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.
|
— 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.
|
— 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.
|
— 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`.
|
— every registry, generated from `src/mods/Schemas.lua`.
|
||||||
|
|
||||||
Regenerate the reference straight into a wiki checkout:
|
Regenerate the reference straight into a wiki checkout:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
luajit tools/gen_registry_docs.lua ../pokemon-gen1-recomp-project.wiki
|
luajit tools/gen_registry_docs.lua ../gen1recomp.wiki
|
||||||
```
|
```
|
||||||
|
|
||||||
## Rendering pipelines
|
## Rendering pipelines
|
||||||
|
|||||||
+151
-7
@@ -86,7 +86,7 @@ Game Boy equivalent:
|
|||||||
## Colors mode
|
## Colors mode
|
||||||
|
|
||||||
The `2` key (and the Options menu COLORS row) cycles the display mode
|
The `2` key (and the Options menu COLORS row) cycles the display mode
|
||||||
through **OG RED → SGB → 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:
|
The first three are the real colorizations; the rest are DMG-shade novelties:
|
||||||
|
|
||||||
- **OG RED**: the Game Boy Color boot-ROM look for Pokemon Red -- one global
|
- **OG RED**: 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
|
- **SGB** (default): the per-map Super Game Boy region palettes
|
||||||
(`data/sgb/sgb_palettes.asm`). Sprites tint with the region palette, as on
|
(`data/sgb/sgb_palettes.asm`). Sprites tint with the region palette, as on
|
||||||
real SGB. (This is the mode formerly mislabeled "GBC".)
|
real SGB. (This is the mode formerly mislabeled "GBC".)
|
||||||
- **RED++**: pokered-gbc SuperPalettes -- real per-tile GBC coloring plus
|
- **ADVANCED**: pokered-gbc SuperPalettes -- real per-tile GBC coloring plus
|
||||||
per-species mon colors (`data/palettes_gbc.lua`).
|
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**: force the four DMG grays (colorization off).
|
||||||
- **OG INV**: inverted DMG grays.
|
- **OG INV**: inverted DMG grays.
|
||||||
- **SGB INV**: each SGB zone palette with shade order reversed.
|
- **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
|
it covers overworld, menus, battles, and tilt upright billboards. OG RED's
|
||||||
global BG palette is supplied by `OverworldState:overworldBgColors` (per-map
|
global BG palette is supplied by `OverworldState:overworldBgColors` (per-map
|
||||||
override in the overworld pass). Persisted as `save.options.colors`; the
|
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
|
## GBC FX
|
||||||
|
|
||||||
@@ -127,6 +129,14 @@ It runs as a final present pass after world + UI composite in
|
|||||||
([github.com/mattakins/Pixel_Transparency](https://github.com/mattakins/Pixel_Transparency)).
|
([github.com/mattakins/Pixel_Transparency](https://github.com/mattakins/Pixel_Transparency)).
|
||||||
Default OFF; persisted as `save.options.gbcfx`.
|
Default OFF; persisted as `save.options.gbcfx`.
|
||||||
|
|
||||||
|
Mobile GPUs often compile the pass but present a black frame, so Android and
|
||||||
|
iOS hide the row entirely, pin the level to OFF, and rewrite a level already
|
||||||
|
persisted in `options.lua` (issue #136). `POKEPORT_GBCFX` overrides that
|
||||||
|
decision either way, same tri-state as `POKEPORT_TOUCH`: `=0` refuses the
|
||||||
|
effect, `=1` forces it available. The Anbernic handheld pack exports `0` from
|
||||||
|
its launcher because the device reports `"Linux"` while its GPU is in the
|
||||||
|
phone class (see [Anbernic RG34XXSP](anbernic-rg34xxsp.md)).
|
||||||
|
|
||||||
## Peer-to-peer link play (lua-enet)
|
## Peer-to-peer link play (lua-enet)
|
||||||
|
|
||||||
Trades and link battles connect two copies of the game directly over
|
Trades and link battles connect two copies of the game directly over
|
||||||
@@ -141,6 +151,30 @@ tradeoff vs. the relay). Headless tests drive the protocol over an
|
|||||||
in-memory loopback (`Net.loopbackPair`); under LÖVE the same test file
|
in-memory loopback (`Net.loopbackPair`); under LÖVE the same test file
|
||||||
also exercises real UDP pairing.
|
also exercises real UDP pairing.
|
||||||
|
|
||||||
|
## Fair play in link and online matches
|
||||||
|
|
||||||
|
A link session is decided by the battle and nothing else, so for its
|
||||||
|
duration:
|
||||||
|
|
||||||
|
- **Game speed is pinned to normal.** The GAME SPEED option and
|
||||||
|
`POKEPORT_SPEED` are ignored from the moment LINK PLAY opens until it
|
||||||
|
closes, and apply again after. Fast-forward otherwise runs one peer's
|
||||||
|
queue faster than the peer it is locked to and drains a tournament shot
|
||||||
|
clock faster than the opponent racing it.
|
||||||
|
- **Online play runs vanilla.** Picking ONLINE MATCH or TOURNAMENT with
|
||||||
|
mods enabled offers to switch them all off and relaunch (mods merge at
|
||||||
|
boot, so a restart is the only way). The restart is confirmed, not
|
||||||
|
silent. They stay listed as disabled, ready to switch back on.
|
||||||
|
- **Only a meaningful split ends a match.** The per-turn state signature
|
||||||
|
both peers exchange is split three ways: `actives` and `bench` carry
|
||||||
|
species, HP, status, stat stages, PP and the rest of the party, and a
|
||||||
|
divergence there ends the match as a draw. `volatile` carries per-turn
|
||||||
|
flags both sides recompute anyway - a divergence there is logged and
|
||||||
|
reported to mods, and play continues.
|
||||||
|
|
||||||
|
The relay logs which component diverged on which turn, so a desync report
|
||||||
|
names something specific.
|
||||||
|
|
||||||
## Custom boot text
|
## Custom boot text
|
||||||
|
|
||||||
The boot sequence replaces the Nintendo / GAME FREAK identifiers with
|
The boot sequence replaces the Nintendo / GAME FREAK identifiers with
|
||||||
@@ -161,8 +195,9 @@ migrated once into `options.lua` on load.
|
|||||||
- Music / SFX volume
|
- Music / SFX volume
|
||||||
- Music Filter
|
- Music Filter
|
||||||
- OG GLITCHES on / off (Gen 1 quirks vs. modern-clean battle rules)
|
- OG GLITCHES on / off (Gen 1 quirks vs. modern-clean battle rules)
|
||||||
- COLORS (OG RED / SGB / RED++ / OG / OG INV / SGB INV / CLASSIC), also
|
- BATTLE LAYOUT (OG / WIDE); see "Widescreen battle layout" below
|
||||||
hotkey `2` (OG RED = GBC boot-ROM look; RED++ uses pokered-gbc
|
- 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)
|
SuperPalettes + per-species mon colors)
|
||||||
- TILT (OFF / 15 / 35 / 50), also hotkey `3` while free-roaming
|
- TILT (OFF / 15 / 35 / 50), also hotkey `3` while free-roaming
|
||||||
- ZOOM (FIT / OUTn / INn), also hotkey `4` while free-roaming; wheel and
|
- ZOOM (FIT / OUTn / INn), also hotkey `4` while free-roaming; wheel and
|
||||||
@@ -181,6 +216,36 @@ void outside the OG wipe fills in lockstep. Once the battle state is up,
|
|||||||
letterbox voids around the battle canvas fill **white** instead of black
|
letterbox voids around the battle canvas fill **white** instead of black
|
||||||
so the whole window reads as one continuous battle screen.
|
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-screen touch controls (mobile)
|
||||||
|
|
||||||
On Android/iOS the game draws a translucent d-pad (bottom-left), A/B
|
On Android/iOS the game draws a translucent d-pad (bottom-left), A/B
|
||||||
@@ -193,4 +258,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
|
stick push hides it, the next screen touch brings it back, and unplugging
|
||||||
the last controller restores it immediately. Layout re-derives from the
|
the last controller restores it immediately. Layout re-derives from the
|
||||||
window size on rotation. Desktop testing: `POKEPORT_TOUCH=1 love .` forces
|
window size on rotation. Desktop testing: `POKEPORT_TOUCH=1 love .` forces
|
||||||
the overlay on and lets the mouse act as a finger (`=0` forces it off).
|
the overlay on and lets the mouse act as a finger (`=0` forces it off).
|
||||||
|
## 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":
|
A release missing either asset is treated as "no in-place update available":
|
||||||
`Check` reports `needs_full` and sends the player to `Check.releaseUrl()`
|
`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
|
## 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
|
-- Native LÖVE2D port of Pokemon Red. A packaged build creates its private
|
||||||
-- game-data cache from a user-provided ROM on first boot.
|
-- game-data cache from a user-provided ROM on first boot.
|
||||||
--
|
--
|
||||||
-- Set POKEPORT_EDITOR=1 or pass `--editor` to `love .` to boot the save
|
-- The save editor (tools/save-editor/) ships inside every build and is
|
||||||
-- editor tool (tools/save-editor/) instead of the game.
|
-- 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
|
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)))
|
return math.max(1, math.floor(require("src.core.GameSpeed").clamp(speedOverride)))
|
||||||
end
|
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)
|
local function bootGame(version)
|
||||||
-- The launcher hands us the chosen game (Red / Blue); scripted and headless
|
-- 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
|
-- runs fall back to POKEPORT_VERSION, then Red. Set the active version and
|
||||||
@@ -81,12 +184,17 @@ function love.load(args)
|
|||||||
end
|
end
|
||||||
love.graphics.setDefaultFilter("nearest", "nearest")
|
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
|
if editorMode then
|
||||||
package.path = love.filesystem.getSource() .. "/tools/save-editor/?.lua;"
|
local version = os.getenv("POKEPORT_VERSION") or "red"
|
||||||
.. love.filesystem.getSource() .. "/tools/save-editor/panels/?.lua;"
|
require("src.core.GameVersion").set(version)
|
||||||
.. package.path
|
require("src.import.CacheFs").mountVersion(version)
|
||||||
|
addEditorRequirePath()
|
||||||
EditorApp = require("App")
|
EditorApp = require("App")
|
||||||
EditorApp.load(savePath)
|
EditorApp.load(savePath, { version = version })
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -127,10 +235,11 @@ function love.load(args)
|
|||||||
-- column shows Play when that game's ROM is already imported, or Choose ROM
|
-- 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
|
-- / 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.
|
-- .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 = RomImporter.new(function(version)
|
||||||
Importer = nil
|
Importer = nil
|
||||||
bootGame(version)
|
bootGame(version)
|
||||||
end, { launcher = true, forceImport = forceImport })
|
end, { launcher = true, forceImport = forceImport, onEditSave = openEditor })
|
||||||
end
|
end
|
||||||
|
|
||||||
function love.update(dt)
|
function love.update(dt)
|
||||||
@@ -206,19 +315,19 @@ end
|
|||||||
|
|
||||||
function love.gamepadpressed(joystick, button)
|
function love.gamepadpressed(joystick, button)
|
||||||
if editorMode then return end
|
if editorMode then return end
|
||||||
if Importer then return end
|
if Importer then return Importer:gamepadpressed(joystick, button) end
|
||||||
Game:gamepadpressed(joystick, button)
|
Game:gamepadpressed(joystick, button)
|
||||||
end
|
end
|
||||||
|
|
||||||
function love.gamepadreleased(joystick, button)
|
function love.gamepadreleased(joystick, button)
|
||||||
if editorMode then return end
|
if editorMode then return end
|
||||||
if Importer then return end
|
if Importer then return Importer:gamepadreleased(joystick, button) end
|
||||||
Game:gamepadreleased(joystick, button)
|
Game:gamepadreleased(joystick, button)
|
||||||
end
|
end
|
||||||
|
|
||||||
function love.gamepadaxis(joystick, axis, value)
|
function love.gamepadaxis(joystick, axis, value)
|
||||||
if editorMode then return end
|
if editorMode then return end
|
||||||
if Importer then return end
|
if Importer then return Importer:gamepadaxis(joystick, axis, value) end
|
||||||
Game:gamepadaxis(joystick, axis, value)
|
Game:gamepadaxis(joystick, axis, value)
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -302,7 +411,7 @@ function love.mousemoved(x, y)
|
|||||||
end
|
end
|
||||||
|
|
||||||
function love.textinput(text)
|
function love.textinput(text)
|
||||||
if Importer then return end
|
if Importer then return Importer:textinput(text) end
|
||||||
if editorMode and EditorApp.textinput then
|
if editorMode and EditorApp.textinput then
|
||||||
return EditorApp.textinput(text)
|
return EditorApp.textinput(text)
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -2,6 +2,12 @@
|
|||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android" >
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android" >
|
||||||
<uses-permission android:name="android.permission.VIBRATE" />
|
<uses-permission android:name="android.permission.VIBRATE" />
|
||||||
<uses-permission android:name="android.permission.BLUETOOTH" />
|
<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 -->
|
<!-- OpenGL ES 2.0 -->
|
||||||
<uses-feature android:glEsVersion="0x00020000" />
|
<uses-feature android:glEsVersion="0x00020000" />
|
||||||
<!-- Touchscreen support -->
|
<!-- Touchscreen support -->
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
return {
|
return {
|
||||||
summary = "Oak's Charmander gift becomes a level 20 Mew with inverted sprites.",
|
summary = "Oak's Charmander gift becomes a level 20 Mew with inverted sprites.",
|
||||||
author = "Pokemon Gen 1 Recompilation Project",
|
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" },
|
tags = { "beginner", "cosmetic", "data-only", "legacy" },
|
||||||
differences = {
|
differences = {
|
||||||
changed = {
|
changed = {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
return {
|
return {
|
||||||
summary = "Faster final starters, half-price TMs, a re-slotted Route 1.",
|
summary = "Faster final starters, half-price TMs, a re-slotted Route 1.",
|
||||||
author = "Pokemon Gen 1 Recompilation Project",
|
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" },
|
tags = { "balance", "data-only", "beginner" },
|
||||||
differences = {
|
differences = {
|
||||||
changed = {
|
changed = {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
return {
|
return {
|
||||||
summary = "A start-menu dex overlay with seen/owned counts and an inter-mod API.",
|
summary = "A start-menu dex overlay with seen/owned counts and an inter-mod API.",
|
||||||
author = "Pokemon Gen 1 Recompilation Project",
|
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" },
|
tags = { "ui", "tool", "quality-of-life" },
|
||||||
differences = {
|
differences = {
|
||||||
changed = { "the START menu gains a DEXNAV row above SAVE" },
|
changed = { "the START menu gains a DEXNAV row above SAVE" },
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
return {
|
return {
|
||||||
summary = "An authored chip song for Pallet Town, a new Mew cry, and a jukebox screen.",
|
summary = "An authored chip song for Pallet Town, a new Mew cry, and a jukebox screen.",
|
||||||
author = "Pokemon Gen 1 Recompilation Project",
|
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" },
|
tags = { "audio", "chiptune", "ui" },
|
||||||
differences = {
|
differences = {
|
||||||
changed = {
|
changed = {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
return {
|
return {
|
||||||
summary = "A courier in Viridian lost a parcel in Pewter. Fetch it for a NUGGET.",
|
summary = "A courier in Viridian lost a parcel in Pewter. Fetch it for a NUGGET.",
|
||||||
author = "Pokemon Gen 1 Recompilation Project",
|
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" },
|
tags = { "quest", "story", "scripting" },
|
||||||
differences = {
|
differences = {
|
||||||
changed = {
|
changed = {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
return {
|
return {
|
||||||
summary = "Sable Cove: one town, three species, one badge. The smallest whole conversion.",
|
summary = "Sable Cove: one town, three species, one badge. The smallest whole conversion.",
|
||||||
author = "Pokemon Gen 1 Recompilation Project",
|
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" },
|
tags = { "total-conversion", "capstone" },
|
||||||
differences = {
|
differences = {
|
||||||
changed = {
|
changed = {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
return {
|
return {
|
||||||
summary = "A teal player recolor derived from your own cache, plus two palette records.",
|
summary = "A teal player recolor derived from your own cache, plus two palette records.",
|
||||||
author = "Pokemon Gen 1 Recompilation Project",
|
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" },
|
tags = { "cosmetic", "graphics", "beginner" },
|
||||||
screenshots = {
|
screenshots = {
|
||||||
{ transform = "shots/pallet_town.lua", caption = "Pallet Town under the recolored palette" },
|
{ transform = "shots/pallet_town.lua", caption = "Pallet Town under the recolored palette" },
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
return {
|
return {
|
||||||
summary = "Oak asks dumb questions during the intro and remembers your answers.",
|
summary = "Oak asks dumb questions during the intro and remembers your answers.",
|
||||||
author = "Pokemon Gen 1 Recompilation Project",
|
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" },
|
tags = { "intro", "ui", "oak", "hooks" },
|
||||||
differences = {
|
differences = {
|
||||||
changed = {
|
changed = {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
return {
|
return {
|
||||||
summary = "Opt-in rain: WATER hits harder, FIRE hits softer, for five turns a battle.",
|
summary = "Opt-in rain: WATER hits harder, FIRE hits softer, for five turns a battle.",
|
||||||
author = "Pokemon Gen 1 Recompilation Project",
|
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" },
|
tags = { "battle", "mechanic", "ruleset", "hardcore" },
|
||||||
differences = {
|
differences = {
|
||||||
changed = {
|
changed = {
|
||||||
|
|||||||
+13
-1
@@ -56,16 +56,28 @@ done
|
|||||||
mkdir -p "$CACHE" "$WORK" "$DIST/mac" "$DIST/win" "$DIST/linux"
|
mkdir -p "$CACHE" "$WORK" "$DIST/mac" "$DIST/win" "$DIST/linux"
|
||||||
|
|
||||||
# --------------------------------------------------------------- game.love
|
# --------------------------------------------------------------- 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"
|
say "packing game.love"
|
||||||
LOVE_FILE="$WORK/game.love"
|
LOVE_FILE="$WORK/game.love"
|
||||||
rm -f "$LOVE_FILE"
|
rm -f "$LOVE_FILE"
|
||||||
(cd "$ROOT" && zip -q -9 -r "$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/*')
|
-x '*.DS_Store' 'data/generated/*' 'assets/generated/*')
|
||||||
if unzip -Z1 "$LOVE_FILE" \
|
if unzip -Z1 "$LOVE_FILE" \
|
||||||
| grep -Eq '^(data|assets)/generated/[^/]+|^(data|assets)/generated/.+/'; then
|
| grep -Eq '^(data|assets)/generated/[^/]+|^(data|assets)/generated/.+/'; then
|
||||||
fail "game.love unexpectedly contains generated ROM data"
|
fail "game.love unexpectedly contains generated ROM data"
|
||||||
fi
|
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)"
|
say "game.love: $(du -h "$LOVE_FILE" | cut -f1)"
|
||||||
|
|
||||||
# ------------------------------------------------------- stamp release version
|
# ------------------------------------------------------- stamp release version
|
||||||
|
|||||||
@@ -113,11 +113,12 @@ import pathlib, re, sys
|
|||||||
path = pathlib.Path(sys.argv[1])
|
path = pathlib.Path(sys.argv[1])
|
||||||
text = path.read_text()
|
text = path.read_text()
|
||||||
|
|
||||||
# Drop network / mic / legacy storage, not needed for offline play.
|
# Drop mic / legacy storage, not needed by this game.
|
||||||
# Keep VIBRATE (love.system.vibrate) and BLUETOOTH (optional gamepads).
|
# 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.
|
# Orientation / label come from gradle.properties placeholders.
|
||||||
for perm in (
|
for perm in (
|
||||||
"android.permission.INTERNET",
|
|
||||||
"android.permission.RECORD_AUDIO",
|
"android.permission.RECORD_AUDIO",
|
||||||
"android.permission.WRITE_EXTERNAL_STORAGE",
|
"android.permission.WRITE_EXTERNAL_STORAGE",
|
||||||
):
|
):
|
||||||
@@ -136,14 +137,19 @@ pack_game_love() {
|
|||||||
say "packing game.love for love-android embed flavor"
|
say "packing game.love for love-android embed flavor"
|
||||||
mkdir -p "$EMBED_ASSETS"
|
mkdir -p "$EMBED_ASSETS"
|
||||||
rm -f "$LOVE_FILE"
|
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" \
|
(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 '*.DS_Store' -x '*/.git/*' -x '*/.DS_Store' \
|
||||||
-x 'data/generated/*' -x 'assets/generated/*')
|
-x 'data/generated/*' -x 'assets/generated/*')
|
||||||
if unzip -Z1 "$LOVE_FILE" \
|
if unzip -Z1 "$LOVE_FILE" \
|
||||||
| grep -Eq '^(data|assets)/generated/[^/]+|^(data|assets)/generated/.+/'; then
|
| grep -Eq '^(data|assets)/generated/[^/]+|^(data|assets)/generated/.+/'; then
|
||||||
fail "game.love unexpectedly contains generated ROM data"
|
fail "game.love unexpectedly contains generated ROM data"
|
||||||
fi
|
fi
|
||||||
|
unzip -Z1 "$LOVE_FILE" | grep -qx 'tools/save-editor/App.lua' \
|
||||||
|
|| 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"
|
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
|
# 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"
|
say "packing game.love for love-ios resources"
|
||||||
mkdir -p "$RESOURCES_DIR"
|
mkdir -p "$RESOURCES_DIR"
|
||||||
rm -f "$LOVE_FILE"
|
rm -f "$LOVE_FILE"
|
||||||
# Same payload as scripts/build.sh / build_android.sh: game sources 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" \
|
(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 '*.DS_Store' -x '*/.git/*' -x '*/.DS_Store' \
|
||||||
-x 'data/generated/*' -x 'assets/generated/*')
|
-x 'data/generated/*' -x 'assets/generated/*')
|
||||||
if unzip -Z1 "$LOVE_FILE" \
|
if unzip -Z1 "$LOVE_FILE" \
|
||||||
| grep -Eq '^(data|assets)/generated/[^/]+|^(data|assets)/generated/.+/'; then
|
| grep -Eq '^(data|assets)/generated/[^/]+|^(data|assets)/generated/.+/'; then
|
||||||
fail "game.love unexpectedly contains generated ROM data"
|
fail "game.love unexpectedly contains generated ROM data"
|
||||||
fi
|
fi
|
||||||
|
unzip -Z1 "$LOVE_FILE" | grep -qx 'tools/save-editor/App.lua' \
|
||||||
|
|| 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"
|
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)"
|
echo "-- T3 content: skipped (--quick)"
|
||||||
else
|
else
|
||||||
run_tier "T3 content behavior (Red)" run_content_behavior
|
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" "$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
|
run_tier "T5 link (loopback lockstep)" "$LUA" tests/run_link_tests.lua
|
||||||
fi
|
fi
|
||||||
else
|
else
|
||||||
|
|||||||
+633
-201
File diff suppressed because it is too large
Load Diff
@@ -8,6 +8,7 @@
|
|||||||
local MoveEffects = require("src.battle.MoveEffects")
|
local MoveEffects = require("src.battle.MoveEffects")
|
||||||
local Runtime = require("src.mods.Runtime")
|
local Runtime = require("src.mods.Runtime")
|
||||||
local StatusRegistry = require("src.battle.StatusRegistry")
|
local StatusRegistry = require("src.battle.StatusRegistry")
|
||||||
|
local Strings = require("src.core.Strings")
|
||||||
|
|
||||||
local EffectRegistry = {}
|
local EffectRegistry = {}
|
||||||
|
|
||||||
@@ -91,7 +92,7 @@ function EffectRegistry.runDamaging(battle, ctx, record)
|
|||||||
if target.invulnerable and not neverMiss then
|
if target.invulnerable and not neverMiss then
|
||||||
-- Explosion/Selfdestruct still animate on a miss (HandleIfPlayerMoveMissed)
|
-- Explosion/Selfdestruct still animate on a miss (HandleIfPlayerMoveMissed)
|
||||||
if not (record and record.explode) then battle:cancelMoveAnim() end
|
if not (record and record.explode) then battle:cancelMoveAnim() end
|
||||||
battle:sayNext(("%s's\nattack missed!"):format(displayName(user)))
|
battle:sayNext(Strings("%s's\nattack missed!", displayName(user)))
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -113,7 +114,7 @@ function EffectRegistry.runDamaging(battle, ctx, record)
|
|||||||
if not battle:accuracyRoll(move, user, target) then
|
if not battle:accuracyRoll(move, user, target) then
|
||||||
-- Explosion/Selfdestruct still animate on a miss (HandleIfPlayerMoveMissed)
|
-- Explosion/Selfdestruct still animate on a miss (HandleIfPlayerMoveMissed)
|
||||||
if not (record and record.explode) then battle:cancelMoveAnim() end
|
if not (record and record.explode) then battle:cancelMoveAnim() end
|
||||||
battle:sayNext(("%s's\nattack missed!"):format(displayName(user)))
|
battle:sayNext(Strings("%s's\nattack missed!", displayName(user)))
|
||||||
-- Jump Kick crash, Explode self-destruct
|
-- Jump Kick crash, Explode self-destruct
|
||||||
if record and record.onMiss then record.onMiss(ctx, "accuracy") end
|
if record and record.onMiss then record.onMiss(ctx, "accuracy") end
|
||||||
user.trappingTurns = nil
|
user.trappingTurns = nil
|
||||||
@@ -140,7 +141,7 @@ function EffectRegistry.runDamaging(battle, ctx, record)
|
|||||||
end
|
end
|
||||||
if not counterable or (battle.lastDamage or 0) == 0 then
|
if not counterable or (battle.lastDamage or 0) == 0 then
|
||||||
battle:cancelMoveAnim()
|
battle:cancelMoveAnim()
|
||||||
battle:sayNext(("%s's\nattack missed!"):format(displayName(user)))
|
battle:sayNext(Strings("%s's\nattack missed!", displayName(user)))
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
dmg = math.min(65535, battle.lastDamage * 2)
|
dmg = math.min(65535, battle.lastDamage * 2)
|
||||||
@@ -163,14 +164,14 @@ function EffectRegistry.runDamaging(battle, ctx, record)
|
|||||||
if info.typeMult == 0 then
|
if info.typeMult == 0 then
|
||||||
-- type immunity zeros damage and sets wMoveMissed in Gen 1, so no anim
|
-- type immunity zeros damage and sets wMoveMissed in Gen 1, so no anim
|
||||||
if not (record and record.explode) then battle:cancelMoveAnim() end
|
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
|
if record and record.onMiss then record.onMiss(ctx, "immune") end
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
if info.missed then
|
if info.missed then
|
||||||
-- 0.25x floored the damage to zero: the original registers a miss
|
-- 0.25x floored the damage to zero: the original registers a miss
|
||||||
if not (record and record.explode) then battle:cancelMoveAnim() end
|
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
|
if record and record.onMiss then record.onMiss(ctx, "floored") end
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
@@ -214,12 +215,12 @@ function EffectRegistry.runDamaging(battle, ctx, record)
|
|||||||
-- multi-hit loop (core.asm .moveDidNotMiss before the jump back
|
-- multi-hit loop (core.asm .moveDidNotMiss before the jump back
|
||||||
-- to GetPlayerAnimationType), so crit/effectiveness reprint on
|
-- to GetPlayerAnimationType), so crit/effectiveness reprint on
|
||||||
-- every strike -- damage was only rolled once
|
-- every strike -- damage was only rolled once
|
||||||
if info.crit then battle:sayNext("Critical hit!") end
|
if info.crit then battle:sayNext(Strings("Critical hit!")) end
|
||||||
if info.ohko then battle:sayNext("One-hit KO!") end
|
if info.ohko then battle:sayNext(Strings("One-hit KO!")) end
|
||||||
if info.typeMult > 10 then
|
if info.typeMult > 10 then
|
||||||
battle:sayNext("It's super\neffective!")
|
battle:sayNext(Strings("It's super\neffective!"))
|
||||||
elseif info.typeMult < 10 then
|
elseif info.typeMult < 10 then
|
||||||
battle:sayNext("It's not very\neffective...")
|
battle:sayNext(Strings("It's not very\neffective..."))
|
||||||
end
|
end
|
||||||
if Runtime.wants("battle.damage_dealt") then
|
if Runtime.wants("battle.damage_dealt") then
|
||||||
Runtime.emit("battle.damage_dealt", {
|
Runtime.emit("battle.damage_dealt", {
|
||||||
@@ -237,9 +238,9 @@ function EffectRegistry.runDamaging(battle, ctx, record)
|
|||||||
if hits > 1 then
|
if hits > 1 then
|
||||||
-- player: _MultiHitText; enemy: _HitXTimesText (always plural)
|
-- player: _MultiHitText; enemy: _HitXTimesText (always plural)
|
||||||
if user.isPlayer then
|
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
|
else
|
||||||
battle:sayNext(("Hit %d times!"):format(hits))
|
battle:sayNext(Strings("Hit %d times!", hits))
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -251,7 +252,7 @@ function EffectRegistry.runDamaging(battle, ctx, record)
|
|||||||
elseif moveInst.struggle then
|
elseif moveInst.struggle then
|
||||||
-- struggle recoils even when its effect id resolves to no record
|
-- struggle recoils even when its effect id resolves to no record
|
||||||
local recoil = math.max(1, math.floor(dmg / 2))
|
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)
|
battle:applyDamage(user, recoil)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
+59
-56
@@ -15,6 +15,7 @@ local Logger = require("src.core.Logger")
|
|||||||
local StatusRegistry = require("src.battle.StatusRegistry")
|
local StatusRegistry = require("src.battle.StatusRegistry")
|
||||||
local TurnOrder = require("src.battle.TurnOrder")
|
local TurnOrder = require("src.battle.TurnOrder")
|
||||||
local TypeChart = require("src.battle.TypeChart")
|
local TypeChart = require("src.battle.TypeChart")
|
||||||
|
local Strings = require("src.core.Strings")
|
||||||
|
|
||||||
local MoveEffects = {}
|
local MoveEffects = {}
|
||||||
|
|
||||||
@@ -36,14 +37,14 @@ local STAT_LABEL = {
|
|||||||
local function changeStage(battle, who, stat, delta, fromEnemy)
|
local function changeStage(battle, who, stat, delta, fromEnemy)
|
||||||
if fromEnemy and (who.substituteHP or who.mist) then
|
if fromEnemy and (who.substituteHP or who.mist) then
|
||||||
if who.mist then
|
if who.mist then
|
||||||
return { displayName(who) .. " is\nprotected by MIST!" }
|
return { Strings("%s is\nprotected by MIST!", displayName(who)) }
|
||||||
end
|
end
|
||||||
return { "But, it failed!" }
|
return { Strings("But, it failed!") }
|
||||||
end
|
end
|
||||||
local cur = who.stages[stat] or 0
|
local cur = who.stages[stat] or 0
|
||||||
local new = math.max(-6, math.min(6, cur + delta))
|
local new = math.max(-6, math.min(6, cur + delta))
|
||||||
if new == cur then
|
if new == cur then
|
||||||
return { ("Nothing happened!") }
|
return { Strings("Nothing happened!") }
|
||||||
end
|
end
|
||||||
who.stages[stat] = new
|
who.stages[stat] = new
|
||||||
-- effects.asm:505-506: after any stat-stage change, modified stats are
|
-- 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
|
-- _MonsStatsRoseText/_MonsStatsFellText: "X's / STAT rose!"; the
|
||||||
-- two-stage variants scroll "greatly" onto a third line
|
-- two-stage variants scroll "greatly" onto a third line
|
||||||
if delta >= 2 then
|
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
|
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
|
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
|
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
|
end
|
||||||
MoveEffects.changeStage = changeStage
|
MoveEffects.changeStage = changeStage
|
||||||
|
|
||||||
@@ -88,10 +89,10 @@ end
|
|||||||
local function statusMove(status)
|
local function statusMove(status)
|
||||||
return function(battle, user, target, move)
|
return function(battle, user, target, move)
|
||||||
if target.mon.status then
|
if target.mon.status then
|
||||||
return { "But, it failed!" }
|
return { Strings("But, it failed!") }
|
||||||
end
|
end
|
||||||
if status == "PSN" and target.substituteHP then
|
if status == "PSN" and target.substituteHP then
|
||||||
return { "But, it failed!" }
|
return { Strings("But, it failed!") }
|
||||||
end
|
end
|
||||||
local msgs = inflictStatus(battle, target, status, {
|
local msgs = inflictStatus(battle, target, status, {
|
||||||
toxic = move and move.id == "TOXIC",
|
toxic = move and move.id == "TOXIC",
|
||||||
@@ -99,7 +100,7 @@ local function statusMove(status)
|
|||||||
source = move and move.id,
|
source = move and move.id,
|
||||||
})
|
})
|
||||||
if #msgs == 0 then
|
if #msgs == 0 then
|
||||||
return { "But, it failed!" }
|
return { Strings("But, it failed!") }
|
||||||
end
|
end
|
||||||
return msgs
|
return msgs
|
||||||
end
|
end
|
||||||
@@ -111,7 +112,7 @@ local function statusSide(status, chance)
|
|||||||
-- target (regardless of the burn roll)
|
-- target (regardless of the burn roll)
|
||||||
if move and move.type == "FIRE" and target.mon.status == "FRZ" then
|
if move and move.type == "FIRE" and target.mon.status == "FRZ" then
|
||||||
target.mon.status = nil
|
target.mon.status = nil
|
||||||
return { ("Fire defrosted\n%s!"):format(displayName(target)) }
|
return { Strings("Fire defrosted\n%s!", displayName(target)) }
|
||||||
end
|
end
|
||||||
if battle.rng(0, 255) >= chance then return {} end
|
if battle.rng(0, 255) >= chance then return {} end
|
||||||
return inflictStatus(battle, target, status, {
|
return inflictStatus(battle, target, status, {
|
||||||
@@ -144,10 +145,10 @@ end
|
|||||||
|
|
||||||
local function confuse(battle, target, pierceSub)
|
local function confuse(battle, target, pierceSub)
|
||||||
if target.confusedTurns or (target.substituteHP and not pierceSub) then
|
if target.confusedTurns or (target.substituteHP and not pierceSub) then
|
||||||
return { "But, it failed!" }
|
return { Strings("But, it failed!") }
|
||||||
end
|
end
|
||||||
target.confusedTurns = battle.rng(2, 5)
|
target.confusedTurns = battle.rng(2, 5)
|
||||||
return { ("%s\nbecame confused!"):format(displayName(target)) }
|
return { Strings("%s\nbecame confused!", displayName(target)) }
|
||||||
end
|
end
|
||||||
|
|
||||||
-- ---------------------------------------------------------------------
|
-- ---------------------------------------------------------------------
|
||||||
@@ -181,53 +182,53 @@ MoveEffects.primary = {
|
|||||||
LEECH_SEED_EFFECT = function(battle, user, target)
|
LEECH_SEED_EFFECT = function(battle, user, target)
|
||||||
-- leech_seed.asm has no substitute check: seeding lands through one
|
-- leech_seed.asm has no substitute check: seeding lands through one
|
||||||
if target.leechSeeded then
|
if target.leechSeeded then
|
||||||
return { "But, it failed!" }
|
return { Strings("But, it failed!") }
|
||||||
end
|
end
|
||||||
for _, t in ipairs(target.curTypes) do
|
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
|
end
|
||||||
target.leechSeeded = true
|
target.leechSeeded = true
|
||||||
return { ("%s\nwas seeded!"):format(displayName(target)) }
|
return { Strings("%s\nwas seeded!", displayName(target)) }
|
||||||
end,
|
end,
|
||||||
|
|
||||||
HEAL_EFFECT = function(battle, user, target, move)
|
HEAL_EFFECT = function(battle, user, target, move)
|
||||||
local mon = user.mon
|
local mon = user.mon
|
||||||
if move.id == "REST" then
|
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.hp = mon.stats.hp
|
||||||
mon.status = "SLP"
|
mon.status = "SLP"
|
||||||
user.sleepTurns = 2
|
user.sleepTurns = 2
|
||||||
user.toxicCounter = nil
|
user.toxicCounter = nil
|
||||||
return { ("%s\nstarted sleeping!"):format(displayName(user)) }
|
return { Strings("%s\nstarted sleeping!", displayName(user)) }
|
||||||
end
|
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))
|
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,
|
end,
|
||||||
|
|
||||||
LIGHT_SCREEN_EFFECT = function(battle, user)
|
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
|
user.lightScreen = true
|
||||||
return { ("%s's\nprotected against\nspecial attacks!"):format(displayName(user)) }
|
return { Strings("%s's\nprotected against\nspecial attacks!", displayName(user)) }
|
||||||
end,
|
end,
|
||||||
|
|
||||||
REFLECT_EFFECT = function(battle, user)
|
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
|
user.reflect = true
|
||||||
return { ("%s\ngained armor!"):format(displayName(user)) }
|
return { Strings("%s\ngained armor!", displayName(user)) }
|
||||||
end,
|
end,
|
||||||
|
|
||||||
MIST_EFFECT = function(battle, user)
|
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
|
user.mist = true
|
||||||
-- _ShroudedInMistText (lowercase "mist")
|
-- _ShroudedInMistText (lowercase "mist")
|
||||||
return { ("%s's\nshrouded in mist!"):format(displayName(user)) }
|
return { Strings("%s's\nshrouded in mist!", displayName(user)) }
|
||||||
end,
|
end,
|
||||||
|
|
||||||
FOCUS_ENERGY_EFFECT = function(battle, user)
|
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
|
user.focusEnergy = true
|
||||||
return { ("%s's\ngetting pumped!"):format(displayName(user)) }
|
return { Strings("%s's\ngetting pumped!", displayName(user)) }
|
||||||
end,
|
end,
|
||||||
|
|
||||||
HAZE_EFFECT = function(battle, user, target)
|
HAZE_EFFECT = function(battle, user, target)
|
||||||
@@ -254,33 +255,33 @@ MoveEffects.primary = {
|
|||||||
target.skipMove = true
|
target.skipMove = true
|
||||||
end
|
end
|
||||||
target.mon.status = nil
|
target.mon.status = nil
|
||||||
return { "All STATUS changes\nare eliminated!" }
|
return { Strings("All STATUS changes\nare eliminated!") }
|
||||||
end,
|
end,
|
||||||
|
|
||||||
SUBSTITUTE_EFFECT = function(battle, user)
|
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)
|
local cost = math.floor(user.mon.stats.hp / 4)
|
||||||
-- substitute.asm only fails on subtraction underflow (current HP
|
-- substitute.asm only fails on subtraction underflow (current HP
|
||||||
-- strictly below maxHP/4); at equality the substitute is built and
|
-- 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 user is left standing on exactly 0 HP (it faints only when
|
||||||
-- the engine next checks HP, not here)
|
-- the engine next checks HP, not here)
|
||||||
if user.mon.hp < cost then
|
if user.mon.hp < cost then
|
||||||
return { "Too weak to make\na SUBSTITUTE!" }
|
return { Strings("Too weak to make\na SUBSTITUTE!") }
|
||||||
end
|
end
|
||||||
user.mon.hp = user.mon.hp - cost
|
user.mon.hp = user.mon.hp - cost
|
||||||
user.substituteHP = cost + 1
|
user.substituteHP = cost + 1
|
||||||
-- _SubstituteText
|
-- _SubstituteText
|
||||||
return { "It created a\nSUBSTITUTE!" }
|
return { Strings("It created a\nSUBSTITUTE!") }
|
||||||
end,
|
end,
|
||||||
|
|
||||||
CONVERSION_EFFECT = function(battle, user, target)
|
CONVERSION_EFFECT = function(battle, user, target)
|
||||||
-- conversion.asm fails against a mid-Fly/Dig target (INVULNERABLE)
|
-- conversion.asm fails against a mid-Fly/Dig target (INVULNERABLE)
|
||||||
if target.invulnerable then
|
if target.invulnerable then
|
||||||
return { "But, it failed!" }
|
return { Strings("But, it failed!") }
|
||||||
end
|
end
|
||||||
user.curTypes = { target.curTypes[1], target.curTypes[2] }
|
user.curTypes = { target.curTypes[1], target.curTypes[2] }
|
||||||
-- _ConvertedTypeText
|
-- _ConvertedTypeText
|
||||||
return { ("Converted type to\n%s's!"):format(displayName(target)) }
|
return { Strings("Converted type to\n%s's!", displayName(target)) }
|
||||||
end,
|
end,
|
||||||
|
|
||||||
-- MIMIC_EFFECT lives in BattleState:resolveMimic: MimicEffect
|
-- MIMIC_EFFECT lives in BattleState:resolveMimic: MimicEffect
|
||||||
@@ -311,27 +312,27 @@ MoveEffects.primary = {
|
|||||||
table.insert(user.curMoves, { id = mv.id, pp = 5, mimic = true })
|
table.insert(user.curMoves, { id = mv.id, pp = 5, mimic = true })
|
||||||
end
|
end
|
||||||
-- _TransformedText: the copied name prints bare (wNameBuffer)
|
-- _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,
|
end,
|
||||||
|
|
||||||
DISABLE_EFFECT = function(battle, user, target)
|
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 = {}
|
local usable = {}
|
||||||
for i, mv in ipairs(target.curMoves) do
|
for i, mv in ipairs(target.curMoves) do
|
||||||
if mv.pp > 0 then table.insert(usable, i) end
|
if mv.pp > 0 then table.insert(usable, i) end
|
||||||
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)]
|
local slot = usable[battle.rng(1, #usable)]
|
||||||
target.disabledSlot = slot
|
target.disabledSlot = slot
|
||||||
target.disabledTurns = battle.rng(1, 8)
|
target.disabledTurns = battle.rng(1, 8)
|
||||||
local id = target.curMoves[slot].id
|
local id = target.curMoves[slot].id
|
||||||
-- _MoveWasDisabledText: "X's / MOVE was / disabled!"
|
-- _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) }
|
battle.data.moves[id].name) }
|
||||||
end,
|
end,
|
||||||
|
|
||||||
SPLASH_EFFECT = function()
|
SPLASH_EFFECT = function()
|
||||||
return { "No effect!" }
|
return { Strings("No effect!") }
|
||||||
end,
|
end,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -428,7 +429,9 @@ local function drainHalf(text)
|
|||||||
local mon = ctx.user.mon
|
local mon = ctx.user.mon
|
||||||
mon.hp = math.min(mon.stats.hp, mon.hp + heal)
|
mon.hp = math.min(mon.stats.hp, mon.hp + heal)
|
||||||
ctx.drain()
|
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
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -436,7 +439,7 @@ end
|
|||||||
-- flags the miss before the special-damage override)
|
-- flags the miss before the special-damage override)
|
||||||
local function immuneMsg(ctx)
|
local function immuneMsg(ctx)
|
||||||
if TypeChart.effectiveness(ctx.move.type, ctx.target.curTypes) == 0 then
|
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
|
end
|
||||||
return nil
|
return nil
|
||||||
end
|
end
|
||||||
@@ -498,12 +501,12 @@ MoveEffects.full = {
|
|||||||
-- removed): overkill and substitute hits recoil at full strength
|
-- removed): overkill and substitute hits recoil at full strength
|
||||||
local recoil = math.max(1, math.floor(ctx.rawDamage
|
local recoil = math.max(1, math.floor(ctx.rawDamage
|
||||||
/ (ctx.moveInst.struggle and 2 or 4)))
|
/ (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)
|
ctx.battle:applyDamage(ctx.user, recoil)
|
||||||
end,
|
end,
|
||||||
},
|
},
|
||||||
DRAIN_HP_EFFECT = {
|
DRAIN_HP_EFFECT = {
|
||||||
afterDamage = drainHalf("Sucked health from\n%s!"),
|
afterDamage = drainHalf(Strings.source("Sucked health from\n%s!")),
|
||||||
},
|
},
|
||||||
DREAM_EATER_EFFECT = {
|
DREAM_EATER_EFFECT = {
|
||||||
-- only works on sleeping targets (checked before damage)
|
-- 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
|
if ctx.target.mon.status ~= "SLP" then return false, "But, it failed!" end
|
||||||
return true
|
return true
|
||||||
end,
|
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
|
-- 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
|
user.thrashTurns, user.thrashMove, user.thrashAnnounced = nil, nil, nil
|
||||||
if not user.confusedTurns then
|
if not user.confusedTurns then
|
||||||
user.confusedTurns = ctx.rng(2, 5)
|
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
|
end
|
||||||
end
|
end
|
||||||
@@ -566,7 +569,7 @@ MoveEffects.full = {
|
|||||||
JUMP_KICK_EFFECT = {
|
JUMP_KICK_EFFECT = {
|
||||||
onMiss = function(ctx, reason)
|
onMiss = function(ctx, reason)
|
||||||
if reason ~= "accuracy" then return end
|
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)
|
ctx.damage(ctx.user, 1)
|
||||||
end,
|
end,
|
||||||
},
|
},
|
||||||
@@ -595,7 +598,7 @@ MoveEffects.full = {
|
|||||||
afterDamage = function(ctx)
|
afterDamage = function(ctx)
|
||||||
local battle = ctx.battle
|
local battle = ctx.battle
|
||||||
battle.payDay = (battle.payDay or 0) + 2 * ctx.user.mon.level
|
battle.payDay = (battle.payDay or 0) + 2 * ctx.user.mon.level
|
||||||
ctx.say("Coins scattered\neverywhere!")
|
ctx.say(Strings("Coins scattered\neverywhere!"))
|
||||||
end,
|
end,
|
||||||
},
|
},
|
||||||
SWIFT_EFFECT = { neverMiss = true },
|
SWIFT_EFFECT = { neverMiss = true },
|
||||||
@@ -610,7 +613,7 @@ MoveEffects.full = {
|
|||||||
local user = ctx.user
|
local user = ctx.user
|
||||||
user.bideTurns = ctx.rng(2, 3)
|
user.bideTurns = ctx.rng(2, 3)
|
||||||
user.bideDamage = 0
|
user.bideDamage = 0
|
||||||
ctx.say(("%s\nis storing energy!"):format(displayName(user)))
|
ctx.say(Strings("%s\nis storing energy!", displayName(user)))
|
||||||
end,
|
end,
|
||||||
},
|
},
|
||||||
SWITCH_AND_TELEPORT_EFFECT = {
|
SWITCH_AND_TELEPORT_EFFECT = {
|
||||||
@@ -631,27 +634,27 @@ MoveEffects.full = {
|
|||||||
end
|
end
|
||||||
if ok then
|
if ok then
|
||||||
if move.id == "ROAR" 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
|
elseif move.id == "WHIRLWIND" then
|
||||||
ctx.say(("%s\nwas blown away!"):format(displayName(target)))
|
ctx.say(Strings("%s\nwas blown away!", displayName(target)))
|
||||||
else
|
else
|
||||||
ctx.say(("%s\nran from battle!"):format(displayName(user)))
|
ctx.say(Strings("%s\nran from battle!", displayName(user)))
|
||||||
end
|
end
|
||||||
battle.result = "run"
|
battle.result = "run"
|
||||||
battle.afterQueue = "finish"
|
battle.afterQueue = "finish"
|
||||||
elseif move.id == "TELEPORT" then
|
elseif move.id == "TELEPORT" then
|
||||||
battle:cancelMoveAnim()
|
battle:cancelMoveAnim()
|
||||||
ctx.say("But, it failed!")
|
ctx.say(Strings("But, it failed!"))
|
||||||
else
|
else
|
||||||
battle:cancelMoveAnim()
|
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
|
end
|
||||||
elseif move.id == "TELEPORT" then
|
elseif move.id == "TELEPORT" then
|
||||||
battle:cancelMoveAnim()
|
battle:cancelMoveAnim()
|
||||||
ctx.say("But, it failed!")
|
ctx.say(Strings("But, it failed!"))
|
||||||
else
|
else
|
||||||
battle:cancelMoveAnim()
|
battle:cancelMoveAnim()
|
||||||
ctx.say(("%s\nis unaffected!"):format(displayName(target)))
|
ctx.say(Strings("%s\nis unaffected!", displayName(target)))
|
||||||
end
|
end
|
||||||
end,
|
end,
|
||||||
},
|
},
|
||||||
@@ -669,7 +672,7 @@ MoveEffects.full = {
|
|||||||
callsMove = function(ctx)
|
callsMove = function(ctx)
|
||||||
local last = ctx.target.lastMove
|
local last = ctx.target.lastMove
|
||||||
if not last then
|
if not last then
|
||||||
ctx.say("The MIRROR MOVE\nfailed!")
|
ctx.say(Strings("The MIRROR MOVE\nfailed!"))
|
||||||
return nil
|
return nil
|
||||||
end
|
end
|
||||||
return last
|
return last
|
||||||
|
|||||||
+31
-21
@@ -5,6 +5,8 @@
|
|||||||
-- and residual sweep. Callers without a battle (pure-module tests) fall
|
-- and residual sweep. Callers without a battle (pure-module tests) fall
|
||||||
-- back to the vanilla records, which is bit-identical behavior.
|
-- back to the vanilla records, which is bit-identical behavior.
|
||||||
|
|
||||||
|
local Strings = require("src.core.Strings")
|
||||||
|
|
||||||
local Status = {}
|
local Status = {}
|
||||||
|
|
||||||
-- pokered's <USER>/<TARGET> text macros print "Enemy " before the enemy
|
-- pokered's <USER>/<TARGET> text macros print "Enemy " before the enemy
|
||||||
@@ -28,8 +30,11 @@ local function hasType(battler, wanted)
|
|||||||
end
|
end
|
||||||
|
|
||||||
-- shared PSN/BRN residual: 1/16 max HP, multiplied (and advanced) by the
|
-- shared PSN/BRN residual: 1/16 max HP, multiplied (and advanced) by the
|
||||||
-- Toxic counter (HandlePoisonBurnLeechSeed)
|
-- Toxic counter (HandlePoisonBurnLeechSeed). The caller passes the whole
|
||||||
local function damageOverTime(what)
|
-- 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)
|
return function(battler)
|
||||||
local mon = battler.mon
|
local mon = battler.mon
|
||||||
local base = math.max(1, math.floor(mon.stats.hp / 16))
|
local base = math.max(1, math.floor(mon.stats.hp / 16))
|
||||||
@@ -39,10 +44,15 @@ local function damageOverTime(what)
|
|||||||
battler.toxicCounter = battler.toxicCounter + 1
|
battler.toxicCounter = battler.toxicCounter + 1
|
||||||
end
|
end
|
||||||
mon.hp = math.max(0, mon.hp - dmg)
|
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
|
||||||
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
|
-- The five persistent conditions as records: the beforeMove gauntlet, the
|
||||||
-- residual sweep, the inflict text/immunities (StatusRegistry.inflict),
|
-- residual sweep, the inflict text/immunities (StatusRegistry.inflict),
|
||||||
-- the catch/wobble bonuses (Catching.attempt), the HUD label, and the
|
-- the catch/wobble bonuses (Catching.attempt), the HUD label, and the
|
||||||
@@ -57,13 +67,13 @@ Status.RECORDS = {
|
|||||||
battler.sleepTurns = (battler.sleepTurns or 1) - 1
|
battler.sleepTurns = (battler.sleepTurns or 1) - 1
|
||||||
if battler.sleepTurns <= 0 then
|
if battler.sleepTurns <= 0 then
|
||||||
battler.mon.status = nil
|
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
|
end
|
||||||
return false, { name(battler) .. "\nis fast asleep!" }
|
return false, { Strings("%s\nis fast asleep!", name(battler)) }
|
||||||
end,
|
end,
|
||||||
onInflict = function(battle, target, opts, display)
|
onInflict = function(battle, target, opts, display)
|
||||||
target.sleepTurns = battle.rng(1, 7)
|
target.sleepTurns = battle.rng(1, 7)
|
||||||
return { ("%s\nfell asleep!"):format(display) }
|
return { Strings("%s\nfell asleep!", display) }
|
||||||
end,
|
end,
|
||||||
},
|
},
|
||||||
FRZ = {
|
FRZ = {
|
||||||
@@ -71,35 +81,35 @@ Status.RECORDS = {
|
|||||||
catchBonus = 25, shakeBonus = 10,
|
catchBonus = 25, shakeBonus = 10,
|
||||||
beforeMovePriority = 30,
|
beforeMovePriority = 30,
|
||||||
beforeMove = function(battler)
|
beforeMove = function(battler)
|
||||||
return false, { name(battler) .. "\nis frozen solid!" }
|
return false, { Strings("%s\nis frozen solid!", name(battler)) }
|
||||||
end,
|
end,
|
||||||
canInflict = function(target) return not hasType(target, "ICE") end,
|
canInflict = function(target) return not hasType(target, "ICE") end,
|
||||||
onInflict = function(_, _, _, display)
|
onInflict = function(_, _, _, display)
|
||||||
return { ("%s\nwas frozen solid!"):format(display) }
|
return { Strings("%s\nwas frozen solid!", display) }
|
||||||
end,
|
end,
|
||||||
},
|
},
|
||||||
PSN = {
|
PSN = {
|
||||||
id = "PSN", label = "PSN", hudLabel = "PSN",
|
id = "PSN", label = "PSN", hudLabel = "PSN",
|
||||||
catchBonus = 12, shakeBonus = 5,
|
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,
|
canInflict = function(target) return not hasType(target, "POISON") end,
|
||||||
onInflict = function(_, target, opts, display)
|
onInflict = function(_, target, opts, display)
|
||||||
if opts.toxic then
|
if opts.toxic then
|
||||||
target.toxicCounter = 1
|
target.toxicCounter = 1
|
||||||
-- _BadlyPoisonedText
|
-- _BadlyPoisonedText
|
||||||
return { ("%s's\nbadly poisoned!"):format(display) }
|
return { Strings("%s's\nbadly poisoned!", display) }
|
||||||
end
|
end
|
||||||
return { ("%s\nwas poisoned!"):format(display) }
|
return { Strings("%s\nwas poisoned!", display) }
|
||||||
end,
|
end,
|
||||||
},
|
},
|
||||||
BRN = {
|
BRN = {
|
||||||
id = "BRN", label = "BRN", hudLabel = "BRN",
|
id = "BRN", label = "BRN", hudLabel = "BRN",
|
||||||
catchBonus = 12, shakeBonus = 5,
|
catchBonus = 12, shakeBonus = 5,
|
||||||
statPenalty = { stat = "attack", div = 2 },
|
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,
|
canInflict = function(target) return not hasType(target, "FIRE") end,
|
||||||
onInflict = function(_, _, _, display)
|
onInflict = function(_, _, _, display)
|
||||||
return { ("%s\nwas burned!"):format(display) }
|
return { Strings("%s\nwas burned!", display) }
|
||||||
end,
|
end,
|
||||||
},
|
},
|
||||||
PAR = {
|
PAR = {
|
||||||
@@ -110,7 +120,7 @@ Status.RECORDS = {
|
|||||||
beforeMove = function(battler, rng)
|
beforeMove = function(battler, rng)
|
||||||
-- cp 25 percent / jr nc: fully paralyzed on rand < 63 (63/256)
|
-- cp 25 percent / jr nc: fully paralyzed on rand < 63 (63/256)
|
||||||
if rng(0, 255) < 63 then
|
if rng(0, 255) < 63 then
|
||||||
return false, { name(battler) .. "'s\nfully paralyzed!" }
|
return false, { Strings("%s's\nfully paralyzed!", name(battler)) }
|
||||||
end
|
end
|
||||||
return true, {}
|
return true, {}
|
||||||
end,
|
end,
|
||||||
@@ -120,7 +130,7 @@ Status.RECORDS = {
|
|||||||
end,
|
end,
|
||||||
onInflict = function(_, _, _, display)
|
onInflict = function(_, _, _, display)
|
||||||
-- _ParalyzedMayNotAttackText (primary and secondary paralysis)
|
-- _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,
|
end,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -156,7 +166,7 @@ function Status.beforeMove(battler, rng, battle)
|
|||||||
end
|
end
|
||||||
if battler.flinched then
|
if battler.flinched then
|
||||||
battler.flinched = false
|
battler.flinched = false
|
||||||
return false, { name(battler) .. "\nflinched!" }
|
return false, { Strings("%s\nflinched!", name(battler)) }
|
||||||
end
|
end
|
||||||
local record = Status.recordFor(battleStatuses(battle), mon.status)
|
local record = Status.recordFor(battleStatuses(battle), mon.status)
|
||||||
local handler = record and record.beforeMove
|
local handler = record and record.beforeMove
|
||||||
@@ -174,23 +184,23 @@ function Status.beforeMove(battler, rng, battle)
|
|||||||
end
|
end
|
||||||
if battler.boundTurns and battler.boundTurns > 0 then
|
if battler.boundTurns and battler.boundTurns > 0 then
|
||||||
battler.boundTurns = battler.boundTurns - 1
|
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
|
return false, msgs
|
||||||
end
|
end
|
||||||
if battler.disabledTurns then
|
if battler.disabledTurns then
|
||||||
battler.disabledTurns = battler.disabledTurns - 1
|
battler.disabledTurns = battler.disabledTurns - 1
|
||||||
if battler.disabledTurns <= 0 then
|
if battler.disabledTurns <= 0 then
|
||||||
battler.disabledTurns, battler.disabledSlot = nil, nil
|
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
|
||||||
end
|
end
|
||||||
if battler.confusedTurns then
|
if battler.confusedTurns then
|
||||||
battler.confusedTurns = battler.confusedTurns - 1
|
battler.confusedTurns = battler.confusedTurns - 1
|
||||||
if battler.confusedTurns <= 0 then
|
if battler.confusedTurns <= 0 then
|
||||||
battler.confusedTurns = nil
|
battler.confusedTurns = nil
|
||||||
table.insert(msgs, name(battler) .. "\nsnapped out of\nconfusion!")
|
table.insert(msgs, Strings("%s\nsnapped out of\nconfusion!", name(battler)))
|
||||||
else
|
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)
|
-- cp 50 percent + 1 / jr c: hurt itself on rand >= 128 (128/256)
|
||||||
if rng(0, 255) < 128 then
|
if rng(0, 255) < 128 then
|
||||||
return false, msgs, true -- hurt itself
|
return false, msgs, true -- hurt itself
|
||||||
@@ -231,7 +241,7 @@ function Status.residual(battler, opponent, battle)
|
|||||||
dmg = math.min(dmg, mon.hp)
|
dmg = math.min(dmg, mon.hp)
|
||||||
mon.hp = mon.hp - dmg
|
mon.hp = mon.hp - dmg
|
||||||
opponent.mon.hp = math.min(opponent.mon.stats.hp, opponent.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
|
end
|
||||||
return msgs
|
return msgs
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
|
|
||||||
local Runtime = require("src.mods.Runtime")
|
local Runtime = require("src.mods.Runtime")
|
||||||
local Status = require("src.battle.Status")
|
local Status = require("src.battle.Status")
|
||||||
|
local Strings = require("src.core.Strings")
|
||||||
|
|
||||||
local StatusRegistry = {}
|
local StatusRegistry = {}
|
||||||
|
|
||||||
@@ -45,7 +46,7 @@ function StatusRegistry.inflict(battle, target, status, opts)
|
|||||||
if record and record.onInflict then
|
if record and record.onInflict then
|
||||||
msgs = record.onInflict(battle, target, opts, display)
|
msgs = record.onInflict(battle, target, opts, display)
|
||||||
else
|
else
|
||||||
msgs = { ("%s\nwas afflicted\nby %s!"):format(display,
|
msgs = { Strings("%s\nwas afflicted\nby %s!", display,
|
||||||
record and record.label or tostring(status)) }
|
record and record.label or tostring(status)) }
|
||||||
end
|
end
|
||||||
Runtime.emit("battle.status_inflicted", {
|
Runtime.emit("battle.status_inflicted", {
|
||||||
|
|||||||
@@ -16,9 +16,10 @@
|
|||||||
-- the MINIMUM-scored move is chosen, ties broken uniformly among the
|
-- the MINIMUM-scored move is chosen, ties broken uniformly among the
|
||||||
-- tied minima (core.asm:2971-3002). A non-minimal move is never
|
-- tied minima (core.asm:2971-3002). A non-minimal move is never
|
||||||
-- selectable. Respects Disable (and PP only when the ruleset depletes
|
-- 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 TypeChart = require("src.battle.TypeChart")
|
||||||
|
local Strings = require("src.core.Strings")
|
||||||
|
|
||||||
local TrainerAI = {}
|
local TrainerAI = {}
|
||||||
|
|
||||||
@@ -99,7 +100,7 @@ function TrainerAI.useItem(battle, item)
|
|||||||
local enemy = battle.enemy
|
local enemy = battle.enemy
|
||||||
local trainerName = battle.trainer.name
|
local trainerName = battle.trainer.name
|
||||||
local itemName = battle.data.items[item] and battle.data.items[item].name or item
|
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
|
if item == "FULL_HEAL" then
|
||||||
enemy.mon.status = nil
|
enemy.mon.status = nil
|
||||||
enemy.toxicCounter = nil
|
enemy.toxicCounter = nil
|
||||||
@@ -112,10 +113,10 @@ function TrainerAI.useItem(battle, item)
|
|||||||
elseif X_STAT[item] then
|
elseif X_STAT[item] then
|
||||||
local stat = X_STAT[item]
|
local stat = X_STAT[item]
|
||||||
enemy.stages[stat] = math.min(6, (enemy.stages[stat] or 0) + 1)
|
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
|
elseif item == "GUARD_SPEC" then
|
||||||
enemy.mist = true
|
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
|
end
|
||||||
return msgs
|
return msgs
|
||||||
end
|
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)())
|
(function() local n = 0 for _ in pairs(self.moves) do n = n + 1 end return n end)())
|
||||||
end
|
end
|
||||||
|
|
||||||
-- dev-mode hot reload only (src/dev/HotReload.lua): drop every namespace the
|
-- Drop every namespace the mod merge created and evict the generated modules
|
||||||
-- mod merge created, then re-require the generated modules so base records
|
-- from package.loaded, so the next load() re-reads them off disk instead of
|
||||||
-- return to their on-disk values even where a mod edited them in place
|
-- handing back the cached tables. Two callers:
|
||||||
function Data:reloadGenerated()
|
-- * 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
|
local pristine = self._pristineKeys
|
||||||
if pristine then
|
if pristine then
|
||||||
for key in pairs(self) do
|
for key in pairs(self) do
|
||||||
@@ -222,6 +227,13 @@ function Data:reloadGenerated()
|
|||||||
for _, name in ipairs(OPTIONAL) do
|
for _, name in ipairs(OPTIONAL) do
|
||||||
package.loaded["data.generated." .. name] = nil
|
package.loaded["data.generated." .. name] = nil
|
||||||
end
|
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()
|
self:load()
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -246,6 +258,20 @@ function Data:resolveText(mapLabel, textConst)
|
|||||||
local s = self.text[entry.text]
|
local s = self.text[entry.text]
|
||||||
if s then return s, entry.asm end
|
if s then return s, entry.asm end
|
||||||
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
|
return nil, entry.asm
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
local Json = require("src.link.Json")
|
local Json = require("src.link.Json")
|
||||||
local Logger = require("src.core.Logger")
|
local Logger = require("src.core.Logger")
|
||||||
local Runtime = require("src.mods.Runtime")
|
local Runtime = require("src.mods.Runtime")
|
||||||
|
local Strings = require("src.core.Strings")
|
||||||
|
|
||||||
local DiscordPresence = {
|
local DiscordPresence = {
|
||||||
APP_ID = "1529183141267374262",
|
APP_ID = "1529183141267374262",
|
||||||
@@ -436,7 +437,7 @@ end
|
|||||||
local function buildActivity()
|
local function buildActivity()
|
||||||
local details, activityState
|
local details, activityState
|
||||||
if state.activity == "battle" then
|
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"
|
activityState = state.location and (state.location) or "Kanto"
|
||||||
elseif state.activity == "exploring" and state.location then
|
elseif state.activity == "exploring" and state.location then
|
||||||
details = state.location
|
details = state.location
|
||||||
@@ -550,20 +551,20 @@ local function subscribe(game)
|
|||||||
|
|
||||||
track("battle.started", function(ev)
|
track("battle.started", function(ev)
|
||||||
local battle = ev and ev.battle
|
local battle = ev and ev.battle
|
||||||
local label = "In battle"
|
local label = Strings("In battle")
|
||||||
if ev and ev.kind == "wild" then
|
if ev and ev.kind == "wild" then
|
||||||
local name = speciesName(game, ev.species)
|
local name = speciesName(game, ev.species)
|
||||||
if name then
|
if name then
|
||||||
label = "Battling wild " .. name
|
label = "Battling wild " .. name
|
||||||
if ev.level then label = label .. " Lv" .. tostring(ev.level) end
|
if ev.level then label = label .. " Lv" .. tostring(ev.level) end
|
||||||
else
|
else
|
||||||
label = "Wild battle"
|
label = Strings("Wild battle")
|
||||||
end
|
end
|
||||||
elseif ev and ev.kind == "trainer" then
|
elseif ev and ev.kind == "trainer" then
|
||||||
local tname = battle and battle.trainer and battle.trainer.name
|
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
|
elseif ev and ev.kind == "link" then
|
||||||
label = "Link battle"
|
label = Strings("Link battle")
|
||||||
end
|
end
|
||||||
local mapId = state.mapId
|
local mapId = state.mapId
|
||||||
or (game.save and game.save.player and game.save.player.map)
|
or (game.save and game.save.player and game.save.player.map)
|
||||||
@@ -594,7 +595,7 @@ local function subscribe(game)
|
|||||||
state.mapId = nil
|
state.mapId = nil
|
||||||
setPresence({
|
setPresence({
|
||||||
activity = "menu",
|
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,
|
clearBattle = true,
|
||||||
})
|
})
|
||||||
end
|
end
|
||||||
|
|||||||
+23
-3
@@ -53,6 +53,9 @@ function Game:load()
|
|||||||
require("src.render.Font").load(Data)
|
require("src.render.Font").load(Data)
|
||||||
-- menu cursor/border/geometry constants; field.theme restyles them
|
-- menu cursor/border/geometry constants; field.theme restyles them
|
||||||
require("src.ui.Theme").load(Data)
|
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
|
self.stack = StateStack
|
||||||
StateStack:init()
|
StateStack:init()
|
||||||
@@ -188,6 +191,15 @@ end
|
|||||||
-- or screenshot run does not depend on whatever the player last chose.
|
-- or screenshot run does not depend on whatever the player last chose.
|
||||||
function Game:logicSpeed()
|
function Game:logicSpeed()
|
||||||
local GameSpeed = require("src.core.GameSpeed")
|
local GameSpeed = require("src.core.GameSpeed")
|
||||||
|
-- Link play is always 1X on both machines, and this wins over every other
|
||||||
|
-- source including POKEPORT_SPEED. Fast-forward multiplies the logic
|
||||||
|
-- clock, so a peer at 10X burned a tournament shot clock ten times faster
|
||||||
|
-- than the opponent it is racing, and drove its own animation/message
|
||||||
|
-- queue at a different rate than the peer it is locked to. Nothing about
|
||||||
|
-- a match should depend on what either player set this to.
|
||||||
|
if self.linkSession or (self.linkNet and not self.linkNet.closed) then
|
||||||
|
return 1
|
||||||
|
end
|
||||||
if self.speedOverride then return GameSpeed.clamp(self.speedOverride) end
|
if self.speedOverride then return GameSpeed.clamp(self.speedOverride) end
|
||||||
local opts = self.save and self.save.options
|
local opts = self.save and self.save.options
|
||||||
return GameSpeed.clamp(opts and opts.speed or GameSpeed.DEFAULT)
|
return GameSpeed.clamp(opts and opts.speed or GameSpeed.DEFAULT)
|
||||||
@@ -235,6 +247,16 @@ function Game:draw()
|
|||||||
-- white clear
|
-- white clear
|
||||||
local base = self.stack:visibleBase()
|
local base = self.stack:visibleBase()
|
||||||
local worldBelow = self.stack.states[base] == self.overworld
|
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)
|
Renderer:beginFrame(worldBelow)
|
||||||
self.stack:draw()
|
self.stack:draw()
|
||||||
-- SGB colorization: the topmost state that knows its palette owns the
|
-- SGB colorization: the topmost state that knows its palette owns the
|
||||||
@@ -378,9 +400,7 @@ end
|
|||||||
-- LÖVE process ensures scripts, registries, and assets are all rebuilt from
|
-- LÖVE process ensures scripts, registries, and assets are all rebuilt from
|
||||||
-- the newly selected mod state.
|
-- the newly selected mod state.
|
||||||
function Game:restartWithMods()
|
function Game:restartWithMods()
|
||||||
if love.event and love.event.quit then
|
require("src.core.HostShell").restart()
|
||||||
love.event.quit("restart")
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
function Game:keyreleased(key)
|
function Game:keyreleased(key)
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
-- Helpers for calling host tools (curl, zenity/kdialog, ...).
|
||||||
|
|
||||||
|
local HostShell = {}
|
||||||
|
|
||||||
|
-- Our AppRun exports LD_LIBRARY_PATH="$APPDIR/lib:..." so every subprocess we
|
||||||
|
-- spawn tries to link against the libraries we're shipping instead of the
|
||||||
|
-- system ones. We want to unset the var so that any system tools can find
|
||||||
|
-- their proper libraries. Only needed when running in an AppImage.
|
||||||
|
function HostShell.envPrefix()
|
||||||
|
if os.getenv("APPIMAGE") then
|
||||||
|
return "env -u LD_LIBRARY_PATH "
|
||||||
|
end
|
||||||
|
return ""
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Wraps io.popen with the AppImage env fix applied and lua errors swallowed
|
||||||
|
function HostShell.popen(command, mode)
|
||||||
|
local ok, pipe = pcall(io.popen, HostShell.envPrefix() .. command, mode or "r")
|
||||||
|
if not ok or not pipe then return nil end
|
||||||
|
return pipe
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Restart the whole app. The obvious love.event.quit("restart") re-runs LÖVE's
|
||||||
|
-- boot in-process, which calls love.filesystem.init a second time -- and inside
|
||||||
|
-- an AppImage physfs is already initialized, so that second init throws
|
||||||
|
-- ("Failed to initialize filesystem: already initialized") and the relaunch
|
||||||
|
-- crashes. So on an AppImage we relaunch the executable; the fresh process's
|
||||||
|
-- Boot step mounts any downloaded update exactly as a manual relaunch would.
|
||||||
|
-- On every other platform the in-process restart works, so keep it.
|
||||||
|
function HostShell.restart()
|
||||||
|
if not (love and love.event and love.event.quit) then return end
|
||||||
|
local appimage = os.getenv("APPIMAGE")
|
||||||
|
if not appimage then
|
||||||
|
love.event.quit("restart")
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
-- We have to restart the process with this cursed execv call to prevent the
|
||||||
|
-- PID from changing, which might cause SteamOS and other Linux launchers to
|
||||||
|
-- think the app has crashed.
|
||||||
|
local ffi = require("ffi")
|
||||||
|
pcall(ffi.cdef, [[
|
||||||
|
int execv(const char *path, char *const argv[]);
|
||||||
|
int unsetenv(const char *name);
|
||||||
|
]])
|
||||||
|
ffi.C.unsetenv("LD_LIBRARY_PATH")
|
||||||
|
local argv = ffi.new("const char *[2]", appimage, nil)
|
||||||
|
ffi.C.execv(appimage, ffi.cast("char *const *", argv))
|
||||||
|
end
|
||||||
|
|
||||||
|
return HostShell
|
||||||
+1
-1
@@ -144,7 +144,7 @@ function Input:step()
|
|||||||
elseif next(sources) ~= nil then
|
elseif next(sources) ~= nil then
|
||||||
self.state[btn] = true
|
self.state[btn] = true
|
||||||
end
|
end
|
||||||
-- sources == {}: real press fully released before this step — keep up
|
-- sources == {}: real press fully released before this step -- keep up
|
||||||
end
|
end
|
||||||
for btn, sources in pairs(self.sources) do
|
for btn, sources in pairs(self.sources) do
|
||||||
if next(sources) == nil then
|
if next(sources) == nil then
|
||||||
|
|||||||
+101
-17
@@ -16,6 +16,7 @@ local SaveSerializer = require("src.core.SaveSerializer")
|
|||||||
local Runtime = require("src.mods.Runtime")
|
local Runtime = require("src.mods.Runtime")
|
||||||
local Semver = require("src.mods.Semver")
|
local Semver = require("src.mods.Semver")
|
||||||
local Boxes = require("src.pokemon.Boxes")
|
local Boxes = require("src.pokemon.Boxes")
|
||||||
|
local Stats = require("src.pokemon.Stats")
|
||||||
local Bag = require("src.inventory.Bag")
|
local Bag = require("src.inventory.Bag")
|
||||||
local Badges = require("src.inventory.Badges")
|
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
|
-- 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
|
-- 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.
|
-- and the plain source-base directory (next to the .exe/AppImage) is used.
|
||||||
local function appContainer(path)
|
local function parentDir(path)
|
||||||
local appPath = path and path:match("^(.*%.app)/Contents/")
|
return path and path:match("^(.*)/[^/]+$") or nil
|
||||||
return appPath and appPath:match("^(.*)/[^/]+$") or nil
|
|
||||||
end
|
end
|
||||||
-- Order: the .app's containing folder (packaged macOS), then the
|
local function appContainer(path)
|
||||||
-- source-base directory (next to a packaged .exe/AppImage), then the
|
return parentDir(path and path:match("^(.*%.app)/Contents/"))
|
||||||
-- source itself (a `love <gamedir>` run drops portable.txt in the game
|
end
|
||||||
-- folder). First one holding the marker wins. Built by appending so a
|
-- The Linux AppImage build works similarly to macOs, but the runtime mounts
|
||||||
-- nil (e.g. no .app in the path) never truncates the ipairs scan.
|
-- 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 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 appDir then candidates[#candidates + 1] = appDir end
|
||||||
if sbd then candidates[#candidates + 1] = sbd end
|
if sbd then candidates[#candidates + 1] = sbd end
|
||||||
if src then candidates[#candidates + 1] = src end
|
if src then candidates[#candidates + 1] = src end
|
||||||
@@ -193,6 +203,9 @@ function SaveData.defaultOptions()
|
|||||||
textSpeed = 3,
|
textSpeed = 3,
|
||||||
animations = true,
|
animations = true,
|
||||||
battleStyle = "shift",
|
battleStyle = "shift",
|
||||||
|
-- battle screen composition: og (the 160x144 original) | wide
|
||||||
|
-- (304x144, src/battle/WideBattle.lua)
|
||||||
|
battleLayout = "og",
|
||||||
ruleset = "gen1_faithful",
|
ruleset = "gen1_faithful",
|
||||||
-- 0-7 like the GB's NR50 master volume
|
-- 0-7 like the GB's NR50 master volume
|
||||||
musicVol = 7,
|
musicVol = 7,
|
||||||
@@ -442,6 +455,25 @@ function SaveData.slotSummary(save)
|
|||||||
}
|
}
|
||||||
end
|
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
|
-- 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
|
-- with whether it holds a save and the cheap summary above. A fresh
|
||||||
-- install with nothing registered returns an empty array; a legacy install
|
-- 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
|
for _, id in ipairs(list) do
|
||||||
local save = decodeSlot(fs, version, id)
|
local save = decodeSlot(fs, version, id)
|
||||||
local name, meta = SaveData.slotSummary(save)
|
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
|
end
|
||||||
return out
|
return out
|
||||||
end
|
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
|
-- 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
|
-- choice to options.lua; also update the process-global cache so the very
|
||||||
-- next save/load lands in the chosen slot.
|
-- next save/load lands in the chosen slot.
|
||||||
@@ -580,6 +645,7 @@ function SaveData.deleteSlot(version, slotId)
|
|||||||
remove(fs, tmp)
|
remove(fs, tmp)
|
||||||
|
|
||||||
table.remove(reg.list, idx)
|
table.remove(reg.list, idx)
|
||||||
|
if reg.names then reg.names[slotId] = nil end
|
||||||
if reg.active == slotId then
|
if reg.active == slotId then
|
||||||
reg.active = reg.list[1] -- may be nil when the list is now empty
|
reg.active = reg.list[1] -- may be nil when the list is now empty
|
||||||
end
|
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
|
for stat, v in pairs(mon.statExp) do mon.statExp[stat] = clamp(v, 0, 65535, 0) end
|
||||||
end
|
end
|
||||||
mon.level = clamp(mon.level, 1, 100, 1)
|
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
|
local moves = mon.moves
|
||||||
if type(moves) ~= "table" then return end
|
if type(moves) ~= "table" then return end
|
||||||
local hadMoves = #moves > 0
|
local hadMoves = #moves > 0
|
||||||
@@ -1155,9 +1232,16 @@ function SaveData.defaultHeal(boot)
|
|||||||
end
|
end
|
||||||
|
|
||||||
-- Post-credits home (issue #103). pokered left the player in HALL_OF_FAME
|
-- 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
|
-- after jp Init; this port relocates CONTINUE instead, and retargets
|
||||||
-- retargets LAST_MAP exits (Red's house mats) at the heal-point town so
|
-- LAST_MAP exits (Red's house mats) at the heal-point town so leaving the
|
||||||
-- leaving the house does not dump the player back at Indigo Plateau.
|
-- 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.
|
-- Marks postGameHomeOk so a later intentional HoF save is not relocated.
|
||||||
function SaveData.applyPostGameHome(save, boot)
|
function SaveData.applyPostGameHome(save, boot)
|
||||||
boot = type(boot) == "table" and boot or {}
|
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.lastHeal = { map = heal.map, x = heal.x, y = heal.y }
|
||||||
save.lastOutdoor = { id = 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 = save.player or {}
|
||||||
save.player.map = boot.startMap or "REDS_HOUSE_2F"
|
save.player.map = heal.map
|
||||||
save.player.x = boot.startX or 3
|
save.player.x = heal.x
|
||||||
save.player.y = boot.startY or 6
|
save.player.y = heal.y
|
||||||
save.player.facing = boot.startFacing or "down"
|
save.player.facing = boot.startFacing or "down"
|
||||||
save.postGameHomeOk = true
|
save.postGameHomeOk = true
|
||||||
return heal
|
return heal
|
||||||
@@ -1209,7 +1293,7 @@ function SaveData.newGame(boot)
|
|||||||
inventory = {},
|
inventory = {},
|
||||||
-- Vanilla Gen1 seeds one Potion in the player's item PC
|
-- Vanilla Gen1 seeds one Potion in the player's item PC
|
||||||
-- (wBoxItems / players_pc.asm); existing saves keep whatever they
|
-- (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 },
|
pcItems = { POTION = 1 },
|
||||||
party = {},
|
party = {},
|
||||||
box = {},
|
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
|
return mkdirFn
|
||||||
end
|
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
|
-- 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.
|
-- 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)
|
return fn(dir, append)
|
||||||
end
|
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.
|
-- The portable game folder when the cache should live there, else nil.
|
||||||
-- Resolved (and, for a fused build, mounted) once and cached. Requires a
|
-- Resolved (and, for a fused build, mounted) once and cached. Requires a
|
||||||
-- desktop portable install (SaveData) and a working windowless mkdir.
|
-- desktop portable install (SaveData) and a working windowless mkdir.
|
||||||
@@ -229,6 +286,23 @@ function CacheFs.remove(rel)
|
|||||||
love.filesystem.remove(rel)
|
love.filesystem.remove(rel)
|
||||||
end
|
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
|
-- 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
|
-- cache-format bump does not leave orphaned files behind. No-op when the
|
||||||
-- portable cache is inactive (the save-directory copy is cleared by
|
-- portable cache is inactive (the save-directory copy is cleared by
|
||||||
@@ -280,4 +354,35 @@ function CacheFs.mountVersion(version)
|
|||||||
return false
|
return false
|
||||||
end
|
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
|
return CacheFs
|
||||||
|
|||||||
+444
-40
@@ -1,4 +1,6 @@
|
|||||||
local GameVersion = require("src.core.GameVersion")
|
local GameVersion = require("src.core.GameVersion")
|
||||||
|
local Strings = require("src.core.Strings")
|
||||||
|
local HostShell = require("src.core.HostShell")
|
||||||
|
|
||||||
local RomImporter = {}
|
local RomImporter = {}
|
||||||
RomImporter.__index = RomImporter
|
RomImporter.__index = RomImporter
|
||||||
@@ -254,8 +256,37 @@ local function fileUrl(path)
|
|||||||
return "file://" .. encoded
|
return "file://" .. encoded
|
||||||
end
|
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)
|
local function commandOutput(command)
|
||||||
local pipe = io.popen(command, "r")
|
releasePointerGrab()
|
||||||
|
local pipe = HostShell.popen(command)
|
||||||
if not pipe then return nil end
|
if not pipe then return nil end
|
||||||
local result = pipe:read("*a")
|
local result = pipe:read("*a")
|
||||||
pipe:close()
|
pipe:close()
|
||||||
@@ -337,7 +368,10 @@ local function chooseRom(promptName)
|
|||||||
"$d=New-Object System.Windows.Forms.OpenFileDialog;",
|
"$d=New-Object System.Windows.Forms.OpenFileDialog;",
|
||||||
"$d.Title='" .. prompt .. "';",
|
"$d.Title='" .. prompt .. "';",
|
||||||
"$d.Filter='Game Boy ROM (*.gb)|*.gb|All files (*.*)|*.*';",
|
"$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(
|
return commandOutput(
|
||||||
'powershell -NoProfile -STA -Command "' .. script .. '"')
|
'powershell -NoProfile -STA -Command "' .. script .. '"')
|
||||||
@@ -356,7 +390,7 @@ end
|
|||||||
-- Returns the chosen absolute path or nil. Android uses love.system.pickFile
|
-- Returns the chosen absolute path or nil. Android uses love.system.pickFile
|
||||||
-- ("mod") instead -- see RomImporter:chooseMod.
|
-- ("mod") instead -- see RomImporter:chooseMod.
|
||||||
local function chooseZip()
|
local function chooseZip()
|
||||||
local prompt = "Choose a mod .zip"
|
local prompt = Strings("Choose a mod .zip")
|
||||||
local platform = love.system.getOS()
|
local platform = love.system.getOS()
|
||||||
if platform == "OS X" then
|
if platform == "OS X" then
|
||||||
return commandOutput(
|
return commandOutput(
|
||||||
@@ -368,7 +402,16 @@ local function chooseZip()
|
|||||||
"$d=New-Object System.Windows.Forms.OpenFileDialog;",
|
"$d=New-Object System.Windows.Forms.OpenFileDialog;",
|
||||||
"$d.Title='" .. prompt .. "';",
|
"$d.Title='" .. prompt .. "';",
|
||||||
"$d.Filter='Mod archive (*.zip)|*.zip|All files (*.*)|*.*';",
|
"$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(
|
return commandOutput(
|
||||||
'powershell -NoProfile -STA -Command "' .. script .. '"')
|
'powershell -NoProfile -STA -Command "' .. script .. '"')
|
||||||
@@ -387,7 +430,7 @@ end
|
|||||||
-- dialogs). Returns the chosen absolute path or nil. Android uses
|
-- dialogs). Returns the chosen absolute path or nil. Android uses
|
||||||
-- love.system.pickFile("sav") instead -- see RomImporter:chooseSaveImport.
|
-- love.system.pickFile("sav") instead -- see RomImporter:chooseSaveImport.
|
||||||
local function chooseSav()
|
local function chooseSav()
|
||||||
local prompt = "Choose a .sav save file"
|
local prompt = Strings("Choose a .sav save file")
|
||||||
local platform = love.system.getOS()
|
local platform = love.system.getOS()
|
||||||
if platform == "OS X" then
|
if platform == "OS X" then
|
||||||
return commandOutput(
|
return commandOutput(
|
||||||
@@ -399,7 +442,8 @@ local function chooseSav()
|
|||||||
"$d=New-Object System.Windows.Forms.OpenFileDialog;",
|
"$d=New-Object System.Windows.Forms.OpenFileDialog;",
|
||||||
"$d.Title='" .. prompt .. "';",
|
"$d.Title='" .. prompt .. "';",
|
||||||
"$d.Filter='Game Boy save (*.sav)|*.sav|All files (*.*)|*.*';",
|
"$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(
|
return commandOutput(
|
||||||
'powershell -NoProfile -STA -Command "' .. script .. '"')
|
'powershell -NoProfile -STA -Command "' .. script .. '"')
|
||||||
@@ -431,7 +475,10 @@ end
|
|||||||
-- own cache (Red at the root, Blue under blue/), so both can be imported and
|
-- 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.
|
-- 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),
|
-- 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)
|
function RomImporter.new(onComplete, opts)
|
||||||
opts = opts or {}
|
opts = opts or {}
|
||||||
local android = love.system.getOS() == "Android"
|
local android = love.system.getOS() == "Android"
|
||||||
@@ -440,6 +487,7 @@ function RomImporter.new(onComplete, opts)
|
|||||||
onComplete = onComplete,
|
onComplete = onComplete,
|
||||||
launcher = opts.launcher or false,
|
launcher = opts.launcher or false,
|
||||||
forceImport = opts.forceImport or false,
|
forceImport = opts.forceImport or false,
|
||||||
|
onEditSave = opts.onEditSave,
|
||||||
android = android,
|
android = android,
|
||||||
tab = "red", -- active launcher tab: "red"/"blue"/"yellow"/"mods"
|
tab = "red", -- active launcher tab: "red"/"blue"/"yellow"/"mods"
|
||||||
logo = love.graphics.newImage("assets/logo/logo.png"),
|
logo = love.graphics.newImage("assets/logo/logo.png"),
|
||||||
@@ -472,6 +520,14 @@ function RomImporter.new(onComplete, opts)
|
|||||||
-- Android SAF create-document: which game's SAVE FILES card should show
|
-- Android SAF create-document: which game's SAVE FILES card should show
|
||||||
-- "Save exported." when export_done.flag appears on focus.
|
-- "Save exported." when export_done.flag appears on focus.
|
||||||
androidPendingExportVersion = nil,
|
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)
|
}, RomImporter)
|
||||||
|
|
||||||
for _, version in ipairs(GameVersion.ORDER) do
|
for _, version in ipairs(GameVersion.ORDER) do
|
||||||
@@ -524,6 +580,15 @@ function RomImporter.new(onComplete, opts)
|
|||||||
end
|
end
|
||||||
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
|
return self
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -927,15 +992,37 @@ function RomImporter:choose(version)
|
|||||||
local path = chooseRom(GameVersion.info(self.chooseVersion).displayName)
|
local path = chooseRom(GameVersion.info(self.chooseVersion).displayName)
|
||||||
if path then
|
if path then
|
||||||
self:startPath(path)
|
self:startPath(path)
|
||||||
elseif love.system.getOS() ~= "OS X"
|
return
|
||||||
and love.system.getOS() ~= "Windows"
|
end
|
||||||
and love.system.getOS() ~= "Linux" then
|
-- 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.")
|
self:setError("File selection is unavailable here. Drop the .gb file onto the window.")
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
function RomImporter:update(dt)
|
function RomImporter:update(dt)
|
||||||
self.pulse = self.pulse + dt
|
self.pulse = self.pulse + dt
|
||||||
|
self:_updatePadCursor(dt)
|
||||||
if self.workState ~= "working" or not self.worker then return end
|
if self.workState ~= "working" or not self.worker then return end
|
||||||
local started = love.timer.getTime()
|
local started = love.timer.getTime()
|
||||||
repeat
|
repeat
|
||||||
@@ -952,6 +1039,125 @@ function RomImporter:update(dt)
|
|||||||
until love.timer.getTime() - started >= 0.008
|
until love.timer.getTime() - started >= 0.008
|
||||||
end
|
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.
|
-- Player pressed Play on a game whose ROM is imported: hand off to boot.
|
||||||
function RomImporter:play(version)
|
function RomImporter:play(version)
|
||||||
if self.workState == "working" then return end
|
if self.workState == "working" then return end
|
||||||
@@ -991,6 +1197,31 @@ local function printB(text, x, y)
|
|||||||
love.graphics.print(text, x + 0.6, y)
|
love.graphics.print(text, x + 0.6, y)
|
||||||
end
|
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
|
-- One reusable unit quad, recoloured per call, for every vertical gradient
|
||||||
-- fill (LOVE has no gradient primitive and a per-frame newMesh would churn
|
-- 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.
|
-- the GPU). Callers set the blend mode; this only touches colour + geometry.
|
||||||
@@ -1146,12 +1377,17 @@ function RomImporter:draw()
|
|||||||
local pulse = self.pulse
|
local pulse = self.pulse
|
||||||
self._s = s
|
self._s = s
|
||||||
|
|
||||||
-- Hover state (desktop only -- touch has no cursor). Panel methods read the
|
-- Hover state. Desktop mouse, or the gamepad virtual cursor on handhelds
|
||||||
-- pointer + set self._anyHover through self:_hover; the cursor is set at the
|
-- (Android stays touch-only -- no hover). Panel methods read the pointer +
|
||||||
-- end. Reset the per-frame hit rects so a tab with no controls (mods) cannot
|
-- 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.
|
-- inherit last frame's game-panel buttons.
|
||||||
self._mx, self._my = love.mouse.getPosition()
|
if self._padCursorActive then
|
||||||
self._hoverEnabled = not self.android
|
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._anyHover = false
|
||||||
self.romButtonRect = nil
|
self.romButtonRect = nil
|
||||||
self.playButtonRect = nil
|
self.playButtonRect = nil
|
||||||
@@ -1159,6 +1395,7 @@ function RomImporter:draw()
|
|||||||
-- Rebuilt only by the active version's SAVE SLOT panel, so the mods tab (or a
|
-- 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.
|
-- version with no panel drawn this frame) cannot inherit last frame's rows.
|
||||||
self.slotRects = nil
|
self.slotRects = nil
|
||||||
|
self.slotEditRects = nil
|
||||||
self.newSlotRect = nil
|
self.newSlotRect = nil
|
||||||
-- Rebuilt only by the mods panel; nil elsewhere so a game tab cannot inherit
|
-- Rebuilt only by the mods panel; nil elsewhere so a game tab cannot inherit
|
||||||
-- last frame's mod toggles / import button.
|
-- last frame's mod toggles / import button.
|
||||||
@@ -1426,7 +1663,7 @@ function RomImporter:draw()
|
|||||||
self.updateButton = { x = rect.x, y = rect.y, width = rect.width,
|
self.updateButton = { x = rect.x, y = rect.y, width = rect.width,
|
||||||
height = rect.height, action = "download" }
|
height = rect.height, action = "download" }
|
||||||
message(upLatest and ("Update v" .. upLatest .. " available")
|
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
|
elseif upStatus == "needs_full" then
|
||||||
local rect = actionButton("Open releases")
|
local rect = actionButton("Open releases")
|
||||||
self.updateButton = { x = rect.x, y = rect.y, width = rect.width,
|
self.updateButton = { x = rect.x, y = rect.y, width = rect.width,
|
||||||
@@ -1508,8 +1745,53 @@ function RomImporter:draw()
|
|||||||
-- events reach the launcher, so click-vs-drag is resolved here)
|
-- events reach the launcher, so click-vs-drag is resolved here)
|
||||||
self:_updateSlotDrag()
|
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)
|
-- 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
|
if self._anyHover then
|
||||||
self.handCursor = self.handCursor or love.mouse.getSystemCursor("hand")
|
self.handCursor = self.handCursor or love.mouse.getSystemCursor("hand")
|
||||||
love.mouse.setCursor(self.handCursor)
|
love.mouse.setCursor(self.handCursor)
|
||||||
@@ -1517,6 +1799,34 @@ function RomImporter:draw()
|
|||||||
resetPointerCursor(self)
|
resetPointerCursor(self)
|
||||||
end
|
end
|
||||||
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
|
end
|
||||||
|
|
||||||
local function inside(r, x, y)
|
local function inside(r, x, y)
|
||||||
@@ -1524,6 +1834,20 @@ local function inside(r, x, y)
|
|||||||
end
|
end
|
||||||
|
|
||||||
function RomImporter:mousepressed(x, y, button)
|
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 button ~= 1 then return end
|
||||||
if inside(self.bcgButton, x, y) or inside(self.linkUrlRect, x, y) then
|
if inside(self.bcgButton, x, y) or inside(self.linkUrlRect, x, y) then
|
||||||
love.system.openURL(COMMUNITY_URL)
|
love.system.openURL(COMMUNITY_URL)
|
||||||
@@ -1536,7 +1860,7 @@ function RomImporter:mousepressed(x, y, button)
|
|||||||
if action == "download" and self.Check then
|
if action == "download" and self.Check then
|
||||||
pcall(self.Check.download)
|
pcall(self.Check.download)
|
||||||
elseif action == "restart" then
|
elseif action == "restart" then
|
||||||
love.event.quit("restart")
|
HostShell.restart()
|
||||||
elseif action == "openurl" and self.Check then
|
elseif action == "openurl" and self.Check then
|
||||||
love.system.openURL(self.Check.releaseUrl())
|
love.system.openURL(self.Check.releaseUrl())
|
||||||
end
|
end
|
||||||
@@ -1576,17 +1900,24 @@ function RomImporter:mousepressed(x, y, button)
|
|||||||
end
|
end
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
-- SAVE SLOT rows / Delete. Delete is checked first so a tap on the Delete
|
-- SAVE SLOT rows / Edit / Delete. The two labels are checked first so a tap
|
||||||
-- label never also selects the row. On desktop a press only ARMS a click:
|
-- on either never also selects the row. On desktop a press only ARMS a row
|
||||||
-- _updateSlotDrag commits it on release when the pointer did not move (a
|
-- click: _updateSlotDrag commits it on release when the pointer did not move
|
||||||
-- moved pointer scrolls instead). Android has no reliable pointer polling,
|
-- (a moved pointer scrolls instead). Android has no reliable pointer
|
||||||
-- so it selects on press. Delete fires immediately (small fixed target).
|
-- 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
|
for _, r in ipairs(self.slotDeleteRects or {}) do
|
||||||
if inside(r, x, y) then
|
if inside(r, x, y) then
|
||||||
self:_deleteSlot(self.panelVersion, r.id)
|
self:_deleteSlot(self.panelVersion, r.id)
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
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
|
for _, r in ipairs(self.slotRects or {}) do
|
||||||
if inside(r, x, y) then
|
if inside(r, x, y) then
|
||||||
if self.android then
|
if self.android then
|
||||||
@@ -1628,6 +1959,16 @@ function RomImporter:mousepressed(x, y, button)
|
|||||||
end
|
end
|
||||||
|
|
||||||
function RomImporter:keypressed(key)
|
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 self.workState == "working" then return end
|
||||||
if key == "return" or key == "space" or key == "kpenter" then
|
if key == "return" or key == "space" or key == "kpenter" then
|
||||||
-- Enter acts on the visible game tab: Play if its ROM is ready, otherwise
|
-- Enter acts on the visible game tab: Play if its ROM is ready, otherwise
|
||||||
@@ -1718,7 +2059,7 @@ function RomImporter:_playButton(x, y, w, h, gameName, ready, locked)
|
|||||||
col(PAL.disabledInk, 0.3)
|
col(PAL.disabledInk, 0.3)
|
||||||
love.graphics.rectangle("line", x, y, w, h, r, r)
|
love.graphics.rectangle("line", x, y, w, h, r, r)
|
||||||
col(PAL.disabledInk)
|
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")
|
printfB(label, x, y + (h - self.playFont:getHeight()) / 2, w, "center")
|
||||||
self.playButtonRect = nil
|
self.playButtonRect = nil
|
||||||
end
|
end
|
||||||
@@ -1730,13 +2071,13 @@ function RomImporter:_drawTabBar(x, y, w, h, chip)
|
|||||||
local s, pulse = self._s, self.pulse
|
local s, pulse = self._s, self.pulse
|
||||||
local tabs = {
|
local tabs = {
|
||||||
{ id = "red", letter = "R", top = PAL.chipRedTop, bot = PAL.chipRedBot,
|
{ 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,
|
{ 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,
|
{ 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,
|
{ 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 gap = 10 * s
|
||||||
local r = 12 * s
|
local r = 12 * s
|
||||||
@@ -1795,7 +2136,7 @@ function RomImporter:_drawTabBar(x, y, w, h, chip)
|
|||||||
local ready = 0
|
local ready = 0
|
||||||
for _, v in ipairs(GameVersion.ORDER) do if self.ready[v] then ready = ready + 1 end end
|
for _, v in ipairs(GameVersion.ORDER) do if self.ready[v] then ready = ready + 1 end end
|
||||||
love.graphics.setFont(self.readyFont)
|
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)
|
local lw = self.readyFont:getWidth(label)
|
||||||
if x + w - lw > cursorX + 8 * s then
|
if x + w - lw > cursorX + 8 * s then
|
||||||
col(PAL.labelGray)
|
col(PAL.labelGray)
|
||||||
@@ -1851,7 +2192,7 @@ function RomImporter:_drawGamePanel(version, x, y, w, h)
|
|||||||
|
|
||||||
-- ROM card contents by state (rehomes the existing import flow)
|
-- ROM card contents by state (rehomes the existing import flow)
|
||||||
local dropHint = self.android and "Copy the .gb via USB."
|
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 accent = locked and PAL.gold or (version == "red" and PAL.red or PAL.blue)
|
||||||
local romState, romDetail, romBtnLabel, romBtnEnabled, romProgress
|
local romState, romDetail, romBtnLabel, romBtnEnabled, romProgress
|
||||||
if locked then
|
if locked then
|
||||||
@@ -1866,12 +2207,12 @@ function RomImporter:_drawGamePanel(version, x, y, w, h)
|
|||||||
romDetail = self.detail or ""
|
romDetail = self.detail or ""
|
||||||
romProgress = self.progress or 0
|
romProgress = self.progress or 0
|
||||||
elseif ready then
|
elseif ready then
|
||||||
romState = self.romName[version] or "ROM imported"
|
romState = self.romName[version] or Strings("ROM imported")
|
||||||
romDetail = "Verified."
|
romDetail = "Verified."
|
||||||
romBtnLabel, romBtnEnabled = "Re-import ROM", true
|
romBtnLabel, romBtnEnabled = "Re-import ROM", true
|
||||||
elseif erroring then
|
elseif erroring then
|
||||||
romState = "Import failed"
|
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
|
romBtnLabel, romBtnEnabled = "Import ROM", true
|
||||||
elseif notice then
|
elseif notice then
|
||||||
romState = "No ROM imported"
|
romState = "No ROM imported"
|
||||||
@@ -2000,7 +2341,7 @@ function RomImporter:_drawGamePanel(version, x, y, w, h)
|
|||||||
if sfNotice and sfNotice.dir then
|
if sfNotice and sfNotice.dir then
|
||||||
iy = iy + 4 * s
|
iy = iy + 4 * s
|
||||||
love.graphics.setFont(self.hintFont)
|
love.graphics.setFont(self.hintFont)
|
||||||
local label = "Open folder"
|
local label = Strings("Open folder")
|
||||||
local lw = self.hintFont:getWidth(label)
|
local lw = self.hintFont:getWidth(label)
|
||||||
local frect = { x = ix, y = iy, width = lw, height = self.hintFont:getHeight(),
|
local frect = { x = ix, y = iy, width = lw, height = self.hintFont:getHeight(),
|
||||||
dir = sfNotice.dir }
|
dir = sfNotice.dir }
|
||||||
@@ -2048,6 +2389,13 @@ function RomImporter:_ensureSlots(version)
|
|||||||
if not self.slots[version] then self:_refreshSlots(version) end
|
if not self.slots[version] then self:_refreshSlots(version) end
|
||||||
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
|
-- Point the active slot at id (persisted immediately, per the contract) and
|
||||||
-- reflect it in the LOADED pill without a full relist.
|
-- reflect it in the LOADED pill without a full relist.
|
||||||
function RomImporter:_selectSlot(version, id)
|
function RomImporter:_selectSlot(version, id)
|
||||||
@@ -2055,6 +2403,33 @@ function RomImporter:_selectSlot(version, id)
|
|||||||
self.activeSlot[version] = id
|
self.activeSlot[version] = id
|
||||||
end
|
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
|
-- "+ 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.
|
-- scroll to the bottom (clamped next draw) so the new row is on screen.
|
||||||
function RomImporter:_newSlot(version)
|
function RomImporter:_newSlot(version)
|
||||||
@@ -2165,6 +2540,7 @@ function RomImporter:_drawSaveSlotPanel(version, x, y, w, h)
|
|||||||
rw - 24 * s, "center")
|
rw - 24 * s, "center")
|
||||||
self.slotRects = {}
|
self.slotRects = {}
|
||||||
self.slotDeleteRects = {}
|
self.slotDeleteRects = {}
|
||||||
|
self.slotEditRects = {}
|
||||||
elseif listH > 0 then
|
elseif listH > 0 then
|
||||||
local nameH = self.slotNameFont:getHeight()
|
local nameH = self.slotNameFont:getHeight()
|
||||||
local metaH = self.labelFont:getHeight()
|
local metaH = self.labelFont:getHeight()
|
||||||
@@ -2184,6 +2560,7 @@ function RomImporter:_drawSaveSlotPanel(version, x, y, w, h)
|
|||||||
|
|
||||||
self.slotRects = {}
|
self.slotRects = {}
|
||||||
self.slotDeleteRects = {}
|
self.slotDeleteRects = {}
|
||||||
|
self.slotEditRects = {}
|
||||||
love.graphics.setScissor(math.floor(rx), math.floor(listTop),
|
love.graphics.setScissor(math.floor(rx), math.floor(listTop),
|
||||||
math.ceil(rw), math.ceil(listH))
|
math.ceil(rw), math.ceil(listH))
|
||||||
for i, slot in ipairs(slots) do
|
for i, slot in ipairs(slots) do
|
||||||
@@ -2210,6 +2587,24 @@ function RomImporter:_drawSaveSlotPanel(version, x, y, w, h)
|
|||||||
love.graphics.print(delText, delX, delY)
|
love.graphics.print(delText, delX, delY)
|
||||||
local rightReserve = delW + 18 * s
|
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
|
-- LOADED pill (top-right of the active row), then reserve its width
|
||||||
local pillW = 0
|
local pillW = 0
|
||||||
if selected then
|
if selected then
|
||||||
@@ -2228,14 +2623,14 @@ function RomImporter:_drawSaveSlotPanel(version, x, y, w, h)
|
|||||||
|
|
||||||
love.graphics.setFont(self.slotNameFont)
|
love.graphics.setFont(self.slotNameFont)
|
||||||
col(PAL.white)
|
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)),
|
printB(ellipsize(self.slotNameFont, name, rw - 24 * s - math.max(pillW, rightReserve)),
|
||||||
rx + 12 * s, ry + rowPadV)
|
rx + 12 * s, ry + rowPadV)
|
||||||
|
|
||||||
local metaTxt
|
local metaTxt
|
||||||
if slot.exists and slot.meta then
|
if slot.exists and slot.meta then
|
||||||
metaTxt = ("%d badges - %s - %d caught"):format(
|
metaTxt = Strings("%d badges - %s - %d caught", slot.meta.badges or 0, slot.meta.timeText or "0:00",
|
||||||
slot.meta.badges or 0, slot.meta.timeText or "0:00",
|
|
||||||
slot.meta.dexCount or 0)
|
slot.meta.dexCount or 0)
|
||||||
else
|
else
|
||||||
metaTxt = "empty slot"
|
metaTxt = "empty slot"
|
||||||
@@ -2259,6 +2654,15 @@ function RomImporter:_drawSaveSlotPanel(version, x, y, w, h)
|
|||||||
self.slotDeleteRects[#self.slotDeleteRects + 1] =
|
self.slotDeleteRects[#self.slotDeleteRects + 1] =
|
||||||
{ x = drect.x, y = dvy, width = drect.width, height = dvy2 - dvy, id = slot.id }
|
{ x = drect.x, y = dvy, width = drect.width, height = dvy2 - dvy, id = slot.id }
|
||||||
end
|
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
|
||||||
end
|
end
|
||||||
love.graphics.setScissor()
|
love.graphics.setScissor()
|
||||||
@@ -2339,7 +2743,7 @@ function RomImporter:_drawModsPanel(x, y, w, h)
|
|||||||
for _, m in ipairs(mods) do if m.enabled then enabledCount = enabledCount + 1 end end
|
for _, m in ipairs(mods) do if m.enabled then enabledCount = enabledCount + 1 end end
|
||||||
love.graphics.setFont(self.hintFont)
|
love.graphics.setFont(self.hintFont)
|
||||||
col(PAL.warning)
|
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)
|
x + nameW + 14 * s, y + (headerH - self.hintFont:getHeight()) / 2)
|
||||||
|
|
||||||
local btnLabel = "Import mod .zip"
|
local btnLabel = "Import mod .zip"
|
||||||
@@ -2360,7 +2764,7 @@ function RomImporter:_drawModsPanel(x, y, w, h)
|
|||||||
else
|
else
|
||||||
col(PAL.warning)
|
col(PAL.warning)
|
||||||
love.graphics.printf(self.android and "Or copy a mod .zip via USB."
|
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
|
end
|
||||||
top = top + self.hintFont:getHeight() + 12 * s
|
top = top + self.hintFont:getHeight() + 12 * s
|
||||||
|
|
||||||
@@ -2376,7 +2780,7 @@ function RomImporter:_drawModsPanel(x, y, w, h)
|
|||||||
col(PAL.warning)
|
col(PAL.warning)
|
||||||
local emptyHint = self.android
|
local emptyHint = self.android
|
||||||
and "No mods installed - tap Import mod .zip to add one."
|
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,
|
love.graphics.printf(emptyHint,
|
||||||
x + 16 * s, top + boxH / 2 - self.hintFont:getHeight() / 2, w - 32 * s, "center")
|
x + 16 * s, top + boxH / 2 - self.hintFont:getHeight() / 2, w - 32 * s, "center")
|
||||||
self.modRects = {}
|
self.modRects = {}
|
||||||
@@ -2463,7 +2867,7 @@ function RomImporter:_drawModsPanel(x, y, w, h)
|
|||||||
love.graphics.printf(m.description, nx, ny + nameH + 6 * s, L.leftW, "left")
|
love.graphics.printf(m.description, nx, ny + nameH + 6 * s, L.leftW, "left")
|
||||||
end
|
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 clusterX = x + w - padH - L.clusterW
|
||||||
local clusterY = cy + (cardH - clusterH) / 2
|
local clusterY = cy + (cardH - clusterH) / 2
|
||||||
local _, chipColor = modStatusChip(m.status)
|
local _, chipColor = modStatusChip(m.status)
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
|
|
||||||
local Pokemon = require("src.pokemon.Pokemon")
|
local Pokemon = require("src.pokemon.Pokemon")
|
||||||
local Flags = require("src.script.Flags")
|
local Flags = require("src.script.Flags")
|
||||||
|
local Strings = require("src.core.Strings")
|
||||||
|
|
||||||
local ItemEffects = {}
|
local ItemEffects = {}
|
||||||
|
|
||||||
@@ -46,6 +47,17 @@ ItemEffects.BALLS = BALLS
|
|||||||
function ItemEffects.isBall(id) return BALLS[id] or false end
|
function ItemEffects.isBall(id) return BALLS[id] or false end
|
||||||
function ItemEffects.isStone(id) return STONES[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?
|
-- Does this item need a party-member target?
|
||||||
function ItemEffects.needsTarget(id, itemDef)
|
function ItemEffects.needsTarget(id, itemDef)
|
||||||
return HEAL_AMOUNT[id] or STATUS_HEAL[id] or id == "MAX_POTION"
|
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"
|
if battle and (VITAMINS[itemId] or STONES[itemId] or itemId == "PP_UP"
|
||||||
or itemId == "RARE_CANDY" or itemId == "COIN_CASE"
|
or itemId == "RARE_CANDY" or itemId == "COIN_CASE"
|
||||||
or (itemDef and itemDef.machine)) then
|
or (itemDef and itemDef.machine)) then
|
||||||
return "failed", { "OAK: " .. save.player.name
|
return "failed", { Strings("OAK: %s!\nThis isn't the\ntime to use that!",
|
||||||
.. "!\nThis isn't the\ntime to use that!" }
|
save.player.name) }
|
||||||
end
|
end
|
||||||
|
|
||||||
if BALLS[itemId] then
|
if BALLS[itemId] then
|
||||||
@@ -138,11 +150,12 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
|
|||||||
local mapId, npc = adjacentSleepingSnorlax(save, ow)
|
local mapId, npc = adjacentSleepingSnorlax(save, ow)
|
||||||
if npc then
|
if npc then
|
||||||
return "flute_wake", { data.text._PlayedFluteHadEffectText
|
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
|
end
|
||||||
-- otherwise: play the tune, nothing happens (ItemUsePokeFlute's
|
-- otherwise: play the tune, nothing happens (ItemUsePokeFlute's
|
||||||
-- PlayedFluteNoEffectText branch)
|
-- 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
|
end
|
||||||
local woke = false
|
local woke = false
|
||||||
local function wake(mon)
|
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
|
-- WakeUpEntireParty runs on the enemy's bench too
|
||||||
for _, mon in ipairs(battle.enemyParty or {}) do wake(mon) end
|
for _, mon in ipairs(battle.enemyParty or {}) do wake(mon) end
|
||||||
if not woke then
|
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
|
end
|
||||||
return "flute", { ("%s played the\nPOKé FLUTE."):format(save.player.name),
|
return "flute", { Strings("%s played the\nPOKé FLUTE.", save.player.name),
|
||||||
"All sleeping\nPOKéMON woke up!" }
|
Strings("All sleeping\nPOKéMON woke up!") }
|
||||||
end
|
end
|
||||||
|
|
||||||
-- battle-only items
|
-- battle-only items
|
||||||
if X_ITEMS[itemId] or itemId == "DIRE_HIT" or itemId == "GUARD_SPEC"
|
if X_ITEMS[itemId] or itemId == "DIRE_HIT" or itemId == "GUARD_SPEC"
|
||||||
or itemId == "POKE_DOLL" then
|
or itemId == "POKE_DOLL" then
|
||||||
if not battle 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
|
end
|
||||||
local b = battle.player
|
local b = battle.player
|
||||||
if itemId == "X_ACCURACY" then
|
if itemId == "X_ACCURACY" then
|
||||||
-- ItemUseXAccuracy sets USING_X_ACCURACY: moves never miss
|
-- ItemUseXAccuracy sets USING_X_ACCURACY: moves never miss
|
||||||
-- (not an accuracy stage)
|
-- (not an accuracy stage)
|
||||||
b.xAccuracy = true
|
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
|
end
|
||||||
if X_ITEMS[itemId] then
|
if X_ITEMS[itemId] then
|
||||||
local stat = X_ITEMS[itemId]
|
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
|
-- effect, so at +6 it is still consumed and StatModifierUpEffect
|
||||||
-- just prints "Nothing happened!"
|
-- just prints "Nothing happened!"
|
||||||
if cur >= 6 then
|
if cur >= 6 then
|
||||||
return "consumed", { "Nothing happened!" }
|
return "consumed", { Strings("Nothing happened!") }
|
||||||
end
|
end
|
||||||
b.stages[stat] = cur + 1
|
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
|
end
|
||||||
-- ItemUseDireHit/ItemUseGuardSpec always set the bit and consume
|
-- ItemUseDireHit/ItemUseGuardSpec always set the bit and consume
|
||||||
-- the item, even when it is already active
|
-- the item, even when it is already active
|
||||||
if itemId == "DIRE_HIT" then
|
if itemId == "DIRE_HIT" then
|
||||||
b.focusEnergy = true
|
b.focusEnergy = true
|
||||||
return "consumed", { ("%s's\ngetting pumped!"):format(b.name) }
|
return "consumed", { Strings("%s's\ngetting pumped!", b.name) }
|
||||||
end
|
end
|
||||||
if itemId == "GUARD_SPEC" then
|
if itemId == "GUARD_SPEC" then
|
||||||
b.mist = true
|
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
|
end
|
||||||
if itemId == "POKE_DOLL" then
|
if itemId == "POKE_DOLL" then
|
||||||
if battle.kind ~= "wild" then
|
if battle.kind ~= "wild" then
|
||||||
-- ItemUsePokeDoll jumps to ItemUseNotTime in trainer battles
|
-- ItemUsePokeDoll jumps to ItemUseNotTime in trainer battles
|
||||||
return "failed", { "OAK: " .. save.player.name
|
return "failed", { Strings(
|
||||||
.. "!\nThis isn't the\ntime to use that!" }
|
"OAK: %s!\nThis isn't the\ntime to use that!", save.player.name) }
|
||||||
end
|
end
|
||||||
return "consumed_escape", { "The wild POKéMON\nran away!" }
|
return "consumed_escape", { Strings("The wild POKéMON\nran away!") }
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -213,7 +226,7 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
|
|||||||
-- restore every move with no menu.
|
-- restore every move with no menu.
|
||||||
if itemId == "ETHER" or itemId == "MAX_ETHER"
|
if itemId == "ETHER" or itemId == "MAX_ETHER"
|
||||||
or itemId == "ELIXER" or itemId == "MAX_ELIXER" then
|
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 restored = false
|
||||||
local full = itemId == "MAX_ETHER" or itemId == "MAX_ELIXER"
|
local full = itemId == "MAX_ETHER" or itemId == "MAX_ELIXER"
|
||||||
local allMoves = itemId == "ELIXER" 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
|
restored = mv and restore(mv) or false
|
||||||
end
|
end
|
||||||
if not restored then
|
if not restored then
|
||||||
return "failed", { "It won't have\nany effect." }
|
return "failed", { Strings("It won't have\nany effect.") }
|
||||||
end
|
end
|
||||||
return "consumed", { ("%s's PP\nwas restored!"):format(monName(data, target)) }
|
return "consumed", { Strings("%s's PP\nwas restored!", monName(data, target)) }
|
||||||
end
|
end
|
||||||
|
|
||||||
local heal = HEAL_AMOUNT[itemId]
|
local heal = HEAL_AMOUNT[itemId]
|
||||||
@@ -250,49 +263,57 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
|
|||||||
target.status = nil
|
target.status = nil
|
||||||
cureActiveToxic(battle, target)
|
cureActiveToxic(battle, target)
|
||||||
require("src.core.Sound").play(data, "Heal_Ailment")
|
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
|
end
|
||||||
if not target or target.hp <= 0 or target.hp >= target.stats.hp then
|
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
|
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
|
if itemId == "MAX_POTION" or itemId == "FULL_RESTORE" then
|
||||||
target.hp = target.stats.hp
|
target.hp = target.stats.hp
|
||||||
else
|
else
|
||||||
target.hp = math.min(target.stats.hp, target.hp + heal)
|
target.hp = math.min(target.stats.hp, target.hp + heal)
|
||||||
end
|
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
|
if itemId == "FULL_RESTORE" then
|
||||||
target.status = nil
|
target.status = nil
|
||||||
cureActiveToxic(battle, target)
|
cureActiveToxic(battle, target)
|
||||||
end
|
end
|
||||||
require("src.core.Sound").play(data, "Heal_HP")
|
require("src.core.Sound").play(data, "Heal_HP")
|
||||||
return "consumed", msgs
|
return "consumed", msgs, { healedFrom = before }
|
||||||
end
|
end
|
||||||
|
|
||||||
local cures = STATUS_HEAL[itemId]
|
local cures = STATUS_HEAL[itemId]
|
||||||
if cures then
|
if cures then
|
||||||
if not target or not target.status or not cures[target.status] 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
|
end
|
||||||
target.status = nil
|
target.status = nil
|
||||||
cureActiveToxic(battle, target)
|
cureActiveToxic(battle, target)
|
||||||
require("src.core.Sound").play(data, "Heal_Ailment")
|
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
|
end
|
||||||
|
|
||||||
if itemId == "REVIVE" or itemId == "MAX_REVIVE" then
|
if itemId == "REVIVE" or itemId == "MAX_REVIVE" then
|
||||||
if not target or target.hp > 0 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
|
end
|
||||||
target.status = nil
|
target.status = nil
|
||||||
target.hp = itemId == "REVIVE" and math.floor(target.stats.hp / 2) or target.stats.hp
|
target.hp = itemId == "REVIVE" and math.floor(target.stats.hp / 2) or target.stats.hp
|
||||||
require("src.core.Sound").play(data, "Heal_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
|
end
|
||||||
|
|
||||||
if itemId == "RARE_CANDY" then
|
if itemId == "RARE_CANDY" then
|
||||||
if not target or target.level >= 100 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
|
end
|
||||||
local Growth = require("src.pokemon.Growth")
|
local Growth = require("src.pokemon.Growth")
|
||||||
local Stats = require("src.pokemon.Stats")
|
local Stats = require("src.pokemon.Stats")
|
||||||
@@ -302,56 +323,56 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
|
|||||||
local old = target.stats
|
local old = target.stats
|
||||||
target.stats = Stats.calc(speciesDef, target.level, target.dvs, target.statExp)
|
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))
|
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 }
|
{ leveledTo = target.level }
|
||||||
end
|
end
|
||||||
|
|
||||||
if STONES[itemId] then
|
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]
|
local speciesDef = data.pokemon[target.species]
|
||||||
for _, evo in ipairs(speciesDef.evolutions) do
|
for _, evo in ipairs(speciesDef.evolutions) do
|
||||||
if evo.method == "ITEM" and evo.item == itemId then
|
if evo.method == "ITEM" and evo.item == itemId then
|
||||||
return "consumed", nil, { evolveTo = evo.species }
|
return "consumed", nil, { evolveTo = evo.species }
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
return "failed", { "It won't have\nany effect." }
|
return "failed", { Strings("It won't have\nany effect.") }
|
||||||
end
|
end
|
||||||
|
|
||||||
-- vitamins: +2560 stat exp, refused at 25600+ (ItemUseVitamin,
|
-- vitamins: +2560 stat exp, refused at 25600+ (ItemUseVitamin,
|
||||||
-- engine/items/item_effects.asm)
|
-- engine/items/item_effects.asm)
|
||||||
local vitaminStat = VITAMINS[itemId]
|
local vitaminStat = VITAMINS[itemId]
|
||||||
if vitaminStat then
|
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 {}
|
target.statExp = target.statExp or {}
|
||||||
local cur = target.statExp[vitaminStat] or 0
|
local cur = target.statExp[vitaminStat] or 0
|
||||||
if cur >= 25600 then
|
if cur >= 25600 then
|
||||||
return "failed", { "It won't have\nany effect." }
|
return "failed", { Strings("It won't have\nany effect.") }
|
||||||
end
|
end
|
||||||
target.statExp[vitaminStat] = math.min(65535, cur + 2560)
|
target.statExp[vitaminStat] = math.min(65535, cur + 2560)
|
||||||
local Stats = require("src.pokemon.Stats")
|
local Stats = require("src.pokemon.Stats")
|
||||||
target.stats = Stats.calc(data.pokemon[target.species], target.level,
|
target.stats = Stats.calc(data.pokemon[target.species], target.level,
|
||||||
target.dvs, target.statExp)
|
target.dvs, target.statExp)
|
||||||
target.hp = math.min(target.hp, target.stats.hp)
|
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()) }
|
vitaminStat == "hp" and "HP" or vitaminStat:upper()) }
|
||||||
end
|
end
|
||||||
|
|
||||||
-- PP UP boosts the move the player picked (ItemUsePPUp's move menu)
|
-- PP UP boosts the move the player picked (ItemUsePPUp's move menu)
|
||||||
if itemId == "PP_UP" then
|
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 mv = target.moves[moveIndex or 1]
|
||||||
local mdef = mv and data.moves[mv.id]
|
local mdef = mv and data.moves[mv.id]
|
||||||
if mdef and (mv.ppUps or 0) < 3 then
|
if mdef and (mv.ppUps or 0) < 3 then
|
||||||
mv.ppUps = (mv.ppUps or 0) + 1
|
mv.ppUps = (mv.ppUps or 0) + 1
|
||||||
-- each PP UP adds maxPP/5 uses on top of the base maximum
|
-- each PP UP adds maxPP/5 uses on top of the base maximum
|
||||||
mv.pp = mv.pp + math.floor(mdef.pp / 5)
|
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
|
end
|
||||||
return "failed", { "It won't have\nany effect." }
|
return "failed", { Strings("It won't have\nany effect.") }
|
||||||
end
|
end
|
||||||
|
|
||||||
if itemDef and itemDef.machine then
|
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 speciesDef = data.pokemon[target.species]
|
||||||
local ok = false
|
local ok = false
|
||||||
for _, m in ipairs(speciesDef.tmhm) do
|
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
|
-- plays SFX_DENIED before MonCannotLearnMachineMoveText (the generic
|
||||||
-- ItemUseNotTime/NoCyclingAllowedHere paths are silent)
|
-- ItemUseNotTime/NoCyclingAllowedHere paths are silent)
|
||||||
require("src.core.Sound").play(data, "Denied")
|
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
|
end
|
||||||
for _, mv in ipairs(target.moves) do
|
for _, mv in ipairs(target.moves) do
|
||||||
if mv.id == itemDef.machine.move then
|
if mv.id == itemDef.machine.move then
|
||||||
return "failed", { "It knows that\nmove already!" }
|
return "failed", { Strings("It knows that\nmove already!") }
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
-- HMs are never consumed; TMs are single-use
|
-- 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 itemId == "OLD_ROD" or itemId == "GOOD_ROD" or itemId == "SUPER_ROD" then
|
||||||
if battle 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
|
end
|
||||||
return "fish", itemId
|
return "fish", itemId
|
||||||
end
|
end
|
||||||
|
|
||||||
if itemId == "BICYCLE" then
|
if itemId == "BICYCLE" then
|
||||||
if battle 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
|
end
|
||||||
return "bicycle"
|
return "bicycle"
|
||||||
end
|
end
|
||||||
@@ -392,26 +413,26 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
|
|||||||
end
|
end
|
||||||
if itemId == "TOWN_MAP" then
|
if itemId == "TOWN_MAP" then
|
||||||
if battle 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
|
end
|
||||||
return "townmap"
|
return "townmap"
|
||||||
end
|
end
|
||||||
if itemId == "ITEMFINDER" then
|
if itemId == "ITEMFINDER" then
|
||||||
if battle 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
|
end
|
||||||
return "itemfinder"
|
return "itemfinder"
|
||||||
end
|
end
|
||||||
if itemId == "COIN_CASE" then
|
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
|
end
|
||||||
if itemId == "REPEL" or itemId == "SUPER_REPEL" or itemId == "MAX_REPEL" then
|
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
|
local steps = itemId == "REPEL" and 100 or itemId == "SUPER_REPEL" and 200 or 250
|
||||||
save.repelSteps = steps
|
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
|
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
|
end
|
||||||
|
|
||||||
return ItemEffects
|
return ItemEffects
|
||||||
|
|||||||
+88
-28
@@ -21,6 +21,7 @@ local Logger = require("src.core.Logger")
|
|||||||
local Protocol = require("src.link.Protocol")
|
local Protocol = require("src.link.Protocol")
|
||||||
local Runtime = require("src.mods.Runtime")
|
local Runtime = require("src.mods.Runtime")
|
||||||
local TurnOrder = require("src.battle.TurnOrder")
|
local TurnOrder = require("src.battle.TurnOrder")
|
||||||
|
local Strings = require("src.core.Strings")
|
||||||
|
|
||||||
local LinkBattle = {}
|
local LinkBattle = {}
|
||||||
|
|
||||||
@@ -111,6 +112,19 @@ local function scalar(v)
|
|||||||
return tostring(v)
|
return tostring(v)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- A volatile slot is "off" three interchangeable ways -- absent, false, or
|
||||||
|
-- a counter sitting at 0 -- because every reader tests `if b.key then` or
|
||||||
|
-- `key > 0`. They have to hash the same, or a match ends over a difference
|
||||||
|
-- that does not exist. They routinely disagree: the menu-phase flinch
|
||||||
|
-- clear (BattleState:update) and the FIGHT-branch boundTurns mirror
|
||||||
|
-- (fightLockedAction) are written by whichever machine is sitting at ITS
|
||||||
|
-- OWN menu, and in a link battle that is a different battler on each side,
|
||||||
|
-- so one peer holds flinched=false / boundTurns=0 where the other still
|
||||||
|
-- holds nil with two identical simulations underneath.
|
||||||
|
local function off(v)
|
||||||
|
return v == nil or v == false or v == 0
|
||||||
|
end
|
||||||
|
|
||||||
local function stageStr(b)
|
local function stageStr(b)
|
||||||
local out = {}
|
local out = {}
|
||||||
for i, stat in ipairs(STAGES) do
|
for i, stat in ipairs(STAGES) do
|
||||||
@@ -130,7 +144,7 @@ end
|
|||||||
local function volStr(b)
|
local function volStr(b)
|
||||||
local out = {}
|
local out = {}
|
||||||
for _, key in ipairs(VOLATILE) do
|
for _, key in ipairs(VOLATILE) do
|
||||||
if b[key] ~= nil then
|
if not off(b[key]) then
|
||||||
out[#out + 1] = key .. "=" .. scalar(b[key])
|
out[#out + 1] = key .. "=" .. scalar(b[key])
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -167,6 +181,17 @@ end
|
|||||||
|
|
||||||
local PARTS = { "actives", "volatile", "bench" }
|
local PARTS = { "actives", "volatile", "bench" }
|
||||||
|
|
||||||
|
-- Which components are allowed to end a match. `actives` and `bench` carry
|
||||||
|
-- what decides one -- species, HP, status, stat stages, PP, the rest of the
|
||||||
|
-- party -- so a divergence there is a real split between the two
|
||||||
|
-- simulations and stays a draw. `volatile` is per-turn bookkeeping that
|
||||||
|
-- both sides recompute from the authoritative state every turn (see `off`
|
||||||
|
-- above): it can disagree for a turn without either side being wrong, and
|
||||||
|
-- when it does mean something real it lands in `actives` as damage or
|
||||||
|
-- status within a turn or two, where it is caught. Ending a match on it
|
||||||
|
-- alone cost players games they were winning, over nothing.
|
||||||
|
local FATAL_PART = { actives = true, bench = true }
|
||||||
|
|
||||||
-- opts: { myParty = packed, theirParty = packed, theirName, role =
|
-- opts: { myParty = packed, theirParty = packed, theirName, role =
|
||||||
-- "host"/"guest", seed, verdict, strict }. Returns nil plus a reason when
|
-- "host"/"guest", seed, verdict, strict }. Returns nil plus a reason when
|
||||||
-- the handshake says the two link surfaces don't match: a lockstep
|
-- the handshake says the two link surfaces don't match: a lockstep
|
||||||
@@ -177,18 +202,18 @@ function LinkBattle.new(game, net, opts)
|
|||||||
local theirName = opts.theirName or "FOE"
|
local theirName = opts.theirName or "FOE"
|
||||||
|
|
||||||
if not Handshake.battleAllowed(opts.verdict) then
|
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
|
end
|
||||||
|
|
||||||
-- both parties pass through the same pack->unpack clamp on both
|
-- both parties pass through the same pack->unpack clamp on both
|
||||||
-- machines, so the copies are identical everywhere
|
-- machines, so the copies are identical everywhere
|
||||||
local unpackOpts = { strict = opts.strict or false, forceLevel = opts.forceLevel }
|
local unpackOpts = { strict = opts.strict or false, forceLevel = opts.forceLevel }
|
||||||
local myParty, myErr = unpackParty(game, opts.myParty, unpackOpts, function(p)
|
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)
|
end)
|
||||||
if not myParty then return nil, myErr end
|
if not myParty then return nil, myErr end
|
||||||
local theirParty, theirErr = unpackParty(game, opts.theirParty, unpackOpts, function(p, why)
|
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)
|
end)
|
||||||
if not theirParty then return nil, theirErr end
|
if not theirParty then return nil, theirErr end
|
||||||
if #myParty == 0 or #theirParty == 0 then
|
if #myParty == 0 or #theirParty == 0 then
|
||||||
@@ -232,7 +257,7 @@ function LinkBattle.new(game, net, opts)
|
|||||||
self.enemyParty = theirParty
|
self.enemyParty = theirParty
|
||||||
self.playerParty = myParty -- intro ball row uses the clamped copies
|
self.playerParty = myParty -- intro ball row uses the clamped copies
|
||||||
self.opponentName = theirName
|
self.opponentName = theirName
|
||||||
self.introText = ("%s wants\nto battle!"):format(theirName)
|
self.introText = Strings("%s wants\nto battle!", theirName)
|
||||||
self.remoteHashes = {}
|
self.remoteHashes = {}
|
||||||
self.localHashes = {}
|
self.localHashes = {}
|
||||||
self.remoteParts = {}
|
self.remoteParts = {}
|
||||||
@@ -292,7 +317,7 @@ function LinkBattle.new(game, net, opts)
|
|||||||
battle = s, side = s.sides[2], battler = s.enemy, previous = previous,
|
battle = s, side = s.sides[2], battler = s.enemy, previous = previous,
|
||||||
})
|
})
|
||||||
s.enemySendingOut = true
|
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:actNext(function()
|
||||||
s.enemySendingOut = false
|
s.enemySendingOut = false
|
||||||
s:startGrowIn(s.enemy)
|
s:startGrowIn(s.enemy)
|
||||||
@@ -314,9 +339,21 @@ function LinkBattle.new(game, net, opts)
|
|||||||
Logger.warn("link: desync turn %s component=%s (%s vs %s)",
|
Logger.warn("link: desync turn %s component=%s (%s vs %s)",
|
||||||
tostring(turn), component, tostring(localH), tostring(remoteH))
|
tostring(turn), component, tostring(localH), tostring(remoteH))
|
||||||
Runtime.emit("link.desync", { turn = turn, component = component,
|
Runtime.emit("link.desync", { turn = turn, component = component,
|
||||||
localHash = localH, remoteHash = remoteH })
|
localHash = localH, remoteHash = remoteH,
|
||||||
endAsDraw(s, ("Link desync!\n%s differs.\fAre both games\nrunning the same\nmods?")
|
fatal = true })
|
||||||
:format(component))
|
endAsDraw(s, Strings(
|
||||||
|
"Link desync!\n%s differs.\fAre both games\nrunning the same\nmods?",
|
||||||
|
component))
|
||||||
|
end
|
||||||
|
|
||||||
|
-- a non-fatal component split: both sides log it and carry on, so the
|
||||||
|
-- match is decided by the battle rather than by bookkeeping
|
||||||
|
local function noteDrift(s, turn, component, localH, remoteH)
|
||||||
|
Logger.warn("link: %s drift on turn %s (%s vs %s) -- match continues",
|
||||||
|
component, tostring(turn), tostring(localH), tostring(remoteH))
|
||||||
|
Runtime.emit("link.desync", { turn = turn, component = component,
|
||||||
|
localHash = localH, remoteHash = remoteH,
|
||||||
|
fatal = false })
|
||||||
end
|
end
|
||||||
|
|
||||||
-- a verified turn stays recorded: consuming it here left a finished
|
-- a verified turn stays recorded: consuming it here left a finished
|
||||||
@@ -331,8 +368,11 @@ function LinkBattle.new(game, net, opts)
|
|||||||
if mine and theirs then
|
if mine and theirs then
|
||||||
for _, component in ipairs(PARTS) do
|
for _, component in ipairs(PARTS) do
|
||||||
if mine[component] ~= theirs[component] then
|
if mine[component] ~= theirs[component] then
|
||||||
reportDesync(s, turn, component, mine[component], theirs[component])
|
if FATAL_PART[component] then
|
||||||
return
|
reportDesync(s, turn, component, mine[component], theirs[component])
|
||||||
|
return
|
||||||
|
end
|
||||||
|
noteDrift(s, turn, component, mine[component], theirs[component])
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -349,7 +389,7 @@ function LinkBattle.new(game, net, opts)
|
|||||||
local function resolveLockstep(s, myMsg, theirMsg)
|
local function resolveLockstep(s, myMsg, theirMsg)
|
||||||
if myMsg.kind == "run" or theirMsg.kind == "run" then
|
if myMsg.kind == "run" or theirMsg.kind == "run" then
|
||||||
local who = myMsg.kind == "run" and game.save.player.name or theirName
|
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
|
return
|
||||||
end
|
end
|
||||||
s.phase = "messages"
|
s.phase = "messages"
|
||||||
@@ -479,9 +519,9 @@ function LinkBattle.new(game, net, opts)
|
|||||||
party = myParty,
|
party = myParty,
|
||||||
onSwitch = function(mon)
|
onSwitch = function(mon)
|
||||||
if mon == s.player.mon then
|
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
|
elseif mon.hp <= 0 then
|
||||||
s:say("There's no will\nto fight!")
|
s:say(Strings("There's no will\nto fight!"))
|
||||||
else
|
else
|
||||||
s:resolveSwitch(mon)
|
s:resolveSwitch(mon)
|
||||||
end
|
end
|
||||||
@@ -491,7 +531,7 @@ function LinkBattle.new(game, net, opts)
|
|||||||
end
|
end
|
||||||
|
|
||||||
self.openItems = function(s)
|
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.phase = "messages"
|
||||||
s.afterQueue = "menu"
|
s.afterQueue = "menu"
|
||||||
end
|
end
|
||||||
@@ -509,7 +549,7 @@ function LinkBattle.new(game, net, opts)
|
|||||||
return
|
return
|
||||||
end
|
end
|
||||||
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))
|
theirName))
|
||||||
s.result = "lose"
|
s.result = "lose"
|
||||||
s.afterQueue = "finish"
|
s.afterQueue = "finish"
|
||||||
@@ -522,7 +562,7 @@ function LinkBattle.new(game, net, opts)
|
|||||||
return
|
return
|
||||||
end
|
end
|
||||||
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))
|
game.save.player.name))
|
||||||
s.result = "win"
|
s.result = "win"
|
||||||
s.afterQueue = "finish"
|
s.afterQueue = "finish"
|
||||||
@@ -543,13 +583,13 @@ function LinkBattle.new(game, net, opts)
|
|||||||
-- only a draw if our own simulation hasn't already decided
|
-- only a draw if our own simulation hasn't already decided
|
||||||
-- (the winner's bye can arrive while we're still animating)
|
-- (the winner's bye can arrive while we're still animating)
|
||||||
if not s.result then
|
if not s.result then
|
||||||
endAsDraw(s, ("%s left the\nbattle."):format(theirName))
|
endAsDraw(s, Strings("%s left the\nbattle.", theirName))
|
||||||
end
|
end
|
||||||
elseif msg.type == "forfeit" then
|
elseif msg.type == "forfeit" then
|
||||||
-- the peer's own shot clock ran out; unlike a mutual RUN/desync
|
-- the peer's own shot clock ran out; unlike a mutual RUN/desync
|
||||||
-- draw, this has a definite winner (us)
|
-- draw, this has a definite winner (us)
|
||||||
if not s.result then
|
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
|
end
|
||||||
else
|
else
|
||||||
-- a tournament control message (bracket_update, the next
|
-- a tournament control message (bracket_update, the next
|
||||||
@@ -563,10 +603,19 @@ function LinkBattle.new(game, net, opts)
|
|||||||
if net.closed and not s.linkEnded and not s.result then
|
if net.closed and not s.linkEnded and not s.result then
|
||||||
endAsDraw(s)
|
endAsDraw(s)
|
||||||
end
|
end
|
||||||
|
-- Both early returns below skip baseUpdate, which is where the
|
||||||
|
-- presentational clock normally advances -- so tick it here, in the same
|
||||||
|
-- order baseUpdate would (fx, then the queue). Without this an
|
||||||
|
-- animation caught mid-flight froze for the whole wait: a flash stopped
|
||||||
|
-- on its inverted BGP step and repainted the UI in inverted shades, and
|
||||||
|
-- a pic part-way through a slide or grow-in stayed off screen, which is
|
||||||
|
-- the "the screen went inverted" / "a Pokemon just vanished" pair.
|
||||||
if s.phase == "waitRemote" then
|
if s.phase == "waitRemote" then
|
||||||
|
s:tickFx()
|
||||||
return -- the other side is still choosing
|
return -- the other side is still choosing
|
||||||
end
|
end
|
||||||
if s.phase == "messages" and s.afterQueue == "linkNext" then
|
if s.phase == "messages" and s.afterQueue == "linkNext" then
|
||||||
|
s:tickFx()
|
||||||
if not s:updateQueue() then
|
if not s:updateQueue() then
|
||||||
s.afterQueue = "menu"
|
s.afterQueue = "menu"
|
||||||
s.phase = "menu"
|
s.phase = "menu"
|
||||||
@@ -582,7 +631,7 @@ function LinkBattle.new(game, net, opts)
|
|||||||
if s.turnClock <= 0 then
|
if s.turnClock <= 0 then
|
||||||
s.turnClockActive = false
|
s.turnClockActive = false
|
||||||
send({ type = "forfeit" })
|
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
|
end
|
||||||
elseif opts.turnLimit then
|
elseif opts.turnLimit then
|
||||||
s.turnClockActive = false
|
s.turnClockActive = false
|
||||||
@@ -643,17 +692,16 @@ function LinkBattle.newSpectator(game, net, opts)
|
|||||||
local guestName = opts.guestName or "GUEST"
|
local guestName = opts.guestName or "GUEST"
|
||||||
|
|
||||||
if not Handshake.battleAllowed(opts.verdict) then
|
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
|
end
|
||||||
|
|
||||||
local unpackOpts = { strict = opts.strict or false, forceLevel = opts.forceLevel }
|
local unpackOpts = { strict = opts.strict or false, forceLevel = opts.forceLevel }
|
||||||
local hostParty, hostErr = unpackParty(game, opts.hostParty, unpackOpts, function(p)
|
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)
|
end)
|
||||||
if not hostParty then return nil, hostErr end
|
if not hostParty then return nil, hostErr end
|
||||||
local guestParty, guestErr = unpackParty(game, opts.guestParty, unpackOpts, function(p, why)
|
local guestParty, guestErr = unpackParty(game, opts.guestParty, unpackOpts, function(p, why)
|
||||||
return ("%s's %s can't\nbattle on this\ngame.\n(%s)"):format(
|
return Strings("%s's %s can't\nbattle on this\ngame.\n(%s)", guestName, tostring(p.species), tostring(why))
|
||||||
guestName, tostring(p.species), tostring(why))
|
|
||||||
end)
|
end)
|
||||||
if not guestParty then return nil, guestErr end
|
if not guestParty then return nil, guestErr end
|
||||||
if #hostParty == 0 or #guestParty == 0 then
|
if #hostParty == 0 or #guestParty == 0 then
|
||||||
@@ -672,7 +720,7 @@ function LinkBattle.newSpectator(game, net, opts)
|
|||||||
self.enemyParty = guestParty
|
self.enemyParty = guestParty
|
||||||
self.playerParty = hostParty
|
self.playerParty = hostParty
|
||||||
self.opponentName = guestName
|
self.opponentName = guestName
|
||||||
self.introText = ("%s vs %s!"):format(hostName, guestName)
|
self.introText = Strings("%s vs %s!", hostName, guestName)
|
||||||
|
|
||||||
local function orderMove(action)
|
local function orderMove(action)
|
||||||
if action and action.id then return game.data.moves[action.id] end
|
if action and action.id then return game.data.moves[action.id] end
|
||||||
@@ -712,7 +760,7 @@ function LinkBattle.newSpectator(game, net, opts)
|
|||||||
battle = s, side = s.sides[2], battler = s.enemy, previous = previous,
|
battle = s, side = s.sides[2], battler = s.enemy, previous = previous,
|
||||||
})
|
})
|
||||||
s.enemySendingOut = true
|
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:actNext(function()
|
||||||
s.enemySendingOut = false
|
s.enemySendingOut = false
|
||||||
s:startGrowIn(s.enemy)
|
s:startGrowIn(s.enemy)
|
||||||
@@ -741,6 +789,13 @@ function LinkBattle.newSpectator(game, net, opts)
|
|||||||
end
|
end
|
||||||
|
|
||||||
s:act(function()
|
s:act(function()
|
||||||
|
-- the two real players cleared their flinch flags when their move
|
||||||
|
-- menu opened; a spectator has no menu, so it does it here instead,
|
||||||
|
-- at the same point in the turn (see BattleState:clearTurnFlinches).
|
||||||
|
-- Without this a flinch survived into the next turn and ate a move
|
||||||
|
-- that landed in the real match, and the replay -- sharing the RNG
|
||||||
|
-- stream -- was watching a different battle from that point on.
|
||||||
|
s:clearTurnFlinches()
|
||||||
local hostAction = hostMsg.kind ~= "switch" and hostMsg.kind ~= "run"
|
local hostAction = hostMsg.kind ~= "switch" and hostMsg.kind ~= "run"
|
||||||
and decodeWireAction(s, hostMsg, s.player) or nil
|
and decodeWireAction(s, hostMsg, s.player) or nil
|
||||||
local guestAction = guestMsg.kind ~= "switch" and guestMsg.kind ~= "run"
|
local guestAction = guestMsg.kind ~= "switch" and guestMsg.kind ~= "run"
|
||||||
@@ -789,7 +844,7 @@ function LinkBattle.newSpectator(game, net, opts)
|
|||||||
return
|
return
|
||||||
end
|
end
|
||||||
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.result = "guestWin"
|
||||||
s.afterQueue = "finish"
|
s.afterQueue = "finish"
|
||||||
end
|
end
|
||||||
@@ -801,7 +856,7 @@ function LinkBattle.newSpectator(game, net, opts)
|
|||||||
return
|
return
|
||||||
end
|
end
|
||||||
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.result = "hostWin"
|
||||||
s.afterQueue = "finish"
|
s.afterQueue = "finish"
|
||||||
end
|
end
|
||||||
@@ -836,7 +891,11 @@ function LinkBattle.newSpectator(game, net, opts)
|
|||||||
if net.closed and not s.linkEnded and not s.result then
|
if net.closed and not s.linkEnded and not s.result then
|
||||||
endSpectate(s)
|
endSpectate(s)
|
||||||
end
|
end
|
||||||
|
-- same as the real-participant loop: every path that skips baseUpdate
|
||||||
|
-- still has to advance the presentational clock, and a spectator sits in
|
||||||
|
-- waitBoth between every single turn
|
||||||
if s.phase == "messages" and s.afterQueue == "linkNext" then
|
if s.phase == "messages" and s.afterQueue == "linkNext" then
|
||||||
|
s:tickFx()
|
||||||
if not s:updateQueue() then
|
if not s:updateQueue() then
|
||||||
s.afterQueue = "waitBoth"
|
s.afterQueue = "waitBoth"
|
||||||
s.phase = "waitBoth"
|
s.phase = "waitBoth"
|
||||||
@@ -844,6 +903,7 @@ function LinkBattle.newSpectator(game, net, opts)
|
|||||||
return
|
return
|
||||||
end
|
end
|
||||||
if s.phase == "menu" or s.phase == "waitBoth" then
|
if s.phase == "menu" or s.phase == "waitBoth" then
|
||||||
|
s:tickFx()
|
||||||
return -- frozen between resolved turns; never a real decision here
|
return -- frozen between resolved turns; never a real decision here
|
||||||
end
|
end
|
||||||
baseUpdate(s, dt)
|
baseUpdate(s, dt)
|
||||||
|
|||||||
+91
-45
@@ -11,6 +11,7 @@ local Protocol = require("src.link.Protocol")
|
|||||||
local Runtime = require("src.mods.Runtime")
|
local Runtime = require("src.mods.Runtime")
|
||||||
local Screens = require("src.ui.Screens")
|
local Screens = require("src.ui.Screens")
|
||||||
local TextBox = require("src.render.TextBox")
|
local TextBox = require("src.render.TextBox")
|
||||||
|
local Strings = require("src.core.Strings")
|
||||||
|
|
||||||
local LinkState = {}
|
local LinkState = {}
|
||||||
LinkState.__index = LinkState
|
LinkState.__index = LinkState
|
||||||
@@ -72,6 +73,9 @@ end
|
|||||||
function LinkState.new(game)
|
function LinkState.new(game)
|
||||||
local self = setmetatable({}, LinkState)
|
local self = setmetatable({}, LinkState)
|
||||||
self.game = game
|
self.game = game
|
||||||
|
-- a link session runs at 1X on both machines whatever either player set
|
||||||
|
-- GAME SPEED to (see Game:logicSpeed); cleared in exitWith
|
||||||
|
game.linkSession = true
|
||||||
self.stage = "menu"
|
self.stage = "menu"
|
||||||
self.index = 1
|
self.index = 1
|
||||||
self.addr = ipDigits(Net.lanIP())
|
self.addr = ipDigits(Net.lanIP())
|
||||||
@@ -90,13 +94,14 @@ function LinkState.newJoinOnline(game, code)
|
|||||||
self.stage = "onlineJoining"
|
self.stage = "onlineJoining"
|
||||||
else
|
else
|
||||||
self.stage = "menu" -- exitWith below needs a real stage to unwind from
|
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
|
end
|
||||||
return self
|
return self
|
||||||
end
|
end
|
||||||
|
|
||||||
function LinkState:exitWith(message, reason)
|
function LinkState:exitWith(message, reason)
|
||||||
DiscordPresence.setJoinCode(nil)
|
DiscordPresence.setJoinCode(nil)
|
||||||
|
self.game.linkSession = nil -- back to the player's own GAME SPEED
|
||||||
Runtime.emit("link.ended", { reason = reason or (message and "error" or "bye") })
|
Runtime.emit("link.ended", { reason = reason or (message and "error" or "bye") })
|
||||||
if self.net then self.net:close() end
|
if self.net then self.net:close() end
|
||||||
self.game.stack:pop()
|
self.game.stack:pop()
|
||||||
@@ -105,6 +110,46 @@ function LinkState:exitWith(message, reason)
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- Online play meets strangers, so it requires vanilla on both ends
|
||||||
|
-- (Handshake.onlineAllowed). Mods merge into the shared Data registries at
|
||||||
|
-- boot and there is no unmerge, so switching them off has to go through a
|
||||||
|
-- relaunch -- but the player should not have to go find the mod manager and
|
||||||
|
-- work out which mods count. This turns every enabled mod off, records
|
||||||
|
-- them so the mod manager can put them back, and relaunches. The restart
|
||||||
|
-- is confirmed rather than silent: it drops unsaved progress.
|
||||||
|
function LinkState:offerVanillaRestart()
|
||||||
|
local game = self.game
|
||||||
|
local loader = game.mods
|
||||||
|
local mods = Handshake.mods(game)
|
||||||
|
local names = {}
|
||||||
|
for i, mod in ipairs(mods) do
|
||||||
|
if i > 2 then break end
|
||||||
|
names[#names + 1] = tostring(mod.id):upper():sub(1, 12)
|
||||||
|
end
|
||||||
|
local list = table.concat(names, ", ")
|
||||||
|
if #mods > #names then list = list .. (" +%d"):format(#mods - #names) end
|
||||||
|
local text = Strings(
|
||||||
|
"Online play runs\nvanilla for both\nplayers.\fTurn off %s\nand restart?", list)
|
||||||
|
self.game.linkSession = nil
|
||||||
|
Runtime.emit("link.ended", { reason = "error" })
|
||||||
|
if self.net then self.net:close() end
|
||||||
|
game.stack:pop()
|
||||||
|
game.stack:push(TextBox.new(game, text, nil, { choice = function(yes)
|
||||||
|
if not yes then return end
|
||||||
|
-- setEnabled persists the toggle itself (Loader:_saveState), so the
|
||||||
|
-- relaunch comes up vanilla and the mod manager lists them as disabled
|
||||||
|
-- for the player to switch back on afterwards
|
||||||
|
for _, mod in ipairs(mods) do
|
||||||
|
if loader and loader.setEnabled then loader:setEnabled(mod.id, false) end
|
||||||
|
end
|
||||||
|
if game.restartWithMods then
|
||||||
|
game:restartWithMods()
|
||||||
|
elseif love.event and love.event.quit then
|
||||||
|
love.event.quit("restart")
|
||||||
|
end
|
||||||
|
end }))
|
||||||
|
end
|
||||||
|
|
||||||
-- -------------------------------------------------------------------
|
-- -------------------------------------------------------------------
|
||||||
-- handshake v2 (D8): both peers announce engine version, api version and
|
-- handshake v2 (D8): both peers announce engine version, api version and
|
||||||
-- a fingerprint of their link surface, and the verdict comes from the two
|
-- a fingerprint of their link surface, and the verdict comes from the two
|
||||||
@@ -174,7 +219,7 @@ function LinkState:update(dt)
|
|||||||
if self.net then
|
if self.net then
|
||||||
self.net:update()
|
self.net:update()
|
||||||
if self.net.error and not PRE_CONNECT_STAGES[self.stage] then
|
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
|
return
|
||||||
end
|
end
|
||||||
-- the peer vanished without a bye (only once the inbox is drained,
|
-- the peer vanished without a bye (only once the inbox is drained,
|
||||||
@@ -183,7 +228,7 @@ function LinkState:update(dt)
|
|||||||
and not PRE_CONNECT_STAGES[self.stage] and self.stage ~= "addrEntry"
|
and not PRE_CONNECT_STAGES[self.stage] and self.stage ~= "addrEntry"
|
||||||
and self.stage ~= "codeEntry" and self.stage ~= "notice"
|
and self.stage ~= "codeEntry" and self.stage ~= "notice"
|
||||||
and self.stage ~= "battleRunning" then
|
and self.stage ~= "battleRunning" then
|
||||||
self:exitWith("The link was\nbroken.")
|
self:exitWith(Strings("The link was\nbroken."))
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -201,7 +246,7 @@ function LinkState:update(dt)
|
|||||||
self.index = 1
|
self.index = 1
|
||||||
elseif self.index == 2 or self.index == 3 then
|
elseif self.index == 2 or self.index == 3 then
|
||||||
if not Handshake.onlineAllowed(self.game) then
|
if not Handshake.onlineAllowed(self.game) then
|
||||||
self:exitWith("Online play needs\nno mods enabled.\fDisable them in\nSTART > MODS.")
|
self:offerVanillaRestart()
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
if self.index == 2 then
|
if self.index == 2 then
|
||||||
@@ -227,7 +272,7 @@ function LinkState:update(dt)
|
|||||||
if self.net:host() then
|
if self.net:host() then
|
||||||
self.stage = "hosting"
|
self.stage = "hosting"
|
||||||
else
|
else
|
||||||
self:exitWith("Link error:\n" .. (self.net.error or "?"))
|
self:exitWith(Strings("Link error:\n%s", self.net.error or "?"))
|
||||||
end
|
end
|
||||||
else
|
else
|
||||||
self.stage = "addrEntry"
|
self.stage = "addrEntry"
|
||||||
@@ -246,7 +291,7 @@ function LinkState:update(dt)
|
|||||||
if self.net:hostOnline() then
|
if self.net:hostOnline() then
|
||||||
self.stage = "onlineHosting"
|
self.stage = "onlineHosting"
|
||||||
else
|
else
|
||||||
self:exitWith("Link error:\n" .. (self.net.error or "?"))
|
self:exitWith(Strings("Link error:\n%s", self.net.error or "?"))
|
||||||
end
|
end
|
||||||
else
|
else
|
||||||
self.stage = "codeEntry"
|
self.stage = "codeEntry"
|
||||||
@@ -284,7 +329,7 @@ function LinkState:update(dt)
|
|||||||
if self.net:joinOnline(nil, code) then
|
if self.net:joinOnline(nil, code) then
|
||||||
self.stage = "onlineJoining"
|
self.stage = "onlineJoining"
|
||||||
else
|
else
|
||||||
self:exitWith("Link error:\n" .. (self.net.error or "?"))
|
self:exitWith(Strings("Link error:\n%s", self.net.error or "?"))
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -323,7 +368,7 @@ function LinkState:update(dt)
|
|||||||
if self.net:join(table.concat(octets, ".")) then
|
if self.net:join(table.concat(octets, ".")) then
|
||||||
self.stage = "joining"
|
self.stage = "joining"
|
||||||
else
|
else
|
||||||
self:exitWith("Link error:\n" .. (self.net.error or "?"))
|
self:exitWith(Strings("Link error:\n%s", self.net.error or "?"))
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -433,7 +478,7 @@ function LinkState:update(dt)
|
|||||||
end
|
end
|
||||||
if not battle then
|
if not battle then
|
||||||
self.net:send({ type = "bye" })
|
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
|
return
|
||||||
end
|
end
|
||||||
self.game.stack:push(battle)
|
self.game.stack:push(battle)
|
||||||
@@ -491,8 +536,8 @@ function LinkState:updateTrade(input)
|
|||||||
local t = self.trade
|
local t = self.trade
|
||||||
|
|
||||||
if t.stage == "cancelled" then
|
if t.stage == "cancelled" then
|
||||||
self:exitWith(t.error and ("The trade stopped:\n%s."):format(t.error)
|
self:exitWith(t.error and Strings("The trade stopped:\n%s.", t.error)
|
||||||
or "The trade was\ncancelled.")
|
or Strings("The trade was\ncancelled."))
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
if t.stage == "done" then
|
if t.stage == "done" then
|
||||||
@@ -508,6 +553,7 @@ function LinkState:updateTrade(input)
|
|||||||
if self.game.writeSave then self.game:writeSave() end
|
if self.game.writeSave then self.game:writeSave() end
|
||||||
local name = received.nickname or self.game.data.pokemon[received.species].name
|
local name = received.nickname or self.game.data.pokemon[received.species].name
|
||||||
Runtime.emit("link.ended", { reason = "done" })
|
Runtime.emit("link.ended", { reason = "done" })
|
||||||
|
self.game.linkSession = nil -- this path pops without exitWith
|
||||||
self.net:close()
|
self.net:close()
|
||||||
self.game.stack:pop()
|
self.game.stack:pop()
|
||||||
local game = self.game
|
local game = self.game
|
||||||
@@ -520,7 +566,7 @@ function LinkState:updateTrade(input)
|
|||||||
enemyOtId = received.otId,
|
enemyOtId = received.otId,
|
||||||
onDone = function()
|
onDone = function()
|
||||||
game.stack:push(TextBox.new(game,
|
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()
|
function()
|
||||||
if evoTo then
|
if evoTo then
|
||||||
-- via="TRADE": a trade evolution cannot be B-cancelled
|
-- via="TRADE": a trade evolution cannot be B-cancelled
|
||||||
@@ -547,7 +593,7 @@ function LinkState:updateTrade(input)
|
|||||||
-- the trade) -- B is dead after that, matching the A branch's own
|
-- the trade) -- B is dead after that, matching the A branch's own
|
||||||
-- self.confirmed == nil guard
|
-- self.confirmed == nil guard
|
||||||
self.net:send({ type = "bye" })
|
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
|
elseif t.stage == "picking" and input:wasPressed("a") then
|
||||||
if t:canPick(self.index) then
|
if t:canPick(self.index) then
|
||||||
self.net:send(t:pick(self.index))
|
self.net:send(t:pick(self.index))
|
||||||
@@ -574,30 +620,30 @@ end
|
|||||||
function LinkState:draw()
|
function LinkState:draw()
|
||||||
if self.stage == "menu" then
|
if self.stage == "menu" then
|
||||||
drawTitle("BOIS CLUB LIVE")
|
drawTitle("BOIS CLUB LIVE")
|
||||||
Font.draw("LINK CABLE (LAN)", 32, 44)
|
Font.draw(Strings("LINK CABLE (LAN)"), 32, 44)
|
||||||
Font.draw("ONLINE MATCH", 32, 60)
|
Font.draw(Strings("ONLINE MATCH"), 32, 60)
|
||||||
Font.draw("TOURNAMENT", 32, 76)
|
Font.draw(Strings("TOURNAMENT"), 32, 76)
|
||||||
Font.drawCode(CURSOR, 24, 44 + (self.index - 1) * 16)
|
Font.drawCode(CURSOR, 24, 44 + (self.index - 1) * 16)
|
||||||
|
|
||||||
elseif self.stage == "lanMenu" then
|
elseif self.stage == "lanMenu" then
|
||||||
drawTitle("LINK CABLE (LAN)")
|
drawTitle("LINK CABLE (LAN)")
|
||||||
Font.draw("HOST A GAME", 32, 48)
|
Font.draw(Strings("HOST A GAME"), 32, 48)
|
||||||
Font.draw("JOIN A GAME", 32, 68)
|
Font.draw(Strings("JOIN A GAME"), 32, 68)
|
||||||
Font.drawCode(CURSOR, 24, self.index == 1 and 48 or 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
|
elseif self.stage == "onlineMenu" then
|
||||||
drawTitle("ONLINE MATCH")
|
drawTitle("ONLINE MATCH")
|
||||||
Font.draw("HOST ONLINE", 32, 48)
|
Font.draw(Strings("HOST ONLINE"), 32, 48)
|
||||||
Font.draw("JOIN ONLINE", 32, 68)
|
Font.draw(Strings("JOIN ONLINE"), 32, 68)
|
||||||
Font.drawCode(CURSOR, 24, self.index == 1 and 48 or 68)
|
Font.drawCode(CURSOR, 24, self.index == 1 and 48 or 68)
|
||||||
|
|
||||||
elseif self.stage == "onlineHosting" then
|
elseif self.stage == "onlineHosting" then
|
||||||
drawTitle("HOSTING ONLINE")
|
drawTitle("HOSTING ONLINE")
|
||||||
Font.draw("Tell your friend", 16, 40)
|
Font.draw(Strings("Tell your friend"), 16, 40)
|
||||||
Font.draw("the code:", 16, 52)
|
Font.draw(Strings("the code:"), 16, 52)
|
||||||
Font.draw(self.net.code or "??????", 32, 68)
|
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
|
elseif self.stage == "codeEntry" then
|
||||||
drawTitle("ENTER CODE")
|
drawTitle("ENTER CODE")
|
||||||
@@ -609,18 +655,18 @@ function LinkState:draw()
|
|||||||
Font.drawCode(0xEE, x, 76) -- ▼ under the active slot
|
Font.drawCode(0xEE, x, 76) -- ▼ under the active slot
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
Font.draw("A: connect B: back", 8, 128)
|
Font.draw(Strings("A: connect B: back"), 8, 128)
|
||||||
|
|
||||||
elseif self.stage == "onlineJoining" then
|
elseif self.stage == "onlineJoining" then
|
||||||
drawTitle("CONNECTING...")
|
drawTitle("CONNECTING...")
|
||||||
Font.draw("Calling...", 8, 56)
|
Font.draw(Strings("Calling..."), 8, 56)
|
||||||
Font.draw(self.net.target or "", 8, 72)
|
Font.draw(self.net.target or "", 8, 72)
|
||||||
|
|
||||||
elseif self.stage == "hosting" then
|
elseif self.stage == "hosting" then
|
||||||
drawTitle("HOSTING")
|
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(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
|
elseif self.stage == "addrEntry" then
|
||||||
drawTitle("ENTER HOST ADDRESS")
|
drawTitle("ENTER HOST ADDRESS")
|
||||||
@@ -635,34 +681,34 @@ function LinkState:draw()
|
|||||||
for octet = 1, 3 do
|
for octet = 1, 3 do
|
||||||
Font.draw(".", 16 + octet * 32 - 8, 64)
|
Font.draw(".", 16 + octet * 32 - 8, 64)
|
||||||
end
|
end
|
||||||
Font.draw("Port: " .. Net.defaultPort(), 16, 96)
|
Font.draw(Strings("Port: %s", Net.defaultPort()), 16, 96)
|
||||||
Font.draw("A: connect B: back", 8, 128)
|
Font.draw(Strings("A: connect B: back"), 8, 128)
|
||||||
|
|
||||||
elseif self.stage == "joining" then
|
elseif self.stage == "joining" then
|
||||||
drawTitle("JOINING...")
|
drawTitle("JOINING...")
|
||||||
Font.draw("Calling...", 8, 56)
|
Font.draw(Strings("Calling..."), 8, 56)
|
||||||
Font.draw(self.net.target or "", 8, 72)
|
Font.draw(self.net.target or "", 8, 72)
|
||||||
|
|
||||||
elseif self.stage == "modeSelect" then
|
elseif self.stage == "modeSelect" then
|
||||||
drawTitle("CONNECTED!")
|
drawTitle("CONNECTED!")
|
||||||
Font.draw("TRADE", 32, 48)
|
Font.draw(Strings("TRADE"), 32, 48)
|
||||||
Font.draw("BATTLE", 32, 68)
|
Font.draw(Strings("BATTLE"), 32, 68)
|
||||||
Font.drawCode(CURSOR, 24, self.index == 1 and 48 or 68)
|
Font.drawCode(CURSOR, 24, self.index == 1 and 48 or 68)
|
||||||
|
|
||||||
elseif self.stage == "battleOptions" then
|
elseif self.stage == "battleOptions" then
|
||||||
drawTitle("BATTLE OPTIONS")
|
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(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
|
elseif self.stage == "waitMode" or self.stage == "waitHello" then
|
||||||
drawTitle("CONNECTED!")
|
drawTitle("CONNECTED!")
|
||||||
if self.stage == "waitHello" then
|
if self.stage == "waitHello" then
|
||||||
Font.draw("Checking the", 16, 56)
|
Font.draw(Strings("Checking the"), 16, 56)
|
||||||
Font.draw("other game...", 16, 72)
|
Font.draw(Strings("other game..."), 16, 72)
|
||||||
else
|
else
|
||||||
Font.draw("Waiting for the", 16, 56)
|
Font.draw(Strings("Waiting for the"), 16, 56)
|
||||||
Font.draw("host to choose...", 16, 72)
|
Font.draw(Strings("host to choose..."), 16, 72)
|
||||||
end
|
end
|
||||||
|
|
||||||
elseif self.stage == "notice" then
|
elseif self.stage == "notice" then
|
||||||
@@ -671,12 +717,12 @@ function LinkState:draw()
|
|||||||
if i > 8 then break end -- what fits above the prompt row
|
if i > 8 then break end -- what fits above the prompt row
|
||||||
Font.draw(line, 8, 24 + (i - 1) * 12)
|
Font.draw(line, 8, 24 + (i - 1) * 12)
|
||||||
end
|
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
|
elseif self.stage == "trade" then
|
||||||
drawTitle("TRADE")
|
drawTitle("TRADE")
|
||||||
local t = self.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
|
for i, mon in ipairs(self.game.save.party) do
|
||||||
local def = self.game.data.pokemon[mon.species]
|
local def = self.game.data.pokemon[mon.species]
|
||||||
local label = (mon.nickname or def.name):sub(1, 8)
|
local label = (mon.nickname or def.name):sub(1, 8)
|
||||||
@@ -684,7 +730,7 @@ function LinkState:draw()
|
|||||||
Font.draw(label, 16, 20 + i * 12)
|
Font.draw(label, 16, 20 + i * 12)
|
||||||
if i == self.index then Font.drawCode(CURSOR, 8, 20 + i * 12) end
|
if i == self.index then Font.drawCode(CURSOR, 8, 20 + i * 12) end
|
||||||
end
|
end
|
||||||
Font.draw("THEIRS", 84, 20)
|
Font.draw(Strings("THEIRS"), 84, 20)
|
||||||
for i, mon in ipairs(t.theirParty or {}) do
|
for i, mon in ipairs(t.theirParty or {}) do
|
||||||
local def = self.game.data.pokemon[mon.species]
|
local def = self.game.data.pokemon[mon.species]
|
||||||
Font.draw((mon.nickname or def.name):sub(1, 8), 92, 20 + i * 12)
|
Font.draw((mon.nickname or def.name):sub(1, 8), 92, 20 + i * 12)
|
||||||
@@ -695,16 +741,16 @@ function LinkState:draw()
|
|||||||
elseif t.stage == "waitParty" then hint = "Exchanging data..."
|
elseif t.stage == "waitParty" then hint = "Exchanging data..."
|
||||||
elseif t.stage == "picking" then
|
elseif t.stage == "picking" then
|
||||||
hint = t:canPick(self.index) and "Pick one to trade"
|
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 == "waitPick" then hint = "Waiting for them..."
|
||||||
elseif t.stage == "confirming" then
|
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
|
end
|
||||||
Font.draw(hint or "", 8, 132)
|
Font.draw(hint or "", 8, 132)
|
||||||
|
|
||||||
elseif self.stage == "battleWait" or self.stage == "battleRunning" then
|
elseif self.stage == "battleWait" or self.stage == "battleRunning" then
|
||||||
drawTitle("LINK BATTLE")
|
drawTitle("LINK BATTLE")
|
||||||
Font.draw("Exchanging data...", 16, 64)
|
Font.draw(Strings("Exchanging data..."), 16, 64)
|
||||||
end
|
end
|
||||||
love.graphics.setColor(1, 1, 1, 1)
|
love.graphics.setColor(1, 1, 1, 1)
|
||||||
end
|
end
|
||||||
|
|||||||
+13
-9
@@ -26,6 +26,7 @@
|
|||||||
|
|
||||||
local Json = require("src.link.Json")
|
local Json = require("src.link.Json")
|
||||||
local Logger = require("src.core.Logger")
|
local Logger = require("src.core.Logger")
|
||||||
|
local Strings = require("src.core.Strings")
|
||||||
|
|
||||||
local hasEnet, enet = pcall(require, "enet")
|
local hasEnet, enet = pcall(require, "enet")
|
||||||
if not hasEnet then enet = nil end
|
if not hasEnet then enet = nil end
|
||||||
@@ -173,7 +174,7 @@ function Net:connectTCP(addr)
|
|||||||
tcp:settimeout(5)
|
tcp:settimeout(5)
|
||||||
local ok, err = tcp:connect(host, port)
|
local ok, err = tcp:connect(host, port)
|
||||||
if not ok then
|
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
|
return false
|
||||||
end
|
end
|
||||||
tcp:settimeout(0)
|
tcp:settimeout(0)
|
||||||
@@ -236,11 +237,14 @@ local function handleGenericRelayControl(self, msg)
|
|||||||
self.paired = true
|
self.paired = true
|
||||||
return true
|
return true
|
||||||
elseif msg.type == "join_error" then
|
elseif msg.type == "join_error" then
|
||||||
self.error = ({
|
self.error = Strings(({
|
||||||
not_found = "That code wasn't\nfound.",
|
not_found = Strings.source("That code wasn't\nfound."),
|
||||||
full = "That game already\nhas two players.",
|
full = Strings.source("That game already\nhas two players."),
|
||||||
expired = "That code has\nexpired.",
|
expired = Strings.source("That code has\nexpired."),
|
||||||
})[msg.reason] or ("Couldn't join:\n%s"):format(tostring(msg.reason))
|
})[msg.reason] or "")
|
||||||
|
if self.error == "" then
|
||||||
|
self.error = Strings("Couldn't join:\n%s", tostring(msg.reason))
|
||||||
|
end
|
||||||
self.closed = true
|
self.closed = true
|
||||||
return true
|
return true
|
||||||
elseif msg.type == "peer_gone" then
|
elseif msg.type == "peer_gone" then
|
||||||
@@ -328,7 +332,7 @@ function Net:update()
|
|||||||
-- an unreachable join target surfaces as a service error
|
-- an unreachable join target surfaces as a service error
|
||||||
-- (ICMP unreachable on the connected UDP socket)
|
-- (ICMP unreachable on the connected UDP socket)
|
||||||
if self.mode == "joining" and not self.paired then
|
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
|
else
|
||||||
self.error = tostring(event)
|
self.error = tostring(event)
|
||||||
end
|
end
|
||||||
@@ -358,14 +362,14 @@ function Net:update()
|
|||||||
self.closed = true
|
self.closed = true
|
||||||
if not self.paired then
|
if not self.paired then
|
||||||
self.error = self.error or
|
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
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
if self.mode == "joining" and not self.paired
|
if self.mode == "joining" and not self.paired
|
||||||
and self.joinDeadline and now() > self.joinDeadline then
|
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
|
self.closed = true
|
||||||
pcall(function() self.peer:disconnect_now() end)
|
pcall(function() self.peer:disconnect_now() end)
|
||||||
end
|
end
|
||||||
|
|||||||
+41
-35
@@ -18,6 +18,7 @@ local Protocol = require("src.link.Protocol")
|
|||||||
local Runtime = require("src.mods.Runtime")
|
local Runtime = require("src.mods.Runtime")
|
||||||
local Sound = require("src.core.Sound")
|
local Sound = require("src.core.Sound")
|
||||||
local TextBox = require("src.render.TextBox")
|
local TextBox = require("src.render.TextBox")
|
||||||
|
local Strings = require("src.core.Strings")
|
||||||
|
|
||||||
local Tournament = {}
|
local Tournament = {}
|
||||||
Tournament.__index = Tournament
|
Tournament.__index = Tournament
|
||||||
@@ -90,6 +91,11 @@ function Tournament.new(game)
|
|||||||
self.settingsIndex = 1
|
self.settingsIndex = 1
|
||||||
self.roster = {}
|
self.roster = {}
|
||||||
self.spectatorRoster = {}
|
self.spectatorRoster = {}
|
||||||
|
-- everything from here to exitWith runs at 1X regardless of the GAME
|
||||||
|
-- SPEED option (see Game:logicSpeed): a tournament's shot clock counts
|
||||||
|
-- down on the logic step, so fast-forward would hand one player less
|
||||||
|
-- real time to choose than the opponent they are racing
|
||||||
|
game.linkSession = true
|
||||||
Sound.startLoop(game.data, MUSIC)
|
Sound.startLoop(game.data, MUSIC)
|
||||||
return self
|
return self
|
||||||
end
|
end
|
||||||
@@ -112,6 +118,7 @@ end
|
|||||||
|
|
||||||
function Tournament:exitWith(message)
|
function Tournament:exitWith(message)
|
||||||
DiscordPresence.setJoinCode(nil)
|
DiscordPresence.setJoinCode(nil)
|
||||||
|
self.game.linkSession = nil -- back to the player's own GAME SPEED
|
||||||
Sound.stopLoop(MUSIC)
|
Sound.stopLoop(MUSIC)
|
||||||
Runtime.emit("link.ended", { reason = message and "error" or "bye" })
|
Runtime.emit("link.ended", { reason = message and "error" or "bye" })
|
||||||
if self.net then self.net:close() end
|
if self.net then self.net:close() end
|
||||||
@@ -128,7 +135,7 @@ end
|
|||||||
function Tournament:startHosting()
|
function Tournament:startHosting()
|
||||||
self.net = Net.new()
|
self.net = Net.new()
|
||||||
if not self.net:connectTCP(Net.defaultRelayAddress()) then
|
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
|
return
|
||||||
end
|
end
|
||||||
local size, minL, maxL = partyStats(self.game.save.party)
|
local size, minL, maxL = partyStats(self.game.save.party)
|
||||||
@@ -151,7 +158,7 @@ end
|
|||||||
function Tournament:startJoining(code)
|
function Tournament:startJoining(code)
|
||||||
self.net = Net.new()
|
self.net = Net.new()
|
||||||
if not self.net:connectTCP(Net.defaultRelayAddress()) then
|
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
|
return
|
||||||
end
|
end
|
||||||
local size, minL, maxL = partyStats(self.game.save.party)
|
local size, minL, maxL = partyStats(self.game.save.party)
|
||||||
@@ -171,9 +178,9 @@ end
|
|||||||
-- -------------------------------------------------------------------
|
-- -------------------------------------------------------------------
|
||||||
|
|
||||||
local JOIN_ERROR_TEXT = {
|
local JOIN_ERROR_TEXT = {
|
||||||
not_found = "That code wasn't\nfound.",
|
not_found = Strings.source("That code wasn't\nfound."),
|
||||||
already_started = "That tournament\nhas already begun.",
|
already_started = Strings.source("That tournament\nhas already begun."),
|
||||||
expired = "That code has\nexpired.",
|
expired = Strings.source("That code has\nexpired."),
|
||||||
}
|
}
|
||||||
|
|
||||||
function Tournament:handleMessage(msg)
|
function Tournament:handleMessage(msg)
|
||||||
@@ -198,17 +205,17 @@ function Tournament:handleMessage(msg)
|
|||||||
end
|
end
|
||||||
elseif msg.type == "tournament_host_error" then
|
elseif msg.type == "tournament_host_error" then
|
||||||
if msg.reason == "party_ineligible" then
|
if msg.reason == "party_ineligible" then
|
||||||
self:exitWith(("Can't host:\nneed %d Pokemon\nLv %s-%s."):format(
|
self:exitWith(Strings("Can't host:\nneed %d Pokemon\nLv %s-%s.", msg.requiredPartySize, levelLabel(msg.minLevel), levelLabel(msg.maxLevel)))
|
||||||
msg.requiredPartySize, levelLabel(msg.minLevel), levelLabel(msg.maxLevel)))
|
|
||||||
else
|
else
|
||||||
self:exitWith("Couldn't host\nthat tournament.")
|
self:exitWith(Strings("Couldn't host\nthat tournament."))
|
||||||
end
|
end
|
||||||
elseif msg.type == "tournament_join_error" then
|
elseif msg.type == "tournament_join_error" then
|
||||||
if msg.reason == "party_ineligible" then
|
if msg.reason == "party_ineligible" then
|
||||||
self:exitWith(("Your party needs\n%d Pokemon, Lv\n%s-%s."):format(
|
self:exitWith(Strings("Your party needs\n%d Pokemon, Lv\n%s-%s.", msg.requiredPartySize, levelLabel(msg.minLevel), levelLabel(msg.maxLevel)))
|
||||||
msg.requiredPartySize, levelLabel(msg.minLevel), levelLabel(msg.maxLevel)))
|
|
||||||
else
|
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
|
end
|
||||||
elseif msg.type == "tournament_roster" then
|
elseif msg.type == "tournament_roster" then
|
||||||
self.roster = msg.players
|
self.roster = msg.players
|
||||||
@@ -281,7 +288,7 @@ function Tournament:beginMatchBattle()
|
|||||||
-- shouldn't happen (both sides already passed the online-play mods
|
-- shouldn't happen (both sides already passed the online-play mods
|
||||||
-- gate), but a mismatched engine/build is still possible -- bail out
|
-- gate), but a mismatched engine/build is still possible -- bail out
|
||||||
-- of just this match rather than crash the tournament
|
-- 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
|
return
|
||||||
end
|
end
|
||||||
self.linkSeed = self.isHost and love.math.random(1, 2 ^ 30) or nil
|
self.linkSeed = self.isHost and love.math.random(1, 2 ^ 30) or nil
|
||||||
@@ -351,11 +358,11 @@ function Tournament:update(dt)
|
|||||||
self.net:update()
|
self.net:update()
|
||||||
if self.net.error and self.stage ~= "menu" and self.stage ~= "hostSettings"
|
if self.net.error and self.stage ~= "menu" and self.stage ~= "hostSettings"
|
||||||
and self.stage ~= "codeEntry" then
|
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
|
return
|
||||||
end
|
end
|
||||||
if self.net.closed and self.stage ~= "done" then
|
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
|
return
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -381,7 +388,7 @@ function Tournament:update(dt)
|
|||||||
and LinkBattle.newHost(self.game, self.net, self.pendingBattleOpts)
|
and LinkBattle.newHost(self.game, self.net, self.pendingBattleOpts)
|
||||||
or LinkBattle.newGuest(self.game, self.net, self.pendingBattleOpts)
|
or LinkBattle.newGuest(self.game, self.net, self.pendingBattleOpts)
|
||||||
if not battle then
|
if not battle then
|
||||||
self:exitWith(why or "Link battle\ncan't start.")
|
self:exitWith(why or Strings("Link battle\ncan't start."))
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
-- anything after `party` in this same batch belongs to the
|
-- anything after `party` in this same batch belongs to the
|
||||||
@@ -418,7 +425,7 @@ function Tournament:update(dt)
|
|||||||
forceLevel = levelForWire(self.settings.forceLevel),
|
forceLevel = levelForWire(self.settings.forceLevel),
|
||||||
})
|
})
|
||||||
if not battle then
|
if not battle then
|
||||||
self:exitWith(why or "Can't watch this\nmatch.")
|
self:exitWith(why or Strings("Can't watch this\nmatch."))
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
for j = #msgs, i + 1, -1 do
|
for j = #msgs, i + 1, -1 do
|
||||||
@@ -541,8 +548,8 @@ local SETTINGS_LABELS = { "POKEMON", "MIN LV", "MAX LV", "TIMER", "LEVELS", "PLA
|
|||||||
function Tournament:draw()
|
function Tournament:draw()
|
||||||
if self.stage == "menu" then
|
if self.stage == "menu" then
|
||||||
drawTitle("TOURNAMENT")
|
drawTitle("TOURNAMENT")
|
||||||
Font.draw("HOST", 32, 48)
|
Font.draw(Strings("HOST"), 32, 48)
|
||||||
Font.draw("JOIN", 32, 68)
|
Font.draw(Strings("JOIN"), 32, 68)
|
||||||
Font.drawCode(CURSOR, 24, self.index == 1 and 48 or 68)
|
Font.drawCode(CURSOR, 24, self.index == 1 and 48 or 68)
|
||||||
|
|
||||||
elseif self.stage == "hostSettings" then
|
elseif self.stage == "hostSettings" then
|
||||||
@@ -561,7 +568,7 @@ function Tournament:draw()
|
|||||||
Font.draw(values[i], 96, y)
|
Font.draw(values[i], 96, y)
|
||||||
if i == self.settingsIndex then Font.drawCode(CURSOR, 8, y) end
|
if i == self.settingsIndex then Font.drawCode(CURSOR, 8, y) end
|
||||||
end
|
end
|
||||||
Font.draw("START: create", 8, 128)
|
Font.draw(Strings("START: create"), 8, 128)
|
||||||
|
|
||||||
elseif self.stage == "codeEntry" then
|
elseif self.stage == "codeEntry" then
|
||||||
drawTitle("ENTER CODE")
|
drawTitle("ENTER CODE")
|
||||||
@@ -573,28 +580,27 @@ function Tournament:draw()
|
|||||||
Font.drawCode(0xEE, x, 76)
|
Font.drawCode(0xEE, x, 76)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
Font.draw("A: join B: back", 8, 128)
|
Font.draw(Strings("A: join B: back"), 8, 128)
|
||||||
|
|
||||||
elseif self.stage == "registering" then
|
elseif self.stage == "registering" then
|
||||||
drawTitle("CONNECTING...")
|
drawTitle("CONNECTING...")
|
||||||
Font.draw("B: cancel", 8, 128)
|
Font.draw(Strings("B: cancel"), 8, 128)
|
||||||
|
|
||||||
elseif self.stage == "bracket" or self.stage == "matchHello"
|
elseif self.stage == "bracket" or self.stage == "matchHello"
|
||||||
or self.stage == "matchWaitParty" or self.stage == "spectateWait" then
|
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
|
if self.bracket then
|
||||||
local y = 20
|
local y = 20
|
||||||
for _, round in ipairs(self.bracket.rounds) do
|
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
|
y = y + 10
|
||||||
for _, m in ipairs(round.matches) do
|
for _, m in ipairs(round.matches) do
|
||||||
local line
|
local line
|
||||||
if m.bye then
|
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
|
else
|
||||||
local mark = m.state == "live" and "*" or (m.winner and "" or "")
|
local mark = m.state == "live" and "*" or (m.winner and "" or "")
|
||||||
line = ("%s%s vs %s%s"):format(
|
line = Strings("%s%s vs %s%s", m.winner == m.a and ">" or " ", m.a or "?",
|
||||||
m.winner == m.a and ">" or " ", m.a or "?",
|
|
||||||
m.b or "?", m.winner == m.b and "<" or (mark == "*" and " *" or ""))
|
m.b or "?", m.winner == m.b and "<" or (mark == "*" and " *" or ""))
|
||||||
end
|
end
|
||||||
Font.draw(line, 12, y)
|
Font.draw(line, 12, y)
|
||||||
@@ -604,11 +610,11 @@ function Tournament:draw()
|
|||||||
end
|
end
|
||||||
else
|
else
|
||||||
if self.participating == false then
|
if self.participating == false then
|
||||||
Font.draw("(organizing --", 16, 32)
|
Font.draw(Strings("(organizing --"), 16, 32)
|
||||||
Font.draw("not playing)", 16, 42)
|
Font.draw(Strings("not playing)"), 16, 42)
|
||||||
end
|
end
|
||||||
Font.draw("Waiting for", 16, 48)
|
Font.draw(Strings("Waiting for"), 16, 48)
|
||||||
Font.draw("players to join:", 16, 60)
|
Font.draw(Strings("players to join:"), 16, 60)
|
||||||
local y = 60
|
local y = 60
|
||||||
for i, name in ipairs(self.roster) do
|
for i, name in ipairs(self.roster) do
|
||||||
y = 60 + i * 10
|
y = 60 + i * 10
|
||||||
@@ -620,18 +626,18 @@ function Tournament:draw()
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
if self.isCreator and #self.roster >= 2 and not self.bracket then
|
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
|
else
|
||||||
Font.draw("B: cancel", 8, 132)
|
Font.draw(Strings("B: cancel"), 8, 132)
|
||||||
end
|
end
|
||||||
|
|
||||||
elseif self.stage == "done" then
|
elseif self.stage == "done" then
|
||||||
drawTitle("TOURNAMENT OVER")
|
drawTitle("TOURNAMENT OVER")
|
||||||
if self.champion then
|
if self.champion then
|
||||||
Font.draw(("%s is the"):format(self.champion), 16, 56)
|
Font.draw(Strings("%s is the", self.champion), 16, 56)
|
||||||
Font.draw("champion!", 16, 68)
|
Font.draw(Strings("champion!"), 16, 68)
|
||||||
end
|
end
|
||||||
Font.draw("A: continue", 8, 128)
|
Font.draw(Strings("A: continue"), 8, 128)
|
||||||
end
|
end
|
||||||
love.graphics.setColor(1, 1, 1, 1)
|
love.graphics.setColor(1, 1, 1, 1)
|
||||||
end
|
end
|
||||||
|
|||||||
+58
-10
@@ -3,8 +3,18 @@
|
|||||||
-- manifests only. The full loader (src/mods/Loader.lua) still owns the real
|
-- 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
|
-- load at boot; this reads the same options.mods enable-state the loader
|
||||||
-- writes, derives per-mod status with the pure ManagerState.resolveToggle,
|
-- writes, derives per-mod status with the pure ManagerState.resolveToggle,
|
||||||
-- installs a dropped/chosen .zip into the save-dir "mods/<id>/" tree, and
|
-- installs a dropped/chosen .zip into a "mods/<id>/" tree, and uninstalls a
|
||||||
-- uninstalls a mod by removing that tree + clearing options.mods[id].
|
-- 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
|
-- Split in two: the pure derivation (deriveList, locateRoot) has no love and
|
||||||
-- no filesystem, so the engine tier can table-drive it; the discovery,
|
-- 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 Semver = require("src.mods.Semver")
|
||||||
local Version = require("src.core.Version")
|
local Version = require("src.core.Version")
|
||||||
local SaveData = require("src.core.SaveData")
|
local SaveData = require("src.core.SaveData")
|
||||||
|
local CacheFs = require("src.import.CacheFs")
|
||||||
|
|
||||||
local LauncherMods = {}
|
local LauncherMods = {}
|
||||||
|
|
||||||
@@ -143,6 +154,13 @@ local function discover()
|
|||||||
local fs = love and love.filesystem
|
local fs = love and love.filesystem
|
||||||
local out = {}
|
local out = {}
|
||||||
if not (fs and fs.getInfo and fs.getDirectoryItems) then return out end
|
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
|
if not fs.getInfo("mods") then return out end
|
||||||
local seen = {}
|
local seen = {}
|
||||||
for _, name in ipairs(fs.getDirectoryItems("mods")) do
|
for _, name in ipairs(fs.getDirectoryItems("mods")) do
|
||||||
@@ -233,11 +251,14 @@ local function topLevelPaths(mount)
|
|||||||
return paths
|
return paths
|
||||||
end
|
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 function copyTree(src, dst)
|
||||||
local fs = love.filesystem
|
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
|
for _, name in ipairs(fs.getDirectoryItems(src)) do
|
||||||
local s = src .. "/" .. name
|
local s = src .. "/" .. name
|
||||||
local d = dst .. "/" .. name
|
local d = dst .. "/" .. name
|
||||||
@@ -248,13 +269,18 @@ local function copyTree(src, dst)
|
|||||||
else
|
else
|
||||||
local data = fs.read(s)
|
local data = fs.read(s)
|
||||||
if data == nil then return nil, "could not read " .. name end
|
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
|
if not ok then return nil, "could not write " .. name .. ": " .. tostring(err) end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
return true
|
return true
|
||||||
end
|
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 function removeTree(path)
|
||||||
local fs = love.filesystem
|
local fs = love.filesystem
|
||||||
local info = fs.getInfo(path)
|
local info = fs.getInfo(path)
|
||||||
@@ -263,7 +289,15 @@ local function removeTree(path)
|
|||||||
for _, child in ipairs(fs.getDirectoryItems(path)) do
|
for _, child in ipairs(fs.getDirectoryItems(path)) do
|
||||||
removeTree(path .. "/" .. child)
|
removeTree(path .. "/" .. child)
|
||||||
end
|
end
|
||||||
|
CacheFs.removeDir(path)
|
||||||
|
else
|
||||||
|
CacheFs.remove(path)
|
||||||
end
|
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)
|
fs.remove(path)
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -322,10 +356,19 @@ function LauncherMods.installZip(source)
|
|||||||
return nil, "a mod named '" .. manifest.id .. "' is already installed"
|
return nil, "a mod named '" .. manifest.id .. "' is already installed"
|
||||||
end
|
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)
|
local copied, copyErr = copyTree(root, dest)
|
||||||
|
if not copied then removeTree(dest) end
|
||||||
|
CacheFs.prefix = savedPrefix
|
||||||
if not copied then
|
if not copied then
|
||||||
removeTree(dest)
|
|
||||||
cleanup()
|
cleanup()
|
||||||
return nil, copyErr or "could not copy the mod files"
|
return nil, copyErr or "could not copy the mod files"
|
||||||
end
|
end
|
||||||
@@ -334,8 +377,9 @@ function LauncherMods.installZip(source)
|
|||||||
end
|
end
|
||||||
|
|
||||||
-- uninstall(id) -> true | nil, errString
|
-- uninstall(id) -> true | nil, errString
|
||||||
-- Removes mods/<id>/ from the save directory and clears options.mods[id] so the
|
-- Removes mods/<id>/ from wherever it was installed (the portable game folder
|
||||||
-- loader and in-game manager no longer see it. Rejects unknown / missing ids.
|
-- 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.
|
-- Does not touch other mods' enable state.
|
||||||
function LauncherMods.uninstall(id)
|
function LauncherMods.uninstall(id)
|
||||||
if type(id) ~= "string" or id == "" then
|
if type(id) ~= "string" or id == "" then
|
||||||
@@ -352,7 +396,11 @@ function LauncherMods.uninstall(id)
|
|||||||
if not fs.getInfo(dest) then
|
if not fs.getInfo(dest) then
|
||||||
return nil, "mod '" .. id .. "' is not installed"
|
return nil, "mod '" .. id .. "' is not installed"
|
||||||
end
|
end
|
||||||
|
-- same root pin as installZip: the mods tree is not version-prefixed (#330)
|
||||||
|
local savedPrefix = CacheFs.prefix
|
||||||
|
CacheFs.prefix = ""
|
||||||
removeTree(dest)
|
removeTree(dest)
|
||||||
|
CacheFs.prefix = savedPrefix
|
||||||
-- Drop the enable flag so a reinstall of the same id starts from the
|
-- Drop the enable flag so a reinstall of the same id starts from the
|
||||||
-- loader's default (enabled) rather than a stale false.
|
-- loader's default (enabled) rather than a stale false.
|
||||||
local options = SaveData.loadOptions()
|
local options = SaveData.loadOptions()
|
||||||
|
|||||||
+5
-1
@@ -162,7 +162,11 @@ function Loader:_loadState()
|
|||||||
if next(options.mods or {}) == nil and self.fs.getInfo
|
if next(options.mods or {}) == nil and self.fs.getInfo
|
||||||
and self.fs.getInfo(MOD_STATE_FILE) then
|
and self.fs.getInfo(MOD_STATE_FILE) then
|
||||||
local chunk = self.fs.load(MOD_STATE_FILE)
|
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
|
if ok and type(state) == "table" then
|
||||||
for id, disabled in pairs(state) do
|
for id, disabled in pairs(state) do
|
||||||
if disabled then
|
if disabled then
|
||||||
|
|||||||
+21
-20
@@ -10,6 +10,7 @@ local Semver = require("src.mods.Semver")
|
|||||||
local Version = require("src.core.Version")
|
local Version = require("src.core.Version")
|
||||||
local Theme = require("src.ui.Theme")
|
local Theme = require("src.ui.Theme")
|
||||||
local OptionRows = require("src.ui.OptionRows")
|
local OptionRows = require("src.ui.OptionRows")
|
||||||
|
local Strings = require("src.core.Strings")
|
||||||
|
|
||||||
local ManagerState = {}
|
local ManagerState = {}
|
||||||
ManagerState.__index = ManagerState
|
ManagerState.__index = ManagerState
|
||||||
@@ -269,7 +270,7 @@ function ManagerState:modRows()
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
if #rows == 0 then
|
if #rows == 0 then
|
||||||
rows[1] = { header = true, label = "NO MODS INSTALLED" }
|
rows[1] = { header = true, label = Strings("NO MODS INSTALLED") }
|
||||||
end
|
end
|
||||||
return rows
|
return rows
|
||||||
end
|
end
|
||||||
@@ -281,7 +282,7 @@ function ManagerState:profileRows()
|
|||||||
rows[#rows + 1] = { profile = p, label = p.name,
|
rows[#rows + 1] = { profile = p, label = p.name,
|
||||||
glyph = opts.activeProfile == p.name and GLYPH.errored or " " }
|
glyph = opts.activeProfile == p.name and GLYPH.errored or " " }
|
||||||
end
|
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,
|
rows[#rows + 1] = { adhoc = true,
|
||||||
label = opts.activeProfile and "[AD-HOC]" or "[AD-HOC] (LIVE)" }
|
label = opts.activeProfile and "[AD-HOC]" or "[AD-HOC] (LIVE)" }
|
||||||
return rows
|
return rows
|
||||||
@@ -316,30 +317,30 @@ function ManagerState:detailRows(m)
|
|||||||
rows[#rows + 1] = { label = m.enabled and "DISABLE" or "ENABLE",
|
rows[#rows + 1] = { label = m.enabled and "DISABLE" or "ENABLE",
|
||||||
action = function() self:beginToggle(m) end }
|
action = function() self:beginToggle(m) end }
|
||||||
if self:schemaFor(m) then
|
if self:schemaFor(m) then
|
||||||
rows[#rows + 1] = { label = "OPTIONS..",
|
rows[#rows + 1] = { label = Strings("OPTIONS.."),
|
||||||
action = function() self:openOptions(m) end }
|
action = function() self:openOptions(m) end }
|
||||||
end
|
end
|
||||||
if m.permissions and #m.permissions > 0 then
|
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 }
|
action = function() self:goTo("permissions") end }
|
||||||
end
|
end
|
||||||
if m.error then
|
if m.error then
|
||||||
rows[#rows + 1] = { label = "VIEW ERROR..",
|
rows[#rows + 1] = { label = Strings("VIEW ERROR.."),
|
||||||
action = function() self:goTo("errors") end }
|
action = function() self:goTo("errors") end }
|
||||||
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
|
return rows
|
||||||
end
|
end
|
||||||
|
|
||||||
function ManagerState:applyRows()
|
function ManagerState:applyRows()
|
||||||
local rows = {}
|
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)
|
self:openConfirm({ "RESTART NOW?" }, function() self:restartGame() end)
|
||||||
end }
|
end }
|
||||||
rows[#rows + 1] = { label = "DISCARD CHANGES", action = function()
|
rows[#rows + 1] = { label = Strings("DISCARD CHANGES"), action = function()
|
||||||
self:discardChanges()
|
self:discardChanges()
|
||||||
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
|
return rows
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -352,7 +353,7 @@ function ManagerState:permissionRows(m)
|
|||||||
label = info and info.text or name }
|
label = info and info.text or name }
|
||||||
end
|
end
|
||||||
if #rows == 0 then
|
if #rows == 0 then
|
||||||
rows[1] = { inert = true, label = "DATA & API ONLY" }
|
rows[1] = { inert = true, label = Strings("DATA & API ONLY") }
|
||||||
end
|
end
|
||||||
return rows
|
return rows
|
||||||
end
|
end
|
||||||
@@ -564,7 +565,7 @@ function ManagerState:openCascade(r, m, want)
|
|||||||
else
|
else
|
||||||
local dep = self.byId[r.alsoDisable[1]]
|
local dep = self.byId[r.alsoDisable[1]]
|
||||||
lines[#lines + 1] = (dep and dep.name or r.alsoDisable[1]) .. " NEEDS THIS."
|
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
|
end
|
||||||
self.overlay = { kind = "confirm", lines = lines, index = 1,
|
self.overlay = { kind = "confirm", lines = lines, index = 1,
|
||||||
onYes = function() self:commitToggle(r.apply) end }
|
onYes = function() self:commitToggle(r.apply) end }
|
||||||
@@ -700,7 +701,7 @@ end
|
|||||||
function ManagerState:saveCurrentAs()
|
function ManagerState:saveCurrentAs()
|
||||||
local NamingScreen = require("src.ui.NamingScreen")
|
local NamingScreen = require("src.ui.NamingScreen")
|
||||||
self.game.stack:push(NamingScreen.new(self.game, {
|
self.game.stack:push(NamingScreen.new(self.game, {
|
||||||
title = "PROFILE NAME?",
|
title = Strings("PROFILE NAME?"),
|
||||||
maxLen = 10,
|
maxLen = 10,
|
||||||
onDone = function(name)
|
onDone = function(name)
|
||||||
local opts = self:optionsTable()
|
local opts = self:optionsTable()
|
||||||
@@ -726,7 +727,7 @@ end
|
|||||||
function ManagerState:renameProfile(p)
|
function ManagerState:renameProfile(p)
|
||||||
local NamingScreen = require("src.ui.NamingScreen")
|
local NamingScreen = require("src.ui.NamingScreen")
|
||||||
self.game.stack:push(NamingScreen.new(self.game, {
|
self.game.stack:push(NamingScreen.new(self.game, {
|
||||||
title = "RENAME?",
|
title = Strings("RENAME?"),
|
||||||
maxLen = 10,
|
maxLen = 10,
|
||||||
default = p.name,
|
default = p.name,
|
||||||
onDone = function(name)
|
onDone = function(name)
|
||||||
@@ -886,7 +887,7 @@ function ManagerState:buildOptionRows(m, schema)
|
|||||||
end }
|
end }
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
rows[#rows + 1] = { id = "__reset", label = "RESET DEFAULTS",
|
rows[#rows + 1] = { id = "__reset", label = Strings("RESET DEFAULTS"),
|
||||||
value = function() return "" end,
|
value = function() return "" end,
|
||||||
activate = function()
|
activate = function()
|
||||||
for _, row in ipairs(schema) do
|
for _, row in ipairs(schema) do
|
||||||
@@ -1047,7 +1048,7 @@ function ManagerState:drawApply()
|
|||||||
y = y + 1
|
y = y + 1
|
||||||
end
|
end
|
||||||
if #staged == 0 then
|
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
|
y = y + 1
|
||||||
end
|
end
|
||||||
local rows = self:rowsForScreen()
|
local rows = self:rowsForScreen()
|
||||||
@@ -1076,12 +1077,12 @@ function ManagerState:drawOverlay()
|
|||||||
end
|
end
|
||||||
if overlay.kind == "confirm" then
|
if overlay.kind == "confirm" then
|
||||||
local yesY = ty + #lines + 1
|
local yesY = ty + #lines + 1
|
||||||
Font.draw("YES", 5 * 8, yesY * 8)
|
Font.draw(Strings("YES"), 5 * 8, yesY * 8)
|
||||||
Font.draw("NO", 5 * 8, (yesY + 1) * 8)
|
Font.draw(Strings("NO"), 5 * 8, (yesY + 1) * 8)
|
||||||
Font.drawCode(Theme.cursor, 4 * 8,
|
Font.drawCode(Theme.cursor, 4 * 8,
|
||||||
(overlay.index == 1 and yesY or yesY + 1) * 8)
|
(overlay.index == 1 and yesY or yesY + 1) * 8)
|
||||||
else
|
else
|
||||||
Font.draw("A:OK", 5 * 8, (ty + #lines + 1) * 8)
|
Font.draw(Strings("A:OK"), 5 * 8, (ty + #lines + 1) * 8)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -1090,7 +1091,7 @@ function ManagerState:draw()
|
|||||||
OptionRows.draw(self.game, self.optionRows or {}, self.cursor,
|
OptionRows.draw(self.game, self.optionRows or {}, self.cursor,
|
||||||
self.scroll or 0)
|
self.scroll or 0)
|
||||||
love.graphics.setColor(0, 0, 0, 1)
|
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)
|
love.graphics.setColor(1, 1, 1, 1)
|
||||||
if self.overlay then self:drawOverlay() end
|
if self.overlay then self:drawOverlay() end
|
||||||
return
|
return
|
||||||
@@ -1099,7 +1100,7 @@ function ManagerState:draw()
|
|||||||
love.graphics.rectangle("fill", 0, 0, 160, 144)
|
love.graphics.rectangle("fill", 0, 0, 160, 144)
|
||||||
love.graphics.setColor(1, 1, 1, 1)
|
love.graphics.setColor(1, 1, 1, 1)
|
||||||
Font.drawBox(0, 0, 20, 18)
|
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
|
if self.screen == "list" then
|
||||||
self:drawList()
|
self:drawList()
|
||||||
elseif self.screen == "detail" then
|
elseif self.screen == "detail" then
|
||||||
|
|||||||
+31
-2
@@ -584,6 +584,17 @@ R.text = {
|
|||||||
example = 'mod.content.text:override("_PalletTownText1", "HELLO!")',
|
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);
|
-- the self-contained bytecode blob ChipAsm.song/sfx emit (13.1 shape 2);
|
||||||
-- shared by every namespace that plays a chip program
|
-- shared by every namespace that plays a chip program
|
||||||
local chipProgram = f.rec{
|
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
|
-- 1 = native pixels; the drawn size relative to the pic's own pixels
|
||||||
scale = f.numRange(0.25, 4.0),
|
scale = f.numRange(0.25, 4.0),
|
||||||
},
|
},
|
||||||
example = 'mod.content.battle_sprite_scales:register("abra_back", ' ..
|
example = [[
|
||||||
'{ path = "assets/generated/battle/back/abrab.png", scale = 1.5 })',
|
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
|
-- ------- progression
|
||||||
@@ -1146,6 +1169,12 @@ R.field = {
|
|||||||
name = f.opt(f.str) })),
|
name = f.opt(f.str) })),
|
||||||
nest = f.opt(f.any) },
|
nest = f.opt(f.any) },
|
||||||
flyOrder = f.list(f.str),
|
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
|
-- the new-game and boot config a total conversion replaces
|
||||||
boot = f.rec{
|
boot = f.rec{
|
||||||
startMap = f.opt(f.str), startX = f.opt(f.int(0)), startY = f.opt(f.int(0)),
|
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 Screens = require("src.ui.Screens")
|
||||||
local Stats = require("src.pokemon.Stats")
|
local Stats = require("src.pokemon.Stats")
|
||||||
local TextBox = require("src.render.TextBox")
|
local TextBox = require("src.render.TextBox")
|
||||||
|
local Strings = require("src.core.Strings")
|
||||||
|
|
||||||
local Evolution = {}
|
local Evolution = {}
|
||||||
|
|
||||||
@@ -23,7 +24,7 @@ Evolution.METHODS = {
|
|||||||
return trigger.kind == "levelup" and mon.level >= (evo.level or 0)
|
return trigger.kind == "levelup" and mon.level >= (evo.level or 0)
|
||||||
end,
|
end,
|
||||||
describe = function(evo)
|
describe = function(evo)
|
||||||
return ("Level %d"):format(evo.level or 0)
|
return Strings("Level %d", evo.level or 0)
|
||||||
end,
|
end,
|
||||||
},
|
},
|
||||||
ITEM = {
|
ITEM = {
|
||||||
@@ -139,7 +140,7 @@ function Evolution.learnEvolutionMoves(game, mon, onDone)
|
|||||||
table.insert(mon.moves, { id = moveId, pp = mdef.pp })
|
table.insert(mon.moves, { id = moveId, pp = mdef.pp })
|
||||||
Runtime.emit("pokemon.move_learned", { mon = mon, moveId = moveId })
|
Runtime.emit("pokemon.move_learned", { mon = mon, moveId = moveId })
|
||||||
game.stack:push(TextBox.new(game,
|
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
|
else
|
||||||
-- LearnMoveFromLevelUp with a full moveset: the forget UI
|
-- LearnMoveFromLevelUp with a full moveset: the forget UI
|
||||||
Screens.push(game, "MoveLearnMenu", mon, moveId, nextStep)
|
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"))
|
Music.play(game.data, Music.special(game.data, "evolution"))
|
||||||
local oldName = mon.nickname or game.data.pokemon[mon.species].name
|
local oldName = mon.nickname or game.data.pokemon[mon.species].name
|
||||||
Evolution.apply(game, mon, newSpecies, via)
|
Evolution.apply(game, mon, newSpecies, via)
|
||||||
local msg = ("What?\n%s is\nevolving!\fCongratulations!\nYour %s\nevolved into\n%s!")
|
local msg = Strings("What?\n%s is\nevolving!\fCongratulations!\nYour %s\nevolved into\n%s!",
|
||||||
:format(oldName, oldName, game.data.pokemon[newSpecies].name)
|
oldName, oldName, game.data.pokemon[newSpecies].name)
|
||||||
game.stack:push(TextBox.new(game, msg, function()
|
game.stack:push(TextBox.new(game, msg, function()
|
||||||
Music.restoreMap(game.data)
|
Music.restoreMap(game.data)
|
||||||
-- re-run the evolved species' level-up learn check before onDone
|
-- 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
|
-- After-battle hook: evolve mons that leveled this battle and still
|
||||||
-- qualify (queued one at a time, party order). Gen1 EvolveAfterBattle
|
-- 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
|
-- means "not this time", and the next offer waits for the next level-up
|
||||||
-- (or Rare Candy / stone, which call Evolution.evolve directly).
|
-- (or Rare Candy / stone, which call Evolution.evolve directly).
|
||||||
-- leveledUp is a set of party mon tables; nil/empty yields no evolutions.
|
-- leveledUp is a set of party mon tables; nil/empty yields no evolutions.
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ end
|
|||||||
|
|
||||||
-- Day Care retrieve (pokered WriteMonMoves + wLearningMovesFromDayCare):
|
-- Day Care retrieve (pokered WriteMonMoves + wLearningMovesFromDayCare):
|
||||||
-- grant learnset moves with startLevel < moveLevel <= newLevel, shifting
|
-- 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)
|
function Pokemon.learnMovesFromDayCare(data, mon, speciesDef, startLevel, newLevel)
|
||||||
if not (speciesDef and speciesDef.learnset and mon) then return end
|
if not (speciesDef and speciesDef.learnset and mon) then return end
|
||||||
mon.moves = mon.moves or {}
|
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 },
|
statExp = { hp = 0, attack = 0, defense = 0, speed = 0, special = 0 },
|
||||||
stats = stats,
|
stats = stats,
|
||||||
hp = stats.hp,
|
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"
|
status = nil, -- "SLP"|"PSN"|"BRN"|"FRZ"|"PAR"
|
||||||
moves = moves,
|
moves = moves,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,8 +3,12 @@
|
|||||||
-- patch pokemon.spriteFront / icons.bySpecies. These helpers are the
|
-- patch pokemon.spriteFront / icons.bySpecies. These helpers are the
|
||||||
-- sanctioned seam: every battle pic and party icon load goes through
|
-- sanctioned seam: every battle pic and party icon load goes through
|
||||||
-- pokemon.sprite / pokemon.icon, which stay live for the whole process.
|
-- 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 Runtime = require("src.mods.Runtime")
|
||||||
|
local FieldDefaults = require("src.world.FieldDefaults")
|
||||||
|
|
||||||
local Sprites = {}
|
local Sprites = {}
|
||||||
|
|
||||||
@@ -36,6 +40,36 @@ function Sprites.path(data, species, side, opts)
|
|||||||
return path, ctx.trueColor and true or false
|
return path, ctx.trueColor and true or false
|
||||||
end
|
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`.
|
-- Resolve a party-menu icon image path for `mon`.
|
||||||
-- vanillaPath is the path PartyMenu already picked from icons.bySpecies /
|
-- vanillaPath is the path PartyMenu already picked from icons.bySpecies /
|
||||||
-- def.icon / icons.byDex; the hook may replace it.
|
-- def.icon / icons.byDex; the hook may replace it.
|
||||||
|
|||||||
@@ -42,6 +42,28 @@ function Stats.calc(speciesDef, level, dvs, statExp)
|
|||||||
return out
|
return out
|
||||||
end
|
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
|
-- Battle stat stage multipliers (data/battle/stat_modifiers.asm): stages
|
||||||
-- -6..+6 map to N/D pairs 25/100 .. 400/100.
|
-- -6..+6 map to N/D pairs 25/100 .. 400/100.
|
||||||
local STAGE_MULT = {
|
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_HOLD = 2 -- frames per palette step
|
||||||
local FLASH_CYCLES = 3
|
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 TILE = 8
|
||||||
local COLS, ROWS = 160 / TILE, 144 / TILE -- 20 x 18 tiles
|
local COLS, ROWS = 160 / TILE, 144 / TILE -- 20 x 18 tiles
|
||||||
|
|
||||||
@@ -208,7 +222,7 @@ function BattleTransition:update(dt)
|
|||||||
self.t = 0
|
self.t = 0
|
||||||
end
|
end
|
||||||
else
|
else
|
||||||
if self.t >= self.wipeLen + 6 then
|
if self.t >= self.wipeLen + BLACK_HOLD then
|
||||||
self.game.stack:pop()
|
self.game.stack:pop()
|
||||||
if self.onDone then self.onDone() end
|
if self.onDone then self.onDone() end
|
||||||
end
|
end
|
||||||
|
|||||||
+69
-20
@@ -102,35 +102,84 @@ local function pageFor(code)
|
|||||||
return nil
|
return nil
|
||||||
end
|
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
|
-- Convert a text string into a list of glyph codes. Unknown characters
|
||||||
-- render as space (and are reported once).
|
-- render as space (and are reported once).
|
||||||
local reported = {}
|
local reported = {}
|
||||||
function Font.encode(text)
|
function Font.encode(text)
|
||||||
local codes = {}
|
local codes = {}
|
||||||
local i = 1
|
for _, span in ipairs(Font.split(text)) do
|
||||||
while i <= #text do
|
local code = span.code
|
||||||
local candidates = state.byFirstByte[text:byte(i)]
|
if not code then
|
||||||
local matched = false
|
local ch = text:sub(span.from, span.to)
|
||||||
if candidates then
|
if not reported[ch] and text:byte(span.from) >= 32 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
|
|
||||||
reported[ch] = true
|
reported[ch] = true
|
||||||
require("src.core.Logger").warn("font: no glyph for %q", ch)
|
require("src.core.Logger").warn("font: no glyph for %q", ch)
|
||||||
end
|
end
|
||||||
codes[#codes + 1] = 0x7F -- space
|
code = SPACE
|
||||||
i = i + 1
|
|
||||||
end
|
end
|
||||||
|
codes[#codes + 1] = code
|
||||||
end
|
end
|
||||||
return codes
|
return codes
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -25,7 +25,16 @@ local shader -- false = unavailable (headless / no shader support)
|
|||||||
-- Mobile GPUs often compile this pass but present a black frame, and the
|
-- Mobile GPUs often compile this pass but present a black frame, and the
|
||||||
-- level persists in options.lua -- soft-bricking the APK until a manual
|
-- level persists in options.lua -- soft-bricking the APK until a manual
|
||||||
-- edit (issue #136). Desktop is unchanged; Android/iOS refuse the effect.
|
-- edit (issue #136). Desktop is unchanged; Android/iOS refuse the effect.
|
||||||
|
--
|
||||||
|
-- POKEPORT_GBCFX overrides the platform default either way ("1" force on,
|
||||||
|
-- "0" force off), same tri-state as POKEPORT_TOUCH. Handheld packs whose
|
||||||
|
-- GPU is in the same class as a phone's but whose getOS() says "Linux" set
|
||||||
|
-- it to 0 in their launcher (build-rg34xxsp.sh); it also lets a desktop
|
||||||
|
-- checkout exercise the unsupported path without stubbing love.system.
|
||||||
function GBCFX.isSupported()
|
function GBCFX.isSupported()
|
||||||
|
local env = os.getenv("POKEPORT_GBCFX")
|
||||||
|
if env == "1" then return true end
|
||||||
|
if env == "0" then return false end
|
||||||
if not love or not love.system or not love.system.getOS then return true end
|
if not love or not love.system or not love.system.getOS then return true end
|
||||||
local osName = love.system.getOS()
|
local osName = love.system.getOS()
|
||||||
return osName ~= "Android" and osName ~= "iOS"
|
return osName ~= "Android" and osName ~= "iOS"
|
||||||
|
|||||||
+80
-22
@@ -2,6 +2,11 @@
|
|||||||
-- screen: pokered overlays the $62-$7F font area with the HP bar /
|
-- screen: pokered overlays the $62-$7F font area with the HP bar /
|
||||||
-- status sheet (font_battle_extra -> $62) and the HUD line tiles
|
-- status sheet (font_battle_extra -> $62) and the HUD line tiles
|
||||||
-- (battle_hud_1 -> $6D, battle_hud_2+3 -> $73).
|
-- (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")
|
local Assets = require("src.render.Assets")
|
||||||
|
|
||||||
@@ -23,32 +28,62 @@ local PAGES = {
|
|||||||
image = "assets/generated/battle/battle_hud_3.png", base = 0x76 },
|
image = "assets/generated/battle/battle_hud_3.png", base = 0x76 },
|
||||||
}
|
}
|
||||||
|
|
||||||
local tiles
|
-- The STATUS SCREEN overlays the SAME sheets differently, and the layout
|
||||||
function HudTiles.tile(code, x, y, tint)
|
-- above would break it: engine/pokemon/status_screen.asm:86-97 copies 3
|
||||||
if not tiles then
|
-- tiles of hud_1 to $6D, ONE tile of hud_2 to $78 and 2 tiles of hud_3 to
|
||||||
tiles = {}
|
-- $76, which leaves $70/$73/$74 as font_battle_extra's <to>, <ID> and № --
|
||||||
local registered = require("src.core.Data").font
|
-- the glyphs the screen prints "№." and "<ID>№/" from
|
||||||
registered = registered and registered.pages or nil
|
-- (constants/charmap.asm:69-73). The battle overlay instead copies
|
||||||
local function add(path, base)
|
-- hud_2+hud_3 contiguously over $73-$78 (engine/battle/core.asm:6520/6532),
|
||||||
local ok, img = pcall(Assets.image, path)
|
-- burying № under a line tile, so the status screen needs its own table.
|
||||||
if not ok then return end
|
-- 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 iw, ih = img:getDimensions()
|
||||||
local per = iw / 8
|
local per = iw / 8
|
||||||
for i = 0, per * (ih / 8) - 1 do
|
local count = page.count or per * (ih / 8)
|
||||||
tiles[base + i] = {
|
for i = 0, count - 1 do
|
||||||
|
out[base + i] = {
|
||||||
img = img,
|
img = img,
|
||||||
quad = love.graphics.newQuad((i % per) * 8,
|
quad = love.graphics.newQuad((i % per) * 8,
|
||||||
math.floor(i / per) * 8, 8, 8, iw, ih),
|
math.floor(i / per) * 8, 8, 8, iw, ih),
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
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
|
end
|
||||||
local t = tiles[code]
|
return out
|
||||||
|
end
|
||||||
|
|
||||||
|
local function put(t, x, y, tint)
|
||||||
if not t then return end
|
if not t then return end
|
||||||
local r, g, b, a = love.graphics.getColor()
|
local r, g, b, a = love.graphics.getColor()
|
||||||
love.graphics.setColor(tint or { 1, 1, 1, 1 })
|
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)
|
love.graphics.setColor(r, g, b, a)
|
||||||
end
|
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
|
-- lazy: the next tile() rebuilds every page from the search path
|
||||||
function HudTiles.invalidate()
|
function HudTiles.invalidate()
|
||||||
tiles = nil
|
tiles = nil
|
||||||
|
statusTiles = nil
|
||||||
end
|
end
|
||||||
|
|
||||||
Assets.register(HudTiles.invalidate)
|
Assets.register(HudTiles.invalidate)
|
||||||
@@ -78,6 +127,11 @@ end
|
|||||||
-- GetHealthBarColor's thresholds (>= 27 px green, >= 10 yellow, else
|
-- GetHealthBarColor's thresholds (>= 27 px green, >= 10 yellow, else
|
||||||
-- red).
|
-- 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
|
-- grayFill (#229): when the caller will colorize this bar with an SGB
|
||||||
-- region palette (BattleState's zone pass, BATTLE_ZONES pal 0/1 =
|
-- region palette (BattleState's zone pass, BATTLE_ZONES pal 0/1 =
|
||||||
-- GetHealthBarColor), leave the fill as its raw DMG shade-2 gray and skip
|
-- 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
|
-- 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 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.
|
-- 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
|
local x, y = tx * 8, ty * 8
|
||||||
|
segments = math.max(1, math.floor(segments or 6))
|
||||||
HudTiles.tile(0x71, x, y)
|
HudTiles.tile(0x71, x, y)
|
||||||
HudTiles.tile(0x62, x + 8, y)
|
HudTiles.tile(0x62, x + 8, y)
|
||||||
local px = 0
|
local px = 0
|
||||||
if mon.stats.hp > 0 and mon.hp > 0 then
|
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
|
end
|
||||||
local tint
|
local tint
|
||||||
if not grayFill then
|
if not grayFill then
|
||||||
local PaletteFX = require("src.render.PaletteFX")
|
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)
|
local colors = PaletteFX.pal(data, name)
|
||||||
if colors then
|
if colors then
|
||||||
local c = colors[3] -- GB color 2 is the fill shade
|
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 }
|
math.min(1, c[3] / 170), 1 }
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
for i = 0, 5 do
|
for i = 0, segments - 1 do
|
||||||
local seg = math.min(8, math.max(0, px - i * 8))
|
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)
|
HudTiles.tile(seg >= 8 and 0x6B or 0x63 + seg, x + 16 + i * 8, y, tint)
|
||||||
end
|
end
|
||||||
HudTiles.tile(HudTiles.capTile(barType), x + 64, y)
|
HudTiles.tile(HudTiles.capTile(barType), x + 16 + segments * 8, y)
|
||||||
end
|
end
|
||||||
|
|
||||||
return HudTiles
|
return HudTiles
|
||||||
|
|||||||
+121
-33
@@ -5,10 +5,11 @@
|
|||||||
-- then drawn once per zone through a shader that remaps the four DMG
|
-- then drawn once per zone through a shader that remaps the four DMG
|
||||||
-- shades to that zone's palette.
|
-- shades to that zone's palette.
|
||||||
--
|
--
|
||||||
-- Port display option: COLORS (GBC / RED++ / OG / OG INV / GBC INV / CLASSIC)
|
-- Port display option: COLORS (OG RED / SGB / ADVANCED / OG / OG INV /
|
||||||
-- transforms every zone's palette at send time via effectiveColors.
|
-- SGB INV / CLASSIC) transforms every zone's palette at send time via
|
||||||
-- RED++ swaps the named-palette pack for pokered-gbc SuperPalettes
|
-- effectiveColors. ADVANCED (the `redpp` id below) swaps the named-palette
|
||||||
-- (data/palettes_gbc.lua), including per-species mon colors.
|
-- pack for pokered-gbc SuperPalettes (data/palettes_gbc.lua), including
|
||||||
|
-- per-species mon colors.
|
||||||
|
|
||||||
local GameVersion = require("src.core.GameVersion")
|
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
|
local gbcPack -- false = missing; nil = not loaded yet
|
||||||
|
|
||||||
-- Cycle order matches OptionsMenu / hotkey 2. The three real colorizations
|
-- Cycle order matches OptionsMenu / hotkey 2. The three real colorizations
|
||||||
-- come first (OG RED = GBC hardware, SGB = per-map Super Game Boy, RED++ =
|
-- come first (OG RED = GBC hardware, SGB = per-map Super Game Boy, ADVANCED =
|
||||||
-- pokered-gbc per-tile), then the DMG-shade novelty modes.
|
-- pokered-gbc per-tile), then the DMG-shade novelty modes.
|
||||||
PaletteFX.MODES = { "ogred", "gbc", "redpp", "og", "og_inv", "gbc_inv", "classic" }
|
PaletteFX.MODES = { "ogred", "gbc", "redpp", "og", "og_inv", "gbc_inv", "classic" }
|
||||||
-- `gbc`/`gbc_inv` keep their save-value ids for back-compat; their LABELS are
|
-- `gbc`/`gbc_inv`/`redpp` keep their save-value ids for back-compat while
|
||||||
-- "SGB"/"SGB INV" because that is what the mode actually is (the old "GBC"
|
-- their LABELS say what the mode actually is: "SGB"/"SGB INV" because the old
|
||||||
-- label was a misnomer -- it never was the real Game Boy Color palette).
|
-- "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 = {
|
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",
|
og_inv = "OG INV", gbc_inv = "SGB INV", classic = "CLASSIC",
|
||||||
}
|
}
|
||||||
PaletteFX.mode = "gbc"
|
PaletteFX.mode = "gbc"
|
||||||
@@ -88,6 +93,25 @@ function PaletteFX.ogObj()
|
|||||||
return PaletteFX.GBC_OBJ, "gbcobj"
|
return PaletteFX.GBC_OBJ, "gbcobj"
|
||||||
end
|
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 }
|
local INV_MAP = { [0] = 3, [1] = 2, [2] = 1, [3] = 0 }
|
||||||
|
|
||||||
function PaletteFX.shader()
|
function PaletteFX.shader()
|
||||||
@@ -202,32 +226,45 @@ function PaletteFX.usesGbcPack(mode)
|
|||||||
end
|
end
|
||||||
|
|
||||||
-- Whether the active mode bakes a per-OBJ palette onto overworld sprites
|
-- Whether the active mode bakes a per-OBJ palette onto overworld sprites
|
||||||
-- (the OBP bake + post-zone redraw path). OG RED and SGB both do: characters
|
-- (the OBP bake + post-zone redraw path). OG RED alone does: the Game Boy
|
||||||
-- wear the GBC boot-ROM object palette (PaletteFX.ogObj -- green over Red's red
|
-- Color boot ROM hands the game one global object palette (PaletteFX.ogObj --
|
||||||
-- background, pink over Blue's blue background), so the player and NPCs carry a
|
-- green over Red's red background, pink over Blue's blue background), so on
|
||||||
-- fixed object color instead of tinting with whatever region palette their
|
-- that machine the player and NPCs carry a fixed object color instead of
|
||||||
-- feet stand over. On real hardware a sprite is an OBJ colored by an OBJ
|
-- tinting with whatever region palette their feet stand over. An EARLIER
|
||||||
-- palette (color/sprites.asm ColorOverworldSprite), distinct from the BG it
|
-- attempt at per-sprite color baked GBC_BG (the RED background ramp) onto
|
||||||
-- 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
|
|
||||||
-- characters -- that was the "reds coloring on the player/NPCs" bug; the object
|
-- 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
|
-- palette is GBC_OBJ (green), so baking that here is the fix, not that
|
||||||
-- regression. RED++ colors sprites through the usesGbcPack() path in
|
-- regression. Terrain is unaffected -- pal() below still hands SGB its per-map
|
||||||
-- SpriteRenderer instead.
|
-- 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)
|
function PaletteFX.usesSpriteObp(mode)
|
||||||
mode = mode or PaletteFX.mode
|
mode = mode or PaletteFX.mode
|
||||||
return mode == "ogred" or mode == "gbc"
|
return mode == "ogred"
|
||||||
end
|
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
|
-- 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;
|
-- pixels come out zone-tinted there) AND records the draw here;
|
||||||
-- Renderer:endFrame replays the list on top of the finished zone pass,
|
-- 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
|
-- 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] }
|
colors[map[2] + 1], colors[map[3] + 1] }
|
||||||
end
|
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)
|
function PaletteFX.setMode(mode)
|
||||||
local prev = PaletteFX.mode
|
local prev = PaletteFX.mode
|
||||||
local ok = false
|
local ok = false
|
||||||
@@ -598,19 +663,42 @@ end
|
|||||||
-- Transform a 4-color palette for the active COLORS display mode.
|
-- Transform a 4-color palette for the active COLORS display mode.
|
||||||
-- GBC and RED++ pass the zone colors through (RED++ already swapped the
|
-- GBC and RED++ pass the zone colors through (RED++ already swapped the
|
||||||
-- pack in pal/monPal); OG* / CLASSIC replace; GBC INV permutes shades.
|
-- 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)
|
function PaletteFX.effectiveColors(c)
|
||||||
if not c then return nil end
|
if not c then return nil end
|
||||||
local mode = PaletteFX.mode or "gbc"
|
local mode = PaletteFX.mode or "gbc"
|
||||||
|
local out = c
|
||||||
if mode == "og" then
|
if mode == "og" then
|
||||||
return PaletteFX.GRAYS
|
out = PaletteFX.GRAYS
|
||||||
elseif mode == "og_inv" then
|
elseif mode == "og_inv" then
|
||||||
return PaletteFX.permute(PaletteFX.GRAYS, INV_MAP)
|
out = PaletteFX.permute(PaletteFX.GRAYS, INV_MAP)
|
||||||
elseif mode == "classic" then
|
elseif mode == "classic" then
|
||||||
return PaletteFX.CLASSIC
|
out = PaletteFX.CLASSIC
|
||||||
elseif mode == "gbc_inv" then
|
elseif mode == "gbc_inv" then
|
||||||
return PaletteFX.permute(c, INV_MAP)
|
out = PaletteFX.permute(c, INV_MAP)
|
||||||
end
|
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
|
end
|
||||||
|
|
||||||
-- send a 4-color (0-255 RGB) palette to the shade-remap shader, after
|
-- 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 = {}
|
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.WIDTH = 160
|
||||||
Renderer.HEIGHT = 144
|
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
|
-- 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
|
-- 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
|
-- 160x144 real pixels, never DPI-scaled: see src/render/PixelCanvas.lua
|
||||||
-- (#208). Every canvas below is sized in framebuffer pixels for the same
|
-- (#208). Every canvas below is sized in framebuffer pixels for the same
|
||||||
-- reason -- worldViewSize() already works in drawable pixels.
|
-- 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.worldCanvas = nil
|
||||||
self.worldActive = false
|
self.worldActive = false
|
||||||
-- tilt mode only: a transparent overlay canvas the size of the world
|
-- tilt mode only: a transparent overlay canvas the size of the world
|
||||||
@@ -115,7 +123,31 @@ end
|
|||||||
-- units via / dpiX and / dpiY when drawing.
|
-- units via / dpiX and / dpiY when drawing.
|
||||||
function Renderer:fitScale()
|
function Renderer:fitScale()
|
||||||
local _, _, pw, ph = displayMetrics()
|
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
|
end
|
||||||
|
|
||||||
-- LOVE-unit draw scales endFrame uses for the UI blit: integer framebuffer
|
-- LOVE-unit draw scales endFrame uses for the UI blit: integer framebuffer
|
||||||
@@ -174,6 +206,9 @@ function Renderer:beginFrame(transparent)
|
|||||||
-- draws this one
|
-- draws this one
|
||||||
PaletteFX.clearTrueColor()
|
PaletteFX.clearTrueColor()
|
||||||
PaletteFX.clearSpriteRedraws()
|
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")
|
PaletteFX.setPass("ui")
|
||||||
love.graphics.setCanvas(self.canvas)
|
love.graphics.setCanvas(self.canvas)
|
||||||
if transparent then
|
if transparent then
|
||||||
@@ -440,10 +475,11 @@ function Renderer:endFrame(zones, worldZones)
|
|||||||
-- Sx/Sy = LOVE-unit draw scales (may differ when dpiX ≠ dpiY).
|
-- Sx/Sy = LOVE-unit draw scales (may differ when dpiX ≠ dpiY).
|
||||||
local Sp = self:fitScale()
|
local Sp = self:fitScale()
|
||||||
local Sx, Sy = Sp / dpiX, Sp / dpiY
|
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.
|
-- Snap the letterbox origin to a framebuffer pixel, then convert to units.
|
||||||
local ox = math.floor((pw - self.WIDTH * Sp) / 2) / dpiX
|
local ox = math.floor((pw - uiw * Sp) / 2) / dpiX
|
||||||
local oy = math.floor((ph - self.HEIGHT * Sp) / 2) / dpiY
|
local oy = math.floor((ph - uih * Sp) / 2) / dpiY
|
||||||
local GBCFX = require("src.render.GBCFX")
|
local GBCFX = require("src.render.GBCFX")
|
||||||
-- Forced mono/Classic modes still need a whole-screen zone when a state
|
-- Forced mono/Classic modes still need a whole-screen zone when a state
|
||||||
-- exposes no SGB packets (raw DMG canvas), so sendColors can remap.
|
-- exposes no SGB packets (raw DMG canvas), so sendColors can remap.
|
||||||
@@ -480,8 +516,11 @@ function Renderer:endFrame(zones, worldZones)
|
|||||||
love.graphics.setCanvas(present)
|
love.graphics.setCanvas(present)
|
||||||
end
|
end
|
||||||
-- Default letterbox is black. Battle (and any state that opts in via
|
-- Default letterbox is black. Battle (and any state that opts in via
|
||||||
-- letterboxWhite) fills the voids white so the window matches the
|
-- letterboxWhite) fills the voids with the display mode's paper shade, so
|
||||||
-- white battle canvas instead of showing black bars.
|
-- 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
|
local clearR, clearG, clearB = 0, 0, 0
|
||||||
if not self.worldActive then
|
if not self.worldActive then
|
||||||
local ok, Game = pcall(require, "src.core.Game")
|
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 base = stack and stack.visibleBase and stack:visibleBase()
|
||||||
local state = base and stack.states and stack.states[base]
|
local state = base and stack.states and stack.states[base]
|
||||||
if state and state.letterboxWhite then
|
if state and state.letterboxWhite then
|
||||||
clearR, clearG, clearB = 1, 1, 1
|
clearR, clearG, clearB = PaletteFX.paperShade(Game and Game.data)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
love.graphics.setColor(clearR, clearG, clearB, 1)
|
love.graphics.setColor(clearR, clearG, clearB, 1)
|
||||||
|
|||||||
@@ -18,10 +18,14 @@ local function getImage(path)
|
|||||||
return imageCache[path]
|
return imageCache[path]
|
||||||
end
|
end
|
||||||
|
|
||||||
-- RED++ overworld sprite OBJ-palette recolor (color/sprites.asm
|
-- Overworld sprite OBJ-palette recolor, baked into an ImageData like
|
||||||
-- ColorOverworldSprite), baked into an ImageData like BattleState's mon-pic
|
-- BattleState's mon-pic palette bake (src/battle/BattleState.lua getImage):
|
||||||
-- palette bake (src/battle/BattleState.lua getImage): CPU-remap the 4 DMG
|
-- CPU-remap the 4 DMG shades to the resolved OBP colors, cached per
|
||||||
-- shades to the resolved OBP colors, cached per (image path, group).
|
-- (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
|
-- Sprite sheets carry no real alpha (every pixel, including the
|
||||||
-- background, is opaque -- confirmed by sampling the extracted PNGs): the
|
-- background, is opaque -- confirmed by sampling the extracted PNGs): the
|
||||||
@@ -110,7 +114,13 @@ function SpriteRenderer:resolveImage()
|
|||||||
-- collide in obpCache) -- see issue #155
|
-- collide in obpCache) -- see issue #155
|
||||||
return getObpImage(self.def.image, PaletteFX.ogObj())
|
return getObpImage(self.def.image, PaletteFX.ogObj())
|
||||||
end
|
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
|
end
|
||||||
|
|
||||||
-- facing: down/up/left/right; walkPhase: 0 stand, 1 walk; flip: alternate
|
-- 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.
|
-- top.
|
||||||
image = getObpImage(self.def.image, PaletteFX.ogObj())
|
image = getObpImage(self.def.image, PaletteFX.ogObj())
|
||||||
redraw = true
|
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
|
end
|
||||||
-- single-frame sprites (item balls, fossils...) have one fixed pose;
|
-- single-frame sprites (item balls, fossils...) have one fixed pose;
|
||||||
-- still 3-frame sprites turn to face (the nurse at her machine,
|
-- 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
|
-- WaitForSoundToFinish; nil headless), then auto.delay frames pass
|
||||||
-- (default 3, Delay3) and the box pops itself + calls onDone. No
|
-- (default 3, Delay3) and the box pops itself + calls onDone. No
|
||||||
-- blinking cursor, no Press_AB beep.
|
-- 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)
|
function TextBox.new(game, text, onDone, opts)
|
||||||
local self = setmetatable({}, TextBox)
|
local self = setmetatable({}, TextBox)
|
||||||
self.game = game
|
self.game = game
|
||||||
@@ -92,13 +97,28 @@ end
|
|||||||
-- pokered ContText waits for A/B + ▼ before scrolling that line in.
|
-- pokered ContText waits for A/B + ▼ before scrolling that line in.
|
||||||
function TextBox.paginate(text, maxCols)
|
function TextBox.paginate(text, maxCols)
|
||||||
maxCols = maxCols or (Theme.textBox and Theme.textBox.maxCols) or MAX_COLS
|
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 pages = {}
|
||||||
local contBefore = {}
|
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)
|
local function pushLine(lines, conts, line, wait)
|
||||||
while #line > maxCols do
|
while true do
|
||||||
local cut = maxCols
|
local spans = Font.split(line)
|
||||||
for i = maxCols, 1, -1 do
|
local fit = Font.spansFitting(spans, budget)
|
||||||
if line:sub(i, i) == " " then cut = i break end
|
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
|
end
|
||||||
table.insert(lines, line:sub(1, cut))
|
table.insert(lines, line:sub(1, cut))
|
||||||
table.insert(conts, wait)
|
table.insert(conts, wait)
|
||||||
@@ -164,9 +184,28 @@ function TextBox:update(dt)
|
|||||||
self.autoSrc = self.auto.sound and self.auto.sound() or nil
|
self.autoSrc = self.auto.sound and self.auto.sound() or nil
|
||||||
self.autoTimer = 0
|
self.autoTimer = 0
|
||||||
end
|
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
|
if self.autoSrc and self.autoSrc.isPlaying and self.autoSrc:isPlaying() then
|
||||||
return -- the cry is still sounding (WaitForSoundToFinish)
|
return -- the cry is still sounding (WaitForSoundToFinish)
|
||||||
end
|
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
|
self.autoTimer = self.autoTimer + 1
|
||||||
local delay = self.auto.delay or 3
|
local delay = self.auto.delay or 3
|
||||||
-- auto.onOverlap: fired once when the delay elapses but before the
|
-- auto.onOverlap: fired once when the delay elapses but before the
|
||||||
@@ -267,10 +306,17 @@ function TextBox:draw()
|
|||||||
self.scrollPx = self.scrollPx - 2
|
self.scrollPx = self.scrollPx - 2
|
||||||
if self.scrollPx <= 0 then self.scrollPx = nil end
|
if self.scrollPx <= 0 then self.scrollPx = nil end
|
||||||
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 off = self.scrollPx or 0
|
||||||
local ys = { self.line1Y, self.line2Y }
|
local ys = { self.line1Y, self.line2Y }
|
||||||
for i, line in ipairs(self.shown) do
|
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
|
for j, code in ipairs(line) do
|
||||||
Font.drawCode(code, self.textX + (j - 1) * 8, y)
|
Font.drawCode(code, self.textX + (j - 1) * 8, y)
|
||||||
end
|
end
|
||||||
|
|||||||
+57
-18
@@ -11,9 +11,9 @@ TileRenderer.__index = TileRenderer
|
|||||||
local BORDER_BLOCKS = 3 -- ring width; > half a screen (2.5 blocks)
|
local BORDER_BLOCKS = 3 -- ring width; > half a screen (2.5 blocks)
|
||||||
|
|
||||||
-- OVERWORLD maps fill beyond-edge space from save.options.voidFill:
|
-- OVERWORLD maps fill beyond-edge space from save.options.voidFill:
|
||||||
-- trees (default) — solid tree wall $0F (Viridian/Cerulean/Celadon)
|
-- trees (default) -- solid tree wall $0F (Viridian/Cerulean/Celadon)
|
||||||
-- water — solid water $43 (Cinnabar/Route 19 border block)
|
-- water -- solid water $43 (Cinnabar/Route 19 border block)
|
||||||
-- black — solid black (no tiled metatile)
|
-- black -- solid black (no tiled metatile)
|
||||||
-- Other tilesets keep their designated border (interiors stay black/void).
|
-- Other tilesets keep their designated border (interiors stay black/void).
|
||||||
local TREE_WALL_BLOCK = 0x0F
|
local TREE_WALL_BLOCK = 0x0F
|
||||||
local WATER_BORDER_BLOCK = 0x43
|
local WATER_BORDER_BLOCK = 0x43
|
||||||
@@ -475,6 +475,13 @@ function TileRenderer.new(map, data)
|
|||||||
-- match the atlas's static tiles instead of showing raw grayscale
|
-- match the atlas's static tiles instead of showing raw grayscale
|
||||||
gbcCtx = { tilesetId = map.tileset.id, mapId = map.id, key = "#gbc:" .. map.id,
|
gbcCtx = { tilesetId = map.tileset.id, mapId = map.id, key = "#gbc:" .. map.id,
|
||||||
groupColors = PaletteFX.worldGroupColors(data, map.tileset.id, map.id, nil) }
|
groupColors = PaletteFX.worldGroupColors(data, map.tileset.id, map.id, nil) }
|
||||||
|
-- ...and feeds the color-0-keyed single tiles the feet overdraw needs
|
||||||
|
-- (see getKeyedTile): same source image and palette groups, so keep the
|
||||||
|
-- context rather than re-deriving it per draw.
|
||||||
|
gbcCtx.imagePath = map.tileset.image
|
||||||
|
gbcCtx.perRow = map.tileset.tilesPerRow
|
||||||
|
self.gbcCtx = gbcCtx
|
||||||
|
self.gbcKeyed = {}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
-- a full-color atlas colors everything it paints, ring and border fill
|
-- a full-color atlas colors everything it paints, ring and border fill
|
||||||
@@ -672,17 +679,48 @@ local function getColor0KeyShader()
|
|||||||
return color0KeyShader or nil
|
return color0KeyShader or nil
|
||||||
end
|
end
|
||||||
|
|
||||||
-- draw a cell's bottom tile row without touching the shader (the caller
|
-- RED++ (COLORS=ADVANCED): the color-0 key, BAKED instead of tested for.
|
||||||
-- owns it). drawCellBottom wraps this with the color-0 key; tilt mode's
|
local function getKeyedTile(self, tile)
|
||||||
-- upright pass wraps it with a color-0-keyed palette shader instead
|
local ctx = self.gbcCtx
|
||||||
-- (PaletteFX.keyedShader) so the feet patch is colorized like the ground.
|
local cached = self.gbcKeyed[tile]
|
||||||
|
if cached ~= nil then return cached or nil end
|
||||||
|
local img = false
|
||||||
|
if ctx.groupColors and love.image and love.image.newImageData then
|
||||||
|
local group = PaletteFX.worldGroupAt(ctx.tilesetId, ctx.mapId, tile)
|
||||||
|
local colors = group and ctx.groupColors[group + 1]
|
||||||
|
local src = Assets.imageData(ctx.imagePath)
|
||||||
|
local ox = (tile % ctx.perRow) * 8
|
||||||
|
local oy = math.floor(tile / ctx.perRow) * 8
|
||||||
|
local out = love.image.newImageData(8, 8)
|
||||||
|
for py = 0, 7 do
|
||||||
|
for px = 0, 7 do
|
||||||
|
local r, g, b, a = src:getPixel(ox + px, oy + py)
|
||||||
|
-- read shade 0 off the RAW sheet, on recolorSample's own cutoff, so
|
||||||
|
-- the keyed pixels are exactly the ones the shader path keys
|
||||||
|
local shade0 = r > 0.83
|
||||||
|
r, g, b, a = recolorSample(r, g, b, a, colors)
|
||||||
|
out:setPixel(px, py, r, g, b, shade0 and 0 or a)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
img = love.graphics.newImage(out)
|
||||||
|
end
|
||||||
|
self.gbcKeyed[tile] = img
|
||||||
|
return img or nil
|
||||||
|
end
|
||||||
|
|
||||||
function TileRenderer:drawCellBottomRaw(cx, cy, camX, camY)
|
function TileRenderer:drawCellBottomRaw(cx, cy, camX, camY)
|
||||||
local ty = cy * 2 + 1
|
local ty = cy * 2 + 1
|
||||||
for i = 0, 1 do
|
for i = 0, 1 do
|
||||||
local tx = cx * 2 + i
|
local tx = cx * 2 + i
|
||||||
local quad = self.quads[self.map:tileAt(tx, ty)]
|
local tile = self.map:tileAt(tx, ty)
|
||||||
if quad then
|
local keyed = tile and self.gbcCtx and getKeyedTile(self, tile)
|
||||||
love.graphics.draw(self.image, quad, tx * 8 - camX, ty * 8 - camY)
|
if keyed then
|
||||||
|
love.graphics.draw(keyed, tx * 8 - camX, ty * 8 - camY)
|
||||||
|
else
|
||||||
|
local quad = self.quads[tile]
|
||||||
|
if quad then
|
||||||
|
love.graphics.draw(self.image, quad, tx * 8 - camX, ty * 8 - camY)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -690,7 +728,9 @@ end
|
|||||||
-- redraw a cell's bottom tile row (tall grass hides the lower half of
|
-- redraw a cell's bottom tile row (tall grass hides the lower half of
|
||||||
-- sprites standing in it, like the GB sprite-priority trick)
|
-- sprites standing in it, like the GB sprite-priority trick)
|
||||||
function TileRenderer:drawCellBottom(cx, cy, camX, camY)
|
function TileRenderer:drawCellBottom(cx, cy, camX, camY)
|
||||||
local shader = getColor0KeyShader()
|
-- the RED++ path is pre-keyed; the white test would be a no-op there at
|
||||||
|
-- best, and a false hit on some other group's near-white color 0 at worst
|
||||||
|
local shader = not self.gbcCtx and getColor0KeyShader() or nil
|
||||||
if shader then love.graphics.setShader(shader) end
|
if shader then love.graphics.setShader(shader) end
|
||||||
self:drawCellBottomRaw(cx, cy, camX, camY)
|
self:drawCellBottomRaw(cx, cy, camX, camY)
|
||||||
if shader then love.graphics.setShader() end
|
if shader then love.graphics.setShader() end
|
||||||
@@ -712,13 +752,6 @@ function TileRenderer:markCellBottomRedraw(cx, cy, camX, camY, colors)
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Window cover for the static tile layer. Refill the reusable window batch
|
|
||||||
-- (and the per-entry animated batches) only when the camera has scrolled past
|
|
||||||
-- what they already cover; a small margin keeps small scrolls free. Cost
|
|
||||||
-- scales with the view, never the map -- crossing a seam or warping in builds
|
|
||||||
-- nothing. The beyond-body area (what the old 3-block ring drew) is painted
|
|
||||||
-- by :drawBorderFill, whose world-aligned border-block tiling is identical
|
|
||||||
-- there, so only body tiles are gathered here.
|
|
||||||
local WINDOW_MARGIN = 8 -- tiles of slack kept around the view between refills
|
local WINDOW_MARGIN = 8 -- tiles of slack kept around the view between refills
|
||||||
|
|
||||||
function TileRenderer:ensureWindow(camX, camY, vw, vh)
|
function TileRenderer:ensureWindow(camX, camY, vw, vh)
|
||||||
@@ -873,6 +906,12 @@ end
|
|||||||
-- atlas -- is unique to this map (gbcAtlasCache is keyed by map id).
|
-- atlas -- is unique to this map (gbcAtlasCache is keyed by map id).
|
||||||
function TileRenderer:release()
|
function TileRenderer:release()
|
||||||
self:releaseBatches()
|
self:releaseBatches()
|
||||||
|
if self.gbcKeyed then
|
||||||
|
-- baked per instance, shared with nobody (see getKeyedTile)
|
||||||
|
for _, img in pairs(self.gbcKeyed) do safeRelease(img) end
|
||||||
|
self.gbcKeyed = nil
|
||||||
|
self.gbcCtx = nil
|
||||||
|
end
|
||||||
if self.gbcAtlas and self.image then
|
if self.gbcAtlas and self.image then
|
||||||
local key = self.map.tileset.image .. "#gbc:" .. self.map.id
|
local key = self.map.tileset.image .. "#gbc:" .. self.map.id
|
||||||
if gbcAtlasCache[key] == self.image then gbcAtlasCache[key] = nil end
|
if gbcAtlasCache[key] == self.image then gbcAtlasCache[key] = nil end
|
||||||
|
|||||||
@@ -38,6 +38,8 @@ local NAME_LENGTH = 11
|
|||||||
local PARTY_LENGTH = 6
|
local PARTY_LENGTH = 6
|
||||||
local MONS_PER_BOX = 20
|
local MONS_PER_BOX = 20
|
||||||
local NUM_BADGES = 8
|
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,
|
local BOX_STRUCT_SIZE = 33 -- Species,HP,Level,Status,Type1,Type2,CatchRate,
|
||||||
-- Moves x4,OTID,Exp x3,HPExp,AtkExp,DefExp,
|
-- Moves x4,OTID,Exp x3,HPExp,AtkExp,DefExp,
|
||||||
-- SpdExp,SpcExp,DVs,PP x4 (macros/ram.asm box_struct)
|
-- 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.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.currentBoxNum = O.mainData + 681 -- 1B (bits 0-6: box 0-11, bit 7: unused here)
|
||||||
O.coins = O.mainData + 685 -- 2B BCD
|
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)
|
O.eventFlags = O.mainData + 1104 -- 320B (flag_array NUM_EVENTS = 2560 bits)
|
||||||
-- Play time (wPlayTimeHours/Maxed/Minutes/Seconds/Frames) lives INSIDE the
|
-- Play time (wPlayTimeHours/Maxed/Minutes/Seconds/Frames) lives INSIDE the
|
||||||
-- sMainData window (wMainDataStart..wMainDataEnd is copied verbatim into
|
-- sMainData window (wMainDataStart..wMainDataEnd is copied verbatim into
|
||||||
@@ -232,12 +250,17 @@ local function encodeName(buf, off, len, text)
|
|||||||
setByte(buf, off + i, charmap.byToken[ch] or charmap.byToken["?"] or 0x50)
|
setByte(buf, off + i, charmap.byToken[ch] or charmap.byToken["?"] or 0x50)
|
||||||
i, pos = i + 1, pos + clen
|
i, pos = i + 1, pos + clen
|
||||||
end
|
end
|
||||||
-- Write exactly ONE $50 terminator and then STOP. The bytes after it are
|
-- Write exactly ONE $50 terminator and then $50-pad the rest of the
|
||||||
-- left untouched: when encoding over a template they stay as the original
|
-- field: every real save the naming screen ever wrote fills the tail
|
||||||
-- save's post-terminator padding (so an unchanged name round-trips
|
-- with $50, and a zero tail is what PKHeX renders as garbage glyphs
|
||||||
-- byte-identical), and on a templateless export they stay zero-filled. The
|
-- after the name ("JOHN{}", #206). Template bytes that are NOT zero
|
||||||
-- game reads a name only up to the first $50, so whatever follows is inert.
|
-- 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
|
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
|
end
|
||||||
|
|
||||||
-- ------------------------------------------------------------------
|
-- ------------------------------------------------------------------
|
||||||
@@ -375,6 +398,40 @@ function GenSave.crosswalks(data)
|
|||||||
}
|
}
|
||||||
end
|
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;
|
-- Mon struct (box_struct is a byte-for-byte prefix of party_struct;
|
||||||
-- decodeMon reads the box_struct fields, then Level+Stats if isParty).
|
-- 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)
|
local mon = decodeMon(bytes, O.partyMons + i * PARTY_STRUCT_SIZE, true, cw)
|
||||||
if mon then
|
if mon then
|
||||||
mon.ot = decodeName(bytes, O.partyMonOT + i * NAME_LENGTH, NAME_LENGTH)
|
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
|
save.party[#save.party + 1] = mon
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -586,7 +646,8 @@ function GenSave.decode(bytes, data, opts)
|
|||||||
local mon = decodeMon(bytes, base + 22 + i * BOX_STRUCT_SIZE, false, cw)
|
local mon = decodeMon(bytes, base + 22 + i * BOX_STRUCT_SIZE, false, cw)
|
||||||
if mon then
|
if mon then
|
||||||
mon.ot = decodeName(bytes, base + 22 + MONS_PER_BOX * BOX_STRUCT_SIZE + i * NAME_LENGTH, NAME_LENGTH)
|
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)
|
table.insert(save.boxes[boxNum], mon)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -610,6 +671,28 @@ function GenSave.decode(bytes, data, opts)
|
|||||||
end
|
end
|
||||||
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
|
-- map + position
|
||||||
local mapIdx = u8(bytes, O.curMap)
|
local mapIdx = u8(bytes, O.curMap)
|
||||||
local mapId = cw.mapsByIndex[mapIdx]
|
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.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")
|
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)
|
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.money, 3, math.min(save.money or 0, 999999))
|
||||||
setBcd(buf, O.coins, 2, math.min(save.coins or 0, 9999))
|
setBcd(buf, O.coins, 2, math.min(save.coins or 0, 9999))
|
||||||
|
|
||||||
@@ -700,6 +796,18 @@ function GenSave.encode(save, data, template)
|
|||||||
end
|
end
|
||||||
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
|
-- party
|
||||||
local party = save.party or {}
|
local party = save.party or {}
|
||||||
local partyN = math.min(#party, PARTY_LENGTH)
|
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)
|
setByte(buf, O.partySpecies + i, cw.pokemonIndex[mon.species] or 0)
|
||||||
encodeName(buf, O.partyMonOT + i * NAME_LENGTH, NAME_LENGTH,
|
encodeName(buf, O.partyMonOT + i * NAME_LENGTH, NAME_LENGTH,
|
||||||
mon.ot or (save.player and save.player.name) or "RED")
|
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,
|
encodeName(buf, O.partyMonNicks + i * NAME_LENGTH, NAME_LENGTH,
|
||||||
mon.nickname or mon.species or "")
|
mon.nickname or speciesName(cw, mon.species))
|
||||||
end
|
end
|
||||||
-- $FF-terminate the species index list right after the last real mon. The
|
-- $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
|
-- 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,
|
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")
|
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,
|
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
|
end
|
||||||
-- $FF-terminate the species list after the last real mon; empty slots past
|
-- $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
|
-- n keep their template bytes (byte-identical round-trip) or zero (fresh
|
||||||
|
|||||||
@@ -107,6 +107,7 @@ local function defaultsSave()
|
|||||||
modData = {},
|
modData = {},
|
||||||
options = {
|
options = {
|
||||||
textSpeed = 3, animations = true, battleStyle = "shift",
|
textSpeed = 3, animations = true, battleStyle = "shift",
|
||||||
|
battleLayout = "og",
|
||||||
ruleset = "gen1_faithful", musicVol = 7, sfxVol = 7, musicFilter = 0,
|
ruleset = "gen1_faithful", musicVol = 7, sfxVol = 7, musicFilter = 0,
|
||||||
speed = 1, colors = "gbc", tilt = 0, gbcfx = 0,
|
speed = 1, colors = "gbc", tilt = 0, gbcfx = 0,
|
||||||
videoMode = "windowed", mods = {},
|
videoMode = "windowed", mods = {},
|
||||||
|
|||||||
+29
-14
@@ -10,6 +10,7 @@ local Flags = require("src.script.Flags")
|
|||||||
local Logger = require("src.core.Logger")
|
local Logger = require("src.core.Logger")
|
||||||
local Screens = require("src.ui.Screens")
|
local Screens = require("src.ui.Screens")
|
||||||
local TextBox = require("src.render.TextBox")
|
local TextBox = require("src.render.TextBox")
|
||||||
|
local Strings = require("src.core.Strings")
|
||||||
|
|
||||||
local Commands = {}
|
local Commands = {}
|
||||||
|
|
||||||
@@ -74,6 +75,9 @@ end
|
|||||||
-- <PROMPT> of its own; the box only closes once WaitForSoundToFinish's poll
|
-- <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
|
-- 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.
|
-- 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)
|
function Commands.show_text(ctx, textId, subs)
|
||||||
local text = ctx.game.data.text[textId]
|
local text = ctx.game.data.text[textId]
|
||||||
if not text and ctx.overworld then
|
if not text and ctx.overworld then
|
||||||
@@ -100,10 +104,12 @@ function Commands.show_text(ctx, textId, subs)
|
|||||||
local opts
|
local opts
|
||||||
if ctx.pendingCry then
|
if ctx.pendingCry then
|
||||||
local species = ctx.pendingCry
|
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()
|
opts = { auto = { sound = function()
|
||||||
return require("src.core.Sound").playCry(ctx.game.data, species)
|
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
|
end
|
||||||
-- text_opts armed the next box: auto = true is the plain no-button-wait
|
-- text_opts armed the next box: auto = true is the plain no-button-wait
|
||||||
-- form, overlap folds under auto, everything else passes through
|
-- 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)
|
-- skips the received text entirely when AddItemToInventory refuses)
|
||||||
if not require("src.inventory.Bag").add(ctx.save, itemId, count or 1) then
|
if not require("src.inventory.Bag").add(ctx.save, itemId, count or 1) then
|
||||||
Commands.show_text(ctx, ctx.game.data.text
|
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
|
return math.huge
|
||||||
end
|
end
|
||||||
local def = ctx.game.data.items[itemId]
|
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")
|
(def and def.keyItem) and "Get_Key_Item" or "Get_Item1")
|
||||||
if gotText ~= false then
|
if gotText ~= false then
|
||||||
Commands.show_text(ctx, gotText
|
Commands.show_text(ctx, gotText
|
||||||
or "{PLAYER} got\n" .. ctx.game.stringBuffer .. "!")
|
or Strings("{PLAYER} got\n%s!", ctx.game.stringBuffer))
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -469,18 +475,26 @@ function Commands.play_once(ctx, songId)
|
|||||||
runner:yield()
|
runner:yield()
|
||||||
end
|
end
|
||||||
|
|
||||||
-- play_cry <species>: PlayCry (home/audio.asm). The text_asm bodies that
|
-- play_cry <species> [waitForButton]: PlayCry (home/audio.asm). The
|
||||||
-- use it run text_far (a no-button-wait "...@" string) -> PlayCry ->
|
-- text_asm bodies that use it run text_far (a no-button-wait "...@" string)
|
||||||
-- WaitForSoundToFinish, all within the same text ID -- the cry only starts
|
-- -> PlayCry -> WaitForSoundToFinish, all within the same text ID -- the cry
|
||||||
-- once the box has finished typing, and the box then auto-closes (no A
|
-- only starts once the box has finished typing, and the box then auto-closes
|
||||||
-- press) the instant the cry finishes, rather than firing immediately
|
-- (no A press) the instant the cry finishes, rather than firing immediately
|
||||||
-- alongside the typewriter effect. Script rows run strictly in order, so
|
-- alongside the typewriter effect. Script rows run strictly in order, so
|
||||||
-- this stashes the species on ctx for the show_text row that always
|
-- this stashes the species on ctx for the show_text row that always
|
||||||
-- immediately follows it (Power Plant Zapdos, Seafoam Articuno, Victory
|
-- immediately follows it (Power Plant Zapdos, Seafoam Articuno, Victory
|
||||||
-- Road Moltres, Cerulean Cave Mewtwo battle text) to play once its box is
|
-- 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.
|
-- 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.pendingCry = species
|
||||||
|
ctx.pendingCryWait = waitForButton or nil
|
||||||
end
|
end
|
||||||
|
|
||||||
-- check_battle_result <r1> [r2 ...]: lastCheck = the last scripted
|
-- 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
|
if ctx.game.data.text and ctx.game.data.text._DoYouWantToNicknameText then
|
||||||
Commands.show_text(ctx, "_DoYouWantToNicknameText", { RAM = name })
|
Commands.show_text(ctx, "_DoYouWantToNicknameText", { RAM = name })
|
||||||
else
|
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
|
end
|
||||||
local ChoiceBox = require("src.ui.ChoiceBox")
|
local ChoiceBox = require("src.ui.ChoiceBox")
|
||||||
ctx.game.stack:push(ChoiceBox.new(ctx.game, function(yes)
|
ctx.game.stack:push(ChoiceBox.new(ctx.game, function(yes)
|
||||||
@@ -527,7 +541,7 @@ local function askNickname(ctx, mon)
|
|||||||
return
|
return
|
||||||
end
|
end
|
||||||
Screens.push(ctx.game, "NamingScreen", {
|
Screens.push(ctx.game, "NamingScreen", {
|
||||||
title = "NICKNAME?", maxLen = 10,
|
title = Strings("NICKNAME?"), maxLen = 10,
|
||||||
onDone = function(nick)
|
onDone = function(nick)
|
||||||
if nick and #nick > 0 then mon.nickname = nick end
|
if nick and #nick > 0 then mon.nickname = nick end
|
||||||
ctx.lastCheck = success
|
ctx.lastCheck = success
|
||||||
@@ -598,8 +612,9 @@ function Commands.give_pokemon(ctx, species, level)
|
|||||||
local TextBox = require("src.render.TextBox")
|
local TextBox = require("src.render.TextBox")
|
||||||
local raw = ctx.game.data.text and ctx.game.data.text._SentToBoxText
|
local raw = ctx.game.data.text and ctx.game.data.text._SentToBoxText
|
||||||
ctx.game.stack:push(TextBox.new(ctx.game, raw or (
|
ctx.game.stack:push(TextBox.new(ctx.game, raw or (
|
||||||
"There's no more\nroom for POKéMON!\v" .. name
|
Strings(
|
||||||
.. " was\vsent to POKéMON\vBOX " .. boxNum .. " on PC!"),
|
"There's no more\nroom for POKéMON!\v%s was\vsent to POKéMON\vBOX %s on PC!",
|
||||||
|
name, boxNum)),
|
||||||
function() end))
|
function() end))
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
local Data = require("src.core.Data")
|
local Data = require("src.core.Data")
|
||||||
local Logger = require("src.core.Logger")
|
local Logger = require("src.core.Logger")
|
||||||
local Runtime = require("src.mods.Runtime")
|
local Runtime = require("src.mods.Runtime")
|
||||||
|
local Strings = require("src.core.Strings")
|
||||||
|
|
||||||
local MapScripts = {}
|
local MapScripts = {}
|
||||||
|
|
||||||
@@ -268,7 +269,7 @@ function MapScripts.validateContribution(contribution, lookup)
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
if type(contribution) ~= "table" then
|
if type(contribution) ~= "table" then
|
||||||
return { "contribution is not a table" }
|
return { Strings("contribution is not a table") }
|
||||||
end
|
end
|
||||||
for textConst, script in pairs(contribution.talk or {}) do
|
for textConst, script in pairs(contribution.talk or {}) do
|
||||||
if type(script) == "table" then collect("talk." .. textConst, script) end
|
if type(script) == "table" then collect("talk." .. textConst, script) end
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
local Commands = require("src.script.Commands")
|
local Commands = require("src.script.Commands")
|
||||||
local Logger = require("src.core.Logger")
|
local Logger = require("src.core.Logger")
|
||||||
local Runtime = require("src.mods.Runtime")
|
local Runtime = require("src.mods.Runtime")
|
||||||
|
local Strings = require("src.core.Strings")
|
||||||
|
|
||||||
local unpack = table.unpack or unpack -- LuaJIT (LÖVE) compatibility
|
local unpack = table.unpack or unpack -- LuaJIT (LÖVE) compatibility
|
||||||
|
|
||||||
@@ -195,7 +196,7 @@ function ScriptRunner:resume(...)
|
|||||||
if not ok then
|
if not ok then
|
||||||
local source = self.ctx and self.ctx.source
|
local source = self.ctx and self.ctx.source
|
||||||
local where = 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 "?"))
|
tostring(source.mapId or "?"), tostring(source.hook or "?"))
|
||||||
or ""
|
or ""
|
||||||
Logger.error("script error%s: %s", where, tostring(err))
|
Logger.error("script error%s: %s", where, tostring(err))
|
||||||
|
|||||||
+61
-26
@@ -9,6 +9,7 @@ local TextBox = require("src.render.TextBox")
|
|||||||
local BagMenu = {}
|
local BagMenu = {}
|
||||||
|
|
||||||
local Bag = require("src.inventory.Bag")
|
local Bag = require("src.inventory.Bag")
|
||||||
|
local Strings = require("src.core.Strings")
|
||||||
|
|
||||||
-- acquisition order like wBagItems (Bag.order), not alphabetical
|
-- acquisition order like wBagItems (Bag.order), not alphabetical
|
||||||
local function buildItems(game)
|
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))
|
game.stack:push(TextBox.new(game, table.concat(msgs, "\f"), onDone))
|
||||||
end
|
end
|
||||||
|
|
||||||
-- run the use-flow for an item on a chosen target
|
-- run the use-flow for an item on a chosen target. `picker` is the party
|
||||||
local function useOn(game, battle, id, target, list, moveIndex)
|
-- 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,
|
local result, payload, extra = ItemEffects.use(game.data, game.save, id, target,
|
||||||
battle, moveIndex, game.overworld)
|
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
|
-- field POKé FLUTE: play the tune, then the no-effect text
|
||||||
if result == "flute_field" then
|
if result == "flute_field" then
|
||||||
@@ -108,13 +116,13 @@ local function useOn(game, battle, id, target, list, moveIndex)
|
|||||||
if game.save.onBike then
|
if game.save.onBike then
|
||||||
game.save.onBike = false
|
game.save.onBike = false
|
||||||
Music.playMap(game.data, ow and ow.map.id, 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
|
elseif bikeAllowed() then
|
||||||
game.save.onBike = true
|
game.save.onBike = true
|
||||||
Music.playMap(game.data, ow.map.id, 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
|
else
|
||||||
showMessages(game, { "No cycling\nallowed here." })
|
showMessages(game, { Strings("No cycling\nallowed here.") })
|
||||||
end
|
end
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
@@ -130,13 +138,14 @@ local function useOn(game, battle, id, target, list, moveIndex)
|
|||||||
return
|
return
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
showMessages(game, { "No good! It's not\neven near water." })
|
showMessages(game, { Strings("No good! It's not\neven near water.") })
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
if result == "ball" then
|
if result == "ball" then
|
||||||
if not battle 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
|
return
|
||||||
end
|
end
|
||||||
consume(game, id)
|
consume(game, id)
|
||||||
@@ -151,7 +160,7 @@ local function useOn(game, battle, id, target, list, moveIndex)
|
|||||||
local function teach()
|
local function teach()
|
||||||
if #target.moves < 4 then
|
if #target.moves < 4 then
|
||||||
table.insert(target.moves, { id = moveId, pp = mdef.pp })
|
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) })
|
game.data.pokemon[target.species].name, mdef.name) })
|
||||||
if result == "learn" then consume(game, id) end
|
if result == "learn" then consume(game, id) end
|
||||||
else
|
else
|
||||||
@@ -172,7 +181,7 @@ local function useOn(game, battle, id, target, list, moveIndex)
|
|||||||
require("src.ui.Screens").push(game, "TownMap")
|
require("src.ui.Screens").push(game, "TownMap")
|
||||||
end)
|
end)
|
||||||
if not ok then
|
if not ok then
|
||||||
showMessages(game, { "The TOWN MAP is\nunreadable here." })
|
showMessages(game, { Strings("The TOWN MAP is\nunreadable here.") })
|
||||||
end
|
end
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
@@ -184,10 +193,10 @@ local function useOn(game, battle, id, target, list, moveIndex)
|
|||||||
local t = game.data.text
|
local t = game.data.text
|
||||||
if ow and ow:hasHiddenItemLeft() then
|
if ow and ow:hasHiddenItemLeft() then
|
||||||
showMessages(game, { t._ItemfinderFoundItemText
|
showMessages(game, { t._ItemfinderFoundItemText
|
||||||
or "Yes! ITEMFINDER\nindicates there's\nan item nearby." })
|
or Strings("Yes! ITEMFINDER\nindicates there's\nan item nearby.") })
|
||||||
else
|
else
|
||||||
showMessages(game, { t._ItemfinderFoundNothingText
|
showMessages(game, { t._ItemfinderFoundNothingText
|
||||||
or "Nope! ITEMFINDER\nisn't responding." })
|
or Strings("Nope! ITEMFINDER\nisn't responding.") })
|
||||||
end
|
end
|
||||||
return
|
return
|
||||||
end
|
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
|
-- departure helper -- the same path Dig/Teleport take from the party menu
|
||||||
ow:beginTeleportOut()
|
ow:beginTeleportOut()
|
||||||
else
|
else
|
||||||
showMessages(game, { "OAK: " .. game.save.player.name
|
showMessages(game, { Strings(
|
||||||
.. "!\nThis isn't the\ntime to use that!" })
|
"OAK: %s!\nThis isn't the\ntime to use that!",
|
||||||
|
game.save.player.name) })
|
||||||
end
|
end
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
@@ -264,7 +274,7 @@ local function useOn(game, battle, id, target, list, moveIndex)
|
|||||||
if #target.moves < 4 then
|
if #target.moves < 4 then
|
||||||
table.insert(target.moves, { id = moveId, pp = mdef.pp })
|
table.insert(target.moves, { id = moveId, pp = mdef.pp })
|
||||||
local name = target.nickname or def.name
|
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)
|
nextStep)
|
||||||
else
|
else
|
||||||
require("src.ui.Screens").push(game, "MoveLearnMenu",
|
require("src.ui.Screens").push(game, "MoveLearnMenu",
|
||||||
@@ -285,16 +295,29 @@ local function useOn(game, battle, id, target, list, moveIndex)
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
list.index = math.min(list.index, math.max(1, #list.items))
|
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
|
if battle then
|
||||||
list:close()
|
list:close()
|
||||||
showMessages(game, payload, function() battle:itemUsed({}) end)
|
showMessages(game, payload, function() battle:itemUsed({}) end)
|
||||||
else
|
else
|
||||||
showMessages(game, payload)
|
showMessages(game, payload, closePicker)
|
||||||
end
|
end
|
||||||
return
|
return
|
||||||
end
|
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
|
end
|
||||||
|
|
||||||
local function pickTargetAndUse(game, battle, id, list)
|
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 def = game.data.items[id]
|
||||||
local opts = {
|
local opts = {
|
||||||
pickOnly = true,
|
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
|
if not wantsMove then
|
||||||
useOn(game, battle, id, mon, list)
|
useOn(game, battle, id, mon, list, nil, picker)
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
local rows = {}
|
local rows = {}
|
||||||
@@ -354,8 +381,8 @@ local function useItem(game, battle, id, list)
|
|||||||
local moveDef = game.data.moves[def.machine.move]
|
local moveDef = game.data.moves[def.machine.move]
|
||||||
local moveName = moveDef and moveDef.name or def.machine.move
|
local moveName = moveDef and moveDef.name or def.machine.move
|
||||||
local booted = def.machine.kind == "HM"
|
local booted = def.machine.kind == "HM"
|
||||||
and "Booted up an HM!" or "Booted up a TM!"
|
and "Booted up an HM!" or Strings("Booted up a TM!")
|
||||||
showMessages(game, { booted, ("It contained\n%s!"):format(moveName) },
|
showMessages(game, { booted, Strings("It contained\n%s!", moveName) },
|
||||||
function() pickTargetAndUse(game, battle, id, list) end)
|
function() pickTargetAndUse(game, battle, id, list) end)
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
@@ -372,6 +399,8 @@ function BagMenu.new(game, opts)
|
|||||||
list = ListMenu.new(game, "ITEMS", buildItems(game), {
|
list = ListMenu.new(game, "ITEMS", buildItems(game), {
|
||||||
kind = "bag",
|
kind = "bag",
|
||||||
footer = ("¥%d"):format(game.save.money),
|
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)
|
-- SELECT reorders items like the original bag (swap_items.asm)
|
||||||
onSelectKey = function(item, l)
|
onSelectKey = function(item, l)
|
||||||
if not item then return end
|
if not item then return end
|
||||||
@@ -400,17 +429,23 @@ function BagMenu.new(game, opts)
|
|||||||
useItem(game, battle, id, list)
|
useItem(game, battle, id, list)
|
||||||
return
|
return
|
||||||
end
|
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")
|
local Menu = require("src.ui.Menu")
|
||||||
game.stack:push(Menu.new(game, {
|
game.stack:push(Menu.new(game, {
|
||||||
{ label = "USE", onSelect = function()
|
{ label = Strings("USE"), onSelect = function()
|
||||||
useItem(game, battle, id, list)
|
useItem(game, battle, id, list)
|
||||||
end },
|
end },
|
||||||
{ label = "TOSS", onSelect = function()
|
{ label = Strings("TOSS"), onSelect = function()
|
||||||
-- KeyItemFlags + HMs decide tossability (not price:
|
-- KeyItemFlags + HMs decide tossability (not price:
|
||||||
-- MOON STONE is price 0 but tossable)
|
-- MOON STONE is price 0 but tossable)
|
||||||
if not def or def.keyItem or id:find("^HM_") then
|
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
|
return
|
||||||
end
|
end
|
||||||
local QuantityBox = require("src.ui.QuantityBox")
|
local QuantityBox = require("src.ui.QuantityBox")
|
||||||
@@ -424,12 +459,12 @@ function BagMenu.new(game, opts)
|
|||||||
Bag.remove(game.save, id, qty)
|
Bag.remove(game.save, id, qty)
|
||||||
list.items = buildItems(game)
|
list.items = buildItems(game)
|
||||||
list.index = math.min(list.index, math.max(1, #list.items))
|
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,
|
end,
|
||||||
}))
|
}))
|
||||||
end },
|
end },
|
||||||
}, { tx = 12, ty = 10, tw = 8, th = 6 }))
|
}, { tx = 13, ty = 10, tw = 7, th = 5 }))
|
||||||
end,
|
end,
|
||||||
})
|
})
|
||||||
return list
|
return list
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
local Font = require("src.render.Font")
|
local Font = require("src.render.Font")
|
||||||
local ListMenu = require("src.ui.ListMenu")
|
local ListMenu = require("src.ui.ListMenu")
|
||||||
local Input = require("src.core.Input")
|
local Input = require("src.core.Input")
|
||||||
|
local Strings = require("src.core.Strings")
|
||||||
|
|
||||||
local BindingsMenu = setmetatable({}, { __index = ListMenu })
|
local BindingsMenu = setmetatable({}, { __index = ListMenu })
|
||||||
BindingsMenu.__index = BindingsMenu
|
BindingsMenu.__index = BindingsMenu
|
||||||
@@ -55,7 +56,9 @@ function BindingsMenu.new(game)
|
|||||||
and game.save.options.bindings
|
and game.save.options.bindings
|
||||||
local items = {}
|
local items = {}
|
||||||
for i, def in ipairs(BUTTONS) do
|
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 }
|
right = boundRight(overlay, def), button = def }
|
||||||
end
|
end
|
||||||
local self = setmetatable(ListMenu.new(game, "CONTROLS", items, {}),
|
local self = setmetatable(ListMenu.new(game, "CONTROLS", items, {}),
|
||||||
@@ -111,7 +114,7 @@ function BindingsMenu:draw()
|
|||||||
if self.capture then
|
if self.capture then
|
||||||
Font.drawBox(1, 6, 18, 4)
|
Font.drawBox(1, 6, 18, 4)
|
||||||
love.graphics.setColor(0, 0, 0, 1)
|
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)
|
love.graphics.setColor(1, 1, 1, 1)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
+35
-25
@@ -7,13 +7,15 @@ local Font = require("src.render.Font")
|
|||||||
local ListMenu = require("src.ui.ListMenu")
|
local ListMenu = require("src.ui.ListMenu")
|
||||||
local Menu = require("src.ui.Menu")
|
local Menu = require("src.ui.Menu")
|
||||||
local Party = require("src.pokemon.Party")
|
local Party = require("src.pokemon.Party")
|
||||||
|
local Stats = require("src.pokemon.Stats")
|
||||||
local TextBox = require("src.render.TextBox")
|
local TextBox = require("src.render.TextBox")
|
||||||
|
local Strings = require("src.core.Strings")
|
||||||
|
|
||||||
local BoxMenu = {}
|
local BoxMenu = {}
|
||||||
|
|
||||||
local function monLabel(game, mon)
|
local function monLabel(game, mon)
|
||||||
local def = game.data.pokemon[mon.species]
|
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
|
end
|
||||||
|
|
||||||
local function monName(game, mon)
|
local function monName(game, mon)
|
||||||
@@ -28,13 +30,13 @@ local function monSubmenu(game, action, mon, onAction)
|
|||||||
game.stack:push(Menu.new(game, {
|
game.stack:push(Menu.new(game, {
|
||||||
{ label = action, onSelect = onAction },
|
{ label = action, onSelect = onAction },
|
||||||
{
|
{
|
||||||
label = "STATS",
|
label = Strings("STATS"),
|
||||||
keepOpen = true,
|
keepOpen = true,
|
||||||
onSelect = function()
|
onSelect = function()
|
||||||
require("src.ui.Screens").push(game, "SummaryMenu", mon)
|
require("src.ui.Screens").push(game, "SummaryMenu", mon)
|
||||||
end,
|
end,
|
||||||
},
|
},
|
||||||
{ label = "CANCEL" },
|
{ label = Strings("CANCEL") },
|
||||||
}, { tx = 9, ty = 10, tw = 11, th = 8, noSound = true }))
|
}, { tx = 9, ty = 10, tw = 11, th = 8, noSound = true }))
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -50,12 +52,12 @@ local function withdraw(game)
|
|||||||
local t = game.data.text
|
local t = game.data.text
|
||||||
if #box == 0 then
|
if #box == 0 then
|
||||||
game.stack:push(TextBox.new(game, t._NoMonText
|
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
|
return
|
||||||
end
|
end
|
||||||
if #game.save.party >= Party.MAX then
|
if #game.save.party >= Party.MAX then
|
||||||
game.stack:push(TextBox.new(game, t._CantTakeMonText
|
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
|
return
|
||||||
end
|
end
|
||||||
local items = {}
|
local items = {}
|
||||||
@@ -63,7 +65,7 @@ local function withdraw(game)
|
|||||||
table.insert(items, { label = monLabel(game, mon), value = i })
|
table.insert(items, { label = monLabel(game, mon), value = i })
|
||||||
end
|
end
|
||||||
game.stack:push(ListMenu.new(game,
|
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)
|
onChoose = function(item, list)
|
||||||
local mon = box[item.value]
|
local mon = box[item.value]
|
||||||
if not mon then return end
|
if not mon then return end
|
||||||
@@ -72,13 +74,21 @@ local function withdraw(game)
|
|||||||
list.footer = "The party is full!"
|
list.footer = "The party is full!"
|
||||||
return
|
return
|
||||||
end
|
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.remove(box, item.value)
|
||||||
table.insert(game.save.party, mon)
|
table.insert(game.save.party, mon)
|
||||||
local name = monName(game, mon)
|
local name = monName(game, mon)
|
||||||
game.stringBuffer = name
|
game.stringBuffer = name
|
||||||
require("src.core.Sound").playCry(game.data, mon.species)
|
require("src.core.Sound").playCry(game.data, mon.species)
|
||||||
afterTransfer(game, list, t._MonIsTakenOutText
|
afterTransfer(game, list, t._MonIsTakenOutText
|
||||||
or (name .. " is\ntaken out.\vGot " .. name .. "."))
|
or Strings("%s is\ntaken out.\vGot %s.", name, name))
|
||||||
end)
|
end)
|
||||||
end,
|
end,
|
||||||
}))
|
}))
|
||||||
@@ -88,13 +98,13 @@ local function deposit(game)
|
|||||||
local t = game.data.text
|
local t = game.data.text
|
||||||
if #game.save.party <= 1 then
|
if #game.save.party <= 1 then
|
||||||
game.stack:push(TextBox.new(game, t._CantDepositLastMonText
|
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
|
return
|
||||||
end
|
end
|
||||||
local box = Boxes.active(game.save)
|
local box = Boxes.active(game.save)
|
||||||
if #box >= Boxes.CAPACITY then
|
if #box >= Boxes.CAPACITY then
|
||||||
game.stack:push(TextBox.new(game, t._BoxFullText
|
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
|
return
|
||||||
end
|
end
|
||||||
local items = {}
|
local items = {}
|
||||||
@@ -107,12 +117,12 @@ local function deposit(game)
|
|||||||
if not mon then return end
|
if not mon then return end
|
||||||
monSubmenu(game, "DEPOSIT", mon, function()
|
monSubmenu(game, "DEPOSIT", mon, function()
|
||||||
if #game.save.party <= 1 then
|
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
|
return
|
||||||
end
|
end
|
||||||
local active = Boxes.active(game.save)
|
local active = Boxes.active(game.save)
|
||||||
if #active >= Boxes.CAPACITY then
|
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
|
return
|
||||||
end
|
end
|
||||||
table.remove(game.save.party, item.value)
|
table.remove(game.save.party, item.value)
|
||||||
@@ -122,7 +132,7 @@ local function deposit(game)
|
|||||||
game.boxNumString = tostring(game.save.currentBox)
|
game.boxNumString = tostring(game.save.currentBox)
|
||||||
require("src.core.Sound").playCry(game.data, mon.species)
|
require("src.core.Sound").playCry(game.data, mon.species)
|
||||||
afterTransfer(game, list, t._MonWasStoredText
|
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)
|
||||||
end,
|
end,
|
||||||
}))
|
}))
|
||||||
@@ -135,7 +145,7 @@ local function release(game)
|
|||||||
local t = game.data.text
|
local t = game.data.text
|
||||||
if #box == 0 then
|
if #box == 0 then
|
||||||
game.stack:push(TextBox.new(game, t._NoMonText
|
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
|
return
|
||||||
end
|
end
|
||||||
local items = {}
|
local items = {}
|
||||||
@@ -143,20 +153,20 @@ local function release(game)
|
|||||||
table.insert(items, { label = monLabel(game, mon), value = i })
|
table.insert(items, { label = monLabel(game, mon), value = i })
|
||||||
end
|
end
|
||||||
game.stack:push(ListMenu.new(game,
|
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)
|
onChoose = function(_, list)
|
||||||
local mon = box[list.index]
|
local mon = box[list.index]
|
||||||
if not mon then return end
|
if not mon then return end
|
||||||
local name = monName(game, mon)
|
local name = monName(game, mon)
|
||||||
local ChoiceBox = require("src.ui.ChoiceBox")
|
local ChoiceBox = require("src.ui.ChoiceBox")
|
||||||
game.stack:push(TextBox.new(game,
|
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)
|
game.stack:push(ChoiceBox.new(game, function(yes)
|
||||||
if not yes then return end
|
if not yes then return end
|
||||||
table.remove(box, list.index)
|
table.remove(box, list.index)
|
||||||
require("src.core.Sound").playCry(game.data, mon.species)
|
require("src.core.Sound").playCry(game.data, mon.species)
|
||||||
game.stack:push(TextBox.new(game,
|
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()
|
list:removeCurrent()
|
||||||
end, { defaultNo = true, noSound = true }))
|
end, { defaultNo = true, noSound = true }))
|
||||||
end))
|
end))
|
||||||
@@ -170,7 +180,7 @@ local function changeBox(game)
|
|||||||
for i = 1, Boxes.COUNT do
|
for i = 1, Boxes.COUNT do
|
||||||
local mark = i == game.save.currentBox and "*" or " "
|
local mark = i == game.save.currentBox and "*" or " "
|
||||||
table.insert(items, {
|
table.insert(items, {
|
||||||
label = ("%sBOX %2d"):format(mark, i),
|
label = Strings("%sBOX %2d", mark, i),
|
||||||
right = ("%d/%d"):format(#boxes[i], Boxes.CAPACITY),
|
right = ("%d/%d"):format(#boxes[i], Boxes.CAPACITY),
|
||||||
value = i,
|
value = i,
|
||||||
})
|
})
|
||||||
@@ -181,7 +191,7 @@ local function changeBox(game)
|
|||||||
-- BOX, data will be saved. OK?"); declining aborts the change
|
-- BOX, data will be saved. OK?"); declining aborts the change
|
||||||
local ChoiceBox = require("src.ui.ChoiceBox")
|
local ChoiceBox = require("src.ui.ChoiceBox")
|
||||||
game.stack:push(TextBox.new(game,
|
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)
|
game.stack:push(ChoiceBox.new(game, function(yes)
|
||||||
if not yes then return end
|
if not yes then return end
|
||||||
game.save.currentBox = item.value
|
game.save.currentBox = item.value
|
||||||
@@ -197,10 +207,10 @@ end
|
|||||||
local function drawChrome(game)
|
local function drawChrome(game)
|
||||||
Font.drawBox(0, 12, 20, 6)
|
Font.drawBox(0, 12, 20, 6)
|
||||||
love.graphics.setColor(0, 0, 0, 1)
|
love.graphics.setColor(0, 0, 0, 1)
|
||||||
Font.draw("What?", 8, 112)
|
Font.draw(Strings("What?"), 8, 112)
|
||||||
Font.drawBox(9, 14, 11, 4)
|
Font.drawBox(9, 14, 11, 4)
|
||||||
love.graphics.setColor(0, 0, 0, 1)
|
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
|
local n = game.save.currentBox or 1
|
||||||
if n >= 10 then
|
if n >= 10 then
|
||||||
Font.draw(tostring(n), 136, 128)
|
Font.draw(tostring(n), 136, 128)
|
||||||
@@ -217,15 +227,15 @@ function BoxMenu.new(game)
|
|||||||
-- full interior (cursor col + label). keepOpen so WITHDRAW/DEPOSIT/
|
-- full interior (cursor col + label). keepOpen so WITHDRAW/DEPOSIT/
|
||||||
-- RELEASE/CHANGE BOX leave this menu underneath (jp BillsPCMenu).
|
-- RELEASE/CHANGE BOX leave this menu underneath (jp BillsPCMenu).
|
||||||
local menu = Menu.new(game, {
|
local menu = Menu.new(game, {
|
||||||
{ label = "WITHDRAW <PK><MN>", keepOpen = true,
|
{ label = Strings("WITHDRAW <PK><MN>"), keepOpen = true,
|
||||||
onSelect = function() withdraw(game) end },
|
onSelect = function() withdraw(game) end },
|
||||||
{ label = "DEPOSIT <PK><MN>", keepOpen = true,
|
{ label = Strings("DEPOSIT <PK><MN>"), keepOpen = true,
|
||||||
onSelect = function() deposit(game) end },
|
onSelect = function() deposit(game) end },
|
||||||
{ label = "RELEASE <PK><MN>", keepOpen = true,
|
{ label = Strings("RELEASE <PK><MN>"), keepOpen = true,
|
||||||
onSelect = function() release(game) end },
|
onSelect = function() release(game) end },
|
||||||
{ label = "CHANGE BOX", keepOpen = true,
|
{ label = Strings("CHANGE BOX"), keepOpen = true,
|
||||||
onSelect = function() changeBox(game) end },
|
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,
|
-- Bill's PC runs silent end to end (BIT_NO_MENU_BUTTON_SOUND,
|
||||||
-- engine/menus/pokemon_pc.asm)
|
-- engine/menus/pokemon_pc.asm)
|
||||||
}, { tx = 0, ty = 0, tw = 14, th = 12, noSound = true })
|
}, { tx = 0, ty = 0, tw = 14, th = 12, noSound = true })
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
local Font = require("src.render.Font")
|
local Font = require("src.render.Font")
|
||||||
local Theme = require("src.ui.Theme")
|
local Theme = require("src.ui.Theme")
|
||||||
|
local Strings = require("src.core.Strings")
|
||||||
|
|
||||||
local ChoiceBox = {}
|
local ChoiceBox = {}
|
||||||
ChoiceBox.__index = ChoiceBox
|
ChoiceBox.__index = ChoiceBox
|
||||||
@@ -46,8 +47,8 @@ function ChoiceBox:draw()
|
|||||||
local tx, ty, tw, th = self.tx, self.ty, self.tw, self.th
|
local tx, ty, tw, th = self.tx, self.ty, self.tw, self.th
|
||||||
Font.drawBox(tx, ty, tw, th)
|
Font.drawBox(tx, ty, tw, th)
|
||||||
love.graphics.setColor(0, 0, 0, 1)
|
love.graphics.setColor(0, 0, 0, 1)
|
||||||
Font.draw("YES", (tx + 2) * 8, (ty + 1) * 8)
|
Font.draw(Strings("YES"), (tx + 2) * 8, (ty + 1) * 8)
|
||||||
Font.draw("NO", (tx + 2) * 8, (ty + 3) * 8)
|
Font.draw(Strings("NO"), (tx + 2) * 8, (ty + 3) * 8)
|
||||||
Font.drawCode(Theme.cursor, (tx + 1) * 8,
|
Font.drawCode(Theme.cursor, (tx + 1) * 8,
|
||||||
(ty + (self.index == 1 and 1 or 3)) * 8)
|
(ty + (self.index == 1 and 1 or 3)) * 8)
|
||||||
love.graphics.setColor(1, 1, 1, 1)
|
love.graphics.setColor(1, 1, 1, 1)
|
||||||
|
|||||||
+6
-5
@@ -31,6 +31,7 @@
|
|||||||
|
|
||||||
local Font = require("src.render.Font")
|
local Font = require("src.render.Font")
|
||||||
local Music = require("src.core.Music")
|
local Music = require("src.core.Music")
|
||||||
|
local Strings = require("src.core.Strings")
|
||||||
|
|
||||||
local Credits = {}
|
local Credits = {}
|
||||||
Credits.__index = Credits
|
Credits.__index = Credits
|
||||||
@@ -261,14 +262,14 @@ function Credits:drawCopyright(xoff)
|
|||||||
love.graphics.draw(self.gfImg, xoff + 80, 88)
|
love.graphics.draw(self.gfImg, xoff + 80, 88)
|
||||||
else
|
else
|
||||||
love.graphics.setColor(0, 0, 0, 1)
|
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)
|
love.graphics.setColor(1, 1, 1, 1)
|
||||||
end
|
end
|
||||||
else
|
else
|
||||||
love.graphics.setColor(0, 0, 0, 1)
|
love.graphics.setColor(0, 0, 0, 1)
|
||||||
Font.draw("Nintendo", xoff + 80, 56)
|
Font.draw(Strings("Nintendo"), xoff + 80, 56)
|
||||||
Font.draw("Creatures inc.", xoff + 80, 72)
|
Font.draw(Strings("Creatures inc."), xoff + 80, 72)
|
||||||
Font.draw("GAME FREAK inc.", xoff + 16, 88)
|
Font.draw(Strings("GAME FREAK inc."), xoff + 16, 88)
|
||||||
love.graphics.setColor(1, 1, 1, 1)
|
love.graphics.setColor(1, 1, 1, 1)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -298,7 +299,7 @@ function Credits:drawTheEnd()
|
|||||||
end
|
end
|
||||||
else
|
else
|
||||||
love.graphics.setColor(0, 0, 0, 1)
|
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)
|
love.graphics.setColor(1, 1, 1, 1)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
-- without permanently marking the mon owned.
|
-- without permanently marking the mon owned.
|
||||||
|
|
||||||
local Font = require("src.render.Font")
|
local Font = require("src.render.Font")
|
||||||
|
local Strings = require("src.core.Strings")
|
||||||
|
|
||||||
local DexEntryMenu = {}
|
local DexEntryMenu = {}
|
||||||
DexEntryMenu.__index = DexEntryMenu
|
DexEntryMenu.__index = DexEntryMenu
|
||||||
@@ -36,7 +37,11 @@ function DexEntryMenu.new(game, speciesOrOpts)
|
|||||||
self.def = game.data.pokemon[species]
|
self.def = game.data.pokemon[species]
|
||||||
local path = require("src.pokemon.Sprites").path(game.data, species, "front",
|
local path = require("src.pokemon.Sprites").path(game.data, species, "front",
|
||||||
{ kind = "dex" })
|
{ 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
|
self.sprite = ok and img or nil
|
||||||
require("src.core.Sound").playCry(game.data, species)
|
require("src.core.Sound").playCry(game.data, species)
|
||||||
return self
|
return self
|
||||||
@@ -76,8 +81,8 @@ function DexEntryMenu:draw()
|
|||||||
-- feet/inches use the dex screen's ′/″ glyphs ("HT ?′??″" in
|
-- feet/inches use the dex screen's ′/″ glyphs ("HT ?′??″" in
|
||||||
-- pokedex.asm; the tiles come from gfx/pokedex/pokedex.png via
|
-- pokedex.asm; the tiles come from gfx/pokedex/pokedex.png via
|
||||||
-- engine/gfx/load_pokedex_tiles.asm)
|
-- engine/gfx/load_pokedex_tiles.asm)
|
||||||
Font.draw(("HT %d′%02d″"):format(e.heightFt, e.heightIn or 0), 72, 44)
|
Font.draw(Strings("HT %d′%02d″", e.heightFt, e.heightIn or 0), 72, 44)
|
||||||
Font.draw(("WT %.1flb"):format((e.weight or 0) / 10), 72, 54)
|
Font.draw(Strings("WT %.1flb", (e.weight or 0) / 10), 72, 54)
|
||||||
end
|
end
|
||||||
local text = owned and e.text and self.game.data.text[e.text] or nil
|
local text = owned and e.text and self.game.data.text[e.text] or nil
|
||||||
local y = 72
|
local y = 72
|
||||||
@@ -88,7 +93,7 @@ function DexEntryMenu:draw()
|
|||||||
y = y + 10
|
y = y + 10
|
||||||
end
|
end
|
||||||
else
|
else
|
||||||
Font.draw("Data unknown.", 8, y)
|
Font.draw(Strings("Data unknown."), 8, y)
|
||||||
end
|
end
|
||||||
love.graphics.setColor(1, 1, 1, 1)
|
love.graphics.setColor(1, 1, 1, 1)
|
||||||
end
|
end
|
||||||
|
|||||||
+35
-13
@@ -1,14 +1,17 @@
|
|||||||
-- The evolution movie (engine/movie/evolution.asm): the mon's pic
|
-- The evolution movie (engine/movie/evolution.asm): the mon's pic
|
||||||
-- flashes back and forth with the evolved form, speeding up, then the
|
-- flashes back and forth with the evolved form, speeding up, then the
|
||||||
-- new form appears with its cry and the congratulations text.
|
-- new form appears with its cry and the congratulations text.
|
||||||
-- pokered engine/pokemon/evos_moves.asm (EvolveMon) polls hJoyHeld during
|
-- pokered engine/movie/evolution.asm (Evolution_CheckForCancel) polls the
|
||||||
-- the flash: holding B aborts the evolution -- the mon keeps its species
|
-- joypad during the flash: holding B aborts the evolution -- the mon keeps
|
||||||
-- and _StoppedEvolvingText ("Huh? MON stopped evolving!") prints. The
|
-- its species and _StoppedEvolvingText ("Huh? MON stopped evolving!")
|
||||||
-- lone exception is trade evolutions (wLinkState == LINK_STATE_TRADING),
|
-- prints. Two kinds are exempt: trade evolutions, which evos_moves.asm
|
||||||
-- which skip that poll and cannot be cancelled (#213).
|
-- 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 Font = require("src.render.Font")
|
||||||
local Music = require("src.core.Music")
|
local Music = require("src.core.Music")
|
||||||
|
local Strings = require("src.core.Strings")
|
||||||
|
|
||||||
local EvolutionState = {}
|
local EvolutionState = {}
|
||||||
EvolutionState.__index = EvolutionState
|
EvolutionState.__index = EvolutionState
|
||||||
@@ -17,6 +20,22 @@ EvolutionState.isOpaque = true
|
|||||||
-- SGB: SetPal_PokemonWholeScreen for the mon on display
|
-- SGB: SetPal_PokemonWholeScreen for the mon on display
|
||||||
function EvolutionState:sgbPalettes(game)
|
function EvolutionState:sgbPalettes(game)
|
||||||
local P = require("src.render.PaletteFX")
|
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
|
-- a cancelled evolution keeps the old species (never applied), so only
|
||||||
-- colorize with the new form once it has actually evolved
|
-- colorize with the new form once it has actually evolved
|
||||||
local species = (self.done and not self.canceled) and self.newSpecies
|
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.newSpecies = newSpecies
|
||||||
self.onDone = onDone
|
self.onDone = onDone
|
||||||
self.via = via
|
self.via = via
|
||||||
-- evos_moves.asm: only trade evolutions (LINK_STATE_TRADING) skip the
|
-- evolution.asm Evolution_CheckForCancel: a B press is discarded when
|
||||||
-- B-cancel poll; level-up, stone and rare-candy evos are all cancelable.
|
-- wForceEvolution is set, and ItemUseEvoStone sets it before calling
|
||||||
self.cancelable = (via ~= "TRADE")
|
-- 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.oldName = mon.nickname or game.data.pokemon[mon.species].name
|
||||||
self.oldSprite = frontSprite(game, mon.species, mon)
|
self.oldSprite = frontSprite(game, mon.species, mon)
|
||||||
self.newSprite = frontSprite(game, newSpecies, mon)
|
self.newSprite = frontSprite(game, newSpecies, mon)
|
||||||
@@ -69,7 +91,7 @@ function EvolutionState:update(dt)
|
|||||||
local TextBox = require("src.render.TextBox")
|
local TextBox = require("src.render.TextBox")
|
||||||
-- mirrors data/generated/text.lua _StoppedEvolvingText
|
-- mirrors data/generated/text.lua _StoppedEvolvingText
|
||||||
game.stack:push(TextBox.new(game,
|
game.stack:push(TextBox.new(game,
|
||||||
("Huh? %s\nstopped evolving!"):format(self.oldName),
|
Strings("Huh? %s\nstopped evolving!", self.oldName),
|
||||||
function()
|
function()
|
||||||
Music.restoreMap(game.data)
|
Music.restoreMap(game.data)
|
||||||
game.stack:pop() -- the evolution screen itself
|
game.stack:pop() -- the evolution screen itself
|
||||||
@@ -85,8 +107,8 @@ function EvolutionState:update(dt)
|
|||||||
local TextBox = require("src.render.TextBox")
|
local TextBox = require("src.render.TextBox")
|
||||||
local newName = game.data.pokemon[self.newSpecies].name
|
local newName = game.data.pokemon[self.newSpecies].name
|
||||||
game.stack:push(TextBox.new(game,
|
game.stack:push(TextBox.new(game,
|
||||||
("Congratulations!\nYour %s\nevolved into\n%s!")
|
Strings("Congratulations!\nYour %s\nevolved into\n%s!",
|
||||||
:format(self.oldName, newName),
|
self.oldName, newName),
|
||||||
function()
|
function()
|
||||||
Music.restoreMap(game.data)
|
Music.restoreMap(game.data)
|
||||||
game.stack:pop() -- the evolution screen itself
|
game.stack:pop() -- the evolution screen itself
|
||||||
@@ -121,9 +143,9 @@ function EvolutionState:draw()
|
|||||||
|
|
||||||
love.graphics.setColor(0, 0, 0, 1)
|
love.graphics.setColor(0, 0, 0, 1)
|
||||||
if not self.done then
|
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(self.oldName .. " is", 8, 114)
|
||||||
Font.draw("evolving!", 8, 124)
|
Font.draw(Strings("evolving!"), 8, 124)
|
||||||
end
|
end
|
||||||
love.graphics.setColor(1, 1, 1, 1)
|
love.graphics.setColor(1, 1, 1, 1)
|
||||||
end
|
end
|
||||||
|
|||||||
+55
-62
@@ -7,10 +7,13 @@
|
|||||||
-- name/time/money boxes plus the dex rating. Plays Music_HallOfFame
|
-- name/time/money boxes plus the dex rating. Plays Music_HallOfFame
|
||||||
-- when the audio data has it. Calls onDone() after popping itself.
|
-- when the audio data has it. Calls onDone() after popping itself.
|
||||||
|
|
||||||
|
local Assets = require("src.render.Assets")
|
||||||
local Font = require("src.render.Font")
|
local Font = require("src.render.Font")
|
||||||
|
local TextBox = require("src.render.TextBox")
|
||||||
local Music = require("src.core.Music")
|
local Music = require("src.core.Music")
|
||||||
local Sound = require("src.core.Sound")
|
local Sound = require("src.core.Sound")
|
||||||
local TypeChart = require("src.battle.TypeChart")
|
local TypeChart = require("src.battle.TypeChart")
|
||||||
|
local Strings = require("src.core.Strings")
|
||||||
|
|
||||||
local HallOfFame = {}
|
local HallOfFame = {}
|
||||||
HallOfFame.__index = HallOfFame
|
HallOfFame.__index = HallOfFame
|
||||||
@@ -47,9 +50,11 @@ local FADE_FRAMES = 20
|
|||||||
-- HoFPrintTextAndDelay after each dex line
|
-- HoFPrintTextAndDelay after each dex line
|
||||||
local DEX_HOLD = 120
|
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)
|
local function tryImage(path)
|
||||||
if not path then return nil end
|
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
|
return ok and img or nil
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -60,17 +65,6 @@ local function dexRatingKey(owned)
|
|||||||
return ("_DexRatingText_Own%dTo%d"):format(lo, lo + 9)
|
return ("_DexRatingText_Own%dTo%d"):format(lo, lo + 9)
|
||||||
end
|
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)
|
function HallOfFame.new(game, onDone)
|
||||||
local self = setmetatable({}, HallOfFame)
|
local self = setmetatable({}, HallOfFame)
|
||||||
self.game = game
|
self.game = game
|
||||||
@@ -79,7 +73,8 @@ function HallOfFame.new(game, onDone)
|
|||||||
self.timer = 0
|
self.timer = 0
|
||||||
self.phase = "mons"
|
self.phase = "mons"
|
||||||
self.sprites = {} -- species -> image or false
|
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.scrollX = PIC_X
|
||||||
self.showHofBanner = false
|
self.showHofBanner = false
|
||||||
self.fade = 0
|
self.fade = 0
|
||||||
@@ -177,26 +172,50 @@ function HallOfFame:update(dt)
|
|||||||
self.phase = "player_stats"
|
self.phase = "player_stats"
|
||||||
self.timer = DEX_HOLD
|
self.timer = DEX_HOLD
|
||||||
elseif self.phase == "player_stats" then
|
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
|
self.timer = self.timer - 1
|
||||||
if skip or self.timer <= 0 then
|
if skip or self.timer <= 0 then
|
||||||
self.phase = "player_dex"
|
self.phase = "player_dex"
|
||||||
self.timer = DEX_HOLD
|
self:showDexTexts()
|
||||||
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
|
|
||||||
end
|
end
|
||||||
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
|
end
|
||||||
|
|
||||||
-- HoFDisplayMonInfo: TextBoxBorder (0,2) b=9,c=10 + LEVEL/TYPE labels
|
-- 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)
|
love.graphics.setColor(0, 0, 0, 1)
|
||||||
local name = mon.nickname or (def and def.name) or mon.species
|
local name = mon.nickname or (def and def.name) or mon.species
|
||||||
Font.draw(name, 1 * 8, 4 * 8)
|
Font.draw(name, 1 * 8, 4 * 8)
|
||||||
Font.draw("LEVEL/", 2 * 8, 6 * 8)
|
Font.draw(Strings("LEVEL/"), 2 * 8, 6 * 8)
|
||||||
Font.draw("TYPE1/", 2 * 8, 7 * 8)
|
Font.draw(Strings("TYPE1/"), 2 * 8, 7 * 8)
|
||||||
local t1 = def and def.types and def.types[1]
|
local t1 = def and def.types and def.types[1]
|
||||||
local t2 = def and def.types and def.types[2]
|
local t2 = def and def.types and def.types[2]
|
||||||
local dual = t2 and t2 ~= t1
|
local dual = t2 and t2 ~= t1
|
||||||
if dual then
|
if dual then
|
||||||
Font.draw("TYPE2/", 2 * 8, 8 * 8)
|
Font.draw(Strings("TYPE2/"), 2 * 8, 8 * 8)
|
||||||
end
|
end
|
||||||
-- PrintLevelCommon at (8,7): bare level digits (no <LV> tile here)
|
-- PrintLevelCommon at (8,7): bare level digits (no <LV> tile here)
|
||||||
Font.draw(tostring(mon.level), 8 * 8, 7 * 8)
|
Font.draw(tostring(mon.level), 8 * 8, 7 * 8)
|
||||||
@@ -229,7 +248,7 @@ end
|
|||||||
function HallOfFame:drawHofBanner()
|
function HallOfFame:drawHofBanner()
|
||||||
Font.drawBox(2, 13, 16, 5)
|
Font.drawBox(2, 13, 16, 5)
|
||||||
love.graphics.setColor(0, 0, 0, 1)
|
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
|
end
|
||||||
|
|
||||||
function HallOfFame:drawPic(img)
|
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)
|
-- play time / money box: TextBoxBorder (0,4) b=6,c=10 → drawBox(0,4,12,8)
|
||||||
Font.drawBox(0, 4, 12, 8)
|
Font.drawBox(0, 4, 12, 8)
|
||||||
love.graphics.setColor(0, 0, 0, 1)
|
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)
|
local t = math.floor(save.playTime or 0)
|
||||||
Font.draw(("%3d:%02d"):format(math.floor(t / 3600), math.floor(t / 60) % 60),
|
Font.draw(("%3d:%02d"):format(math.floor(t / 3600), math.floor(t / 60) % 60),
|
||||||
5 * 8, 7 * 8)
|
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
|
-- PrintBCDNumber with MONEY_SIGN; port uses ¥ like TrainerCard
|
||||||
Font.draw(("¥%d"):format(save.money or 0), 4 * 8, 10 * 8)
|
Font.draw(("¥%d"):format(save.money or 0), 4 * 8, 10 * 8)
|
||||||
end
|
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()
|
function HallOfFame:draw()
|
||||||
love.graphics.setColor(1, 1, 1, 1)
|
love.graphics.setColor(1, 1, 1, 1)
|
||||||
love.graphics.rectangle("fill", 0, 0, 160, 144)
|
love.graphics.rectangle("fill", 0, 0, 160, 144)
|
||||||
@@ -304,14 +301,10 @@ function HallOfFame:draw()
|
|||||||
elseif self.phase == "player_stats" then
|
elseif self.phase == "player_stats" then
|
||||||
self:drawPic(self.playerPic)
|
self:drawPic(self.playerPic)
|
||||||
self:drawPlayerStats()
|
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:drawPic(self.playerPic)
|
||||||
self:drawPlayerStats()
|
self:drawPlayerStats()
|
||||||
self:drawDexBox("seen")
|
|
||||||
elseif self.phase == "player_rating" then
|
|
||||||
self:drawPic(self.playerPic)
|
|
||||||
self:drawPlayerStats()
|
|
||||||
self:drawDexBox("rating")
|
|
||||||
end
|
end
|
||||||
|
|
||||||
love.graphics.setColor(1, 1, 1, 1)
|
love.graphics.setColor(1, 1, 1, 1)
|
||||||
|
|||||||
@@ -27,6 +27,7 @@
|
|||||||
local Font = require("src.render.Font")
|
local Font = require("src.render.Font")
|
||||||
local Music = require("src.core.Music")
|
local Music = require("src.core.Music")
|
||||||
local Sound = require("src.core.Sound")
|
local Sound = require("src.core.Sound")
|
||||||
|
local Strings = require("src.core.Strings")
|
||||||
|
|
||||||
local IntroMovie = {}
|
local IntroMovie = {}
|
||||||
IntroMovie.__index = IntroMovie
|
IntroMovie.__index = IntroMovie
|
||||||
@@ -287,7 +288,7 @@ function IntroMovie:drawSplash()
|
|||||||
end
|
end
|
||||||
-- custom studio name (replaces the GAME FREAK splash text)
|
-- custom studio name (replaces the GAME FREAK splash text)
|
||||||
love.graphics.setColor(0, 0, 0, dim and 0.35 or 1)
|
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)
|
Font.draw(card, (160 - #card * 8) / 2, TEXT_Y)
|
||||||
love.graphics.setColor(1, 1, 1, 1)
|
love.graphics.setColor(1, 1, 1, 1)
|
||||||
end
|
end
|
||||||
@@ -348,7 +349,7 @@ function IntroMovie:drawFight()
|
|||||||
|
|
||||||
if not gengar and not nido then
|
if not gengar and not nido then
|
||||||
love.graphics.setColor(0, 0, 0, 1)
|
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)
|
love.graphics.setColor(1, 1, 1, 1)
|
||||||
end
|
end
|
||||||
drawBars()
|
drawBars()
|
||||||
@@ -366,7 +367,7 @@ function IntroMovie:draw()
|
|||||||
-- custom boot card (replaces the Nintendo / GAME FREAK copyright
|
-- custom boot card (replaces the Nintendo / GAME FREAK copyright
|
||||||
-- card; no (c) glyph in the charmap, keep it ASCII-safe)
|
-- card; no (c) glyph in the charmap, keep it ASCII-safe)
|
||||||
love.graphics.setColor(0, 0, 0, 1)
|
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("2026", (160 - 4 * 8) / 2, 48)
|
||||||
Font.draw(credit, (160 - #credit * 8) / 2, 64)
|
Font.draw(credit, (160 - #credit * 8) / 2, 64)
|
||||||
Font.draw("bryanthaboi", (160 - 11 * 8) / 2, 80)
|
Font.draw("bryanthaboi", (160 - 11 * 8) / 2, 80)
|
||||||
|
|||||||
+7
-3
@@ -5,6 +5,7 @@
|
|||||||
local Font = require("src.render.Font")
|
local Font = require("src.render.Font")
|
||||||
local Runtime = require("src.mods.Runtime")
|
local Runtime = require("src.mods.Runtime")
|
||||||
local Theme = require("src.ui.Theme")
|
local Theme = require("src.ui.Theme")
|
||||||
|
local Strings = require("src.core.Strings")
|
||||||
|
|
||||||
local ListMenu = {}
|
local ListMenu = {}
|
||||||
ListMenu.__index = ListMenu
|
ListMenu.__index = ListMenu
|
||||||
@@ -74,7 +75,7 @@ function ListMenu.new(game, title, items, opts)
|
|||||||
self.dialogue = opts.dialogue
|
self.dialogue = opts.dialogue
|
||||||
-- PC item lists (players_pc.asm): PrintListMenuEntries shows 4 names
|
-- PC item lists (players_pc.asm): PrintListMenuEntries shows 4 names
|
||||||
-- and PrintText footers ("How many?", stored/withdrew) use the standard
|
-- 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.messageBox = opts.messageBox
|
||||||
self.money = opts.money -- () -> current money for the box
|
self.money = opts.money -- () -> current money for the box
|
||||||
self.rows = opts.rows or ((opts.dialogue or opts.messageBox) and 4 or ROWS)
|
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)
|
love.graphics.setColor(0, 0, 0, 1)
|
||||||
Font.draw(self.title, 8, 4)
|
Font.draw(self.title, 8, 4)
|
||||||
if #self.items == 0 then
|
if #self.items == 0 then
|
||||||
Font.draw("Nothing here.", 16, 64)
|
Font.draw(Strings("Nothing here."), 16, 64)
|
||||||
end
|
end
|
||||||
for row = 1, self.rows do
|
for row = 1, self.rows do
|
||||||
local i = self.scroll + row
|
local i = self.scroll + row
|
||||||
@@ -201,7 +202,10 @@ function ListMenu:draw()
|
|||||||
local y = 8 + row * 16
|
local y = 8 + row * 16
|
||||||
Font.draw(item.label, 16, y)
|
Font.draw(item.label, 16, y)
|
||||||
if item.ball then -- the Pokédex owned-ball marker tile
|
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
|
local by = y + 3
|
||||||
love.graphics.circle("fill", bx, by, 3.5)
|
love.graphics.circle("fill", bx, by, 3.5)
|
||||||
love.graphics.setColor(1, 1, 1, 1)
|
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.
|
-- can't be forgotten; B / CANCEL gives up on the new move.
|
||||||
|
|
||||||
local Font = require("src.render.Font")
|
local Font = require("src.render.Font")
|
||||||
|
local Strings = require("src.core.Strings")
|
||||||
|
|
||||||
local MoveLearnMenu = {}
|
local MoveLearnMenu = {}
|
||||||
MoveLearnMenu.__index = MoveLearnMenu
|
MoveLearnMenu.__index = MoveLearnMenu
|
||||||
@@ -43,9 +44,9 @@ function MoveLearnMenu:enter()
|
|||||||
local name = self:monName()
|
local name = self:monName()
|
||||||
self.selecting = false
|
self.selecting = false
|
||||||
game.stack:push(TextBox.new(game,
|
game.stack:push(TextBox.new(game,
|
||||||
("%s is\ntrying to learn\v%s!\fBut, %s\ncan't learn more\vthan 4 moves!\f")
|
Strings("%s is\ntrying to learn\v%s!\fBut, %s\ncan't learn more\vthan 4 moves!\f",
|
||||||
:format(name, mdef.name, name) ..
|
name, mdef.name, name) ..
|
||||||
("Delete an older\nmove to make room\vfor %s?"):format(mdef.name),
|
Strings("Delete an older\nmove to make room\vfor %s?", mdef.name),
|
||||||
nil, {
|
nil, {
|
||||||
choice = function(yes)
|
choice = function(yes)
|
||||||
if yes then
|
if yes then
|
||||||
@@ -76,7 +77,7 @@ function MoveLearnMenu:update(dt)
|
|||||||
-- HMCantDeleteText, then back to the forget list
|
-- HMCantDeleteText, then back to the forget list
|
||||||
local TextBox = require("src.render.TextBox")
|
local TextBox = require("src.render.TextBox")
|
||||||
self.game.stack:push(TextBox.new(self.game,
|
self.game.stack:push(TextBox.new(self.game,
|
||||||
"HM techniques\ncan't be deleted!"))
|
Strings("HM techniques\ncan't be deleted!")))
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
local mdef = self.game.data.moves[self.newMoveId]
|
local mdef = self.game.data.moves[self.newMoveId]
|
||||||
@@ -96,7 +97,7 @@ function MoveLearnMenu:confirmAbandon()
|
|||||||
local mdef = game.data.moves[self.newMoveId]
|
local mdef = game.data.moves[self.newMoveId]
|
||||||
self.selecting = false
|
self.selecting = false
|
||||||
game.stack:push(TextBox.new(game,
|
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)
|
choice = function(yes)
|
||||||
if yes then self:finish(false) else self:enter() end
|
if yes then self:finish(false) else self:enter() end
|
||||||
end,
|
end,
|
||||||
@@ -113,11 +114,11 @@ function MoveLearnMenu:finish(learned)
|
|||||||
local msg
|
local msg
|
||||||
if learned then
|
if learned then
|
||||||
-- OneTwoAndText/PoofText/ForgotAndText
|
-- OneTwoAndText/PoofText/ForgotAndText
|
||||||
msg = ("1, 2 and... Poof!\f%s forgot\n%s!\fAnd...\f%s learned\n%s!")
|
msg = Strings("1, 2 and... Poof!\f%s forgot\n%s!\fAnd...\f%s learned\n%s!",
|
||||||
:format(name, self.forgot, name, mdef.name)
|
name, self.forgot, name, mdef.name)
|
||||||
else
|
else
|
||||||
-- DidNotLearnText
|
-- DidNotLearnText
|
||||||
msg = ("%s\ndid not learn\v%s!"):format(name, mdef.name)
|
msg = Strings("%s\ndid not learn\v%s!", name, mdef.name)
|
||||||
end
|
end
|
||||||
game.stack:push(TextBox.new(game, msg, function()
|
game.stack:push(TextBox.new(game, msg, function()
|
||||||
if self.onDone then self.onDone(learned) end
|
if self.onDone then self.onDone(learned) end
|
||||||
@@ -133,12 +134,12 @@ function MoveLearnMenu:draw()
|
|||||||
for i, mv in ipairs(self.mon.moves) do
|
for i, mv in ipairs(self.mon.moves) do
|
||||||
Font.draw(self.game.data.moves[mv.id].name, 48, (5 + i) * 8)
|
Font.draw(self.game.data.moves[mv.id].name, 48, (5 + i) * 8)
|
||||||
end
|
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)
|
Font.drawCode(CURSOR, 40, (5 + self.index) * 8)
|
||||||
-- WhichMoveToForgetText in the bottom dialogue box
|
-- WhichMoveToForgetText in the bottom dialogue box
|
||||||
Font.drawBox(0, 12, 20, 6)
|
Font.drawBox(0, 12, 20, 6)
|
||||||
Font.draw("Which move should", 8, 14 * 8)
|
Font.draw(Strings("Which move should"), 8, 14 * 8)
|
||||||
Font.draw("be forgotten?", 8, 16 * 8)
|
Font.draw(Strings("be forgotten?"), 8, 16 * 8)
|
||||||
love.graphics.setColor(1, 1, 1, 1)
|
love.graphics.setColor(1, 1, 1, 1)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ local Font = require("src.render.Font")
|
|||||||
local Runtime = require("src.mods.Runtime")
|
local Runtime = require("src.mods.Runtime")
|
||||||
local Sound = require("src.core.Sound")
|
local Sound = require("src.core.Sound")
|
||||||
local Theme = require("src.ui.Theme")
|
local Theme = require("src.ui.Theme")
|
||||||
|
local Strings = require("src.core.Strings")
|
||||||
|
|
||||||
local NamingScreen = {}
|
local NamingScreen = {}
|
||||||
NamingScreen.__index = NamingScreen
|
NamingScreen.__index = NamingScreen
|
||||||
@@ -64,7 +65,7 @@ function NamingScreen.new(game, opts)
|
|||||||
opts = opts or {}
|
opts = opts or {}
|
||||||
local self = setmetatable({}, NamingScreen)
|
local self = setmetatable({}, NamingScreen)
|
||||||
self.game = game
|
self.game = game
|
||||||
self.title = opts.title or "YOUR NAME?"
|
self.title = opts.title or Strings("YOUR NAME?")
|
||||||
self.presets = opts.presets
|
self.presets = opts.presets
|
||||||
self.maxLen = opts.maxLen or 7
|
self.maxLen = opts.maxLen or 7
|
||||||
self.default = opts.default
|
self.default = opts.default
|
||||||
@@ -78,7 +79,7 @@ end
|
|||||||
function NamingScreen:enter()
|
function NamingScreen:enter()
|
||||||
if self.presets and #self.presets > 0 then
|
if self.presets and #self.presets > 0 then
|
||||||
local Menu = require("src.ui.Menu")
|
local Menu = require("src.ui.Menu")
|
||||||
local items = { { label = "NEW NAME" } }
|
local items = { { label = Strings("NEW NAME") } }
|
||||||
for _, preset in ipairs(self.presets) do
|
for _, preset in ipairs(self.presets) do
|
||||||
table.insert(items, {
|
table.insert(items, {
|
||||||
label = preset,
|
label = preset,
|
||||||
|
|||||||
+24
-13
@@ -7,12 +7,14 @@
|
|||||||
-- mod can insertBefore("name_player", ...) without counting indices.
|
-- mod can insertBefore("name_player", ...) without counting indices.
|
||||||
-- Calls onDone() after popping itself.
|
-- Calls onDone() after popping itself.
|
||||||
|
|
||||||
|
local Assets = require("src.render.Assets")
|
||||||
local Sound = require("src.core.Sound")
|
local Sound = require("src.core.Sound")
|
||||||
local Music = require("src.core.Music")
|
local Music = require("src.core.Music")
|
||||||
local Logger = require("src.core.Logger")
|
local Logger = require("src.core.Logger")
|
||||||
local Runtime = require("src.mods.Runtime")
|
local Runtime = require("src.mods.Runtime")
|
||||||
local TextBox = require("src.render.TextBox")
|
local TextBox = require("src.render.TextBox")
|
||||||
local Font = require("src.render.Font")
|
local Font = require("src.render.Font")
|
||||||
|
local Strings = require("src.core.Strings")
|
||||||
|
|
||||||
local OakSpeech = {}
|
local OakSpeech = {}
|
||||||
OakSpeech.__index = OakSpeech
|
OakSpeech.__index = OakSpeech
|
||||||
@@ -38,12 +40,12 @@ function OakSpeech:sgbPalettes(game)
|
|||||||
end
|
end
|
||||||
|
|
||||||
local FALLBACKS = {
|
local FALLBACKS = {
|
||||||
_OakSpeechText1 = "Hello there!\nWelcome to the\vworld of POKéMON!\fMy name is OAK!\nPeople call me\vthe POKéMON PROF!",
|
_OakSpeechText1 = Strings.source("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!",
|
_OakSpeechText2A = Strings.source("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.",
|
_OakSpeechText2B = Strings.source("\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!",
|
_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 = "First, what is\nyour name?",
|
_IntroducePlayerText = Strings.source("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?",
|
_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)
|
local function textOr(game, key)
|
||||||
@@ -51,9 +53,11 @@ local function textOr(game, key)
|
|||||||
return (t and t[key]) or FALLBACKS[key]
|
return (t and t[key]) or FALLBACKS[key]
|
||||||
end
|
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)
|
local function tryImage(path)
|
||||||
if not path then return nil end
|
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
|
return ok and img or nil
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -101,7 +105,9 @@ function OakSpeech.resolvePic(game, desc, speech)
|
|||||||
if speech and speech.playerPic and not desc.path then
|
if speech and speech.playerPic and not desc.path then
|
||||||
return speech.playerPic, false
|
return speech.playerPic, false
|
||||||
end
|
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
|
elseif t == "image" then
|
||||||
return tryImage(desc.path), desc.flip and true or false
|
return tryImage(desc.path), desc.flip and true or false
|
||||||
elseif t == "sprite" then
|
elseif t == "sprite" then
|
||||||
@@ -140,7 +146,7 @@ function OakSpeech.defaultSteps(speech)
|
|||||||
id = "name_player",
|
id = "name_player",
|
||||||
kind = "name",
|
kind = "name",
|
||||||
who = "player",
|
who = "player",
|
||||||
title = "YOUR NAME?",
|
title = Strings("YOUR NAME?"),
|
||||||
presetsWho = "player",
|
presetsWho = "player",
|
||||||
presetsFallback = { "RED", "ASH", "JACK" },
|
presetsFallback = { "RED", "ASH", "JACK" },
|
||||||
},
|
},
|
||||||
@@ -154,7 +160,7 @@ function OakSpeech.defaultSteps(speech)
|
|||||||
id = "name_rival",
|
id = "name_rival",
|
||||||
kind = "name",
|
kind = "name",
|
||||||
who = "rival",
|
who = "rival",
|
||||||
title = "HIS NAME?",
|
title = Strings("HIS NAME?"),
|
||||||
presetsWho = "rival",
|
presetsWho = "rival",
|
||||||
presetsFallback = { "BLUE", "GARY", "JOHN" },
|
presetsFallback = { "BLUE", "GARY", "JOHN" },
|
||||||
},
|
},
|
||||||
@@ -220,7 +226,8 @@ function OakSpeech.new(game, onDone)
|
|||||||
self.nameLen = constants.playerNameLength or 7
|
self.nameLen = constants.playerNameLength or 7
|
||||||
-- RedPicFront (gfx/player/red.png, shared with the trainer card) and
|
-- RedPicFront (gfx/player/red.png, shared with the trainer card) and
|
||||||
-- the ShrinkPic1/ShrinkPic2 frames (gfx/player/shrink{1,2}.png)
|
-- 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
|
self.shrinkPic1 = tryImage(oakGfx.shrink1
|
||||||
or "assets/generated/intro/shrink1.png")
|
or "assets/generated/intro/shrink1.png")
|
||||||
self.shrinkPic2 = tryImage(oakGfx.shrink2
|
self.shrinkPic2 = tryImage(oakGfx.shrink2
|
||||||
@@ -337,7 +344,7 @@ function OakSpeech:runStep(step)
|
|||||||
self.picFlip = true
|
self.picFlip = true
|
||||||
self:revealPic("wipe", function()
|
self:revealPic("wipe", function()
|
||||||
Sound.playCry(self.game.data, self.demoSpecies)
|
Sound.playCry(self.game.data, self.demoSpecies)
|
||||||
self:say("_OakSpeechText2A", function() self:advance() end)
|
self:say(Strings("_OakSpeechText2A"), function() self:advance() end)
|
||||||
end)
|
end)
|
||||||
elseif kind == "name" then
|
elseif kind == "name" then
|
||||||
local who = step.who or "player"
|
local who = step.who or "player"
|
||||||
@@ -345,7 +352,7 @@ function OakSpeech:runStep(step)
|
|||||||
or namePresets(self.game, step.presetsWho or who,
|
or namePresets(self.game, step.presetsWho or who,
|
||||||
step.presetsFallback or { "RED" })
|
step.presetsFallback or { "RED" })
|
||||||
require("src.ui.Screens").push(self.game, "NamingScreen", {
|
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,
|
presets = presets,
|
||||||
maxLen = step.maxLen or self.nameLen,
|
maxLen = step.maxLen or self.nameLen,
|
||||||
onDone = function(name)
|
onDone = function(name)
|
||||||
@@ -542,6 +549,10 @@ function OakSpeech:update(dt)
|
|||||||
elseif s.frame >= 79 and s.frame <= 102 then
|
elseif s.frame >= 79 and s.frame <= 102 then
|
||||||
self.fadeLevel = math.floor((s.frame - 79) / 8) + 1
|
self.fadeLevel = math.floor((s.frame - 79) / 8) + 1
|
||||||
elseif s.frame > 102 then
|
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()
|
self:finish()
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
+36
-21
@@ -22,6 +22,7 @@ local Logger = require("src.core.Logger")
|
|||||||
local Runtime = require("src.mods.Runtime")
|
local Runtime = require("src.mods.Runtime")
|
||||||
local OptionRows = require("src.ui.OptionRows")
|
local OptionRows = require("src.ui.OptionRows")
|
||||||
local Renderer = require("src.render.Renderer")
|
local Renderer = require("src.render.Renderer")
|
||||||
|
local Strings = require("src.core.Strings")
|
||||||
|
|
||||||
local OptionsMenu = {}
|
local OptionsMenu = {}
|
||||||
OptionsMenu.__index = OptionsMenu
|
OptionsMenu.__index = OptionsMenu
|
||||||
@@ -116,14 +117,14 @@ local function sameRows(_, rows) return rows end
|
|||||||
-- ladder's, so the save.options mutations are unchanged
|
-- ladder's, so the save.options mutations are unchanged
|
||||||
local function buildRows(game)
|
local function buildRows(game)
|
||||||
local rows = {
|
local rows = {
|
||||||
{ id = "textSpeed", label = "TEXT SPEED",
|
{ id = "textSpeed", label = Strings("TEXT SPEED"),
|
||||||
value = function(g) return SPEEDS[speedIndex(g)][2] end,
|
value = function(g) return SPEEDS[speedIndex(g)][2] end,
|
||||||
step = function(g)
|
step = function(g)
|
||||||
local i = speedIndex(g) % #SPEEDS + 1
|
local i = speedIndex(g) % #SPEEDS + 1
|
||||||
g.save.options.textSpeed = SPEEDS[i][1]
|
g.save.options.textSpeed = SPEEDS[i][1]
|
||||||
return true
|
return true
|
||||||
end },
|
end },
|
||||||
{ id = "animations", label = "BATTLE ANIMATION",
|
{ id = "animations", label = Strings("BATTLE ANIMATION"),
|
||||||
value = function(g)
|
value = function(g)
|
||||||
return g.save.options.animations == false and "OFF" or "ON"
|
return g.save.options.animations == false and "OFF" or "ON"
|
||||||
end,
|
end,
|
||||||
@@ -132,7 +133,7 @@ local function buildRows(game)
|
|||||||
o.animations = o.animations == false and true or false
|
o.animations = o.animations == false and true or false
|
||||||
return true
|
return true
|
||||||
end },
|
end },
|
||||||
{ id = "battleStyle", label = "BATTLE STYLE",
|
{ id = "battleStyle", label = Strings("BATTLE STYLE"),
|
||||||
value = function(g)
|
value = function(g)
|
||||||
return g.save.options.battleStyle == "set" and "SET" or "SHIFT"
|
return g.save.options.battleStyle == "set" and "SET" or "SHIFT"
|
||||||
end,
|
end,
|
||||||
@@ -141,7 +142,18 @@ local function buildRows(game)
|
|||||||
o.battleStyle = o.battleStyle == "set" and "shift" or "set"
|
o.battleStyle = o.battleStyle == "set" and "shift" or "set"
|
||||||
return true
|
return true
|
||||||
end },
|
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,
|
value = function(g) return rulesetName(g) end,
|
||||||
step = function(g, dir)
|
step = function(g, dir)
|
||||||
local ids = rulesetIds(g)
|
local ids = rulesetIds(g)
|
||||||
@@ -150,7 +162,7 @@ local function buildRows(game)
|
|||||||
g.save.options.ruleset = ids[wrapIndex(i - 1 + dir, #ids) + 1]
|
g.save.options.ruleset = ids[wrapIndex(i - 1 + dir, #ids) + 1]
|
||||||
return true
|
return true
|
||||||
end },
|
end },
|
||||||
{ id = "musicVol", label = "MUSIC VOL",
|
{ id = "musicVol", label = Strings("MUSIC VOL"),
|
||||||
value = function(g) return volLabel(g.save.options.musicVol) end,
|
value = function(g) return volLabel(g.save.options.musicVol) end,
|
||||||
step = function(g, dir)
|
step = function(g, dir)
|
||||||
local o = g.save.options
|
local o = g.save.options
|
||||||
@@ -158,7 +170,7 @@ local function buildRows(game)
|
|||||||
require("src.core.Music").setVolumeLevel(o.musicVol)
|
require("src.core.Music").setVolumeLevel(o.musicVol)
|
||||||
return true
|
return true
|
||||||
end },
|
end },
|
||||||
{ id = "sfxVol", label = "SFX VOL",
|
{ id = "sfxVol", label = Strings("SFX VOL"),
|
||||||
value = function(g) return volLabel(g.save.options.sfxVol) end,
|
value = function(g) return volLabel(g.save.options.sfxVol) end,
|
||||||
step = function(g, dir)
|
step = function(g, dir)
|
||||||
local o = g.save.options
|
local o = g.save.options
|
||||||
@@ -166,7 +178,7 @@ local function buildRows(game)
|
|||||||
require("src.core.Sound").setVolumeLevel(o.sfxVol)
|
require("src.core.Sound").setVolumeLevel(o.sfxVol)
|
||||||
return true
|
return true
|
||||||
end },
|
end },
|
||||||
{ id = "musicFilter", label = "MUSIC FILTER",
|
{ id = "musicFilter", label = Strings("MUSIC FILTER"),
|
||||||
value = function(g)
|
value = function(g)
|
||||||
return FILTERS[(g.save.options.musicFilter or 0) + 1]
|
return FILTERS[(g.save.options.musicFilter or 0) + 1]
|
||||||
end,
|
end,
|
||||||
@@ -176,7 +188,7 @@ local function buildRows(game)
|
|||||||
require("src.core.Music").setFilterLevel(o.musicFilter)
|
require("src.core.Music").setFilterLevel(o.musicFilter)
|
||||||
return true
|
return true
|
||||||
end },
|
end },
|
||||||
{ id = "colors", label = "COLORS",
|
{ id = "colors", label = Strings("COLORS"),
|
||||||
value = function(g)
|
value = function(g)
|
||||||
return PaletteFX.modeLabel(g.save.options.colors or "gbc")
|
return PaletteFX.modeLabel(g.save.options.colors or "gbc")
|
||||||
end,
|
end,
|
||||||
@@ -188,7 +200,7 @@ local function buildRows(game)
|
|||||||
PaletteFX.setMode(o.colors)
|
PaletteFX.setMode(o.colors)
|
||||||
return true
|
return true
|
||||||
end },
|
end },
|
||||||
{ id = "tilt", label = "TILT",
|
{ id = "tilt", label = Strings("TILT"),
|
||||||
value = function(g) return Tilt.levelLabel(g.save.options.tilt or 0) end,
|
value = function(g) return Tilt.levelLabel(g.save.options.tilt or 0) end,
|
||||||
step = function(g, dir)
|
step = function(g, dir)
|
||||||
local o = g.save.options
|
local o = g.save.options
|
||||||
@@ -205,7 +217,7 @@ local function buildRows(game)
|
|||||||
end
|
end
|
||||||
return true
|
return true
|
||||||
end },
|
end },
|
||||||
{ id = "gbcfx", label = "GBC FX",
|
{ id = "gbcfx", label = Strings("GBC FX"),
|
||||||
value = function(g)
|
value = function(g)
|
||||||
return GBCFX.levelLabel(g.save.options.gbcfx or 0)
|
return GBCFX.levelLabel(g.save.options.gbcfx or 0)
|
||||||
end,
|
end,
|
||||||
@@ -215,7 +227,7 @@ local function buildRows(game)
|
|||||||
GBCFX.setLevel(o.gbcfx)
|
GBCFX.setLevel(o.gbcfx)
|
||||||
return true
|
return true
|
||||||
end },
|
end },
|
||||||
{ id = "zoom", label = "ZOOM",
|
{ id = "zoom", label = Strings("ZOOM"),
|
||||||
value = function(g)
|
value = function(g)
|
||||||
return Zoom.offsetLabel(g.save.options.zoom or 0)
|
return Zoom.offsetLabel(g.save.options.zoom or 0)
|
||||||
end,
|
end,
|
||||||
@@ -230,7 +242,7 @@ local function buildRows(game)
|
|||||||
Zoom.offset = off
|
Zoom.offset = off
|
||||||
return true
|
return true
|
||||||
end },
|
end },
|
||||||
{ id = "voidFill", label = "VOID FILL",
|
{ id = "voidFill", label = Strings("VOID FILL"),
|
||||||
value = function(g)
|
value = function(g)
|
||||||
return TileRenderer.voidFillLabel(g.save.options.voidFill)
|
return TileRenderer.voidFillLabel(g.save.options.voidFill)
|
||||||
end,
|
end,
|
||||||
@@ -246,7 +258,7 @@ local function buildRows(game)
|
|||||||
TileRenderer.setVoidFill(o.voidFill)
|
TileRenderer.setVoidFill(o.voidFill)
|
||||||
return true
|
return true
|
||||||
end },
|
end },
|
||||||
{ id = "videoMode", label = "VIDEO MODE",
|
{ id = "videoMode", label = Strings("VIDEO MODE"),
|
||||||
value = function(g)
|
value = function(g)
|
||||||
return VideoMode.modeLabel(g.save.options.videoMode)
|
return VideoMode.modeLabel(g.save.options.videoMode)
|
||||||
end,
|
end,
|
||||||
@@ -259,7 +271,7 @@ local function buildRows(game)
|
|||||||
-- hard render cap (issue #88): bounds the present rate so a
|
-- hard render cap (issue #88): bounds the present rate so a
|
||||||
-- driver-forced vsync-off run cannot spin at thousands of FPS. Logic
|
-- driver-forced vsync-off run cannot spin at thousands of FPS. Logic
|
||||||
-- is fixed-step off dt, so this touches presentation only.
|
-- is fixed-step off dt, so this touches presentation only.
|
||||||
{ id = "fpsCap", label = "MAX FPS",
|
{ id = "fpsCap", label = Strings("MAX FPS"),
|
||||||
value = function(g)
|
value = function(g)
|
||||||
return FrameCap.label(g.save.options.fpsCap)
|
return FrameCap.label(g.save.options.fpsCap)
|
||||||
end,
|
end,
|
||||||
@@ -271,7 +283,7 @@ local function buildRows(game)
|
|||||||
end },
|
end },
|
||||||
-- fast-forward the logic clock only; music and sfx keep their tempo
|
-- fast-forward the logic clock only; music and sfx keep their tempo
|
||||||
-- (src/core/GameSpeed.lua), so this is safe to leave on
|
-- (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)
|
value = function(g)
|
||||||
return GameSpeed.levelLabel(g.save.options.speed)
|
return GameSpeed.levelLabel(g.save.options.speed)
|
||||||
end,
|
end,
|
||||||
@@ -282,17 +294,17 @@ local function buildRows(game)
|
|||||||
end },
|
end },
|
||||||
-- the manager's discoverable home (18-mod-manager-ux); inert until
|
-- the manager's discoverable home (18-mod-manager-ux); inert until
|
||||||
-- opened, so the row costs a vanilla install nothing
|
-- opened, so the row costs a vanilla install nothing
|
||||||
{ id = "mods", label = "MODS",
|
{ id = "mods", label = Strings("MODS"),
|
||||||
value = function(g)
|
value = function(g)
|
||||||
local status = g.modStatus or {}
|
local status = g.modStatus or {}
|
||||||
return ("%d INSTALLED"):format(#(status.available or {}))
|
return Strings("%d INSTALLED", #(status.available or {}))
|
||||||
end,
|
end,
|
||||||
activate = function(g)
|
activate = function(g)
|
||||||
require("src.ui.Screens").push(g, "ManagerState")
|
require("src.ui.Screens").push(g, "ManagerState")
|
||||||
end },
|
end },
|
||||||
-- rebinding UI (gap C2, 12-ui-extensibility 4.4); captured inputs
|
-- rebinding UI (gap C2, 12-ui-extensibility 4.4); captured inputs
|
||||||
-- live in options.bindings, so the row costs a vanilla install nothing
|
-- live in options.bindings, so the row costs a vanilla install nothing
|
||||||
{ id = "controls", label = "CONTROLS",
|
{ id = "controls", label = Strings("CONTROLS"),
|
||||||
activate = function(g)
|
activate = function(g)
|
||||||
require("src.ui.Screens").push(g, "BindingsMenu")
|
require("src.ui.Screens").push(g, "BindingsMenu")
|
||||||
end },
|
end },
|
||||||
@@ -328,7 +340,8 @@ local function buildRows(game)
|
|||||||
return rows
|
return rows
|
||||||
end
|
end
|
||||||
|
|
||||||
function OptionsMenu.new(game)
|
function OptionsMenu.new(game, opts)
|
||||||
|
opts = opts or {}
|
||||||
local rows = buildRows(game)
|
local rows = buildRows(game)
|
||||||
local hooked = Runtime.call("ui.options.rows", sameRows, game, rows)
|
local hooked = Runtime.call("ui.options.rows", sameRows, game, rows)
|
||||||
if type(hooked) == "table" then
|
if type(hooked) == "table" then
|
||||||
@@ -337,8 +350,8 @@ function OptionsMenu.new(game)
|
|||||||
Logger.error("ui.options.rows returned %s; keeping the vanilla rows",
|
Logger.error("ui.options.rows returned %s; keeping the vanilla rows",
|
||||||
type(hooked))
|
type(hooked))
|
||||||
end
|
end
|
||||||
return setmetatable({ game = game, rows = rows, index = 1, scroll = 0 },
|
return setmetatable({ game = game, rows = rows, index = 1, scroll = 0,
|
||||||
OptionsMenu)
|
onCancel = opts.onCancel }, OptionsMenu)
|
||||||
end
|
end
|
||||||
|
|
||||||
function OptionsMenu:update(dt)
|
function OptionsMenu:update(dt)
|
||||||
@@ -361,9 +374,11 @@ function OptionsMenu:update(dt)
|
|||||||
changed = row.step(self.game, dir) and true or false
|
changed = row.step(self.game, dir) and true or false
|
||||||
elseif input:wasPressed("a") then -- CANCEL
|
elseif input:wasPressed("a") then -- CANCEL
|
||||||
self.game.stack:pop()
|
self.game.stack:pop()
|
||||||
|
if self.onCancel then self.onCancel() end
|
||||||
end
|
end
|
||||||
elseif input:wasPressed("b") or input:wasPressed("start") then
|
elseif input:wasPressed("b") or input:wasPressed("start") then
|
||||||
self.game.stack:pop()
|
self.game.stack:pop()
|
||||||
|
if self.onCancel then self.onCancel() end
|
||||||
end
|
end
|
||||||
if changed and self.game.writeOptions then
|
if changed and self.game.writeOptions then
|
||||||
self.game:writeOptions()
|
self.game:writeOptions()
|
||||||
|
|||||||
+242
-51
@@ -17,14 +17,57 @@ local Screens = require("src.ui.Screens")
|
|||||||
local Theme = require("src.ui.Theme")
|
local Theme = require("src.ui.Theme")
|
||||||
local FieldDefaults = require("src.world.FieldDefaults")
|
local FieldDefaults = require("src.world.FieldDefaults")
|
||||||
local Map = require("src.world.Map")
|
local Map = require("src.world.Map")
|
||||||
|
local Strings = require("src.core.Strings")
|
||||||
|
|
||||||
local PartyMenu = {}
|
local PartyMenu = {}
|
||||||
PartyMenu.__index = PartyMenu
|
PartyMenu.__index = PartyMenu
|
||||||
PartyMenu.isOpaque = true
|
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)
|
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
|
end
|
||||||
|
|
||||||
local function sameItems(_, items) return items 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
|
-- Frame1; SNAKE/QUADRUPED are the reverse. Sprite-reused icons draw
|
||||||
-- from 16x16x6 overworld sheets where index 3 is walk-down (tile 12):
|
-- from 16x16x6 overworld sheets where index 3 is walk-down (tile 12):
|
||||||
-- MON/FAIRY/BIRD rest on the walk frame and animate to standing
|
-- 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 = {
|
PartyMenu.iconFrames = {
|
||||||
BUG = { rest = 1, alt = 0 }, -- BugIconFrame2 <-> BugIconFrame1
|
BUG = { rest = 1, alt = 0 }, -- BugIconFrame2 <-> BugIconFrame1
|
||||||
GRASS = { rest = 1, alt = 0 }, -- PlantIconFrame2 <-> PlantIconFrame1
|
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
|
return alt and ((ih or 0) >= 64 and 3 or 1) or 0
|
||||||
end
|
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 = {}
|
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 function drawIcon(game, mon, x, y, selected, counter)
|
||||||
local icons = game.data.icons
|
local icons = game.data.icons
|
||||||
if not icons then return end
|
if not icons then return end
|
||||||
@@ -97,14 +180,26 @@ local function drawIcon(game, mon, x, y, selected, counter)
|
|||||||
end
|
end
|
||||||
path = require("src.pokemon.Sprites").iconPath(game.data, mon, path, { name = name })
|
path = require("src.pokemon.Sprites").iconPath(game.data, mon, path, { name = name })
|
||||||
if not path then return end
|
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
|
-- resolve through Assets so an overrides/ or transform-derived icon
|
||||||
-- (e.g. a per-species image at assets/generated/icons/<name>.png) is
|
-- (e.g. a per-species image at assets/generated/icons/<name>.png) is
|
||||||
-- picked up the same way battle sprites are
|
-- picked up the same way battle sprites are
|
||||||
local ok, img = pcall(love.graphics.newImage, Assets.resolve(path))
|
local ok, img
|
||||||
iconImages[path] = ok and img or false
|
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
|
end
|
||||||
local img = iconImages[path]
|
local img = iconImages[key]
|
||||||
if not img then return end
|
if not img then return end
|
||||||
local alt = false
|
local alt = false
|
||||||
if selected then
|
if selected then
|
||||||
@@ -117,10 +212,28 @@ local function drawIcon(game, mon, x, y, selected, counter)
|
|||||||
alt = false
|
alt = false
|
||||||
end
|
end
|
||||||
local iw, ih = img:getDimensions()
|
local iw, ih = img:getDimensions()
|
||||||
if ih > 16 then
|
-- a 16x16 sheet (BALL, HELIX) is its own only frame
|
||||||
local frame = PartyMenu.frameFor(name, alt, ih)
|
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)
|
love.graphics.draw(img, love.graphics.newQuad(0, frame * 16, 16, 16, iw, ih), x, y)
|
||||||
else
|
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)
|
love.graphics.draw(img, x, y)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -133,6 +246,11 @@ function PartyMenu.new(game, opts)
|
|||||||
self.onSwitch = opts.onSwitch
|
self.onSwitch = opts.onSwitch
|
||||||
self.onCancel = opts.onCancel
|
self.onCancel = opts.onCancel
|
||||||
self.pickOnly = opts.pickOnly
|
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 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
|
-- 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
|
-- "Use TM on which POKeMON?" prompt). Set by BagMenu.pickTargetAndUse. #210
|
||||||
@@ -147,9 +265,47 @@ function PartyMenu.new(game, opts)
|
|||||||
return self
|
return self
|
||||||
end
|
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)
|
function PartyMenu:update(dt)
|
||||||
-- icon animation counter; 320 = a whole cycle at every HP speed
|
-- icon animation counter; 320 = a whole cycle at every HP speed
|
||||||
self.blink = ((self.blink or 0) + 1) % 320
|
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 input = self.game.input
|
||||||
local party = self.party or self.game.save.party
|
local party = self.party or self.game.save.party
|
||||||
|
|
||||||
@@ -204,7 +360,7 @@ function PartyMenu:update(dt)
|
|||||||
self.game.save.flashLit = true
|
self.game.save.flashLit = true
|
||||||
self.game.stack:push(TextBox.new(self.game,
|
self.game.stack:push(TextBox.new(self.game,
|
||||||
self.game.data.text._FlashLightsAreaText
|
self.game.data.text._FlashLightsAreaText
|
||||||
or "A blinding FLASH\nlights the area!", function()
|
or Strings("A blinding FLASH\nlights the area!"), function()
|
||||||
self.game.stack:push(Transition.whiteFlash(self.game))
|
self.game.stack:push(Transition.whiteFlash(self.game))
|
||||||
end))
|
end))
|
||||||
return
|
return
|
||||||
@@ -247,7 +403,7 @@ function PartyMenu:update(dt)
|
|||||||
current = "_CurrentTooFastText",
|
current = "_CurrentTooFastText",
|
||||||
no_place = "_SurfingNoPlaceToGetOffText" })[reason]
|
no_place = "_SurfingNoPlaceToGetOffText" })[reason]
|
||||||
or "_NoSurfingHereText"
|
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)
|
:gsub("{RAM:wNameBuffer}", mon.nickname or def.name)
|
||||||
if reason == "no_place" then
|
if reason == "no_place" then
|
||||||
-- .cannotStopSurfing prints _SurfingNoPlaceToGetOffText but
|
-- .cannotStopSurfing prints _SurfingNoPlaceToGetOffText but
|
||||||
@@ -278,7 +434,7 @@ function PartyMenu:update(dt)
|
|||||||
local def = self.game.data.pokemon[mon.species]
|
local def = self.game.data.pokemon[mon.species]
|
||||||
local key = (reason == "no_badge") and "_NewBadgeRequiredText"
|
local key = (reason == "no_badge") and "_NewBadgeRequiredText"
|
||||||
or "_NothingToCutText"
|
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)
|
:gsub("{RAM:wNameBuffer}", mon.nickname or def.name)
|
||||||
self.game.stack:push(TextBox.new(self.game, txt))
|
self.game.stack:push(TextBox.new(self.game, txt))
|
||||||
return -- .loop: submenu stays open behind the message
|
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)
|
self.game.stack:pop() -- close the party menu (jp .goBackToMap)
|
||||||
ow.strengthActive = true
|
ow.strengthActive = true
|
||||||
local t1 = (self.game.data.text._UsedStrengthText
|
local t1 = (self.game.data.text._UsedStrengthText
|
||||||
or "{RAM:wNameBuffer} used\nSTRENGTH."):gsub("{RAM:wNameBuffer}", name)
|
or Strings("{RAM:wNameBuffer} used\nSTRENGTH.")):gsub("{RAM:wNameBuffer}", name)
|
||||||
local t2 = (self.game.data.text._CanMoveBouldersText
|
local t2 = (self.game.data.text._CanMoveBouldersText
|
||||||
or "{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, t1, function()
|
||||||
self.game.stack:push(TextBox.new(self.game, t2, function()
|
self.game.stack:push(TextBox.new(self.game, t2))
|
||||||
self.game.stack:push(Transition.whiteFlash(self.game))
|
|
||||||
end))
|
|
||||||
end, { auto = { sound = function()
|
end, { auto = { sound = function()
|
||||||
return require("src.core.Sound").playCry(self.game.data, mon.species)
|
return require("src.core.Sound").playCry(self.game.data, mon.species)
|
||||||
end } }))
|
end } }))
|
||||||
@@ -348,7 +506,7 @@ function PartyMenu:update(dt)
|
|||||||
or user.hp <= heal then
|
or user.hp <= heal then
|
||||||
self.softboiledFrom = nil
|
self.softboiledFrom = nil
|
||||||
local TextBox = require("src.render.TextBox")
|
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
|
else
|
||||||
user.hp = user.hp - heal
|
user.hp = user.hp - heal
|
||||||
mon.hp = math.min(mon.stats.hp, mon.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 def = self.game.data.pokemon[mon.species]
|
||||||
local TextBox = require("src.render.TextBox")
|
local TextBox = require("src.render.TextBox")
|
||||||
self.game.stack:push(TextBox.new(self.game,
|
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
|
end
|
||||||
elseif self.swapFrom then
|
elseif self.swapFrom then
|
||||||
if self.swapFrom ~= self.index then
|
if self.swapFrom ~= self.index then
|
||||||
@@ -366,8 +524,12 @@ function PartyMenu:update(dt)
|
|||||||
end
|
end
|
||||||
self.swapFrom = nil
|
self.swapFrom = nil
|
||||||
elseif self.onSwitch and (self.forceSwitch or self.pickOnly or not self.battle) then
|
elseif self.onSwitch and (self.forceSwitch or self.pickOnly or not self.battle) then
|
||||||
self.game.stack:pop()
|
-- keepOpen callers (HP medicine) need the menu still drawn while the
|
||||||
self.onSwitch(mon)
|
-- 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
|
else
|
||||||
self.submenu = true
|
self.submenu = true
|
||||||
self.subIndex = 1
|
self.subIndex = 1
|
||||||
@@ -375,55 +537,55 @@ function PartyMenu:update(dt)
|
|||||||
local ow = self.game.overworld
|
local ow = self.game.overworld
|
||||||
if self.battle and self.onSwitch then
|
if self.battle and self.onSwitch then
|
||||||
-- SwitchStatsCancelText (core.asm PartyMenuOrRockOrRun)
|
-- SwitchStatsCancelText (core.asm PartyMenuOrRockOrRun)
|
||||||
items = { { label = "SWITCH", action = "battle_switch" },
|
items = { { label = Strings("SWITCH"), action = "battle_switch" },
|
||||||
{ label = "STATS", action = "stats" },
|
{ label = Strings("STATS"), action = "stats" },
|
||||||
{ label = "CANCEL", action = "cancel" } }
|
{ label = Strings("CANCEL"), action = "cancel" } }
|
||||||
else
|
else
|
||||||
-- STATS/SWITCH plus this mon's field moves (start_sub_menus.asm
|
-- STATS/SWITCH plus this mon's field moves (start_sub_menus.asm
|
||||||
-- builds the same dynamic list)
|
-- builds the same dynamic list)
|
||||||
items = { { label = "STATS", action = "stats" },
|
items = { { label = Strings("STATS"), action = "stats" },
|
||||||
{ label = "SWITCH", action = "switch" } }
|
{ label = Strings("SWITCH"), action = "switch" } }
|
||||||
-- Field moves (HMs/TMs) are usable out of battle even when the mon
|
-- 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.
|
-- is fainted -- Gen 1 does not require HP for Cut/Fly/Surf/etc.
|
||||||
-- Battle still excludes this list via `not self.battle`. Softboiled
|
-- Battle still excludes this list via `not self.battle`. Softboiled
|
||||||
-- can appear for a fainted user; its heal transfer then no-ops.
|
-- can appear for a fainted user; its heal transfer then no-ops.
|
||||||
if not self.battle and ow then
|
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)
|
-- Route 23 / Indigo Plateau outdoor), not OVERWORLD alone (#83)
|
||||||
local outside = Map.isOutside(ow.map.def,
|
local outside = Map.isOutside(ow.map.def,
|
||||||
FieldDefaults.field(self.game.data, "outsideTilesets"))
|
FieldDefaults.field(self.game.data, "outsideTilesets"))
|
||||||
for _, mv in ipairs(mon.moves) do
|
for _, mv in ipairs(mon.moves) do
|
||||||
if mv.id == "FLY" and outside
|
if mv.id == "FLY" and outside
|
||||||
and self.game.save.inventory.THUNDERBADGE then
|
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
|
elseif mv.id == "FLASH" and ow.dark
|
||||||
and self.game.save.inventory.BOULDERBADGE then
|
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
|
elseif mv.id == "CUT" and self.game.save.inventory.CASCADEBADGE then
|
||||||
-- CUT/SURF/STRENGTH are party-menu field moves too
|
-- CUT/SURF/STRENGTH are party-menu field moves too
|
||||||
-- (start_sub_menus.asm .outOfBattleMovePointers); listed here
|
-- (start_sub_menus.asm .outOfBattleMovePointers); listed here
|
||||||
-- with the same list-time badge filter this file already uses
|
-- with the same list-time badge filter this file already uses
|
||||||
-- for FLY/FLASH. The facing-tile/activation check happens on
|
-- for FLY/FLASH. The facing-tile/activation check happens on
|
||||||
-- selection (useCutFieldMove/useSurfFieldMove).
|
-- 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
|
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
|
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
|
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
|
elseif mv.id == "TELEPORT" and outside then
|
||||||
-- TELEPORT works only OUTDOORS (start_sub_menus.asm
|
-- TELEPORT works only OUTDOORS (start_sub_menus.asm
|
||||||
-- .teleport -> CheckIfInOutsideMap); dark maps don't
|
-- .teleport -> CheckIfInOutsideMap); dark maps don't
|
||||||
-- block it
|
-- 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]
|
elseif mv.id == "DIG" and DIG_TILESETS[ow.map.def.tileset]
|
||||||
and ow.map.id ~= "AGATHAS_ROOM" then
|
and ow.map.id ~= "AGATHAS_ROOM" then
|
||||||
-- DIG runs ItemUseEscapeRope (.dig sets wCurItem =
|
-- DIG runs ItemUseEscapeRope (.dig sets wCurItem =
|
||||||
-- ESCAPE_ROPE): usable in the dungeon tilesets of
|
-- ESCAPE_ROPE): usable in the dungeon tilesets of
|
||||||
-- escape_rope_tilesets.asm minus Agatha's room, even in
|
-- escape_rope_tilesets.asm minus Agatha's room, even in
|
||||||
-- the dark (Rock Tunnel)
|
-- the dark (Rock Tunnel)
|
||||||
table.insert(items, { label = "DIG", action = "escape" })
|
table.insert(items, { label = Strings("DIG"), action = "escape" })
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -456,13 +618,13 @@ function PartyMenu:bottomMessage()
|
|||||||
return "Use on which one?"
|
return "Use on which one?"
|
||||||
elseif self.tmhm then
|
elseif self.tmhm then
|
||||||
return self.game.data.text._PartyMenuUseTMText
|
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
|
elseif self.battle then
|
||||||
return self.game.data.text._PartyMenuBattleText
|
return self.game.data.text._PartyMenuBattleText
|
||||||
or "Bring out which\nPOKéMON?"
|
or Strings("Bring out which\nPOKéMON?")
|
||||||
else
|
else
|
||||||
return self.game.data.text._PartyMenuNormalText
|
return self.game.data.text._PartyMenuNormalText
|
||||||
or "Choose a POKéMON."
|
or Strings("Choose a POKéMON.")
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -480,9 +642,19 @@ function PartyMenu:draw()
|
|||||||
love.graphics.setColor(0, 0, 0, 1)
|
love.graphics.setColor(0, 0, 0, 1)
|
||||||
local party = self.party or self.game.save.party
|
local party = self.party or self.game.save.party
|
||||||
if #party == 0 then
|
if #party == 0 then
|
||||||
Font.draw("No POKéMON!", 16, 64)
|
Font.draw(Strings("No POKéMON!"), 16, 64)
|
||||||
end
|
end
|
||||||
local HudTiles = require("src.render.HudTiles")
|
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
|
for i, mon in ipairs(party) do
|
||||||
local def = self.game.data.pokemon[mon.species]
|
local def = self.game.data.pokemon[mon.species]
|
||||||
local y = PartyMenu.entryY(i)
|
local y = PartyMenu.entryY(i)
|
||||||
@@ -512,33 +684,52 @@ function PartyMenu:draw()
|
|||||||
end
|
end
|
||||||
-- right-aligned so the shorter "ABLE" shares "NOT ABLE"'s right edge
|
-- right-aligned so the shorter "ABLE" shares "NOT ABLE"'s right edge
|
||||||
if can then
|
if can then
|
||||||
Font.draw("ABLE", 120, y + 8)
|
Font.draw(Strings("ABLE"), 120, y + 8)
|
||||||
else
|
else
|
||||||
Font.draw("NOT ABLE", 88, y + 8)
|
Font.draw(Strings("NOT ABLE"), 88, y + 8)
|
||||||
end
|
end
|
||||||
else
|
else
|
||||||
if mon.hp <= 0 then
|
if mon.hp <= 0 then
|
||||||
Font.draw("FNT", 136, y)
|
Font.draw(Strings("FNT"), 136, y)
|
||||||
elseif mon.status then
|
elseif mon.status then
|
||||||
Font.draw(mon.status, 136, y)
|
Font.draw(mon.status, 136, y)
|
||||||
end
|
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)
|
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)
|
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
|
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
|
if i == self.index then
|
||||||
Font.drawCode(Theme.cursor, 0, y)
|
Font.drawCode(Theme.cursor, 0, cursorY)
|
||||||
end
|
end
|
||||||
if i == self.swapFrom or i == self.softboiledFrom then
|
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
|
||||||
end
|
end
|
||||||
if self.swapFrom then
|
if self.swapFrom then
|
||||||
Font.draw("Move to where?", 8, 136)
|
Font.draw(Strings("Move to where?"), 8, 136)
|
||||||
elseif self.softboiledFrom then
|
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
|
elseif self.tmhm then
|
||||||
-- "Use TM on which\nPOKeMON?" in the standard bottom text box
|
-- "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
|
-- (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)
|
Font.drawBox(0, 12, 20, 6)
|
||||||
love.graphics.setColor(0, 0, 0, 1)
|
love.graphics.setColor(0, 0, 0, 1)
|
||||||
local prompt = self.game.data.text._PartyMenuUseTMText
|
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
|
local ly = 112
|
||||||
for line in (prompt .. "\n"):gmatch("([^\n]*)\n") do
|
for line in (prompt .. "\n"):gmatch("([^\n]*)\n") do
|
||||||
Font.draw(line, 8, ly)
|
Font.draw(line, 8, ly)
|
||||||
ly = ly + 16
|
ly = ly + 16
|
||||||
end
|
end
|
||||||
elseif self.pickOnly then
|
elseif self.pickOnly then
|
||||||
Font.draw("Use on which one?", 8, 136)
|
Font.draw(Strings("Use on which one?"), 8, 136)
|
||||||
else
|
else
|
||||||
-- default field party menu (StartMenu) and the battle voluntary-switch
|
-- default field party menu (StartMenu) and the battle voluntary-switch
|
||||||
-- (BattleState:openParty): Gen1 prints PartyMenuNormalText / PartyMenuBattleText
|
-- (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 ListMenu = require("src.ui.ListMenu")
|
||||||
local Menu = require("src.ui.Menu")
|
local Menu = require("src.ui.Menu")
|
||||||
local Sound = require("src.core.Sound")
|
local Sound = require("src.core.Sound")
|
||||||
|
local Strings = require("src.core.Strings")
|
||||||
|
|
||||||
local PlayerPC = {}
|
local PlayerPC = {}
|
||||||
|
|
||||||
@@ -76,14 +77,14 @@ local function withdraw(game)
|
|||||||
askQuantity(game, list, pc[item.value] or 1, item.value, function(qty)
|
askQuantity(game, list, pc[item.value] or 1, item.value, function(qty)
|
||||||
local Bag = require("src.inventory.Bag")
|
local Bag = require("src.inventory.Bag")
|
||||||
if not Bag.add(game.save, item.value, qty) then
|
if not Bag.add(game.save, item.value, qty) then
|
||||||
list.footer = "You can't carry\nany more items."
|
list.footer = Strings("You can't carry\nany more items.")
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
pc[item.value] = pc[item.value] - qty
|
pc[item.value] = pc[item.value] - qty
|
||||||
if pc[item.value] <= 0 then pc[item.value] = nil end
|
if pc[item.value] <= 0 then pc[item.value] = nil end
|
||||||
refreshRow(list, pc, item.value)
|
refreshRow(list, pc, item.value)
|
||||||
Sound.play(game.data, "Withdraw_Deposit")
|
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)
|
||||||
end,
|
end,
|
||||||
}))
|
}))
|
||||||
@@ -112,14 +113,14 @@ local function deposit(game)
|
|||||||
onChoose = function(item, list)
|
onChoose = function(item, list)
|
||||||
askQuantity(game, list, inv[item.value] or 1, item.value, function(qty)
|
askQuantity(game, list, inv[item.value] or 1, item.value, function(qty)
|
||||||
if pcFull(game, pc, item.value) then
|
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
|
return
|
||||||
end
|
end
|
||||||
require("src.inventory.Bag").remove(game.save, item.value, qty)
|
require("src.inventory.Bag").remove(game.save, item.value, qty)
|
||||||
pc[item.value] = (pc[item.value] or 0) + qty
|
pc[item.value] = (pc[item.value] or 0) + qty
|
||||||
refreshRow(list, inv, item.value)
|
refreshRow(list, inv, item.value)
|
||||||
Sound.play(game.data, "Withdraw_Deposit")
|
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)
|
||||||
end,
|
end,
|
||||||
}))
|
}))
|
||||||
@@ -132,7 +133,7 @@ local function toss(game)
|
|||||||
onChoose = function(item, list)
|
onChoose = function(item, list)
|
||||||
local def = game.data.items[item.value]
|
local def = game.data.items[item.value]
|
||||||
if (def and def.keyItem) or item.value:find("^HM_") then
|
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
|
return
|
||||||
end
|
end
|
||||||
local QuantityBox = require("src.ui.QuantityBox")
|
local QuantityBox = require("src.ui.QuantityBox")
|
||||||
@@ -140,13 +141,13 @@ local function toss(game)
|
|||||||
max = pc[item.value] or 1,
|
max = pc[item.value] or 1,
|
||||||
onDone = function(qty)
|
onDone = function(qty)
|
||||||
if not qty then return end
|
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)
|
game.stack:push(ChoiceBox.new(game, function(yes)
|
||||||
if yes then
|
if yes then
|
||||||
pc[item.value] = pc[item.value] - qty
|
pc[item.value] = pc[item.value] - qty
|
||||||
if pc[item.value] <= 0 then pc[item.value] = nil end
|
if pc[item.value] <= 0 then pc[item.value] = nil end
|
||||||
refreshRow(list, pc, item.value)
|
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
|
else
|
||||||
list.footer = nil
|
list.footer = nil
|
||||||
end
|
end
|
||||||
@@ -160,10 +161,13 @@ end
|
|||||||
function PlayerPC.new(game)
|
function PlayerPC.new(game)
|
||||||
game.save.pcItems = game.save.pcItems or {}
|
game.save.pcItems = game.save.pcItems or {}
|
||||||
return Menu.new(game, {
|
return Menu.new(game, {
|
||||||
{ label = "WITHDRAW ITEM", onSelect = function() withdraw(game) end },
|
-- keepOpen so B in the item lists returns here instead of dropping the
|
||||||
{ label = "DEPOSIT ITEM", onSelect = function() deposit(game) end },
|
-- whole PC session (players_pc.asm re-shows the PC menu); same pattern
|
||||||
{ label = "TOSS ITEM", onSelect = function() toss(game) end },
|
-- as BoxMenu's rows
|
||||||
{ label = "LOG OFF" },
|
{ 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
|
-- silent PC session (BIT_NO_MENU_BUTTON_SOUND); players_pc.asm
|
||||||
-- PlayersPCMenu TextBoxBorder (0,0) b=8 c=14 → 16x10
|
-- PlayersPCMenu TextBoxBorder (0,0) b=8 c=14 → 16x10
|
||||||
}, { tx = 0, ty = 0, tw = 16, th = 10, noSound = true })
|
}, { tx = 0, ty = 0, tw = 16, th = 10, noSound = true })
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
-- Minimal Pokédex: dex-ordered list with seen/owned markers.
|
-- Minimal Pokédex: dex-ordered list with seen/owned markers.
|
||||||
|
|
||||||
local ListMenu = require("src.ui.ListMenu")
|
local ListMenu = require("src.ui.ListMenu")
|
||||||
|
local Strings = require("src.core.Strings")
|
||||||
|
|
||||||
local PokedexMenu = {}
|
local PokedexMenu = {}
|
||||||
|
|
||||||
@@ -9,7 +10,8 @@ function PokedexMenu:sgbPalettes(game)
|
|||||||
return require("src.render.PaletteFX").wholeNamed(game.data, "BROWNMON")
|
return require("src.render.PaletteFX").wholeNamed(game.data, "BROWNMON")
|
||||||
end
|
end
|
||||||
|
|
||||||
function PokedexMenu.new(game)
|
function PokedexMenu.new(game, opts)
|
||||||
|
opts = opts or {}
|
||||||
local dex = game.save.pokedex or { seen = {}, owned = {} }
|
local dex = game.save.pokedex or { seen = {}, owned = {} }
|
||||||
local byDex = {}
|
local byDex = {}
|
||||||
for species, def in pairs(game.data.pokemon) do
|
for species, def in pairs(game.data.pokemon) do
|
||||||
@@ -45,8 +47,9 @@ function PokedexMenu.new(game)
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
local list = ListMenu.new(game, "POKéDEX", items, {
|
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
|
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)
|
onChoose = function(item)
|
||||||
if not item.value then return end
|
if not item.value then return end
|
||||||
-- the DATA / CRY / AREA / QUIT choice (engine/menus/pokedex.asm
|
-- 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 Menu = require("src.ui.Menu")
|
||||||
local Screens = require("src.ui.Screens")
|
local Screens = require("src.ui.Screens")
|
||||||
game.stack:push(Menu.new(game, {
|
game.stack:push(Menu.new(game, {
|
||||||
{ label = "DATA", onSelect = function()
|
{ label = Strings("DATA"), onSelect = function()
|
||||||
Screens.push(game, "DexEntryMenu", item.value)
|
Screens.push(game, "DexEntryMenu", item.value)
|
||||||
end },
|
end },
|
||||||
{ label = "CRY", keepOpen = true, onSelect = function()
|
{ label = Strings("CRY"), keepOpen = true, onSelect = function()
|
||||||
require("src.core.Sound").playCry(game.data, item.value)
|
require("src.core.Sound").playCry(game.data, item.value)
|
||||||
end },
|
end },
|
||||||
{ label = "AREA", onSelect = function()
|
{ label = Strings("AREA"), onSelect = function()
|
||||||
Screens.push(game, "TownMap", { nestSpecies = item.value })
|
Screens.push(game, "TownMap", { nestSpecies = item.value })
|
||||||
end },
|
end },
|
||||||
{ label = "QUIT" },
|
{ label = Strings("QUIT") },
|
||||||
}, { tx = 12, ty = 8, tw = 8, th = 10 }))
|
}, { tx = 12, ty = 8, tw = 8, th = 10 }))
|
||||||
end,
|
end,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
|
|
||||||
local Font = require("src.render.Font")
|
local Font = require("src.render.Font")
|
||||||
local SaveData = require("src.core.SaveData")
|
local SaveData = require("src.core.SaveData")
|
||||||
|
local Strings = require("src.core.Strings")
|
||||||
|
|
||||||
local QuarantineReport = {}
|
local QuarantineReport = {}
|
||||||
QuarantineReport.__index = QuarantineReport
|
QuarantineReport.__index = QuarantineReport
|
||||||
@@ -38,12 +39,12 @@ local function buildLines(report, meta)
|
|||||||
end
|
end
|
||||||
local rows = {}
|
local rows = {}
|
||||||
for _, mon in ipairs(report.lostMons or {}) do
|
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
|
end
|
||||||
section(lines, "Moved to LOST box:", rows)
|
section(lines, "Moved to LOST box:", rows)
|
||||||
rows = {}
|
rows = {}
|
||||||
for _, item in ipairs(report.lostItems or {}) do
|
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
|
end
|
||||||
section(lines, "Items removed:", rows)
|
section(lines, "Items removed:", rows)
|
||||||
rows = {}
|
rows = {}
|
||||||
@@ -51,16 +52,16 @@ local function buildLines(report, meta)
|
|||||||
if map.to then
|
if map.to then
|
||||||
rows[#rows + 1] = ("%s>%s"):format(map.id or "?", map.to)
|
rows[#rows + 1] = ("%s>%s"):format(map.id or "?", map.to)
|
||||||
else
|
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
|
||||||
end
|
end
|
||||||
section(lines, "Location reset:", rows)
|
section(lines, "Location reset:", rows)
|
||||||
rows = {}
|
rows = {}
|
||||||
for _, mon in ipairs(report.restoredMons or {}) do
|
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
|
end
|
||||||
for _, item in ipairs(report.restoredItems or {}) do
|
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
|
end
|
||||||
section(lines, "Restored:", rows)
|
section(lines, "Restored:", rows)
|
||||||
local notice = SaveData.modsDiffNotice(report.modsDiff, meta)
|
local notice = SaveData.modsDiffNotice(report.modsDiff, meta)
|
||||||
@@ -113,7 +114,7 @@ function QuarantineReport:draw()
|
|||||||
love.graphics.rectangle("fill", 0, 0, 160, 144)
|
love.graphics.rectangle("fill", 0, 0, 160, 144)
|
||||||
Font.drawBox(0, 0, 20, 18)
|
Font.drawBox(0, 0, 20, 18)
|
||||||
love.graphics.setColor(0, 0, 0, 1)
|
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
|
for row = 1, VISIBLE do
|
||||||
local line = self.lines[self.offset + row]
|
local line = self.lines[self.offset + row]
|
||||||
if line then Font.draw(line, 8, 12 + row * 8) end
|
if line then Font.draw(line, 8, 12 + row * 8) end
|
||||||
@@ -121,7 +122,7 @@ function QuarantineReport:draw()
|
|||||||
if self.offset < self:maxOffset() then
|
if self.offset < self:maxOffset() then
|
||||||
Font.drawCode(require("src.ui.Theme").moreArrow, 144, 124)
|
Font.drawCode(require("src.ui.Theme").moreArrow, 144, 124)
|
||||||
end
|
end
|
||||||
Font.draw("A:CONTINUE", 8, 130)
|
Font.draw(Strings("A:CONTINUE"), 8, 130)
|
||||||
love.graphics.setColor(1, 1, 1, 1)
|
love.graphics.setColor(1, 1, 1, 1)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
+10
-9
@@ -12,6 +12,7 @@ local ChoiceBox = require("src.ui.ChoiceBox")
|
|||||||
local ListMenu = require("src.ui.ListMenu")
|
local ListMenu = require("src.ui.ListMenu")
|
||||||
local Menu = require("src.ui.Menu")
|
local Menu = require("src.ui.Menu")
|
||||||
local QuantityBox = require("src.ui.QuantityBox")
|
local QuantityBox = require("src.ui.QuantityBox")
|
||||||
|
local Strings = require("src.core.Strings")
|
||||||
|
|
||||||
local ShopMenu = {}
|
local ShopMenu = {}
|
||||||
|
|
||||||
@@ -33,7 +34,7 @@ local function buy(game, stock)
|
|||||||
end
|
end
|
||||||
local greet = txt(game, "_PokemartBuyingGreetingText", "Take your time.")
|
local greet = txt(game, "_PokemartBuyingGreetingText", "Take your time.")
|
||||||
local notEnough = txt(game, "_PokemartNotEnoughMoneyText",
|
local notEnough = txt(game, "_PokemartNotEnoughMoneyText",
|
||||||
"You don't have\nenough money.")
|
Strings("You don't have\nenough money."))
|
||||||
local list
|
local list
|
||||||
list = ListMenu.new(game, "BUY", items, {
|
list = ListMenu.new(game, "BUY", items, {
|
||||||
dialogue = true,
|
dialogue = true,
|
||||||
@@ -56,7 +57,7 @@ local function buy(game, stock)
|
|||||||
end
|
end
|
||||||
local cost = qty * def.price
|
local cost = qty * def.price
|
||||||
-- _PokemartTellBuyPriceText + yes/no confirm
|
-- _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)
|
game.stack:push(ChoiceBox.new(game, function(yes)
|
||||||
if not yes then
|
if not yes then
|
||||||
list.footer = greet
|
list.footer = greet
|
||||||
@@ -68,13 +69,13 @@ local function buy(game, stock)
|
|||||||
end
|
end
|
||||||
if not Bag.add(game.save, item.value, qty) then
|
if not Bag.add(game.save, item.value, qty) then
|
||||||
list.footer = txt(game, "_PokemartItemBagFullText",
|
list.footer = txt(game, "_PokemartItemBagFullText",
|
||||||
"You can't carry\nany more items.")
|
Strings("You can't carry\nany more items."))
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
require("src.core.Sound").play(game.data, "Purchase")
|
require("src.core.Sound").play(game.data, "Purchase")
|
||||||
game.save.money = game.save.money - cost
|
game.save.money = game.save.money - cost
|
||||||
list.footer = txt(game, "_PokemartBoughtItemText",
|
list.footer = txt(game, "_PokemartBoughtItemText",
|
||||||
"Here you are!\nThank you!")
|
Strings("Here you are!\nThank you!"))
|
||||||
end))
|
end))
|
||||||
end,
|
end,
|
||||||
}))
|
}))
|
||||||
@@ -112,7 +113,7 @@ local function sell(game)
|
|||||||
-- ITEM_NONE "0" from Blue's House before that pickup was fixed (#11).
|
-- 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
|
if not def or def.keyItem or item.value:find("^HM_") then
|
||||||
list.footer = txt(game, "_PokemartUnsellableItemText",
|
list.footer = txt(game, "_PokemartUnsellableItemText",
|
||||||
"I can't put a\nprice on that.")
|
Strings("I can't put a\nprice on that."))
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
local unit = math.floor(def.price / 2)
|
local unit = math.floor(def.price / 2)
|
||||||
@@ -125,7 +126,7 @@ local function sell(game)
|
|||||||
return
|
return
|
||||||
end
|
end
|
||||||
-- _PokemartTellSellPriceText + yes/no confirm
|
-- _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)
|
game.stack:push(ChoiceBox.new(game, function(yes)
|
||||||
if not yes then
|
if not yes then
|
||||||
list.footer = greet
|
list.footer = greet
|
||||||
@@ -152,9 +153,9 @@ function ShopMenu.new(game, stock, onQuit)
|
|||||||
-- keepOpen: the mart menu stays underneath its list so closing the
|
-- keepOpen: the mart menu stays underneath its list so closing the
|
||||||
-- list lands back here; only QUIT (or B) leaves and fires onQuit
|
-- list lands back here; only QUIT (or B) leaves and fires onQuit
|
||||||
local menu = Menu.new(game, {
|
local menu = Menu.new(game, {
|
||||||
{ label = "BUY", keepOpen = true, onSelect = function() buy(game, stock) end },
|
{ label = Strings("BUY"), keepOpen = true, onSelect = function() buy(game, stock) end },
|
||||||
{ label = "SELL", keepOpen = true, onSelect = function() sell(game) end },
|
{ label = Strings("SELL"), keepOpen = true, onSelect = function() sell(game) end },
|
||||||
{ label = "QUIT", onSelect = onQuit },
|
{ label = Strings("QUIT"), onSelect = onQuit },
|
||||||
}, { tx = 0, ty = 0, tw = 8, th = 8 })
|
}, { tx = 0, ty = 0, tw = 8, th = 8 })
|
||||||
menu.onCancel = onQuit
|
menu.onCancel = onQuit
|
||||||
return menu
|
return menu
|
||||||
|
|||||||
@@ -45,6 +45,7 @@
|
|||||||
|
|
||||||
local Font = require("src.render.Font")
|
local Font = require("src.render.Font")
|
||||||
local Sound = require("src.core.Sound")
|
local Sound = require("src.core.Sound")
|
||||||
|
local Strings = require("src.core.Strings")
|
||||||
|
|
||||||
local SlotMachine = {}
|
local SlotMachine = {}
|
||||||
SlotMachine.__index = SlotMachine
|
SlotMachine.__index = SlotMachine
|
||||||
@@ -356,7 +357,7 @@ function SlotMachine:resolveWin(win)
|
|||||||
-- SlotReward300Func prints "Yeah!" (text_pause) before the flash; the port
|
-- SlotReward300Func prints "Yeah!" (text_pause) before the flash; the port
|
||||||
-- shows it in the box while the screen flashes. LinedUpText follows.
|
-- shows it in the box while the screen flashes. LinedUpText follows.
|
||||||
self.yeah = (sym == "7")
|
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
|
-- .flashScreenLoop: flip rBGP, wait 5 frames, b times. The coins are not
|
||||||
-- credited until the player dismisses the "lined up" text (see startPayout).
|
-- credited until the player dismisses the "lined up" text (see startPayout).
|
||||||
self.stage = "flash"
|
self.stage = "flash"
|
||||||
@@ -394,7 +395,7 @@ end
|
|||||||
-- delay then CloseTextDisplay), otherwise ask "One more go?".
|
-- delay then CloseTextDisplay), otherwise ask "One more go?".
|
||||||
function SlotMachine:afterSpin()
|
function SlotMachine:afterSpin()
|
||||||
if coins(self) == 0 then
|
if coins(self) == 0 then
|
||||||
self.message = "Darn!\nRan out of coins!"
|
self.message = Strings("Darn!\nRan out of coins!")
|
||||||
self.stage = "message"
|
self.stage = "message"
|
||||||
self.afterMessage = nil
|
self.afterMessage = nil
|
||||||
self.exitTimer = 60
|
self.exitTimer = 60
|
||||||
@@ -515,7 +516,7 @@ function SlotMachine:update(dt)
|
|||||||
self.bet = 3 - self.betIndex
|
self.bet = 3 - self.betIndex
|
||||||
if input:wasPressed("a") then
|
if input:wasPressed("a") then
|
||||||
if coins(self) < self.bet then
|
if coins(self) < self.bet then
|
||||||
self.message = "Not enough\ncoins!"
|
self.message = Strings("Not enough\ncoins!")
|
||||||
self.afterMessage = "bet"
|
self.afterMessage = "bet"
|
||||||
self.stage = "message"
|
self.stage = "message"
|
||||||
return
|
return
|
||||||
@@ -671,8 +672,8 @@ function SlotMachine:drawBottom()
|
|||||||
local by = self.stage == "intro" and 6 or 11
|
local by = self.stage == "intro" and 6 or 11
|
||||||
Font.drawBox(13, by, 6, 5)
|
Font.drawBox(13, by, 6, 5)
|
||||||
love.graphics.setColor(0, 0, 0, 1)
|
love.graphics.setColor(0, 0, 0, 1)
|
||||||
Font.draw("YES", 15 * 8, (by + 1) * 8)
|
Font.draw(Strings("YES"), 15 * 8, (by + 1) * 8)
|
||||||
Font.draw("NO", 15 * 8, (by + 2) * 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)
|
Font.drawCode(0xED, 14 * 8, (by + 1 + (self.yesno == 1 and 0 or 1)) * 8)
|
||||||
end
|
end
|
||||||
love.graphics.setColor(1, 1, 1, 1)
|
love.graphics.setColor(1, 1, 1, 1)
|
||||||
@@ -723,8 +724,8 @@ function SlotMachine:drawPlain(art)
|
|||||||
love.graphics.setColor(1, 1, 1, 1)
|
love.graphics.setColor(1, 1, 1, 1)
|
||||||
love.graphics.rectangle("fill", 0, 0, 160, 144)
|
love.graphics.rectangle("fill", 0, 0, 160, 144)
|
||||||
love.graphics.setColor(0, 0, 0, 1)
|
love.graphics.setColor(0, 0, 0, 1)
|
||||||
Font.draw("SLOT MACHINE", 32, 4)
|
Font.draw(Strings("SLOT MACHINE"), 32, 4)
|
||||||
Font.draw(("COINS %4d"):format(coins(self)), 8, 16)
|
Font.draw(Strings("COINS %4d", coins(self)), 8, 16)
|
||||||
if art then self:drawReels(art) end
|
if art then self:drawReels(art) end
|
||||||
love.graphics.setColor(0, 0, 0, 1)
|
love.graphics.setColor(0, 0, 0, 1)
|
||||||
Font.draw(">", 12, 56)
|
Font.draw(">", 12, 56)
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user